Skip to content

[Start] ssr: false hydration can throw undefined when a pending match loses its loadPromise #7910

Description

@Aokoooooo

Which project does this relate to?

TanStack Start / React Router

Describe the bug

During the initial hydration of a route with ssr: false, React can render a
stale match snapshot whose status is still pending after the router has
already cleared the match's non-reactive loadPromise.

MatchInnerImpl then executes:

if (match.status === 'pending') {
  throw getMatchPromise(match, 'loadPromise')
}

getMatchPromise(...) returns undefined, so React receives
throw undefined. The route error boundary cannot render a useful error and
the thrown value reported by React is literally undefined.

We instrumented the router state synchronously from React's onCaughtError
callback and captured this state at the time of failure:

{
  "routeId": "/example",
  "status": "pending",
  "isFetching": false,
  "hasLoadPromise": false,
  "hasMinPendingPromise": true,
  "hasDisplayPendingPromise": false,
  "error": { "type": "undefined" }
}

The component stack starts at MatchInnerImpl / Match. Shortly after the
failure, inspecting the router shows the match as success.

No document or static asset requests fail. The server bootstrap data contains
the leaf match as pending with ssr: false.

This appears to mix two points in time:

  • status comes from the reactive match snapshot retained by a concurrent
    React render;
  • loadPromise is read from the router's current _nonReactive state by
    getMatchPromise.

The current router state has already removed loadPromise, while React is
still rendering the older pending snapshot.

Reproduction shape

The affected route has this shape:

const RouteView = lazyRouteComponent(
  createClientOnlyFn(() => import('./route-view')),
)

function Component() {
  return <RouteView />
}

Object.assign(Component, {
  preload: RouteView.preload,
})

export const Route = createFileRoute('/example')({
  ssr: false,
  pendingComponent: Pending,
  component: Component,
})

Forwarding the lazy component's .preload() causes the server bootstrap match
to be serialized as pending. The issue is timing-sensitive and is easier to
reproduce in a larger nested route tree during concurrent hydration.

Expected behavior

A match rendered with status: 'pending' should always have a valid thenable
available to suspend on. Router state should not allow MatchInnerImpl to
execute throw undefined.

Possible approaches might be to keep loadPromise stable until no reactive
pending snapshot can observe the match, or to read the status and promise
from a consistent snapshot.

Platform

  • @tanstack/react-router: 1.170.16
  • @tanstack/router-core: 1.171.13
  • @tanstack/react-start: 1.168.26
  • React / React DOM: 19.2.4
  • Browser: Chrome
  • OS: macOS

Additional context

The relevant source paths appear to be:

  • packages/react-router/src/Match.tsx: the status === 'pending' branch and
    getMatchPromise
  • packages/router-core/src/load-matches.ts: resolving and clearing
    _nonReactive.loadPromise

The same getMatchPromise / throw pattern is still present on the current
main branch.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions