Skip to content

fix(router): useLocationState hydrates as undefined inside Suspense (#992) - #1017

Merged
ivogt merged 5 commits into
mainfrom
fix/992-location-state-late-hydration
Oct 3, 2026
Merged

ivogt merged 5 commits into
mainfrom
fix/992-location-state-late-hydration

Conversation

@ivogt

@ivogt ivogt commented Oct 1, 2026 •

Copy link
Copy Markdown
Collaborator

Closes #992.

Problem

useLocationState reads history.state on the first client render once <html> has data-hydrated. The root sets that attribute from an effect after it hydrates. A reader inside a <Suspense> boundary often hydrates later. By then data-hydrated is set, so the initializer renders the stored value while the server rendered undefined. React reports a hydration mismatch and client-renders the boundary.

The old deferred read covered readers that hydrate with the root. In an RSC app most per-page UI sits under <Suspense>, so it missed the common case. Seen with React 19.2. A long-lived history entry (soft navigation, then hard reload) is what puts a value in history.state for that reload.

Before / after

export const GridState = createLocationState<{ count: number }>();

export function Grid() {
  const state = useLocationState(GridState);
  return <p>{state?.count ?? 0}</p>;
}

// page
<Suspense fallback={<p>…</p>}>
  <Grid />
</Suspense>
router.push(url, { state: [GridState({ count: 3 })] });
// then hard-reload that history entry

Before, the server renders 0, the boundary hydrates 3, and React reports "Hydration failed because the server rendered text didn't match the client". After, the hydrating render is 0, and the reader updates to 3 on the next render. No API change. Callers do not adopt anything.

End-to-end consumer example

A filter grid whose selection lives in location state, rendered under the route's suspense boundary:

export const Filters = createLocationState<{ category: string }>();

"use client";
export function FilterBar() {
  const filters = useLocationState(Filters);
  return <p>{filters?.category ?? "all"}</p>;
}

// server page
<Suspense fallback={<p>Loading filters</p>}>
  <FilterBar />
</Suspense>

The shopper picks shoes, which navigates with state: [Filters({ category: "shoes" })]. They reload. The server still renders all. The bar then shows shoes without a hydration mismatch, including under StrictMode.

Semantics

Reader Before After
Hydrates with the root, before data-hydrated first render undefined, then the stored value same
Hydrates later, data-hydrated already set first render is the stored value; hydration mismatch first render undefined, then the stored value
Client mount that is not hydrating (already on the page) reads the stored value on the first render reads the stored value on the first render
Flash state ({ flash: true }) shown once, cleared from history after paint same. StrictMode still keeps the shown value; the clear runs before the second effect pass and must not paint undefined
popstate applies the destination entry, including an empty flash slot same
__rsc_locationstate updates persistent state; an empty flash slot does not wipe the shown value same
useLocationState() with no definition reads history.state.state same

Code-change summary

useLocationState in packages/rangojs-router/src/browser/react/location-state.ts now reads through useSyncExternalStore(subscribe, getSnapshot, () => undefined). React uses the server snapshot for whichever render is hydrating, wherever the component sits, then re-renders with the client snapshot. Flash's read-once value stays in a ref so the clear-after-paint effect cannot clobber it under StrictMode. createLocationState options are unchanged. location-state-shared.ts is unchanged.

Test plan

  • packages/rangojs-router/src/testing/__tests__/location-state-hydration.test.tsx (new, 8 tests). Pins: hydrating persistent and flash readers stay undefined then show the stored value, with and without StrictMode; a non-hydrating mount reads on the first render; popstate and __rsc_locationstate; plain useLocationState() late-hydrates from history.state.state. Before the hook change, the persistent, flash, and plain hydration tests failed because the first render was the stored value.
  • Existing renderRoute location-state tests (3), mini (4), and cloudflare-basic location-state (7) passed after the change.
  • pnpm run typecheck
  • pnpm run test:unit:all
  • pnpm run lint
  • pnpm run format
  • Dev and production e2e for location state. Not run. The new test hydrates with hydrateRoot in happy-dom while data-hydrated is already set. It is not a streamed Suspense boundary inside the RSC shell.

Notes

No migration. Stored history entries are unchanged. A reader still updates to the stored value immediately after hydration, so a reload can still flash the empty value for one paint. That is the same catch-up the root-hydration path already did.

Added 2026-10-03: e2e coverage and a hydration mode for renderRoute

Rebased onto main (0.20.0 plus #1016 and #1019). Three commits on top of the fix.

Dev and production e2e, both apps

New page in the router test-app (/location-state/late-suspense/:gate) and in cloudflare-basic (/location-state-late-suspense/:gate): a useLocationState reader inside a <Suspense> boundary whose server content is held until the test releases it (GET <page>/:gate/release, 15 s safety timeout). The shared body expectLateSuspenseReaderHydratesClean (tests/shared-e2e/src/index.ts) loads the page, waits for the root to hydrate, asserts the fallback is still visible, releases the boundary, writes state, reloads, repeats, and requires the stored value with no hydration error (guardHydrationErrors). "The boundary hydrates after the root" is therefore asserted, not left to a timer.

Red with main's hook in the router test-app: dev fails with Hydration failed because the server rendered text didn't match the client, production with Minified React error #418. Green with the fix: router location-state file dev 60 passed, production 29 passed; cloudflare-basic dev 15 passed, production 13 passed. The late-Suspense test was repeated 3 times per mode in the router app, 3/3 each.

renderRoute(routes, { hydrate: true })

The PR's hook test had to call hydrateRoot with hand-written server HTML because renderRoute mounts with createRoot. The repo rule is to extend the primitive when it cannot reach a feature, so renderRoute (from @rangojs/router/testing/dom) gains a hydration mode:

const { serverHtml, recoverableErrors, getByTestId } = await renderRoute(
  [{ path: "/grid", Component: Grid }],
  { hydrate: true, locationState: [[GridState, { count: 3 }]] },
);
expect(serverHtml).toContain(">0<"); // the server never sees history.state
expect(recoverableErrors).toEqual([]); // no hydration mismatch
expect(getByTestId("count").textContent).toBe("3");
Without hydrate With hydrate: true
Mount createRoot (unchanged) renderToString of the same element, then hydrateRoot
Result RenderRouteResult (unchanged) plus serverHtml, recoverableErrors
window / document during the server pass n/a removed, restored afterwards
data-hydrated on <html> not set (unchanged) set in an effect like the production root, removed on unmount
Content that suspends in the server pass n/a emitted as its fallback, client-rendered, reported in recoverableErrors
@testing-library/react < 16.2.0 works throws: those versions do not report hydration errors

Limits, also in the docs: history, localStorage and navigator stay defined in the server pass, so a hook reading them outside a server snapshot can pass here and still mismatch in real SSR; an attribute-only mismatch is not a recoverable error.

Tests (src/testing/__tests__/render-route-hydrate.test.tsx): server HTML and interactivity of a simple component; a deliberate typeof window mismatch is reported; the #992 contract through the primitive with a real late <Suspense> boundary, StrictMode on and off (red on main's hook: Hydration failed because the server rendered text didn't match the client); the root marker; suspended content; an unguarded document read rejects and restores the globals; an old RTL is refused.

Docs: skills/testing/client-components.md, skills/testing/SKILL.md, skills/testing/server-tree.md, docs/testing.md, docs/internal/feature-map.md, and two ## Unreleased CHANGELOG entries (the fix, and the new option).

Gate on the pushed head: pnpm run typecheck, pnpm run test:unit:all (router 7117, RSC 440), pnpm run lint, pnpm run format, check:e2e-bucketing, check:e2e-parity --strict, check:docs-api; no *.gen.ts drift.

Notes:

@pkg-pr-new

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

Copy link
Copy Markdown

Open in StackBlitz

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

commit: fcc44a0

ivogt added 5 commits October 3, 2026 15:45
A reader that hydrates after the root sets data-hydrated was reading
history.state during its own hydration, so React mismatched the
server's undefined. The hook now uses useSyncExternalStore with a
server snapshot of undefined, then updates to the stored value.

Closes #992.
useSyncExternalStore re-read history.state on any parent render, so
definition.write() updated the hook without popstate or
__rsc_locationstate.
renderRoute({ hydrate: true }) renders the same client element (providers,
seeds, RTL reactStrictMode) to HTML with react-dom/server renderToString, puts
it in the container and hydrates it through RTL's render({ hydrate: true })
instead of mounting with createRoot. The result adds serverHtml and
recoverableErrors (the messages React passes to onRecoverableError, where a
hydration mismatch arrives), typed as RenderRouteHydrateResult through an
overload. Without hydrate the call and its result are unchanged.

The server pass runs with window and document deleted from globalThis and
restored before hydration. With them left in place a typeof window branch takes
its client side on the "server" and hydrates clean; the pre-#992
useLocationState rendered the stored history value into the server HTML that
way and reported nothing.

Content that suspends in the synchronous server pass is emitted as its fallback
and client-rendered; React reports that boundary in recoverableErrors. A
Suspense boundary whose content renders synchronously hydrates in a later pass,
after the effects of the tree above it, inside the same awaited act.

@testing-library/react 16.0-16.1 drop onRecoverableError, so hydrate throws
there instead of returning an empty error list.

render-route-hydrate.test.tsx pins the mode and the #992 contract through the
primitive: with data-hydrated set, the hydrating reader stays undefined, no
recoverable error is reported, and it ends on the stored value. On main's hook
the same test reports "Hydration failed because the server rendered text didn't
match the client" (+3 / -0).
… e2e body (#992)

renderRoute({ hydrate: true }) now sets data-hydrated on <html> after
hydration, as production's root does from its effect
(browser/rsc-router.tsx), and removes it on unmount. A <Suspense> boundary
hydrates in a later pass than the tree above it, so a reader inside one is late
by structure and no test sets the attribute. The #992 regression test through
the primitive is that structural case, StrictMode on and off; on main's hook it
reports "Hydration failed because the server rendered text didn't match the
client".

The hydrate plumbing is plain locals with one return per mode, and the RTL
capability check and getConfig read live in the hydrate branch.
withoutBrowserGlobals is written for its two globals. The option's JSDoc states
the fidelity limit: a hook that reads a global that stays defined in the server
pass (history, localStorage, navigator) outside a server snapshot renders the
same in both passes and can still mismatch in real SSR.

E2E: the late-Suspense body moves to @shared/e2e
(expectLateSuspenseReaderHydratesClean) and uses guardHydrationErrors. Both
apps' fixtures hold the boundary's server content under a :gate until the test
releases it (GET <page>/:gate/release), after it has seen the root hydrate with
the fallback still on screen, so "the boundary hydrates after the root" is
asserted instead of left to a 500 ms timer. The held render polls a release
count with a 15 s safety timeout.

Also carries the CHANGELOG entries for #1017 and the comment update in
location-state-hydration.test.tsx.
@ivogt
ivogt force-pushed the fix/992-location-state-late-hydration branch from 456ed18 to fcc44a0 Compare October 3, 2026 13:08
@ivogt
ivogt merged commit 8f75b56 into main Oct 3, 2026
43 checks passed
@ivogt
ivogt deleted the fix/992-location-state-late-hydration branch October 3, 2026 13:54
@ivogt ivogt mentioned this pull request Oct 4, 2026
2 tasks done
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.

useLocationState: hydration mismatch when the reader hydrates inside a Suspense boundary

1 participant