Skip to content

fix(router): a Suspense boundary still suspended on the client hydrates as the server rendered it when location state is read outside it - #1116

Merged
ivogt merged 5 commits into
mainfrom
fix/treeless-commit-hydration
Oct 12, 2026
Merged

ivogt merged 5 commits into
mainfrom
fix/treeless-commit-hydration

Conversation

@ivogt

@ivogt ivogt commented Oct 12, 2026

Copy link
Copy Markdown
Collaborator

Problem

On a document load where the history entry has location state (after a reload, or Back to a page that loads its document), a <Suspense> boundary that couldn't hydrate yet got client-rendered over its server HTML. React logged Hydration failed because the server rendered text didn't match the client in dev and minified error #418 in production. A boundary is in that state when the client suspends it after the document has finished streaming: its React.lazy chunk is still loading, or a deferred-hydration gate such as a hydrate-when-visible island holds it. It only happened when a useLocationState reader rendered next to that boundary, outside it, had state.

Before / after

// page.tsx (server)
export function Page() {
  return (
    <>
      <FlashBanner />            {/* "use client": useLocationState(Flash) */}
      <Suspense fallback={<Spinner />}>
        <LazyChart />            {/* React.lazy chunk still loading at reveal */}
      </Suspense>
    </>
  );
}
  • Before: the banner received the entry's state while LazyChart's boundary was still dehydrated. When the chunk arrived, the boundary hydrated against the new values, so React threw away the server HTML (dev: "Hydration failed …"; prod: Fix prefetched parallel navigation streaming #418).
  • After: the boundary hydrates as the server rendered it. The banner shows undefined until every boundary of the document has hydrated, and then the entry's state, which matches what useLocationState already promised during hydration.

Consumer example

"use client";
import { useLocationState } from "@rangojs/router/client";
import { Flash } from "../location-states";

export function FlashBanner() {
  const flash = useLocationState(Flash); // undefined during hydration, then the entry's state
  return flash ? <p role="status">{flash.text}</p> : null;
}

Nothing changes in consumer code.

Semantics

Document load Before After
no location state in the entry unchanged unchanged
state; every boundary hydrated at reveal readers get it at the reveal commit readers get it one commit later (a transition after the barrier commit)
state; a boundary still suspended on the client, no reader outside it barrier waits for the boundary unchanged
state; a boundary still suspended on the client, a reader rendered next to it barrier commits around the boundary; hydration fails barrier waits for the boundary; location state and late handles arrive after it hydrates
server-streamed boundary still pending ($?) awaitDocumentRevealed waits for it unchanged
navigation, action or popstate during the hydration window releases the lock before its update unchanged

Trade-off: in the fourth row, a deferred-hydration gate with no upper bound (an off-screen hydrate-when-visible island) keeps location state undefined until it hydrates. Late handle data already waited this long whenever no location-state reader sat outside the boundary.

Code change summary

The change is in src/browser/react/NavigationProvider.tsx (about 20 lines):

  • The reveal transition now changes only the hydration barrier counter. Nothing reads HydrationBarrierContext, so React holds that commit until every boundary has hydrated.
  • An effect after the barrier's commit sets location state in its own transition and releases the hydration lock.
  • Why there: React 19.3 tells a dehydrated boundary about a context change only through propagation from an ancestor that bails out, and that propagation skips the boundary. A reader of the changed context outside the boundary therefore let the transition commit around it. The rule that follows is that the barrier must be the only update in its transition lane. Setting location state in a second startTransition inside the same callback lands in the same lane and fails the same tests.
  • The full account, with react-dom 19.3.0 source locations, is in docs/internal/execution-model.md ("The reveal barrier is alone in its lane"). context.ts, feature-map.md and handle-push-ownership.md point to it.

Test plan

  • Userland, render-route-hydrate.test.tsx through renderRoute: "a boundary suspended on the client when the document is revealed hydrates as rendered…", StrictMode and non-StrictMode, including a useHandle reader with late pushes. Red on base: recoverableErrors = ["Hydration failed …"].
  • Internal, location-state-hydration.test.tsx on the real initBrowserApp document. Red on base.
  • e2e expectGatedBoundaryHydratesWithOutsideReader (shared-e2e), dev and (production), router test-app and cloudflare-basic:
    • The test waits until no boundary is pending, then until the outside reader shows state or 3 s pass. It asserts the reader still showed the server's value before the chunk opened.
    • Red on the base provider in all four (dev "Hydration failed …", production Fix prefetched parallel navigation streaming #418); green with the fix, 3/3 repeats.
  • Mutation testing (Opus review):
    • Fail: full revert (4 fail); location state back in the barrier (4 fail); lock released at barrier start (2 fail); separate transition in the same event (4 fail).
    • Pass: without startTransition; without the new guard, which is defensive and kept.
  • Neighbouring e2e, dev and production: suspense-cases, held-boundary, location-state and hydration-detection in the test-app (173); the first three in cloudflare-basic (122).
  • pnpm run typecheck, pnpm run test:unit:all, pnpm run lint, pnpm run format, check:e2e-bucketing, check-e2e-parity --strict, check:skills, check:docs-api.

Also in this PR: location state from a commit with no new page

The same failure had a second way in. A commit that renders no new page during the hydration window set location state at once. Such commits are:

  • a shallow navigation (router.push(url, { revalidate: false, state }) or <Link revalidate={false} state>);
  • a Back/Forward or revalidation that keeps every segment;
  • an action that sets location state;
  • a fill's server-set state.

A reader outside a boundary that was still suspended on the client got the new state, and the boundary then hydrated against it. Reproduced in both apps: dev "Hydration failed … + shallow-value − empty", production #418.

Fix (NavigationProvider.tsx): while the window is open, the treeless-commit subscription leaves the state in the event controller. The barrier's post-commit effect then applies the latest state. It defers rather than releasing the lock, because such a commit mounts no new tree, and it adds no wait on navigation.

Same trade-off: a deferred-hydration gate that stays shut holds such state with no upper bound, while the URL and search params already show the new entry. Two state-only commits during the window coalesce, and the latest wins. A navigation during the window replaces the deferred state with its own.

Tests:

  • location-state-hydration.test.tsx "a treeless commit before/after reveal waits for a boundary suspended on the client", StrictMode on and off (4 cases).
  • Userland render-route-hydrate.test.tsx "a back/forward while a boundary is suspended on the client waits for it to hydrate" (2 cases).
  • e2e expectGatedBoundaryHydratesAfterShallowNavigation, dev and (production), both apps, 3/3 repeats. All red before the fix.
  • Opus mutation testing: removing the guard, or limiting it to shallow navigations, to before the reveal or to after it, each turns cases red.
  • Neighbouring e2e: test-app dev 93 and production 82; cloudflare-basic dev 66 and production 66.

Notes

  • The action path is covered by unit tests through the shared commit path (commitLocationState(x, true)), not by its own e2e.
  • React bug: this relies on a React 19.3 behaviour — the lanes marked on a dehydrated boundary are lost after a selective-hydration commit. If React fixes it, nothing here breaks. If React changes how it hydrates a boundary under a changed provider, the red-before-green tests running against [email protected] catch it on upgrade.

ivogt added 4 commits October 11, 2026 23:00
…ered when location state is read outside it

On a document load with location state in the entry, a Suspense boundary
that could not hydrate yet (a lazy or client component chunk still loading)
was client-rendered over its server HTML when a useLocationState reader
outside it had state: React reported "Hydration failed because the server
rendered text didn't match the client" (#418 in a build) and replaced the
boundary's DOM.

Cause: NavigationProvider's reveal transition set the entry's location
state and HydrationBarrierContext together, and released the store after
that commit. React 19.3 tells a dehydrated boundary about a context change
only by lazy propagation from an ancestor that bails out with no work below
it (attemptEarlyBailoutIfNoScheduledUpdate returns a dehydrated boundary
without propagating). After the selective-hydration attempt commits the
boundary still dehydrated, the outside reader keeps the transition lane in
their common ancestor's childLanes, nothing propagates again, and the
transition commits around the boundary. The boundary then hydrated against
the entry's state and the released store.

Fix: the reveal transition changes HydrationBarrierContext alone (nothing
reads it), so React holds its commit until every boundary has hydrated.
The effect after that commit gives the entry's location state in a
transition of its own and releases the store. A navigation that commits
first still ends the window itself, as before.

Red before green:
- render-route-hydrate.test.tsx and location-state-hydration.test.tsx, new
  "suspended on the client" cases: without the fix, recoverableErrors =
  ["Hydration failed because the server rendered text didn't match the
  client. ..."] (4 failed); with it, 25 passed.
- e2e expectGatedBoundaryHydratesWithOutsideReader (router test-app and
  cloudflare-basic, dev and production): without the fix all four fail with
  "Hydration failed ..." (dev) / "Minified React error #418" (production);
  with it all four pass, 5/5 repeats each.
…terministic

Review round for 2d26298.

- The invariant is "the barrier is the only update in its transition lane",
  not "nothing a component reads changes in it": moving setLocationState
  into its own startTransition inside the same callback batches it into the
  same lane and fails the same four unit tests. NavigationProvider and
  context.ts now state that rule and point to execution-model.md, which holds
  the one full account (React 19.3 source locations, the tree shape that
  triggers it, the trade-off).
- Trade-off documented in CHANGELOG, skills/hooks and execution-model:
  location state waits for every boundary still suspended on the client at
  reveal, a deferred-hydration gate included (no upper bound). Late handle
  data already waited for such a boundary whenever no reader sat outside it.
- expectGatedBoundaryHydratesWithOutsideReader no longer waits a fixed
  500 ms: once countDomBoundaryMarkers reports no pending ($?/$~) boundary it
  waits until the outside reader shows the entry's state or 3 s pass, and
  asserts the reader still showed the server's value before the chunk
  opened. countDomBoundaryMarkers gains a `pending` count.
- Comments state the rule instead of the history; feature-map,
  handle-push-ownership and context.ts carry one-line pointers.

Re-proof on the base provider (8a843aa NavigationProvider.tsx), the new
e2e fails in the router test-app and cloudflare-basic, dev ("Hydration
failed because the server rendered text didn't match the client") and
production ("Minified React error #418"); with the fix all four pass, 3/3
repeats each.
…L through a shallow navigation that sets location state

While a Suspense boundary of the document was still suspended on the
client (a lazy chunk loading, a deferred-hydration gate), a commit no
payload follows reached a useLocationState reader outside the boundary at
once: a shallow navigation (`revalidate: false` with state), a traversal or
revalidation that keeps every segment, an action's or a fill's server-set
state. When the boundary then hydrated it rendered against that state:
"Hydration failed because the server rendered text didn't match the
client" (#418 in a build), and React replaced its DOM.

Cause: NavigationProvider's treeless-commit subscription called
setLocationState without checking hydrationOpen. The reveal barrier
(2d26298) holds the entry's state until every boundary has hydrated; this
subscription was a second door around it. The update commits around the
dehydrated boundary, the same failure the barrier fixed.

Fix: while the hydration window is open the subscription leaves the state
in the controller. The barrier's effect already reads
eventController.getLocationState() after its commit, so readers get the
latest state, this commit's included, once every boundary has hydrated. The
window stays open: a treeless commit mounts nothing that must read the live
store, so ending it (as onUpdate does for a navigation) would only let the
boundary hydrate against the new state. A navigation committing later still
ends the window itself.

Red before green (without the NavigationProvider change):
- location-state-hydration.test.tsx "a treeless commit before/after reveal
  waits for a boundary suspended on the client" (4 cases): recoverable =
  ["Hydration failed ... + 5 - 0"]; with the fix, pass.
- render-route-hydrate.test.tsx "a back/forward while a boundary is
  suspended on the client waits for it to hydrate" (userland, renderRoute
  popstate; 2 cases): recoverableErrors = ["Hydration failed ... + 5 - 0"];
  with the fix, pass.
- e2e expectGatedBoundaryHydratesAfterShallowNavigation (router test-app and
  cloudflare-basic): dev "Hydration failed ... + shallow-value - empty",
  production "Minified React error #418"; the outside reader showed
  "shallow-value" before the chunk opened and the boundary's node was
  replaced. With the fix both gated-hydration cases pass 3/3 repeats in each
  app and mode.
…state; execution-model wording

Review of ba7b4fb: the CHANGELOG says a deferred-hydration gate that stays
shut holds state from a shallow navigation or a state-only action with no
upper bound while the URL already shows the new entry, as for the entry's
own state. execution-model.md: name what a treeless commit does not do,
and drop a loose pointer to the barrier-lane bullet.
@pkg-pr-new

pkg-pr-new Bot commented Oct 12, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@rangojs/router@1116

commit: d745519

…ydration

# Conflicts:
#	packages/rangojs-router/CHANGELOG.md
@ivogt
ivogt merged commit 75cbb8f into main Oct 12, 2026
59 checks passed
@ivogt
ivogt deleted the fix/treeless-commit-hydration branch October 12, 2026 04:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant