Repository navigation
Root-route pendingComponent wraps <html> in an unhydratable Suspense since 1.170.19, discarding the whole SSR document #8053
Description
Which project does this relate to?
Router
Describe the bug
Since 1.170.19, setting pendingComponent on the root route wraps the whole document in a <Suspense> boundary that React cannot hydrate. React recovers by client-rendering the boundary — which is the entire document — so the SSR HTML is painted and then thrown away on every page load. The page still looks correct, which is why this is easy to miss.
45c4ad8d6 ("fix(router-core): lane match loader rewrite", #7805) removed the root-route guard from MatchView:
const ResolvedSuspenseBoundary =
- // If we're on the root route, allow forcefully wrapping in suspense
- (!route.isRoot || route.options.wrapInSuspense || resolvedNoSsr) &&
(route.options.wrapInSuspense ??
- PendingComponent ??
+ pendingElement ??
((route.options.errorComponent as any)?.preload || resolvedNoSsr))
? React.Suspense
: SafeFragmentThe removed conjunct is what kept a mere pendingComponent from wrapping the root — previously the root only got a boundary if it opted in explicitly via wrapInSuspense, or if SSR was off. That distinction looks deliberate given the comment, and dropping it changes behaviour for any app whose root route renders <html> from component (which is the shape used by examples/react/start-bare itself).
Why React can't hydrate it. When a Suspense boundary contains the <html>/<head>/<body> singletons, React's Fizz hoists them into the document preamble and writes the boundary's start marker inside <body>, followed by preamble-contribution comments:
<body><!--$--><!--html--><!--head--><!--body--><div>…
On the client the Suspense fiber sits above <html>, so when React reaches it the hydration cursor is still up in the preamble — it lands on a <script> in <head>. canHydrateHydrationBoundary only scans forward over siblings, so it can never reach the marker down in <body>; it returns null and throwOnHydrationMismatch fires on the Suspense fiber. React's own diff shows exactly this:
<MatchImpl routeId="__root__">
<MatchView match={{id:"__root__/", …}}>
<SafeFragment>
<Suspense fallback={<TSRPendingComponent>}>
- <script type="module" src="/@id/virtual:tanstack-start-dev-client-entry">
React then calls clearHydrationBoundary, which walks from the marker to the matching <!--/$--> removing everything — including releasing the <html>/<head>/<body> singletons via those preamble-contribution comments — and re-renders it all client-side.
Not React-version-specific: I checked the preamble markers are emitted identically by react-dom 19.2.6, 19.2.8 and a 19.3 canary, so this affects React 19.2 stable, not just canaries.
Complete minimal reproducer
Your own examples/react/start-bare, plus one line — no separate project needed. I ran it standalone (its deps are all published versions, so npm install works outside the monorepo) on @tanstack/react-router@1.170.27 / @tanstack/react-start@1.168.44 / react-dom@19.2.8 / vite@8.2.1:
// examples/react/start-bare/src/routes/__root.tsx
export const Route = createRootRoute({
head: () => ({
links: [{ rel: 'stylesheet', href: appCss }],
}),
component: RootComponent,
+ pendingComponent: () => null,
})Happy to publish this as a standalone git repo if you'd rather have a clone-and-run link — it's literally the above, so I didn't want to add a redundant repo to the pile.
Steps to Reproduce the Bug
cd examples/react/start-bare && pnpm dev(ornpm install && npx vite devstandalone).- Open the page with the browser console open — no error, and
view-sourceshows<body><div>…. - Add
pendingComponent: () => nullto the root route as above. - Reload. The console now shows an uncaught "Hydration failed because the server rendered HTML didn't match the client", and
view-sourceshows<body><!--$--><!--html--><!--head--><!--body-->….
To see the blast radius rather than just the error, count how much of the document React discards. Paste this before load (DevTools → Sources → snippet, or a Playwright addInitScript):
let removed = 0
new MutationObserver(rs => {
for (const r of rs) if (r.target === document.body) removed += r.removedNodes.length
}).observe(document, { childList: true, subtree: true })
setTimeout(() => console.log('body child nodes removed:', removed), 4000)- Unmodified: 2 (unrelated script cleanup).
- With
pendingComponent: 13, inclearHydrationBoundaryorder —$,html,head,body,div,$,main,/$,div,script,/$. That's the whole document.
Expected behavior
A pendingComponent on the root route shouldn't put a Suspense boundary above <html>. Either restore the !route.isRoot guard so the root needs an explicit wrapInSuspense opt-in, or — if the root genuinely should be wrappable now — keep the boundary below the document singletons so React can hydrate it.
Worth noting the current behaviour has no good workaround from userland other than "don't set pendingComponent on the root route", which is what we've done. The guard is still absent in 1.170.27, so upgrading doesn't help.
Screenshots or Videos
No response
Platform
- Router / Start Version: 1.170.27 / 1.168.44 (also reproduced on 1.170.23 / 1.168.40; not present on 1.170.18)
- OS: macOS 15 (Darwin 24.4.0, arm64)
- Browser: Chromium (HeadlessChrome via Playwright)
- Browser Version: 148.0.7778.96
- Bundler: vite
- Bundler Version: 8.2.1
Additional context
I bisected across every published release from 1.170.17 to 1.170.27 by checking the compiled ResolvedSuspenseBoundary expression in dist/esm/Match.js: the guard is present in 1.170.17 and 1.170.18, and absent from 1.170.19 onwards.
For anyone hitting this before it's fixed: the symptom is silent. The page renders correctly and, depending on your setup, the error may only appear as an uncaught pageerror rather than a console.error, so it can hide behind an onRecoverableError handler that doesn't log. The reliable tell is the SSR HTML — if the first bytes after <body> are <!--$--><!--html--><!--head--><!--body-->, your whole document is being re-rendered on the client.
Activity
Sheraff commentedon Aug 12, 2026
Could you give me a 2 sentence version of the issue please?
schiller-manuel commentedon Aug 12, 2026
please stop copy pasting your LLM output into here. just manually describe concisely what is the issue. i guess 2 sentences would be enough.
also, we require minimal reproducers:
please help us help you. next time issues with this "quality" will be closed.