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")