Link hydrates with wrong active state when a navigation starts before the route content has hydrated
Nobody has claimed this yet.
- Dominant language
- TypeScript
- Stars
- 15.1k
- Forks
- 1.9k
- Avg merge
- 1d 20h
- Merged PRs (30d)
- 143
Description
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>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. - The route content's boundary hydrates.
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 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">./abouthas 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:
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: 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-router1.170.32,@tanstack/router-core1.171.27,@tanstack/react-start1.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.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with Match.tsx, load-client.ts, packages/react-store/src/useSelector.ts, and the useLinkProps path described in the report; run the linked minimal reproducer with CPU throttling. Done means deferred route hydration matches the server-rendered link state after a navigation begins, without React hydration mismatch errors or stale active attributes.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- react, typescript
- Domain
- frontend
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 68/100