Skip to content

feat(routing): cover nested roots and blocked navigations with native route replay - #67

Merged
UberMouse merged 1 commit into
masterfrom
retire-routing-shim-cases
Aug 26, 2026
Merged

UberMouse merged 1 commit into
masterfrom
retire-routing-shim-cases

Conversation

@UberMouse

@UberMouse UberMouse commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator

Closes the two gaps that force consumers to hand-roll route delivery.

Background

Koordinates codebase carries a v5RoutingShim helper that patches a machine's entry action to re-match window.location itself 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 meta reduced 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/:id deep 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

getViewForInterpreter replays the active route events when a slot mounts. It is called from exactly one place — slot rendering. But buildRootComponent renders its own machine through XstateTreeView, 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.

RootComponent now does the same replay, reading the ancestor context:

const ancestorRouteEvents = useLatestRouteEvents();

useEffect(() => {
  const actor = interpreter as AnyActorRef;
  ancestorRouteEvents?.forEach((event) => {
    if (actor.getSnapshot().can(event)) actor.send(event);
  });
}, []);

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. shouldBlockActiveRouteUpdate froze delivery, not just route-active UI

The option exists so a host can keep a background page's navigation highlighted while an overlay owns the URL. But it suppressed the whole activeRouteEvents update — 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:

frozen by the block? read by
activeRouteEvents yes useIsRouteActive, useOnRoute, useRouteArgsIfActive
latestRouteEvents (new, private) no mount-time replay (slots + nested roots)

activeRouteEvents keeps exactly its current meaning, so every route-active hook is unchanged. Providers that don't distinguish the two (TestRoutingContext) fall back to activeRouteEvents.

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 on master.
  • 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 removed latestRouteEvents from the provider value to simulate the old behaviour, and the sheet renders with no id.

Verification

  • npx jest133 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 errors
  • npm run build — clean
  • npm run api-extractor -- --localpublic API surface unchanged (useLatestRouteEvents is @private)

… 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>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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 buildRootComponent so 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.

@UberMouse
UberMouse merged commit a0439f9 into master Aug 26, 2026
2 checks passed
@UberMouse
UberMouse deleted the retire-routing-shim-cases branch August 26, 2026 01:53
@github-actions

Copy link
Copy Markdown

🎉 This PR is included in version 5.6.0 🎉

The release is available on:

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants