Repository navigation
fix(router): useLocationState hydrates as undefined inside Suspense (#992) - #1017
Merged
Merged
Conversation
commit: |
11 tasks done
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
force-pushed
the
fix/992-location-state-late-hydration
branch
from
October 3, 2026 13:08
456ed18 to
fcc44a0
Compare
2 tasks done
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 #992.
Problem
useLocationStatereadshistory.stateon the first client render once<html>hasdata-hydrated. The root sets that attribute from an effect after it hydrates. A reader inside a<Suspense>boundary often hydrates later. By thendata-hydratedis set, so the initializer renders the stored value while the server renderedundefined. 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 inhistory.statefor that reload.Before / after
Before, the server renders
0, the boundary hydrates3, and React reports "Hydration failed because the server rendered text didn't match the client". After, the hydrating render is0, and the reader updates to3on 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:
The shopper picks
shoes, which navigates withstate: [Filters({ category: "shoes" })]. They reload. The server still rendersall. The bar then showsshoeswithout a hydration mismatch, including under StrictMode.Semantics
data-hydratedundefined, then the stored valuedata-hydratedalready setundefined, then the stored value{ flash: true })undefinedpopstate__rsc_locationstateuseLocationState()with no definitionhistory.state.stateCode-change summary
useLocationStateinpackages/rangojs-router/src/browser/react/location-state.tsnow reads throughuseSyncExternalStore(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.createLocationStateoptions are unchanged.location-state-shared.tsis unchanged.Test plan
packages/rangojs-router/src/testing/__tests__/location-state-hydration.test.tsx(new, 8 tests). Pins: hydrating persistent and flash readers stayundefinedthen show the stored value, with and without StrictMode; a non-hydrating mount reads on the first render;popstateand__rsc_locationstate; plainuseLocationState()late-hydrates fromhistory.state.state. Before the hook change, the persistent, flash, and plain hydration tests failed because the first render was the stored value.renderRoutelocation-state tests (3), mini (4), and cloudflare-basic location-state (7) passed after the change.pnpm run typecheckpnpm run test:unit:allpnpm run lintpnpm run formathydrateRootin happy-dom whiledata-hydratedis 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
renderRouteRebased 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): auseLocationStatereader inside a<Suspense>boundary whose server content is held until the test releases it (GET <page>/:gate/release, 15 s safety timeout). The shared bodyexpectLateSuspenseReaderHydratesClean(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 withHydration failed because the server rendered text didn't match the client, production withMinified React error #418. Green with the fix: routerlocation-statefile 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
hydrateRootwith hand-written server HTML becauserenderRoutemounts withcreateRoot. The repo rule is to extend the primitive when it cannot reach a feature, sorenderRoute(from@rangojs/router/testing/dom) gains a hydration mode:hydratehydrate: truecreateRoot(unchanged)renderToStringof the same element, thenhydrateRootRenderRouteResult(unchanged)serverHtml,recoverableErrorswindow/documentduring the server passdata-hydratedon<html>recoverableErrors@testing-library/react< 16.2.0Limits, also in the docs:
history,localStorageandnavigatorstay 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 deliberatetypeof windowmismatch is reported; the #992 contract through the primitive with a real late<Suspense>boundary, StrictMode on and off (red onmain's hook:Hydration failed because the server rendered text didn't match the client); the root marker; suspended content; an unguardeddocumentread 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## UnreleasedCHANGELOG 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.tsdrift.Notes:
@testing-library/reactcheck inspectsrender's source foronRecoverableError. Reviewers preferred a behavioral probe or a raised peer range; it is kept because an old version silently reporting no hydration errors is worse than a throw, and the peer range is unchanged.