Repository navigation
React: a no-SSR route logs a hydration mismatch with pendingMinMs: 0 since #8643 #8647
Description
Which project does this relate to?
Router
Describe the bug
Since #8643, a ssr: 'data-only' or ssr: false route with a pending component logs a React hydration mismatch on a hard load when the client's pendingMinMs is 0. React then client-renders the boundary. This affects only main; the published 1.170.41 does not do it.
React hydrates the route's <Suspense> boundary after the shell. With a minimum of 0, the client load commits success before React reaches that boundary. MatchInner now decides from match.status alone, so it renders the route component where the server rendered the pending component:
<MatchInnerImpl match={{id:"/dashb...", ...}}>
<Lazy>
<Dashboard>
+ <h1 data-content={true}>
- <div data-skeleton="true">
Before #8643, ClientOnly rendered its fallback from the server snapshot of useSyncExternalStore during hydration, whatever the match status, so this case could not mismatch. The #8640 regression tests use pendingMinMs: 200, which keeps the match pending until the boundary hydrates.
Complete minimal reproducer
https://github.com/Tarekkharsa/tanstack-router-pending-min-after-hydration-repro
The browser part needs main packed locally until a release contains #8643 (the README says how). The quickest check is the unit test below.
Steps to Reproduce the Bug
In packages/react-router, copy tests/issue-8640-data-only-pending-hydration.test.tsx, set the dashboard route's pendingMinMs to 0, and drop the toBeGreaterThanOrEqual(150) timing assertion, which only applies to a non-zero minimum.
| Commit | pendingMinMs: 200 |
pendingMinMs: 0 |
|---|---|---|
| 663282b (before #8643) | 8 of 8 hydration cases fail (#8640) | 9 of 9 pass |
| ff9f753 (#8643) | pass | 5 of 8 fail with a recoverable hydration error |
The failing cases are data-only success, ssr: false success, data-only loader notFound, data-only unmatched URL, and ssr: false unmatched URL. The same change to the Solid and Vue copies of the test passes all cases.
In the browser reproduction, /dashboard?min=0 logs React error #418 on 6 of 6 loads on main, and 0 of 6 on 1.170.41.
Expected behavior
A no-SSR boundary hydrates against its server pending UI with any pendingMinMs, then shows the content.
Screenshots or Videos
No response
Platform
- Router / Start Version:
mainat ff9f753; not in a release yet - OS: macOS
- Browser: Chromium (Playwright 1.62.1), and jsdom in the unit test
- Bundler: vite 8.3.1
Additional context
A change I tried in MatchInner (React only):
+ const hydrated = useHydrated(match.ssr === false || match.ssr === 'data-only')
+
if (
match.status === 'pending' ||
((isServer ?? router.isServer) &&
- (match.ssr === false || match.ssr === 'data-only'))
+ (match.ssr === false || match.ssr === 'data-only')) ||
+ !hydrated
) {
...
- if (router._tx) {
+ if (router._tx && match.status === 'pending') {
throw router._tx[5]
}
return renderPending(router, route)useHydrated(enabled) is the existing internal overload, so other matches keep the same snapshot on both sides and get no extra render. The second change is needed: during hydration the match can already be success while the transaction waits for that commit to render, and throwing router._tx[5] there makes them wait for each other.
With this change, the pendingMinMs: 0 copy and the existing #8640 test pass (18 of 18), the full @tanstack/react-router unit suite passes (99 files, 1248 tests), and the browser reproduction logs no #418. I have not run the type, lint, or bundle-size checks yet. I can open a PR with the pendingMinMs: 0 cases added to the #8640 test.
This also blocks #8648: a shorter hold after hydration produces the same commit-before-hydration timing.
Activity