Skip to content

Link hydrates with wrong active state when a navigation starts before the route content has hydrated #8233

Description

@k3dom

Which project does this relate to?

Router

Describe the bug

<Link> hydrates with the wrong active state (data-status="active", aria-current="page", active class) when a navigation starts after the shell has hydrated but before the route content's Suspense boundary has. React logs a hydration mismatch for every <Link> in the not-yet-hydrated content that points at the pending destination.

Chain (verified against @tanstack/react-router 1.170.32, @tanstack/router-core 1.171.27, @tanstack/react-start 1.168.49):

  1. Start hydrates inside startTransition(() => hydrateRoot(…)) and the root <Outlet> wraps child routes in <Suspense> (Match.tsx). The shell commits first; route content hydrates later in its own task.
  2. A click on an already-hydrated shell <Link> runs router.navigate(). loadClientRoute sets stores.location synchronously (load-client.ts, inside the status.set("pending") batch); resolvedLocation moves only after the new matches commit. While the loader is in flight the old page stays mounted with the new location in the store.
  3. The route content's boundary hydrates. useLinkProps derives the active state from useStore(router.stores.location, …), and @tanstack/react-store's useStore/useSelector passes the same live source.get() to useSyncExternalStoreWithSelector as both getSnapshot and getServerSnapshot (packages/react-store/src/useSelector.ts). React calls getServerSnapshot during hydration, gets the post-click location, and renders links to the pending destination as active — against HTML the server rendered as inactive.

React's getServerSnapshot contract is "what the server rendered with"; using the live getter is only safe while nothing mutates the store between SSR and hydration. Deferred boundary hydration plus a user click is exactly that gap.

Sibling of #8198 (same window, useMatch invariant variant). This one does not throw and is reachable in any Start app with nav links in the root layout.

Complete minimal reproducer

https://github.com/k3dom/tanstack-link-hydration-repro

pnpm install && pnpm repro starts vite dev, loads / in Chromium with 6x CPU throttling (DevTools' "mid-tier mobile" preset), clicks the header <Link to="/about"> as soon as the shell has hydrated, and counts React's hydration mismatch errors. Routes: __root.tsx has a header <Link to="/about"> above <Outlet />; index.tsx has 500 <Link to="/about">s in the body; about.tsx has a 3s loader.

Steps to Reproduce the Bug

  1. Open / (dev server, or a production build with CPU throttling — the window is the gap between shell hydration and route content hydration).

  2. As soon as the header is interactive, click the header <Link to="/about">. /about has a slow loader, so the navigation stays pending.

  3. Console:

    A tree hydrated but some attributes of the server rendered HTML didn't match the client properties.
      <Home>
        <main>
          <p>
            <a
    +         className="active"
    -         className={null}
              href="/about"
    +         data-status="active"
    -         data-status={null}
    +         aria-current="page"
    -         aria-current={null}
    

    Reported for every <Link to="/about"> inside the route content; the header link itself was already hydrated and is fine.

Measured by the script (fresh clone, 6x throttle, reproduces on every run):

shell hydrated at:            702ms
header link clicked at:       1078ms
route content hydrated at:    1832ms
hydration mismatch errors:    1

Same run without the click: 0 errors. At 1x the window is ~90ms and Playwright's click lands after the content has hydrated, so it does not reproduce — it needs a slow device or a heavy page.

Expected behavior

Route content hydrates against the location the server rendered with, so the output matches the HTML; React then re-renders with the live location and flips the active state. That is what the getServerSnapshot / getSnapshot split exists for.

Either would close the window:

  • useStore accepts a separate server snapshot, and the router supplies the location captured at hydration time for stores.location until hydration completes.
  • useLinkProps resolves the active state against the hydration-time location while hydrating. useHydrated() is already in scope there (used only for includeHash).

There is no user-land opt-out: useLinkProps always emits data-status/aria-current when active regardless of activeProps, and no activeOptions flag selects resolvedLocation (which router.matchRoute() already uses while pending).

Screenshots or Videos

No response

Platform

  • Router / Start Version: @tanstack/react-router 1.170.32, @tanstack/router-core 1.171.27, @tanstack/react-start 1.168.49
  • React: 19.2.8 / react-dom 19.2.8
  • OS: Linux
  • Browser: Chromium 149 (Playwright-driven)
  • Bundler: Vite 8.2.2

Additional context

Impact is mostly dev-time: React 19 only logs attribute mismatches in development and never patches them. In production the consequence is a stale DOM attribute on a reused <a> fibre when source and destination share a route.

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