← Back to registry

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

Torben Sko @torben v1 frontend:routing
Install in Blint

Opens 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

  1. Never use NavigationLink(destination:) on a path-bound stack. Push values: NavigationLink(value:) plus a navigationDestination registration. 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.
  2. Register navigationDestination statically 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.
  3. 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 changed endlessly (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.