Repository navigation
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
Conversation
…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.
commit: |
…ydration # Conflicts: # packages/rangojs-router/CHANGELOG.md
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.
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 loggedHydration failed because the server rendered text didn't match the clientin dev and minified error #418 in production. A boundary is in that state when the client suspends it after the document has finished streaming: itsReact.lazychunk is still loading, or a deferred-hydration gate such as a hydrate-when-visible island holds it. It only happened when auseLocationStatereader rendered next to that boundary, outside it, had state.Before / after
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).undefineduntil every boundary of the document has hydrated, and then the entry's state, which matches whatuseLocationStatealready promised during hydration.Consumer example
Nothing changes in consumer code.
Semantics
$?)awaitDocumentRevealedwaits for itTrade-off: in the fourth row, a deferred-hydration gate with no upper bound (an off-screen hydrate-when-visible island) keeps location state
undefineduntil 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):HydrationBarrierContext, so React holds that commit until every boundary has hydrated.startTransitioninside the same callback lands in the same lane and fails the same tests.docs/internal/execution-model.md("The reveal barrier is alone in its lane").context.ts,feature-map.mdandhandle-push-ownership.mdpoint to it.Test plan
render-route-hydrate.test.tsxthroughrenderRoute: "a boundary suspended on the client when the document is revealed hydrates as rendered…", StrictMode and non-StrictMode, including auseHandlereader with late pushes. Red on base:recoverableErrors = ["Hydration failed …"].location-state-hydration.test.tsxon the realinitBrowserAppdocument. Red on base.expectGatedBoundaryHydratesWithOutsideReader(shared-e2e), dev and(production), router test-app and cloudflare-basic:startTransition; without the new guard, which is defensive and kept.suspense-cases,held-boundary,location-stateandhydration-detectionin 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:
router.push(url, { revalidate: false, state })or<Link revalidate={false} 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).render-route-hydrate.test.tsx"a back/forward while a boundary is suspended on the client waits for it to hydrate" (2 cases).expectGatedBoundaryHydratesAfterShallowNavigation, dev and(production), both apps, 3/3 repeats. All red before the fix.Notes
commitLocationState(x, true)), not by its own e2e.[email protected]catch it on upgrade.