Skip to content

hydrate matches the current location, not the server-rendered URL, when Back is pressed before hydration #8736

Open

Description

@rbalicki2

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

  1. Load route /a of an SSR TanStack Router app.
  2. Click a <Link> to /b (client navigation).
  3. Reload /b.
  4. Press the browser's Back button before the client hydrates.
  5. The URL is /a and /a's data loads, but /b's server markup stays on screen and /a renders 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:

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