Skip to content

Stamp routed location on view controllers for navigation identity#248

Merged
svara merged 2 commits into
mainfrom
stamp-location
Jul 2, 2026
Merged

Stamp routed location on view controllers for navigation identity#248
svara merged 2 commits into
mainfrom
stamp-location

Conversation

@svara

@svara svara commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Summary

Navigation identity checks — visitingSamePage (→ replace) and visitingPreviousPage (→ pop) in NavigationHierarchyController — decided a screen's URL by casting the on-stack controller to Visitable/VisitableViewController and reading initialVisitableURL, falling back to view-controller type equality when the cast failed.

That fallback is wrong for custom native view controllers: two instances of the same custom class at different URLs are indistinguishable, so the navigator incorrectly pops or replaces instead of pushing.

This PR makes the routed location the single source of truth for navigation identity. The navigator stamps the proposal URL onto every routed controller at route time; the identity checks compare that stamped location and no longer look at the controller's type at all.

This is a direct port of how Hotwire Native Android already solves the same problem (see Parity with Android). It is an alternative to the opt-in protocol approach in #237.

The change

  • New UIViewController.routedLocation: URL? — an associated-object property.
  • NavigationHierarchyController.route(controller:proposal:) stamps controller.routedLocation = proposal.url at the single routing choke point (covers the main and modal stacks, push/replace/replaceRoot).
  • visitingSamePage / visitingPreviousPage compare the stamped location via isSameLocation(as:pathProperties:). The candidate-controller type(of:) comparison is gone.
  • If a stack member has no location (a controller placed on the stack without being routed through the navigator — a bug), we assertionFailure in debug and return false in release, so the visit is treated as a new page and pushed (safe: never a wrong pop/replace).

Parity with Android

Android has always used exactly this model, and this PR converges iOS onto it:

  • NavigatorRule reads controller.currentBackStackEntry.location / previousBackStackEntry.location, where .location is arguments?.getString(ARG_LOCATION) — the location stored in the back-stack entry's arguments, stamped at navigation time in NavigatorRule.withNavArguments(). It never derives identity from the fragment or its type.
  • locationsAreSame returns false when either side is null → falls through to push (the same push-on-miss behavior).
  • Android has no assertion here, because AndroidX Navigation owns the back stack — you navigate to destinations, you never hand it a raw fragment — so an unrouted destination is structurally impossible. UIKit lets anyone pushViewController(_:) an arbitrary controller, so the unrouted-controller bug is reachable on iOS. The assertionFailure is the iOS-specific guard for a gap Android closes by construction.

Alternatives considered — identity source

How should the navigator know a screen's URL?

