Repository navigation
data-only route mounts a second pendingComponent after hydration when pendingMinMs is set #8640
Description
Which project does this relate to?
Router
Describe the bug
A route with ssr: 'data-only' and a pendingComponent server-renders the pending component, and the client hydrates it. When pendingMinMs (or defaultPendingMinMs) is set, React then hides the hydrated pending component and mounts a second copy from the route's Suspense fallback. The page holds two pending components, one with display: none, and any animation in the pending UI restarts.
Cause, as I read main (663282b):
matchViewrenders a data-only match as<ClientOnly fallback={pendingElement}>{<MatchInner />}</ClientOnly>, so the server HTML is the pending element.hydrate()presents the boundary asstatus: 'pending'.offerPendingfinds a presented pending match with no session and startspendingMinMsfrom now ("conservatively start pendingMinMs from now"), so the match stays pending for at least that long.- After hydration,
ClientOnlyswitches fromfallbacktochildren.MatchInnerseesstatus === 'pending'with a liverouter._txand throwsrouter._tx[5]. - That update is not a transition, so React hides the already-mounted primary content (the hydrated pending element) and mounts the Suspense fallback: a second
pendingElement.
With pendingMinMs: 0 the transaction has usually finished before ClientOnly switches, so nothing suspends. The bug needs the match to still be pending when ClientOnly switches, and pendingMinMs makes that certain.
Complete minimal reproducer
https://github.com/Tarekkharsa/tanstack-router-hydration-pending-min-repro
Steps to Reproduce the Bug
pnpm i
pnpm test # builds, serves with vite preview on 7302, runs Playwright; exits 1The test loads /dashboard (ssr: 'data-only', instant loader, pendingComponent renders <div data-skeleton>) three times per scenario. It records every distinct [data-skeleton] element and when the content appears:
FAIL pendingMinMs=400, route chunk delay 0 ms, run 1: skeleton elements 2, ref attaches 2, most in the DOM at once 2 (1 visible), content at 617 ms
FAIL pendingMinMs=400, route chunk delay 0 ms, run 2: skeleton elements 2, ref attaches 2, most in the DOM at once 2 (1 visible), content at 474 ms
FAIL pendingMinMs=400, route chunk delay 0 ms, run 3: skeleton elements 2, ref attaches 2, most in the DOM at once 2 (1 visible), content at 472 ms
ok pendingMinMs=0, route chunk delay 0 ms: skeleton elements 1, content at ~60 ms (3/3)
ok pendingMinMs=0, route chunk delay 300 ms: skeleton elements 1, content at ~320 ms (3/3)
3/9 loads mounted a second skeleton
I get the same result with packages packed from main at 663282b.
Expected behavior
The server-rendered pending component is the only one. It stays mounted until the route content replaces it.
Screenshots or Videos
No response
Platform
- Router / Start Version:
@tanstack/react-router1.170.41,@tanstack/router-core1.171.34,@tanstack/react-start1.168.60; alsomainat 663282b - OS: macOS
- Browser: Chromium (Playwright 1.62.1)
- Browser Version: 151.0.7922.34
- Bundler: vite
- Bundler Version: 8.3.1
Additional context
A possible fix. While the match is pending, render the pending element in ClientOnly's children slot too. The element then has the same type at the same position before and after hydration, so React keeps the hydrated node:
if (resolvedNoSsr) {
- content = <ClientOnly fallback={pendingElement}>{content}</ClientOnly>
+ content = (
+ <ClientOnly fallback={pendingElement}>
+ {match.status === 'pending' ? pendingElement : content}
+ </ClientOnly>
+ )
}With that change built from main, the reproducer passes 9/9 with one skeleton element on every load. I have only tried React; Solid and Vue wrap data-only matches in ClientOnly too.
A related question. After hydration, pendingMinMs still holds the loaded content for about 400 ms (content at ~450 ms against ~50 ms without the minimum). The pending UI has been on screen since first paint, so counting the minimum from navigation start instead of from hydrate() would keep its purpose and avoid most of the hold. The current comment says the choice is conservative on purpose, so I'm asking rather than calling it a bug.
We hit this in a production Start app. We work around it by running the first document with defaultPendingMinMs: 0 and restoring it on the first onRendered.
I'm happy to open a PR with an SSR-then-hydrate regression test if this direction looks right.
Activity
Sheraff commentedon Oct 9, 2026
Can you open a separate issue (if there isn't one already) for this? i'm aware of this, but just never made it a priority
Tarekkharsa commentedon Oct 9, 2026
Thanks for #8643. I opened #8648 for the hold after hydration.
While measuring it on
mainI found a second problem, now #8647: withpendingMinMs: 0on the client, a no-SSR route logs a React hydration mismatch on a hard load. The client load commits before React hydrates the route's Suspense boundary, andMatchInnerrenders the content against the server's pending HTML. A copy of the #8640 React test withpendingMinMs: 0passes before #8643 and fails 5 of 8 cases after it; Solid and Vue pass. It matters for the delay too, because a shorter hold produces the same timing. Both issues have the measurements and a change I tried.fix: keep server-rendered pending UI of no-SSR routes through hydrati…
Sheraff commentedon Oct 9, 2026
yeah found it too, and reverted #8643. It was never released so I also closed #8647.