From ce2b5433d5dd506b5dceeab141f11ae58d731807 Mon Sep 17 00:00:00 2001 From: ide-agent Date: Fri, 13 Nov 2026 14:00:07 -0800 Subject: [PATCH] A tab's new root is written into More while More holds the old one Why === More holds a tab's root and the routes above it once a row of its list is picked. When the app replaced that root (a fresh entry, as `open` makes for another URL), the old root's node left the tree, so `moreTab`, which names the tab of More's first route by the tabpanel holding its node, named none. `syncTabs` then wrote the new root into the tab's own controller while More still showed the old one. How === Each route's controller records the tabpanel it was last projected from (`RouteController.panel`), and `tabIndex(ofRoute:)` falls back to it once the route's node has left the tree. `moreTab` then names the tab while More holds its replaced root, and the sync writes the new root into More, where UIKit holds the tab's routes. `followMore`, `followSelection` and `declineMoreRow` read the same lookup through `tabIndex(ofShown:)`, so they too map a route More shows that the app replaced or removed to its tab. `NavigationMoreIOSTests.testATabsNewRootIsWrittenIntoMoreWhileMoreHoldsTheOldOne` opens Five at another URL from its root under More (the More fixture's Renew) and checks that More shows the new root and Five's own controller holds none of its routes. Test Plan === With this change on `main` `1229a5be4c`, on an iPhone 18 Pro simulator (iOS 27.0), `NavigationMoreIOSTests`, `NavigationSequenceIOSTests`, `NavigationPopIOSTests`, `UIKitTabFactsIOSTests`, `NavigationTabsIOSTests`, `TabStacksIOSTests` and `TabsIOSTests` pass bare and hosted, run through `xcodebuild test-without-building` with the run files, fixture environment and accessibility setting `bun host/apple/build.mjs --test --ios` gives them. `NavigationTabsIOSTests.testSharePresentsOverAnAppOwnedTabContainer` is left out: on this simulator it has hung in `UIActivityViewController`'s presentation, waiting for `sharingd`. The iPad sidebar case and the height-only size class case skip on the iPhone. Without the fallback, `testATabsNewRootIsWrittenIntoMoreWhileMoreHoldsTheOldOne` fails: More holds the old root, and Five's own controller holds the new one. `bun host/apple/build.mjs --tvos --no-install` compiles, and `bun scripts/caps.mjs` passes. --- QUEUE.md | 1 - .../Sources/ExactKit/IOS/NavigationIOS.swift | 5 ++++ .../ExactKit/IOS/NavigationTabsIOS.swift | 7 ++++-- .../NavigationMoreIOSTests.swift | 25 ++++++++++++++++++- host/apple/tests/fixtures/more-tabs.contract | 20 +++++++++------ 5 files changed, 46 insertions(+), 12 deletions(-) diff --git a/QUEUE.md b/QUEUE.md index c008503d4..d650bb721 100644 --- a/QUEUE.md +++ b/QUEUE.md @@ -90,7 +90,6 @@ - **Virtualized iOS snap geometry (#219):** unmounted rows do not contribute a target on a descendant of the row root, a root displaced by a transform, a root with a font-relative or percentage inset or margin, or a target farther than the bounded window from the projection. The synchronous drag-end query examines at most 64 rows; with sparse targets, the nearest one can lie outside that window. -- **A tab's root route replaced while More holds it is written into the tab's own controller** (2026-11-11, read, not run). `moreTab` finds the tab by the tabpanel of More's first route; if the app replaces that route's node, `moreTab` is nil, and `syncTabs` writes the new root into the tab's own controller while More still shows the old one. - **With an app's own delegate on a stack, a pop UIKit makes there holds `changing` until Exact next hears a call** (2026-11-12, read, not run). Exact hears a stack's pops only through the delegate calls it forwards. Under an app's own delegate it hears none of that stack's, so the popped routes are in flight (`routesInFlight`), `changing` is true and the agent reads `in-progress`, and `popMayBegin` refuses the stack's back swipe, until the next tab delegate call or the turn after the next sync (`followPopsNextTurn`) reports the pop. Following a stack whose delegate the app replaced needs a call of UIKit's that Exact still hears, such as the route's own appearance callbacks. - **The agent's `back` to More's list can reply before More reports its list** (2026-11-11, Duo Lab on the iPhone 18 Pro simulator, iOS 27.0, `--chrome platform`, on `main` `35ac89eb7b`). After `back` at a tab's root under More, the next `state navigation` read `transition` `in-progress` and an empty `stack` in every drive on `main` and in some with that change, and `idle` with the tab's root in the others. More's `willShow` and `didShow` for its list come after the pop call returns (LLP 1075.003 ยง9.16); the reply did not always wait for them. The same drives log UIKit's "Unbalanced calls to begin/end appearance transitions" for a `RouteController` once. - **Duo Lab's Panes title draws over its first line on the iPhone 18 Pro simulator** (2026-11-11, iOS 27.0, `main` `35ac89eb7b`, `--chrome platform`). On Panes' root, and on `panes-next` ("Pushed panes"), the large title overlaps the text "Responsive layout. Split and Overlay require the native iOS app on iOS 27.1 or later." at the top of the screen. diff --git a/host/apple/Sources/ExactKit/IOS/NavigationIOS.swift b/host/apple/Sources/ExactKit/IOS/NavigationIOS.swift index 0f1f8fa13..1f855f429 100644 --- a/host/apple/Sources/ExactKit/IOS/NavigationIOS.swift +++ b/host/apple/Sources/ExactKit/IOS/NavigationIOS.swift @@ -303,6 +303,9 @@ final class RouteController: UIViewController, UIScrollViewDelegate { host?.presenter.elementScrolls.revealEnded(scrollView) } + /// The tabpanel the route was last projected from (`NavigationHost.tabIndex(ofRoute:)`). + var panel: UInt32? + /// Whether UIKit has held this route in a stack since a pop of it last /// reached the router (`NavigationHost.followPops`), and the navigation /// controller that last held it. With these a route of the router's that @@ -668,6 +671,7 @@ final class NavigationHost: NSObject, UINavigationControllerDelegate, UIGestureR shownKeys = Array(keys(stacks[at])[range]) selectedRouteCount = stacks[at].count let wanted = stacks.enumerated().map { index, routes in + let panel = tabs?.panels[index].id var presented = false return (index == at ? Array(routes[range]) : routes).map { node -> RouteController in presented = presented || ["modal", "fullscreen"].contains(node.props["navigationPresentation"] ?? "") @@ -675,6 +679,7 @@ final class NavigationHost: NSObject, UINavigationControllerDelegate, UIGestureR // A separate visual viewport accommodates the keyboard without reflowing the route c.usesPageViewport = !presented && !ExactEnv.authoredChrome && presenter.keyboardPolicy == "resizes-visual" c.host = self + c.panel = panel controllers[node.id] = c c.mount() return c diff --git a/host/apple/Sources/ExactKit/IOS/NavigationTabsIOS.swift b/host/apple/Sources/ExactKit/IOS/NavigationTabsIOS.swift index 030334998..33b02be52 100644 --- a/host/apple/Sources/ExactKit/IOS/NavigationTabsIOS.swift +++ b/host/apple/Sources/ExactKit/IOS/NavigationTabsIOS.swift @@ -425,9 +425,12 @@ extension NavigationHost { return tabController?.tabBar.items?.contains(where: { $0 === item }) == true ? item : nil } - /// The tab a route belongs to, by its tabpanel. + /// The tab a route belongs to, by the tabpanel that holds its node. A node + /// that has left the tree while UIKit still holds its controller, such as + /// a root More holds that the app replaced or removed, takes the tabpanel + /// it was last projected from, if that is still a tab. func tabIndex(ofRoute route: RouteController) -> Int? { - logicalParent(of: route.node).flatMap { tabPanels.firstIndex(of: $0.id) } + (logicalParent(of: route.node)?.id ?? route.panel).flatMap { tabPanels.firstIndex(of: $0) } } /// More shows a controller. A tab UIKit selected that the router is not diff --git a/host/apple/tests/ExactKitTests/NavigationMoreIOSTests.swift b/host/apple/tests/ExactKitTests/NavigationMoreIOSTests.swift index 2e64e26d7..f8d4b9aff 100644 --- a/host/apple/tests/ExactKitTests/NavigationMoreIOSTests.swift +++ b/host/apple/tests/ExactKitTests/NavigationMoreIOSTests.swift @@ -885,11 +885,29 @@ final class NavigationMoreIOSTests: XCTestCase { session.presenter.press(try node(session, "tab-five").id) waitFor("the router on Five") { navigation.routerTab == 4 && !navigation.inTransition } waitForMainQueue() - XCTAssertTrue(more.topViewController === full, "More keeps Full: More holds \(more.stackRoutes.map { ($0 as? RouteController)?.key ?? "?" })") + XCTAssertTrue(more.topViewController === full, "More keeps Full: More holds \(routeKeys(more))") XCTAssertEqual(navigation.selectedKey, full.key, "and the router stays on it") XCTAssertNil(navigation.restDifference, "UIKit and the router agree") } + /// The app opens Five at another URL, a new root route, while More holds + /// Five's root: Exact writes the new root into More, where UIKit holds + /// Five's routes, not into Five's own controller. + func testATabsNewRootIsWrittenIntoMoreWhileMoreHoldsTheOldOne() throws { + let (session, tabs, more) = try moreFixture("more-root-renewed") + let navigation = session.presenter.navigation + let five = try XCTUnwrap(tabs.tabControllers?[4] as? UINavigationController) + tapTab(tabs, of: tabs.moreNavigationController) + pickRow(tabs, 4) + waitFor("the router follows Five, whose root More holds") { navigation.routerTab == 4 && more.stackRoutes.count == 1 && more.transitionCoordinator == nil } + let old = try XCTUnwrap(more.stackRoutes.first as? RouteController) + try tapNode(session, "renew") + waitFor("a new root for Five") { navigation.selectedKey != old.key && !navigation.inTransition } + waitForMainQueue() + XCTAssertEqual(routeKeys(more), [navigation.selectedKey ?? ""], "More shows the new root") + XCTAssertEqual(routeKeys(five), [], "Five's own controller holds none of its routes") + } + /// An app that sets More's delegate itself takes More's calls from Exact: /// a pop of More reaches the router in the turn after the next sync, and /// holds no sync after that. @@ -1131,6 +1149,11 @@ final class NavigationMoreIOSTests: XCTestCase { try XCTUnwrap(session.presenter.views.values.first { $0.props["testId"] == testId }, "no \(testId)") } + /// The keys of the routes `stack` holds, `?` for a controller that is no route. + private func routeKeys(_ stack: UIViewController) -> [String] { + stack.stackRoutes.map { ($0 as? RouteController)?.key ?? "?" } + } + /// How many times the session's journal holds `line`. private func journal(_ session: ExactSession, _ line: String) -> Int { session.agent(#"{"op":"logs","since":0}"#).components(separatedBy: line).count - 1 diff --git a/host/apple/tests/fixtures/more-tabs.contract b/host/apple/tests/fixtures/more-tabs.contract index 7da9f2d42..612d08864 100644 --- a/host/apple/tests/fixtures/more-tabs.contract +++ b/host/apple/tests/fixtures/more-tabs.contract @@ -3,14 +3,14 @@ // Barless, Three and Five draw their tab's stack, so each tab is a navigation // controller: Home and Three show a bar; Barless and Five's root have no // header, and under More, Five's root shows More's Back to its list. Four's -// panel shows one fixed route with a header, so its tab is a navigation -// controller too, whose bar shows that header. Six's panel shows one fixed -// route with no header, so its tab is a plain view controller. Home and Five -// push a route, and Home presents Sheet, whose Five selects Five. Five's -// Full hides the tablist, so its controller hides UIKit's tab bar. With -// Redirect on, Four's press selects Three: a press that moves the router to a -// tab other than the one tapped. With Decline on, Six's press leaves the -// router where it is. +// panel shows a fixed route with a header, so its tab is a navigation +// controller too, whose bar shows that header. Six's panel shows a fixed route +// with no header, so its tab is a plain view controller. Home and Five push a +// route, and Home presents Sheet, whose Five selects Five. Five's Full hides +// the tablist, so its controller hides UIKit's tab bar. Five's Renew replaces +// Five's stack with a new root route at another URL. With Redirect on, Four's +// press selects Three: a press that moves the router to a tab other than the +// one tapped. With Decline on, Six's press leaves the router where it is. routes nav tab home "/" detail "/detail" @@ -30,6 +30,8 @@ component MoreTabs nav = (declining and name == "six") ? nav : select(nav, (redirecting and name == "four") ? "three" : name) action open(url: string) nav = push(nav, url) + action renew + nav = open(nav, "/five?renewed=1") action back nav = back(nav) action follow(url: string) @@ -79,6 +81,8 @@ component MoreTabs text "Deeper" button press=open("/five/full") testId="open-full" text "Full" + button press=renew testId="renew" + text "Renew" if e.name == "barless" text e.name testId=`text-${e.name}` row role="tablist" display=(top(nav).name == "full" ? "none" : "flex")