Approach Pros Cons
Stamp the location (this PR) Zero opt-in; works for any controller (UIHostingController, third-party, plain UIViewController); single source of truth; matches Android Location is stored via the ObjC runtime (see storage alternatives below)
Opt-in PathIdentifiable protocol (#237) Explicit, compile-checked contract; VC owns its identity Opt-in — only fixes VCs that conform; the type-equality bug survives for everything else
Blanket extension UIViewController: PathIdentifiable No opt-in Fatal. initialVisitableURL is non-optional, so a plain VC must return a sentinel URL — and once every VC "conforms," two of them collide on that sentinel and get wrongly treated as the same page (worse than the original bug). Also, a non-@objc property declared in a class extension cannot be overridden by subclasses (verified: error: non-'@objc' property 'initialVisitableURL' is declared in extension of 'UIViewController' and cannot be overridden), so VisitableViewController could no longer report its real URL. The @objc dynamic escape hatch drags Visitable into @objc land and still doesn't fix the sentinel problem.
Mandatory acceptCustom(UIViewController & PathIdentifiable) Compile-time enforcement; lets the type-equality fallback be deleted as a true invariant Source-breaking (major version + migration); breaks alertsacceptCustom currently carries UIAlertController (and could carry a share sheet), which can't sanely be PathIdentifiable, forcing an API split (pages vs. presentations); UIHostingController can't conform without a subclass; shifts a correctness responsibility onto adopters (a VC can report a URL that disagrees with the proposal that routed it)
Base class everyone subclasses Real stored property; normal dispatch Single inheritance kills it — SwiftUI's UIHostingController, UITableViewController, third-party VCs, and apps with their own base class can't all subclass it; undermines the whole point of acceptCustom(UIViewController); still opt-in (remember to subclass)

We concluded the navigator (which generated the URL) should own it, rather than requiring every custom VC to re-declare a URL the framework already handed it in handle(proposal:).

Alternatives considered — storage mechanism

Given that we stamp the location onto the controller, how should it be stored? Extensions can't add stored properties to UIViewController, so:

Mechanism Pros Cons
objc_setAssociatedObject (this PR) Rides with the controller, so it stays correct through system-driven stack mutations (interactive back-swipe, long-press back menu, popToViewController); auto-freed on dealloc — no lifecycle bookkeeping; faithful analog of Android's per-entry arguments bag; a mainstream, Apple-documented idiom Invisible state via the ObjC runtime; not compile-time discoverable
Parallel [URL] stack in the navigator Pure Swift Desyncs — UIKit mutates the stack (back-swipe, back menu, system pops) without routing through our methods
Dictionary<ObjectIdentifier, URL> in the navigator Pure Swift, no ObjC Leaks unless removed on every exit path; ObjectIdentifier is reused after dealloc, so a stale entry can match a different controller — correctness landmine
NSMapTable (weak keys) in the navigator Auto-cleanup on VC dealloc; rides with the controller; keeps storage off UIViewController Lateral move, not cleaner — weak-key reclamation timing is more opaque

Why objc_setAssociatedObject is the right choice here

  1. Correctness under real UIKit behavior. The stack can be mutated by the system (back-swipe, back menu, system pops) without passing through the navigator. Storage that rides with the controller stays correct; a parallel structure in the navigator desyncs, and an ObjectIdentifier map risks matching a reused identity.
  2. No lifecycle bookkeeping. The value is released automatically when the controller deallocates — no "remember to clean up on pop/dismiss/replace" across many exit paths.
  3. It's the faithful iOS translation of Android's design — per-entry, framework-managed storage that's freed when the entry leaves the stack. UIKit has no first-class "arguments bag" on a controller; this is the closest equivalent.
  4. It's mainstream, not a hack. objc_getAssociatedObject/objc_setAssociatedObject is the canonical, Apple-documented mechanism for attaching state to a class you don't own. It's used across the ecosystem.

The malloc(1)! key is deliberate: it's a stable, unique, Swift-6-concurrency-clean key that avoids the &staticVar warning. The whole thing is isolated to one small internal extension, so the runtime call doesn't leak into the rest of the codebase.

Design decisions within the chosen approach

  • Stamped location is the single source of truth (we dropped an earlier Visitable.initialVisitableURL ?? routedLocation preference). For every controller the framework routes, initialVisitableURL == routedLocation == proposal.url, so the preference was redundant on the happy path. Its only effect was to silently exempt an unrouted Visitable from the "everything on the stack is routed" invariant — but an unrouted Visitable bypassed delegate.visit() and is itself a bug, so it should trip the assertion like anything else.
  • assertionFailure + push, not a hard precondition. A hard trap would crash apps that mix manual UIKit navigation with Hotwire (and would break tests that place a pre-existing screen on the stack). assertionFailure flags the bug loudly in development while degrading safely (push) in release.

Testing

  • New regression tests route custom, non-Visitable controllers of the same class and assert push / replace / pop by URL. Verified they fail without the production change and pass with it (the different-URL and previous-URL cases; the same-URL case guards the collapse invariant).
  • Tests that place a pre-existing screen on the stack now stamp it (via a routedViewController(url:) helper), reflecting that every stack member is routed.

Navigation identity checks (visitingSamePage/visitingPreviousPage)
derived a screen's URL by casting to Visitable/VisitableViewController
and fell back to view-controller type equality otherwise. Two instances
of the same custom view-controller class at different URLs were then
indistinguishable, causing an incorrect pop or replace instead of a push.

Stamp the routed URL onto every routed controller at route time and read
it back for identity comparisons, preferring a Visitable's own
initialVisitableURL when present. This mirrors the Android navigator,
which stores the location in the back-stack entry's arguments rather than
deriving it from the destination's type, and works for any custom view
controller without opt-in.
@svara
svara marked this pull request as ready for review July 1, 2026 11:07

@zoejessica zoejessica left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sometimes the old ways are the best @svara 👏

No issues with this, apart from a question on the assertion failure. UIKit does let users manually push unrouted view controllers on the stack whether in production or in testing, so I'm thinking we should assume that lots of users do. I can imagine it being really annoying to have to fork just to remove the assertion in development. Maybe we should provide a setting, or downgrade it to a log?

@svara

svara commented Jul 2, 2026

Copy link
Copy Markdown
Contributor Author

Sometimes the old ways are the best @svara 👏

No issues with this, apart from a question on the assertion failure. UIKit does let users manually push unrouted view controllers on the stack whether in production or in testing, so I'm thinking we should assume that lots of users do. I can imagine it being really annoying to have to fork just to remove the assertion in development. Maybe we should provide a setting, or downgrade it to a log?

My intent with the assertion was to guard against unintended/uncommon use of the library. But you are right that we can't distinguish that from legitimate mixed navigation. This is something we should aim for.

I've downgraded it to a logger.warning. Since our logger is off unless Hotwire.config.debugLoggingEnabled is set, it's silent by default and only surfaces when opted into debug logging, so it doubles as the setting you suggested without new config.

@svara
svara merged commit 978ed1b into main Jul 2, 2026
1 of 3 checks passed
@svara
svara deleted the stamp-location branch July 2, 2026 15:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants