Which project does this relate to?
Router
Describe the bug
On initial page load, when an async beforeLoad takes a few hundred ms (in our case: an auth token refresh) and then throws redirect(), there is a race where MatchInner renders a match observed as status === 'redirected' after the redirect navigation has already settled. At that point the match's loadPromise has been cleared, so MatchInner executes throw getMatchPromise(match, 'loadPromise') — which evaluates to throw undefined.
getMatchPromise can return undefined by design of its lookup:
// packages/react-router/src/Match.tsx
const getMatchPromise = (match, key) => {
return (
router.getMatch(match.id)?._nonReactive[key] ?? match._nonReactive[key]
)
}
…and router-core clears the promise once loading settles:
// packages/router-core/src/load-matches.ts
match._nonReactive.loadPromise?.resolve()
match._nonReactive.loadPromise = undefined
The thrown undefined is then not contained by CatchBoundary, because its render callback checks truthiness of the error:
// packages/react-router/src/CatchBoundary.tsx
children={({ error, reset }) => {
if (error) { // <- undefined is falsy
return React.createElement(errorComponent, { error, reset })
}
return props.children // <- children re-throw, boundary is considered failed
}}
getDerivedStateFromError(undefined) stores the falsy error, the boundary re-renders its children, they throw again, React treats the boundary as failed and escalates. With React 19 the error reaches the root, the whole tree is unmounted and defaultOnUncaughtError calls reportError(undefined) — the only console output is a stackless Uncaught undefined, and the user gets a permanent white screen (#root is empty).
Steps to reproduce
Timing-dependent race, so hard to make deterministic, but the shape is:
- SPA with
createRouter (no SSR), React 19, defaultPendingComponent set.
- Root-ish route
/ whose beforeLoad awaits something slow (~300–800 ms, e.g. a token refresh request) and then does throw redirect({ to: '/somewhere' }).
- Cold-load the app at
/.
- Intermittently, the app white-screens with
Uncaught undefined in the console and an empty #root. Reloading (when the slow work is no longer needed, e.g. token now fresh) loads fine.
We hit this consistently-intermittently in production; the window opens whenever the first render commits while the redirect is being processed.
Expected behavior
Either the stale render suspends/retries (e.g. getMatchPromise falling back to a resolved promise instead of undefined), or at minimum the error boundary contains whatever was thrown — CatchBoundaryImpl tracking a hasError sentinel instead of relying on error truthiness — so a falsy thrown value can't unmount the entire app.
Platform
- Package: @tanstack/react-router 1.170.17 (also present in current
main sources)
- React 19, client-side only (no SSR)
- Browser: Chrome (any)
Additional context
Happy to provide more details or test a patch. Workaround we're shipping: an app-level error boundary above RouterProvider that treats falsy thrown values as errors and remounts once.
Which project does this relate to?
Router
Describe the bug
On initial page load, when an async
beforeLoadtakes a few hundred ms (in our case: an auth token refresh) and then throwsredirect(), there is a race whereMatchInnerrenders a match observed asstatus === 'redirected'after the redirect navigation has already settled. At that point the match'sloadPromisehas been cleared, soMatchInnerexecutesthrow getMatchPromise(match, 'loadPromise')— which evaluates tothrow undefined.getMatchPromisecan returnundefinedby design of its lookup:…and
router-coreclears the promise once loading settles:The thrown
undefinedis then not contained byCatchBoundary, because its render callback checks truthiness of the error:getDerivedStateFromError(undefined)stores the falsy error, the boundary re-renders its children, they throw again, React treats the boundary as failed and escalates. With React 19 the error reaches the root, the whole tree is unmounted anddefaultOnUncaughtErrorcallsreportError(undefined)— the only console output is a stacklessUncaught undefined, and the user gets a permanent white screen (#rootis empty).Steps to reproduce
Timing-dependent race, so hard to make deterministic, but the shape is:
createRouter(no SSR), React 19,defaultPendingComponentset./whosebeforeLoadawaits something slow (~300–800 ms, e.g. a token refresh request) and then doesthrow redirect({ to: '/somewhere' })./.Uncaught undefinedin the console and an empty#root. Reloading (when the slow work is no longer needed, e.g. token now fresh) loads fine.We hit this consistently-intermittently in production; the window opens whenever the first render commits while the redirect is being processed.
Expected behavior
Either the stale render suspends/retries (e.g.
getMatchPromisefalling back to a resolved promise instead ofundefined), or at minimum the error boundary contains whatever was thrown —CatchBoundaryImpltracking ahasErrorsentinel instead of relying on error truthiness — so a falsy thrown value can't unmount the entire app.Platform
mainsources)Additional context
Happy to provide more details or test a patch. Workaround we're shipping: an app-level error boundary above
RouterProviderthat treats falsy thrown values as errors and remounts once.