From 7edb8b3451bbed9be43934d23c5b19cccf3da832 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Wed, 12 Aug 2026 18:33:34 +0200 Subject: [PATCH 1/8] fix(react-router): avoid suspense above root documents --- .changeset/fresh-ducks-hydrate.md | 5 ++ packages/react-router/src/Match.tsx | 24 ++++++++- .../issue-7986-retained-pending.test.tsx | 2 +- .../issue-8053-root-document-pending.test.tsx | 52 +++++++++++++++++++ .../tests/root-pending-min.test.tsx | 32 ++++++------ 5 files changed, 97 insertions(+), 18 deletions(-) create mode 100644 .changeset/fresh-ducks-hydrate.md create mode 100644 packages/react-router/tests/issue-8053-root-document-pending.test.tsx diff --git a/.changeset/fresh-ducks-hydrate.md b/.changeset/fresh-ducks-hydrate.md new file mode 100644 index 00000000000..27c897932da --- /dev/null +++ b/.changeset/fresh-ducks-hydrate.md @@ -0,0 +1,5 @@ +--- +'@tanstack/react-router': patch +--- + +Avoid wrapping root components that may render the HTML document in a Suspense boundary during SSR and hydration. diff --git a/packages/react-router/src/Match.tsx b/packages/react-router/src/Match.tsx index 922c0aa5880..654986a559b 100644 --- a/packages/react-router/src/Match.tsx +++ b/packages/react-router/src/Match.tsx @@ -48,6 +48,15 @@ const outletMatchSelectionEqual = ( b: OutletMatchSelection, ) => a[0] === b[0] && a[1] === b[1] +function canWrapRouteInSuspense(route: AnyRoute, resolvedNoSsr: boolean) { + return ( + !route.isRoot || + !!(route.options as RootRouteOptions).shellComponent || + !!route.options.wrapInSuspense || + resolvedNoSsr + ) +} + export const Match = React.memo(function MatchImpl({ routeId, }: { @@ -89,10 +98,14 @@ function MatchView({ : route.options.notFoundComponent const resolvedNoSsr = match.ssr === false || match.ssr === 'data-only' + // A root component may render the document itself. Only place its Suspense + // boundary inside an explicit shell, unless the route forcefully opts in. + const canWrapInSuspense = canWrapRouteInSuspense(route, resolvedNoSsr) const ResolvedSuspenseBoundary = + canWrapInSuspense && (route.options.wrapInSuspense ?? - pendingElement ?? - ((route.options.errorComponent as any)?.preload || resolvedNoSsr)) + pendingElement ?? + ((route.options.errorComponent as any)?.preload || resolvedNoSsr)) ? React.Suspense : SafeFragment @@ -198,6 +211,13 @@ export const MatchInner = React.memo(function MatchInnerImpl({ }, [key, route.options.component, router.options.defaultComponent]) if (match.status === 'pending') { + const resolvedNoSsr = match.ssr === false || match.ssr === 'data-only' + if (!canWrapRouteInSuspense(route, resolvedNoSsr)) { + // Replacing an SSR document root with pending UI would remove . + // Hydrated matches retain their prior data, so keep rendering it while + // pure CSR roots can safely render the pending element directly. + return router.ssr ? out : renderPending(router, route) + } if (router._tx) { throw router._tx[5] } diff --git a/packages/react-router/tests/issue-7986-retained-pending.test.tsx b/packages/react-router/tests/issue-7986-retained-pending.test.tsx index 1d2dfdf5c8b..1d3e0556a17 100644 --- a/packages/react-router/tests/issue-7986-retained-pending.test.tsx +++ b/packages/react-router/tests/issue-7986-retained-pending.test.tsx @@ -480,7 +480,7 @@ test('a global not-found destination does not retain the mounted root success', }) expect(await screen.findByTestId('pending')).toBeVisible() - expect(screen.getByTestId('content')).not.toBeVisible() + expect(screen.queryByTestId('content')).not.toBeInTheDocument() await act(async () => { missingLoader.resolve() diff --git a/packages/react-router/tests/issue-8053-root-document-pending.test.tsx b/packages/react-router/tests/issue-8053-root-document-pending.test.tsx new file mode 100644 index 00000000000..54ff71bdcaf --- /dev/null +++ b/packages/react-router/tests/issue-8053-root-document-pending.test.tsx @@ -0,0 +1,52 @@ +import { expect, test } from 'vitest' +import { Outlet, createRootRoute, createRoute, createRouter } from '../src' +import { + RouterServer, + createRequestHandler, + renderRouterToStream, +} from '../src/ssr/server' + +// https://github.com/TanStack/router/issues/8053 +test('a root pendingComponent does not wrap the SSR document in Suspense', async () => { + const rootRoute = createRootRoute({ + pendingComponent: () => null, + component: () => ( + + + Root document + + + + + + ), + }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () =>
Root content
, + }) + const handler = createRequestHandler({ + request: new Request('http://localhost/'), + createRouter: () => + createRouter({ + routeTree: rootRoute.addChildren([indexRoute]), + isServer: true, + }), + }) + + const response = await handler(({ request, router, responseHeaders }) => + renderRouterToStream({ + request, + router, + responseHeaders, + children: , + }), + ) + const html = await response.text() + const serverDocument = new DOMParser().parseFromString(html, 'text/html') + + expect(response.status).toBe(200) + expect(serverDocument.body.textContent).toContain('Root content') + expect(html).not.toContain('') +}) diff --git a/packages/react-router/tests/root-pending-min.test.tsx b/packages/react-router/tests/root-pending-min.test.tsx index 0216ae4fec2..ea744e82a70 100644 --- a/packages/react-router/tests/root-pending-min.test.tsx +++ b/packages/react-router/tests/root-pending-min.test.tsx @@ -84,8 +84,8 @@ test('a post-hydration root reload keeps its fallback through pendingMinMs', asy }) expect(rootLoader).toHaveBeenCalledTimes(1) - expect(screen.getByTestId('root-pending')).toBeInTheDocument() - expect(screen.getByTestId('root-content')).not.toBeVisible() + expect(screen.queryByTestId('root-pending')).not.toBeInTheDocument() + expect(screen.getByTestId('root-content')).toHaveTextContent('Generation 1') await act(async () => { reloadGate.resolve() @@ -95,7 +95,7 @@ test('a post-hydration root reload keeps its fallback through pendingMinMs', asy await act(async () => { await vi.advanceTimersByTimeAsync(99) }) - expect(screen.getByTestId('root-pending')).toBeInTheDocument() + expect(screen.queryByTestId('root-pending')).not.toBeInTheDocument() await act(async () => { await vi.advanceTimersByTimeAsync(1) @@ -105,7 +105,7 @@ test('a post-hydration root reload keeps its fallback through pendingMinMs', asy expect(screen.getByTestId('root-content')).toHaveTextContent('Generation 2') }) -test('root route hydration preserves component state across its Suspense boundary', async () => { +test('root route hydration without a pending boundary preserves component state', async () => { const mounts = vi.fn() const unmounts = vi.fn() const initializers = vi.fn(() => 'preserved') @@ -127,14 +127,13 @@ test('root route hydration preserves component state across its Suspense boundar }) await router.load() - // Model the server render and the client router produced by hydrate(). The - // outer root boundary is intentionally absent from both trees, while the - // route's own pending boundary is present in both. + // Model the server render and the client router produced by hydrate(). A root + // without a separate shell stays unwrapped on both sides of hydration. router.ssr = { manifest: { routes: {} } } router.isServer = true const html = renderToString() router.isServer = false - expect(html).toContain('') + expect(html).not.toContain('') expect(html).toContain('preserved') const container = document.createElement('div') @@ -153,19 +152,23 @@ test('root route hydration preserves component state across its Suspense boundar }) expect(container).toHaveTextContent('preserved') - // One initializer belongs to the server render and one to client - // hydration. The stable boundary must preserve that hydrated client - // instance instead of creating a third one. + // One initializer belongs to the server render and one to client hydration. expect(initializers).toHaveBeenCalledTimes(2) expect(mounts).toHaveBeenCalledTimes(1) expect(unmounts).not.toHaveBeenCalled() expect(consoleError).not.toHaveBeenCalled() }) -test('server rendering uses the root pending boundary for route component suspension', async () => { +test('server rendering uses the root pending boundary inside its document shell', async () => { const gate = createControlledPromise() const rootRoute = createRootRoute({ pendingComponent: () =>
Server root pending
, + shellComponent: ({ children }) => ( + + + {children} + + ), component: () => { throw gate }, @@ -179,8 +182,7 @@ test('server rendering uses the root pending boundary for route component suspen const html = renderToString() - // renderToString cannot wait for Suspense, but the root route's stable - // boundary contains the suspension and emits its fallback. Streaming SSR - // can wait for the same boundary instead. + // renderToString cannot wait for Suspense, but the boundary inside the root + // shell contains the suspension and emits its fallback. expect(html).toContain('Server root pending') }) From 794368ee1bab3b1e78e3ae722751ce705c65114f Mon Sep 17 00:00:00 2001 From: Sheraff Date: Wed, 12 Aug 2026 19:37:36 +0200 Subject: [PATCH 2/8] test(react-start): cover root document hydration --- e2e/react-start/dev-ssr-styles/package.json | 3 +- .../dev-ssr-styles/src/routes/__root.tsx | 12 ++++ ...issue-8053-root-document-hydration.spec.ts | 62 +++++++++++++++++++ 3 files changed, 76 insertions(+), 1 deletion(-) create mode 100644 e2e/react-start/dev-ssr-styles/tests/issue-8053-root-document-hydration.spec.ts diff --git a/e2e/react-start/dev-ssr-styles/package.json b/e2e/react-start/dev-ssr-styles/package.json index c9c14943d44..a931c358733 100644 --- a/e2e/react-start/dev-ssr-styles/package.json +++ b/e2e/react-start/dev-ssr-styles/package.json @@ -25,9 +25,10 @@ "test:e2e:dev:bundled-dev:nitro": "MODE=dev VITE_USE_NITRO=true E2E_VITE_BUNDLED_DEV=true playwright test --project=chromium", "test:e2e:dev:disabled:bundled-dev:nitro": "MODE=dev SSR_STYLES=disabled VITE_USE_NITRO=true E2E_VITE_BUNDLED_DEV=true playwright test --project=chromium", "test:e2e:dev:custom-basepath:bundled-dev:nitro": "MODE=dev SSR_STYLES=custom-basepath VITE_USE_NITRO=true E2E_VITE_BUNDLED_DEV=true playwright test --project=chromium", + "test:e2e:prod": "MODE=prod playwright test tests/issue-8053-root-document-hydration.spec.ts --project=chromium", "test:e2e:bundled-dev": "pnpm run test:e2e:dev:bundled-dev && pnpm run test:e2e:dev:disabled:bundled-dev && pnpm run test:e2e:dev:custom-basepath:bundled-dev", "test:e2e:nitro:bundled-dev": "pnpm run test:e2e:dev:bundled-dev:nitro && pnpm run test:e2e:dev:disabled:bundled-dev:nitro && pnpm run test:e2e:dev:custom-basepath:bundled-dev:nitro", - "test:e2e": "rm -rf port*.txt; pnpm run test:e2e:dev && pnpm run test:e2e:dev:disabled && pnpm run test:e2e:dev:custom-basepath && pnpm run test:e2e:bundled-dev", + "test:e2e": "rm -rf port*.txt; pnpm run test:e2e:dev && pnpm run test:e2e:dev:disabled && pnpm run test:e2e:dev:custom-basepath && pnpm run test:e2e:bundled-dev && pnpm run test:e2e:prod", "test:e2e:nitro": "rm -rf port*.txt; pnpm run test:e2e:dev:nitro && pnpm run test:e2e:dev:disabled:nitro && pnpm run test:e2e:dev:custom-basepath:nitro && pnpm run test:e2e:nitro:bundled-dev" }, "dependencies": { diff --git a/e2e/react-start/dev-ssr-styles/src/routes/__root.tsx b/e2e/react-start/dev-ssr-styles/src/routes/__root.tsx index 0265c772e9e..a539dc24d77 100644 --- a/e2e/react-start/dev-ssr-styles/src/routes/__root.tsx +++ b/e2e/react-start/dev-ssr-styles/src/routes/__root.tsx @@ -4,6 +4,7 @@ import { Scripts, createRootRoute, } from '@tanstack/react-router' +import { useEffect } from 'react' import '~/styles/app.css' export const Route = createRootRoute({ @@ -13,16 +14,27 @@ export const Route = createRootRoute({ { name: 'viewport', content: 'width=device-width, initial-scale=1' }, ], }), + pendingComponent: () => null, component: RootComponent, }) function RootComponent() { + useEffect(() => { + document.documentElement.dataset.hydrated = 'true' + }, []) + return ( +
+