From fba7a18f52b5c96da97437200c6910ebee830732 Mon Sep 17 00:00:00 2001 From: Andreas Turku Date: Thu, 20 Aug 2026 13:32:54 +0200 Subject: [PATCH] fix(react-router): render the pending document root only when its beforeLoad context exists The document-root pending exception keeps rendering the real component because pending UI would remove and hydrated matches retain their prior data. An uncommitted hydrated root has no prior data: hydrate() only merges the dehydrated beforeLoad context (b) for committed matches, so an id-mismatched or stale root renders with every beforeLoad-provided context key missing (#8115's production symptom, still present with #8116). Track beforeLoad settlement on the client (__beforeLoadContext, mirroring load-server) and via the dehydrated b for committed hydration, and gate the document-root exception on it. --- packages/react-router/src/Match.tsx | 12 +- ...sue-8115-hydration-context-window.test.tsx | 111 ++++++++++++++++++ packages/router-core/src/load-client.ts | 2 + 3 files changed, 123 insertions(+), 2 deletions(-) create mode 100644 packages/react-router/tests/issue-8115-hydration-context-window.test.tsx diff --git a/packages/react-router/src/Match.tsx b/packages/react-router/src/Match.tsx index a0bfa89d55..188f12ae4b 100644 --- a/packages/react-router/src/Match.tsx +++ b/packages/react-router/src/Match.tsx @@ -213,9 +213,17 @@ export const MatchInner = React.memo(function MatchInnerImpl({ }, [key, route.options.component, router.options.defaultComponent]) if (match.status === 'pending') { - if (router.ssr && !canWrapInSuspense(router, route, match.ssr)) { + if ( + router.ssr && + !canWrapInSuspense(router, route, match.ssr) && + (!route.options.beforeLoad || + (match as { __beforeLoadContext?: Record }) + .__beforeLoadContext !== undefined) + ) { // Replacing an SSR document root with pending UI would remove . - // Hydrated matches retain their prior data, so keep rendering it. + // Hydrated matches retain their prior data, so keep rendering it — but + // only when the data is actually there: a root whose beforeLoad has not + // contributed yet would render with its context keys missing (#8115). return out } if (router._tx) { diff --git a/packages/react-router/tests/issue-8115-hydration-context-window.test.tsx b/packages/react-router/tests/issue-8115-hydration-context-window.test.tsx new file mode 100644 index 0000000000..0c68962a9e --- /dev/null +++ b/packages/react-router/tests/issue-8115-hydration-context-window.test.tsx @@ -0,0 +1,111 @@ +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, test, vi } from 'vitest' +import { hydrate } from '@tanstack/router-core/ssr/client' +import { dehydrateSsrMatchId } from '../../router-core/src/ssr/ssr-match-id' +import { + Outlet, + RouterProvider, + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, +} from '../src' +import type { AnyRouteMatch } from '@tanstack/router-core' +import type { TsrSsrGlobal } from '@tanstack/router-core/ssr/client' + +function bootstrap( + matches: Array<{ + id: string + status: AnyRouteMatch['status'] + ssr: AnyRouteMatch['ssr'] + data?: unknown + beforeLoadContext?: unknown + }>, +): void { + window.$_TSR = { + router: { + manifest: undefined, + matches: matches.map(({ id, status, ssr, data, beforeLoadContext }) => ({ + i: dehydrateSsrMatchId(id), + l: data, + s: status, + ssr, + u: Date.now(), + ...(beforeLoadContext !== undefined ? { b: beforeLoadContext } : {}), + })), + }, + h: vi.fn(), + e: vi.fn(), + c: vi.fn(), + p: vi.fn(), + buffer: [], + } as TsrSsrGlobal +} + +afterEach(() => { + cleanup() + delete window.$_TSR +}) + +// The document root cannot be replaced by pending UI (it holds ), so a +// pending root renders its real component (Match.tsx). Hydration only merges a +// dehydrated match's beforeLoad context (`b`) for committed matches — an +// uncommitted root (id mismatch between server and client, e.g. a URL rewrite +// disagreement) therefore renders its real component with every +// beforeLoad-provided context key missing. Production impact in #8115. +describe('hydration beforeLoad context window', () => { + test('an uncommitted hydrated document root never renders with its beforeLoad context stripped', async () => { + const observed: Array = [] + let releaseBeforeLoad!: () => void + const beforeLoadGate = new Promise((resolve) => { + releaseBeforeLoad = resolve + }) + const rootRoute = createRootRoute({ + beforeLoad: async () => { + await beforeLoadGate + return { locale: 'en' } + }, + component: function Root() { + observed.push(String(rootRoute.useRouteContext().locale)) + return + }, + }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>
home
, + }) + const router = createRouter({ + history: createMemoryHistory({ initialEntries: ['/'] }), + routeTree: rootRoute.addChildren([indexRoute]), + defaultPendingComponent: () =>
pending
, + defaultPendingMs: 15, + defaultPendingMinMs: 0, + }) + const matches = router.matchRoutes(router.state.location) + bootstrap([ + // The server dehydrated a different root match id than the client + // rebuilt (a rewrite/serialization disagreement), so commitment stops at + // index 0 and the root's `b` is never merged. + { + id: `${matches[0]!.id}__server-skew`, + status: 'success', + ssr: true, + beforeLoadContext: { locale: 'en' }, + }, + { id: matches[1]!.id, status: 'success', ssr: true }, + ]) + + // Render while hydration is still in flight (RouterProvider is public API + // and does not require hydrate() to settle first). The document root cannot + // be replaced by pending UI, so it renders its real component. + const hydration = hydrate(router) + render() + releaseBeforeLoad() + await hydration + await screen.findByText('home') + + expect(observed.length).toBeGreaterThan(0) + expect([...new Set(observed)]).toEqual(['en']) + }) +}) diff --git a/packages/router-core/src/load-client.ts b/packages/router-core/src/load-client.ts index 71c97a63ab..0b97c8ff9c 100644 --- a/packages/router-core/src/load-client.ts +++ b/packages/router-core/src/load-client.ts @@ -452,6 +452,7 @@ async function contextualize( releaseFlight(router, match) return [index, outcome] } + match.__beforeLoadContext = result ?? {} match.context = { ...context, ...result, @@ -2286,6 +2287,7 @@ export async function hydrate(router: AnyRouter): Promise { } candidate.status = dehydrated.s candidate.ssr = dehydrated.ssr + candidate.__beforeLoadContext = dehydrated.b ?? {} route.options.ssr = candidate.ssr candidate.updatedAt = dehydrated.u candidate.error = dehydrated.e