Skip to content

SPA mode: hydrating the prerendered shell throws React #418 at any address whose route content is available on the first pass #8473

Description

@christian-apollo

Which project does this relate to?

Start

Describe the bug

In SPA mode, the prerendered shell throws Minified React error #418 ("Hydration failed because the server rendered HTML didn't match the client") whenever the browser can render route content on its first pass. React then discards the server HTML and re-renders the whole document.

The shell leaves the route area empty inside a resolved suspense boundary:

<div id="app"><!--$--><!--$--><!--/$--><!--/$--></div>

Hydration therefore only lines up when the client's first pass also renders nothing, which happens by accident whenever the matched route's chunk has not loaded yet. It does not line up when a component is already available synchronously, and two ordinary cases make it available:

  • an address that matches no route - notFoundComponent is declared on the root route, so it sits in the main bundle and renders immediately;
  • the address the shell was prerendered for - its route chunk is in the shell's modulepreload list.

This only shows up in the built output served the way a static host serves a SPA (unknown addresses answered with the shell). vite dev never shows it, because it renders a shell per request. vite preview never shows it either, because it answers an unknown address with a 404 document instead of the shell.

Your Example Website or App

This repository's own e2e/react-start/spa-mode app reproduces it, no changes needed.

Steps to Reproduce the Bug or Issue

Using this repo at ac223be:

pnpm install
pnpm nx run @tanstack/react-start:build
pnpm nx run @tanstack/react-router-devtools:build
cd e2e/react-start/spa-mode && pnpm exec vite build

Then serve dist/client the way a static host serves a SPA: an existing file wins, a directory's index.html wins, anything else falls back to /index.html with a 200 (nginx try_files $uri $uri/index.html /index.html). Open each address and watch the console:

Address Served Console
/ the shell React #418
/posts/1 its own prerendered page clean
/unknown the shell (SPA fallback) React #418

npx serve dist/client, which package.json currently uses for the e2e run, answers /unknown with its own 404 page, so the suite never loads the shell at an address it was not prerendered for. / is loaded, and it throws, but nothing in the suite listens for uncaught errors.

A minimal app outside this repo, on the published @tanstack/react-router@1.170.36 / @tanstack/react-start@1.168.53 with React 19.3.0, behaves the same:

vite.config.ts

import {defineConfig} from 'vite'
import {tanstackStart} from '@tanstack/react-start/plugin/vite'
import viteReact from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [
    tanstackStart({spa: {enabled: true, prerender: {outputPath: '/index.html'}}}),
    viteReact(),
  ],
})

src/routes/__root.tsx

import {HeadContent, Outlet, Scripts, createRootRoute} from '@tanstack/react-router'
import type {ReactNode} from 'react'

export const Route = createRootRoute({
  ssr: false,
  shellComponent: RootDocument,
  component: RootComponent,
  notFoundComponent: NotFound,
})

function RootDocument({children}: {children: ReactNode}) {
  return (
    <html lang="en">
      <head>
        <HeadContent />
      </head>
      <body>
        <div id="app">{children}</div>
        <Scripts />
      </body>
    </html>
  )
}

function RootComponent() {
  return <Outlet />
}

function NotFound() {
  return <h1>Not found</h1>
}

with src/routes/index.tsx and src/routes/about.tsx rendering an <h1>. Built and served with the same fallback rule: / throws #418 (its chunk is preloaded by the shell), /no-such-page throws #418 (the not-found component is in the main bundle), /about is clean (its chunk has not loaded on the first pass). Replacing notFoundComponent with a splat route src/routes/$.tsx that renders the same <h1> makes /no-such-page clean, because the page then arrives in its own chunk like any other route.

Expected behavior

Hydrating the shell at any address is clean, and the client fills the empty route area as an ordinary update.

Screenshots or Videos

No response

Platform

  • OS: macOS 15
  • Browser: Chromium (Playwright 1.5x)
  • Version: @tanstack/react-router 1.170.36 and this repo at ac223be; React 19.3.0 / 19.2.3; Vite 8

Additional context

What I could establish while tracking this down, in case it saves someone time:

  • It is not the not-found page's content. With notFoundComponent stubbed to return null the error still fires, so what differs is the shape of the tree, not what is drawn in it.
  • Deferring that component does not help either: neither lazyRouteComponent(() => import('./NotFound')) nor wrapping it in the router's own ClientOnly changes anything.
  • In shell mode resolveSsr (packages/router-core/src/load-server.ts) returns route.id === rootRouteId, so the root match is dehydrated with ssr: true and it is the only match dehydrated (packages/router-core/src/ssr/ssr-server.ts). On the client nothing then marks the subtree below the root as "not rendered on the server", so MatchView takes the plain path rather than the ClientOnly path that produced the empty HTML.
  • I tried carrying an isShell flag in the dehydrated payload and setting ssr: false on the client for the matches below the dehydrated prefix. It did not fix it, which I read as the mismatch being the boundary structure itself rather than the ssr flag: the shell renders no child match at all (only the root is in router.stores.matches, so the root's <Outlet /> returns null), while the hydrating client renders <Suspense> plus a child Match. I did not take that further because the hydration path is intricate enough that a guess seemed likely to cost you more time than it saves.

Workaround for anyone hitting the not-found half of this: a catch-all route (src/routes/$.tsx) rendering the same page, so an unknown address matches an ordinary code-split route.

I have a PR adding the missing e2e coverage for this (serving the built app with a SPA fallback, and asserting nothing uncaught on load) that I can link once it is up.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions