SwiftUI Navigation on Path-Bound Stacks
Value-route every flow on a path-bound NavigationStack — an active view-destination link re-asserts its push whenever the path changes
Install in BlintOpens the Blint desktop app and adds this pattern to your project. Don't have Blint?
SwiftUI Navigation on Path-Bound Stacks
When an app root drives its NavigationStack from a bound path
(NavigationStack(path: $path)), SwiftUI re-syncs every hosted level whenever
the path changes — and that re-sync is where view-destination navigation
breaks down.
The rules
- Never use
NavigationLink(destination:)on a path-bound stack. Push values:NavigationLink(value:)plus anavigationDestinationregistration. An active view-destination link (one whose destination is currently pushed) re-asserts its push whenever the path changes for any other reason, stacking a second copy of its destination on top of whatever the path just pushed. - Register
navigationDestinationstatically on the stack's root content — beside the stack declaration, never inside a pushed or re-rendering view. A registration inside a pushed view re-registers on every render of that view, and the stack's re-sync then shuffles the pushed order. - Route flows end to end. Converting the deepest link is not enough: one active view-destination link anywhere in the chain poisons every push below it. Give each flow a route enum whose cases the links push — menu entries push route values, list rows push model values — and register all of their destinations at the stack root.
Unbound stacks (e.g. a sheet or cover with its own NavigationStack and no
path binding) tolerate view-destination links — but a flow that exists on the
path-bound root must not rely on that tolerance in its cover variant.
How the failure presents
- An eternal freeze, never a crash — dev builds have no watchdog, so the main thread spins forever. In one real case, list taps froze the app at 27k renders per view before the flow was value-routed.
- An invalidation storm: paused stacks show Observation registrar churn
with no app frames; FPS pegs;
Self._printChanges()shows two or three views ping-ponging@self changedendlessly (each re-render hands the stack fresh destination view values, which re-renders the others). - Pushed-order inversion: the back button carries the title of the page that should be on top — a second copy of a list stacked over the page it pushed.
Diagnosing a suspected recurrence
- Drop
let _ = Self._printChanges()into the suspect bodies; the line that repeats names the loop's participants. Remove it after. - UI-test the real hierarchy, never a stand-in mounted in its own test stack — the update dynamics live in the path binding. Appearance is not enough: assert a post-push interaction lands (push, dwell, tap, assert the selection registered).
- On the simulator the app is a macOS process:
sample <pid>beats a debugger for storms. On device, XCUITest performs real taps headlessly.