feat(routing): cover nested roots and blocked navigations with native route replay - #67
Merged
Merged
Conversation
… route replay Two gaps where a machine could not get its startup route from the router and had to read `window.location` itself. Consumers work around both by patching the machine's entry action to re-match the URL and raise the event — which delivers the route a second time, with different meta, wherever the native path DOES fire. 1. A root nested inside another root's routing context got no mount-time replay. `buildRootComponent` renders its machine through `XstateTreeView`, which never calls `getViewForInterpreter`, so the replay that covers slotted children skipped nested roots entirely. Such a root mounting in RESPONSE to a navigation never heard the route that opened it — the broadcast had already fired before it existed. `RootComponent` now performs the same mount-time replay, reading the ANCESTOR context. A routing root reads no ancestor, so this is a no-op for it. It cannot double-deliver alongside the broadcast either: a root alive at boot mounts before the routing root has resolved the URL, so there is nothing to replay yet, and a root that mounts later missed the broadcast entirely. 2. `shouldBlockActiveRouteUpdate` froze route delivery, not just route-active UI. Its purpose is to keep a background page's navigation highlighted while an overlay owns the URL, but suppressing the whole update also pinned what mount-time replay reads — so a machine mounting INTO that overlay was replayed the background page's route instead of the one it was being opened on. The context now carries two channels: `activeRouteEvents` keeps its current meaning and stays frozen (so `useIsRouteActive` / `useOnRoute` / `useRouteArgsIfActive` are unchanged), while a new private `latestRouteEvents` always tracks the URL actually matched. Both replays read the latter. The public API surface is unchanged — `useLatestRouteEvents` is `@private`, and providers that do not distinguish the two (`TestRoutingContext`) fall back to `activeRouteEvents`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR improves xstate-tree’s routing delivery guarantees by ensuring mount-time route replay works for (1) roots nested inside another root’s routing context and (2) children mounting into navigations where shouldBlockActiveRouteUpdate freezes route-active UI state.
Changes:
- Add a new “latest route events” channel to the routing context (never frozen) and use it for mount-time replay.
- Replay ancestor route events on mount in
buildRootComponentso nested roots receive the route that caused them to mount. - Add Jest coverage for nested-root replay and blocked-navigation replay behavior.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
| src/xstateTree.tsx | Switch slot mount-time replay to “latest” route events and add mount-time replay for nested roots in buildRootComponent. |
| src/routing/providers.tsx | Extend routing context to carry latestRouteEvents and introduce useLatestRouteEvents() (private) with fallback behavior. |
| src/routing/index.ts | Re-export useLatestRouteEvents from the routing barrel for internal consumption. |
| src/tests/nestedRootRouteReplay.spec.tsx | New test verifying nested roots receive route replay both on boot and when mounting after navigation. |
| src/tests/blockedRouteStillReplays.spec.tsx | New test verifying blocked navigations still replay the real route to newly mounted children while route-active UI remains frozen. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
|
🎉 This PR is included in version 5.6.0 🎉 The release is available on: Your semantic-release bot 📦🚀 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes the two gaps that force consumers to hand-roll route delivery.
Background
Koordinates codebase carries a
v5RoutingShimhelper that patches a machine'sentryaction to re-matchwindow.locationitself and raise the routing event. It exists purely for machines the router's own delivery paths miss. Its doc enumerates three such cases — this PR removes two of them from the framework, so those call sites can drop the shim.That matters beyond tidiness: the shim rebuilds the event with
metareduced to{ indexEvent: true }, whereas native delivery carries the router's full meta. Anywhere both fire, the route arrives twice, with different meta — which is what recently broke a/manage/users/:iddeep link in kawaka (a one-shot echo marker was consumed by the first delivery, so the second fell through into the branch that closed the dialog).1. Nested roots got no mount-time replay
getViewForInterpreterreplays the active route events when a slot mounts. It is called from exactly one place — slot rendering. ButbuildRootComponentrenders its own machine throughXstateTreeView, which never calls it.So a root nested inside another root's routing context — legal, and common when a v5 subtree is embedded in a host that can't invoke it as an actor — got no replay. If it mounted in response to a navigation, it never heard the route that opened it: the broadcast had already fired before the root existed.
RootComponentnow does the same replay, reading the ancestor context:A routing root reads no ancestor context, so this is a no-op for it.
It cannot double-deliver alongside the broadcast, by the same argument that makes the existing slot replay safe: a root alive at boot mounts before the routing root has resolved the URL, so there is nothing to replay yet and the broadcast is the only delivery; a root that mounts later missed the broadcast entirely, so the replay is the only delivery.
2.
shouldBlockActiveRouteUpdatefroze delivery, not just route-active UIThe option exists so a host can keep a background page's navigation highlighted while an overlay owns the URL. But it suppressed the whole
activeRouteEventsupdate — and that is also what mount-time replay reads.Net effect: a machine mounting into the overlay was replayed the background page's route rather than the one it was being opened on. The overlay's whole subtree therefore had to read the URL itself.
The context now carries two channels:
activeRouteEventsuseIsRouteActive,useOnRoute,useRouteArgsIfActivelatestRouteEvents(new, private)activeRouteEventskeeps exactly its current meaning, so every route-active hook is unchanged. Providers that don't distinguish the two (TestRoutingContext) fall back toactiveRouteEvents.Tests
nestedRootRouteReplay.spec.tsx— a nested bare root gets the route both when it mounts in response to a navigation and when it is present from boot. Both cases fail onmaster.blockedRouteStillReplays.spec.tsx— a child mounting into a blocked navigation receives the real route (sheet:42), and the block still does its job (the list stays highlighted). Verified this fails without the fix — I removedlatestRouteEventsfrom the provider value to simulate the old behaviour, and the sheet renders with no id.Verification
npx jest— 133 passed, no regressions (three consecutive clean runs; two of the suite's timing-sensitive tests flake under load, so I ran it repeatedly rather than trusting one pass)npm run lint— 0 errorsnpm run build— cleannpm run api-extractor -- --local— public API surface unchanged (useLatestRouteEventsis@private)