Skip to content

beforeLoad redirect on an ssr: false route settles before hydration commits, causing React #418 #7947

Description

@timothytn

Which project does this relate to?

Start

Describe the bug

A route with ssr: false whose beforeLoad throws redirect() can complete that redirect before React commits hydration. React then hydrates the destination route's tree against the HTML the server rendered for the origin route, reports a hydration mismatch, and discards the server-rendered markup for the whole document.

This is not the SPA-fallback case in #6455 — the server renders the correct route's document here. The client leaves it before hydration finishes.

In hydrate() (packages/router-core/src/ssr/ssr-client.ts, read from @tanstack/router-core@1.171.13's dist/esm/ssr/ssr-client.js):

const isSpaMode = matches[matches.length - 1].id !== lastMatchId;
if (!matches.some((m) => m.ssr === false) && !isSpaMode) {
  return routeChunkPromise;                                  // clean path, early return
}
const loadPromise = Promise.resolve().then(() => router.load()).catch(...);
if (isSpaMode) {
  setMatchForcePending(match);
  match._displayPending = true;
  match._nonReactive.displayPendingPromise = loadPromise;     // coupled — SPA mode only
  loadPromise.then(() => { /* clears it */ });
}
return routeChunkPromise;                                     // NOT loadPromise

When the match set contains an ssr: false route and the app is not in SPA mode, loadPromise is created and then nothing references it. hydrate() returns routeChunkPromise, hydrateStart() awaits only that, and StartClient renders <Await promise={hydrationPromise}> — so React starts hydrating while router.load() is still resolving a redirect. In SPA mode the same promise is tied to what renders, via _displayPending / displayPendingPromise. The two branches are four lines apart.

What diverges is the <head>, which makes this easy to misdiagnose. In the reproduction both routes render nothing on the server (ssr: false, no pending component), so the bodies are byte-identical — and it still fires. Every route contributes its own <title>, <meta> and modulepreload links, so any two routes differ: /a emits 4 head tags, /b emits 6. That is an element mismatch, hence #418 with args[]=HTML rather than args[]=text. (Cross-checked: give the routes structurally different pendingComponents and the same bug reports args[]=text instead, because React reaches the text difference first.)

Note React's default onRecoverableError calls reportError(), so this arrives as an uncaught error, not a console.error — a page.on('console') listener alone misses it.

Complete minimal reproducer

https://github.com/timothytn/tanstack-start-beforeload-redirect-hydration

Steps to Reproduce the Bug or Issue

git clone https://github.com/timothytn/tanstack-start-beforeload-redirect-hydration
cd tanstack-start-beforeload-redirect-hydration
npm install
npx playwright install chromium   # only for the automated check
npm run build
npm run preview &                 # http://localhost:4321
npm run repro

Output:

/a  (redirects to /b)
  ended at:         http://localhost:4321/b
  hydration errors: 1
    ! uncaught: Minified React error #418; visit https://react.dev/errors/418?args[]=HTML&args[]= ...

/b  (control, no redirect)
  ended at:         http://localhost:4321/b
  hydration errors: 0

REPRODUCED: the redirected load does produce a hydration error; the direct load does not.

Or manually: npm run build && npm run preview, open http://localhost:4321/a, watch the console.

The repo is two routes: /a is ssr: false with beforeLoad: () => { throw redirect({ to: '/b' }) }, and /b is the destination with a head that differs. /b loaded directly is the control — same route, same ssr: false, same head, no redirect — and it comes back clean, so the redirect is the variable rather than the route or its metadata.

No delays, no CPU throttling, no timing tricks; it fires on every run, headless or headed. --cpu N throttles the renderer via CDP if you want to widen the margin, but it changes nothing.

Expected behavior

A beforeLoad redirect discovered during the initial load should not be able to land mid-hydration. Either the pending element stays rendered until the load (and any redirect it produces) settles — the coupling SPA mode already has — or hydration waits for a redirect-producing load, so the redirect becomes an ordinary client navigation.

Currently the server-rendered markup for the whole document is thrown away on every such load, which for an ssr: true route would mean losing the SSR it exists for.

Platform

  • Router / Start Version: @tanstack/react-router 1.170.16, @tanstack/router-core 1.171.13, @tanstack/react-start 1.168.32, @tanstack/start-client-core 1.170.14
  • OS: macOS (originally observed on Linux CI)
  • Browser: Chromium 133 (originally observed in Chromium, Firefox, WebKit and mobile WebKit)
  • Bundler: Vite 8.0.16
  • React: 19.2.5

Additional context

Found by a Playwright suite on a real app, where it was invisible locally and deterministic in CI — the redirect has to beat the commit, and on an idle dev machine with code splitting it usually doesn't. Measured there: dev machine redirect ~751ms vs commit ~429ms (no mismatch); CI runner with 3 parallel workers, redirect ~1153ms vs commit ~1597ms (mismatch on all four browser projects, both the initial attempt and the retry). The reproduction above removes that variance by keeping the app small enough that the redirect always wins.

Two things that cost us time and might save someone else:

  • A workaround that does not work. Awaiting a "hydration has committed" promise (resolved from a root mount effect) at the top of beforeLoad does remove the mismatch, but holding the route load open across that await means a superseded or invalidated load rejects into an unhandled continuation — uncaught CancelledError went from 0 to 284 in our suite, taking the run from 2 failures to 33. That escaping rather than being swallowed may be worth a look on its own.
  • Do not measure this in a backgrounded tab. Chrome throttles timers and defers rendering there hard enough to invert the race and make the bug vanish; it produced several false negatives for us before we noticed.

Related: #6455 (same symptom, SPA-fallback trigger — and notably SPA mode has the _displayPending coupling and still reports #418, which suggests a fix needs to consider the <head> and not only which fallback renders); #5787 / #5793 (adjacent, solid-router, about which fallback renders).

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