Skip to content

fix(routing): subscribe roots to broadcasts before any root broadcasts - #65

Merged
UberMouse merged 1 commit into
masterfrom
fix-root-subscribe-before-broadcast
Aug 25, 2026
Merged

UberMouse merged 1 commit into
masterfrom
fix-root-subscribe-before-broadcast

Conversation

@UberMouse

Copy link
Copy Markdown
Collaborator

The bug

buildRootComponent subscribes the root machine to the global emitter from a passive effect:

useEffect(() => {
  function handler(event: GlobalEvents) {
    recursivelySend(interpreter, event);
  }
  emitter.on("event", handler);
  return () => emitter.off("event", handler);
}, [interpreter]);

A routing root broadcasts the initial route from a passive effect too (the mount effect that calls handleLocationChange).

Passive effects run in tree order. So whether a root receives the initial routing event depends entirely on where it renders relative to the routing root:

<><RoutingRoot /><ListenerRoot /></>   // ListenerRoot MISSES the initial route
<><ListenerRoot /><RoutingRoot /></>   // ListenerRoot receives it

In the first case the routing root's mount effect has already broadcast by the time ListenerRoot's subscribe effect runs. The event is emitted into an emitter nobody is listening on yet, and it's gone — there's no replay for a non-slotted root.

Event delivery silently depending on JSX sibling order is not something callers can reasonably be expected to know about, and it fails in the direction that's hardest to debug: no error, just a machine that never got its route.

The fix

Subscribe during the layout phase.

React runs all layout effects for a commit before any passive effect for that commit, so every root is attached to the emitter before a passive effect can broadcast — regardless of render order. Broadcasting stays where it is; only the subscription moves.

Test

src/tests/rootSubscribesBeforeBroadcast.spec.tsx renders a routing root and a bare root in both orders and asserts the bare root sees the initial routing event either way.

On master the "rendered after" case fails and the "rendered before" case passes — the order dependence, pinned. Both pass with this change.

Context

Kawaka has been carrying exactly this change as a local pnpm patch (common/pnpm-patches/@koordinates__xstate-tree@5.5.1.patch) since May. It was added during an XState v5 migration wave, alongside a spec comment describing a routing event being "lost mid-transition".

The commit that introduced it describes the patch as being for "a TestRoutingContext export needed by the stories" — that's inaccurate; TestRoutingContext was already a public export in stock 5.5.1, and the patch has only ever contained this one-line effect change. Upstreaming it so the patch can be dropped.

Verification

  • npx jest129 passed, 28 suites, no regressions (three consecutive clean runs)
  • npm run lint -- --fix — 0 errors
  • npm run build — clean
  • npm run api-extractor -- --local — public API surface unchanged

`buildRootComponent` subscribed the root machine to the global emitter from a
passive effect, and a routing root broadcasts the initial route from one too.
Passive effects run in tree order, so whether a root receives the initial route
depended on where it sat relative to the routing root: a root rendered before it
was subscribed in time, a root rendered after it was not, and silently missed
the event.

Subscribe during the layout phase instead. Layout effects for a commit all run
before any passive effect for that commit, so every root is attached before a
passive effect can broadcast, regardless of render order.

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 fixes an order-dependent routing bug in buildRootComponent where non-slotted roots could miss the initial routing broadcast depending on JSX sibling order. It does so by moving the global broadcast subscription from a passive effect to a layout effect, ensuring all roots are subscribed before any passive-effect routing broadcasts run.

Changes:

  • Subscribe roots to the global emitter in useLayoutEffect (instead of useEffect) to eliminate sibling-order dependence for initial routing events.
  • Add a regression test that renders a routing root and a listener root in both sibling orders and asserts the listener receives the initial route event.
  • Add an in-code rationale comment explaining the React effect ordering issue being addressed.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

File Description
src/xstateTree.tsx Moves root broadcast subscription to layout effect to guarantee subscription precedes passive-effect broadcasts (e.g., initial routing).
src/tests/rootSubscribesBeforeBroadcast.spec.tsx Adds a regression test covering both sibling orders to prevent reintroducing the missed-initial-route behavior.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/xstateTree.tsx
Comment on lines +408 to +413
// Layout, not passive. Effects run in tree order, so a root rendered after a
// routing root would otherwise still be unsubscribed when that routing root
// broadcasts the initial route from its own (passive) effect, and would miss
// it entirely. Subscribing during the layout phase gets every root attached
// before any broadcast a passive effect makes.
useLayoutEffect(() => {
@UberMouse
UberMouse merged commit 6877205 into master Aug 25, 2026
2 checks passed
@UberMouse
UberMouse deleted the fix-root-subscribe-before-broadcast branch August 25, 2026 22:11
@github-actions

Copy link
Copy Markdown

🎉 This PR is included in version 5.5.2 🎉

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