Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion QUEUE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 5 additions & 0 deletions host/apple/Sources/ExactKit/IOS/NavigationIOS.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -668,13 +671,15 @@ 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"] ?? "")
let c = controllers[node.id] ?? RouteController(node)
// 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
Expand Down
7 changes: 5 additions & 2 deletions host/apple/Sources/ExactKit/IOS/NavigationTabsIOS.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 24 additions & 1 deletion host/apple/tests/ExactKitTests/NavigationMoreIOSTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
20 changes: 12 additions & 8 deletions host/apple/tests/fixtures/more-tabs.contract
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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)
Expand Down Expand Up @@ -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")
Expand Down
Loading