Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions packages/react-router/src/Match.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> })
.__beforeLoadContext !== undefined)
) {
// Replacing an SSR document root with pending UI would remove <html>.
// 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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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 <html>), 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<string> = []
let releaseBeforeLoad!: () => void
const beforeLoadGate = new Promise<void>((resolve) => {
releaseBeforeLoad = resolve
})
const rootRoute = createRootRoute({
beforeLoad: async () => {
await beforeLoadGate
return { locale: 'en' }
},
component: function Root() {
observed.push(String(rootRoute.useRouteContext().locale))
return <Outlet />
},
})
const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/',
component: () => <div>home</div>,
})
const router = createRouter({
history: createMemoryHistory({ initialEntries: ['/'] }),
routeTree: rootRoute.addChildren([indexRoute]),
defaultPendingComponent: () => <div>pending</div>,
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(<RouterProvider router={router} />)
releaseBeforeLoad()
await hydration
await screen.findByText('home')

expect(observed.length).toBeGreaterThan(0)
expect([...new Set(observed)]).toEqual(['en'])
})
})
2 changes: 2 additions & 0 deletions packages/router-core/src/load-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -452,6 +452,7 @@ async function contextualize(
releaseFlight(router, match)
return [index, outcome]
}
match.__beforeLoadContext = result ?? {}
match.context = {
...context,
...result,
Expand Down Expand Up @@ -2286,6 +2287,7 @@ export async function hydrate(router: AnyRouter): Promise<void> {
}
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
Expand Down