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
: SafeFragment
The 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 (or npm install && npx vite dev standalone).
- Open the page with the browser console open — no error, and
view-source shows <body><div>….
- Add
pendingComponent: () => null to 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-source shows <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, in clearHydrationBoundary order — $, 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.
Which project does this relate to?
Router
Describe the bug
Since 1.170.19, setting
pendingComponenton 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 fromMatchView:The removed conjunct is what kept a mere
pendingComponentfrom wrapping the root — previously the root only got a boundary if it opted in explicitly viawrapInSuspense, 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>fromcomponent(which is the shape used byexamples/react/start-bareitself).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: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>.canHydrateHydrationBoundaryonly scans forward over siblings, so it can never reach the marker down in<body>; it returnsnullandthrowOnHydrationMismatchfires on the Suspense fiber. React's own diff shows exactly this: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-dom19.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, sonpm installworks 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).view-sourceshows<body><div>….pendingComponent: () => nullto the root route as above.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):pendingComponent: 13, inclearHydrationBoundaryorder —$,html,head,body,div,$,main,/$,div,script,/$. That's the whole document.Expected behavior
A
pendingComponenton the root route shouldn't put a Suspense boundary above<html>. Either restore the!route.isRootguard so the root needs an explicitwrapInSuspenseopt-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
pendingComponenton 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
Additional context
I bisected across every published release from 1.170.17 to 1.170.27 by checking the compiled
ResolvedSuspenseBoundaryexpression indist/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
pageerrorrather than aconsole.error, so it can hide behind anonRecoverableErrorhandler 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.