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):
- 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.
- 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.
- 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
-
Open / (dev server, or a production build with CPU throttling — the window is the gap between shell hydration and route content hydration).
-
As soon as the header is interactive, click the header <Link to="/about">. /about has a slow loader, so the navigation stays pending.
-
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.
Which project does this relate to?
Router
Describe the bug
<Link>hydrates with the wrong active state (data-status="active",aria-current="page",activeclass) 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-router1.170.32,@tanstack/router-core1.171.27,@tanstack/react-start1.168.49):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.<Link>runsrouter.navigate().loadClientRoutesetsstores.locationsynchronously (load-client.ts, inside thestatus.set("pending")batch);resolvedLocationmoves only after the new matches commit. While the loader is in flight the old page stays mounted with the new location in the store.useLinkPropsderives the active state fromuseStore(router.stores.location, …), and@tanstack/react-store'suseStore/useSelectorpasses the same livesource.get()touseSyncExternalStoreWithSelectoras bothgetSnapshotandgetServerSnapshot(packages/react-store/src/useSelector.ts). React callsgetServerSnapshotduring hydration, gets the post-click location, and renders links to the pending destination as active — against HTML the server rendered as inactive.React's
getServerSnapshotcontract 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,
useMatchinvariant 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 reprostartsvite 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.tsxhas a header<Link to="/about">above<Outlet />;index.tsxhas 500<Link to="/about">s in the body;about.tsxhas a 3s loader.Steps to Reproduce the Bug
Open
/(dev server, or a production build with CPU throttling — the window is the gap between shell hydration and route content hydration).As soon as the header is interactive, click the header
<Link to="/about">./abouthas a slow loader, so the navigation stays pending.Console:
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):
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/getSnapshotsplit exists for.Either would close the window:
useStoreaccepts a separate server snapshot, and the router supplies the location captured at hydration time forstores.locationuntil hydration completes.useLinkPropsresolves the active state against the hydration-time location while hydrating.useHydrated()is already in scope there (used only forincludeHash).There is no user-land opt-out:
useLinkPropsalways emitsdata-status/aria-currentwhen active regardless ofactiveProps, and noactiveOptionsflag selectsresolvedLocation(whichrouter.matchRoute()already uses while pending).Screenshots or Videos
No response
Platform
@tanstack/react-router1.170.32,@tanstack/router-core1.171.27,@tanstack/react-start1.168.49Additional 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.