Skip to content

data-only route mounts a second pendingComponent after hydration when pendingMinMs is set #8640

Description

@Tarekkharsa

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):

  1. matchView renders a data-only match as <ClientOnly fallback={pendingElement}>{<MatchInner />}</ClientOnly>, so the server HTML is the pending element.
  2. hydrate() presents the boundary as status: 'pending'. offerPending finds a presented pending match with no session and starts pendingMinMs from now ("conservatively start pendingMinMs from now"), so the match stays pending for at least that long.
  3. After hydration, ClientOnly switches from fallback to children. MatchInner sees status === 'pending' with a live router._tx and throws router._tx[5].
  4. 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 1

The 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-router 1.170.41, @tanstack/router-core 1.171.34, @tanstack/react-start 1.168.60; also main at 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

  1. self-assigned this
    on Oct 9, 2026
  2. Sheraff commented on Oct 9, 2026

    @Sheraff
    Collaborator

    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.

    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

  3. reopened this on Oct 9, 2026
  4. Tarekkharsa commented on Oct 9, 2026

    @Tarekkharsa
    Author

    Thanks for #8643. I opened #8648 for the hold after hydration.

    While measuring it on main I found a second problem, now #8647: with pendingMinMs: 0 on 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, and MatchInner renders the content against the server's pending HTML. A copy of the #8640 React test with pendingMinMs: 0 passes 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.

  5. added a commit that references this issue on Oct 9, 2026
    ff9f753
  6. Sheraff commented on Oct 9, 2026

    @Sheraff
    Collaborator

    yeah found it too, and reverted #8643. It was never released so I also closed #8647.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

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