Repository navigation
hydrate matches the current location, not the server-rendered URL, when Back is pressed before hydration #8736
Description
Which project does this relate to?
Router (@tanstack/react-router / @tanstack/router-core, SSR with streaming hydration)
Describe the bug
If the browser's location changes after the server-rendered document commits but before the client hydrates, hydrate renders the routes for the new location against the markup of the old one.
Concretely: open page A, client-navigate to page B, reload B, and press Back while B's client JS is still loading. The address bar shows A and the router reports A as loaded, but the screen still shows B's server markup, with A rendered as a second app shell below it. Nothing is logged. With React 19 and hydrateRoot(document, ...), a mismatch between direct children of <body> is skipped rather than recovered by client rendering, so the duplicated shell stays.
The cause is in hydrate (@tanstack/router-core/dist/esm/load-client.js, 1.170.44): after router.options.hydrate resolves, it reads router.history.location, calls router.updateLatestLocation(), and runs router.matchRoutes(location) for the current location. The dehydrated payload (window.$_TSR.router) carries match ids but not the href the server rendered, so hydrate has nothing else to match against. When a candidate's id differs from the dehydrated match id, it takes the pendingBoundary path built for ssr: false routes and treats the old page's server markup as pending UI for the new route. Location changes after that read are already handled (the hydration handoff's claim compares router.history.location with what it read, and Transitioner calls router.load() on mount); the window before the read is the one that isn't.
Per the HTML spec, entries created with pushState share the document state of the entry they came from, so after A → (client nav) → B → reload, the A entry points at the reloaded document and a traversal to it fires popstate on that document without loading a new one. That is why this happens before any script has run.
Complete minimal reproducer
No hosted reproducer yet. The steps below reproduce it in any SSR TanStack Router app whose hydration takes longer than about a second after the document commits (throttle the network in DevTools, or add a delay before hydrateRoot).
Steps to Reproduce the Bug
- Load route
/aof an SSR TanStack Router app. - Click a
<Link>to/b(client navigation). - Reload
/b. - Press the browser's Back button before the client hydrates.
- The URL is
/aand/a's data loads, but/b's server markup stays on screen and/arenders below it.
Expected behavior
Hydration matches the URL the server rendered, so the server markup hydrates as /b, and the router then navigates to the current location (/a) once hydration commits, the same way it already handles a location change after its read.
One way to get there: dehydrate the server's href (or the matched location) alongside the match ids, have hydrate match against it, and treat a different router.history.location as a navigation after hydration. A narrower option is to have the browser history report the document URL until hydration finishes (createBrowserHistory's parseLocation could do this today, but the replay afterwards has to be wired by hand).
Screenshots or Videos
No response
Platform
- Router version: 1.170.44 (
@tanstack/react-router), React 19.3 - Browsers: Chrome and Firefox (reproduced); Safari not tested
- OS: macOS
Additional context
Our workaround wraps router.options.hydrate and reloads when the location no longer matches the document's URL. It compares two URLs the browser serialized (the navigation timing entry and window.location), so it avoids the false positives Remix hit when it compared the server's view of the pathname:
export function setupReloadOnLocationChangeBeforeHydration(router: AnyRouter): void {
const originalHydrate = router.options.hydrate;
router.options.hydrate = async function hydrateUnlessLocationChanged(dehydrated: never) {
if (originalHydrate != null) {
await originalHydrate(dehydrated);
}
const documentEntry = performance.getEntriesByType('navigation')[0];
if (documentEntry == null) {
return;
}
const documentUrl = new URL(documentEntry.name);
if (
documentUrl.pathname === window.location.pathname &&
documentUrl.search === window.location.search
) {
return;
}
window.location.reload();
// Hydration stays suspended, with the server markup on screen, until the reload replaces the document.
await Promise.withResolvers<never>().promise;
};
}It runs last among the hydrate steps; hydrate reads the location in the microtasks after router.options.hydrate resolves, so no popstate task can run between the check and that read. A reload is a blunt fix, which is why a router-level fix seems better.
Other SSR routers have hit the same race:
- Remix: [Bug]: App crash on fast browser navigation remix-run/remix#1757 (same diagnosis), fixed with a reload in fix: reload page when routeModules doesn't contain module for current route remix-run/remix#6409, later removed in Remove hydration URL check remix-run/remix#9890 because comparing the server's pathname with
window.locationlooped on trailing slashes, encoded characters and 404s, and because React 18 recovered from the mismatch by client rendering. - React Router 7: 'No result found for routeId' and hydration errors when navigating during page loading remix-run/react-router#13873 (open), reload then instant Back gives "No result found for routeId" and hydration errors in production.
- Next.js: fixed in Fix race when navigating Back before hydration vercel/next.js#96252 by hydrating the server's tree and replaying the missed traversal (detected with the Navigation API); its first version needed [Fiber] Fix hang when updating a dehydrated boundary inside a hidden tree react/react#37135.
- SvelteKit: Race condition: client side navigation vs SSR sveltejs/kit#10876 (open), with a reload as the workaround.
Activity