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.
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 astale match snapshot whose
statusis stillpendingafter the router hasalready cleared the match's non-reactive
loadPromise.MatchInnerImplthen executes:getMatchPromise(...)returnsundefined, so React receivesthrow undefined. The route error boundary cannot render a useful error andthe thrown value reported by React is literally
undefined.We instrumented the router state synchronously from React's
onCaughtErrorcallback 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 thefailure, inspecting the router shows the match as
success.No document or static asset requests fail. The server bootstrap data contains
the leaf match as
pendingwithssr: false.This appears to mix two points in time:
statuscomes from the reactive match snapshot retained by a concurrentReact render;
loadPromiseis read from the router's current_nonReactivestate bygetMatchPromise.The current router state has already removed
loadPromise, while React isstill rendering the older
pendingsnapshot.Reproduction shape
The affected route has this shape:
Forwarding the lazy component's
.preload()causes the server bootstrap matchto be serialized as
pending. The issue is timing-sensitive and is easier toreproduce in a larger nested route tree during concurrent hydration.
Expected behavior
A match rendered with
status: 'pending'should always have a valid thenableavailable to suspend on. Router state should not allow
MatchInnerImpltoexecute
throw undefined.Possible approaches might be to keep
loadPromisestable until no reactivependingsnapshot can observe the match, or to read the status and promisefrom a consistent snapshot.
Platform
@tanstack/react-router: 1.170.16@tanstack/router-core: 1.171.13@tanstack/react-start: 1.168.26Additional context
The relevant source paths appear to be:
packages/react-router/src/Match.tsx: thestatus === 'pending'branch andgetMatchPromisepackages/router-core/src/load-matches.ts: resolving and clearing_nonReactive.loadPromiseThe same
getMatchPromise/throwpattern is still present on the currentmainbranch.