You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
MatchInner can throw undefined when a redirected match's loadPromise was already cleared — escapes CatchBoundary and unmounts the whole app #7753
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:
The thrown undefined is then not contained by CatchBoundary, because its render callback checks truthiness of the error:
// packages/react-router/src/CatchBoundary.tsxchildren={({ error, reset })=>{if(error){// <- undefined is falsyreturnReact.createElement(errorComponent,{ error, reset })}returnprops.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.
We hit the same failure mode (MatchInner throwing literal undefined → escapes CatchBoundary via the if (error) truthiness check → React 19 unmounts the whole tree → permanent blank page, stackless Uncaught undefined), but through a second entry path that doesn't involve redirects, so noting it here in case it affects the shape of the fix in #7805.
Trigger: invalidate({ forcePending: true }) — pending before loadPromise exists
We call this from an effect after hydrating a prerendered SPA shell, to re-run the root loader on the client:
In router-core, invalidate flips the match to status: "pending" in the store beforeload() runs and attaches _nonReactive.loadPromise:
// router-core/src/router.tsthis.invalidate=(opts)=>{constinvalidate=(d)=>({
...d,invalid: true,
...(opts?.forcePending|| ... ? {status: 'pending',error: void0} : void0),})this.batch(()=>{/* setMatches / setCached / setPending with status: 'pending' */})returnthis.load({sync: opts?.sync})// loadPromise attached only in here}
If React flushes a sync re-render inside that window (for us: the store update lands in a microtask and gets flushed via flushSyncWorkAcrossRoots_impl before load() attaches the promise), MatchInnerImpl takes the pending branch:
So this issue's title condition ("loadPromise was already cleared") has a sibling: "loadPromise not yet attached". Same throw undefined, same boundary escape, same full-app unmount.
Observations
Captured with CDP Debugger.setPauseOnExceptions('all') + evaluateOnCallFrame at the throw site: { routeId: '__root__', status: 'pending', errorType: 'undefined' }, with _forcePending/_displayPending unset — i.e. it reaches the status === 'pending' throw, not the _forcePending one.
It's a microtask-interleaving race: on one route it reproduced 100% of the time, on a heavier route (same code path) it never fired. That timing sensitivity made it look environment-specific for a while.
Versions: @tanstack/react-router 1.170.18 / @tanstack/router-core 1.171.15, React 19.2.4. Reproduces in Vite dev and (by mechanism) applies to production builds too.
Workaround
Dropping forcePending (a plain invalidate() keeps the match in status: "success" with stale loader data while it reloads) removes the pending window entirely and fixed it for us.
Since #7861 was closed in favor of the #7805 loader rewrite: it would be great if the rewrite's test coverage includes the invalidate({ forcePending: true })-during-render window as well, not just the redirect/cleared-promise race. A defense-in-depth hasError sentinel in CatchBoundaryImpl (as #7861 proposed) would also keep any future falsy throw from unmounting the entire app.
@levi i could not reproduce your described issue in a reasonable test. I do think it will be fixed by #7805 and the extremely convoluted reproduction I got does fail on main and succeeds on #7805. But if you have a reasonable reproduction of this issue I'd gladly add it to our tests.
Activity
fix: prevent a blank page when reloading an analysis page
fix: speed up coverage charts
levi commentedon Jul 31, 2026
We hit the same failure mode (
MatchInnerthrowing literalundefined→ escapesCatchBoundaryvia theif (error)truthiness check → React 19 unmounts the whole tree → permanent blank page, stacklessUncaught undefined), but through a second entry path that doesn't involve redirects, so noting it here in case it affects the shape of the fix in #7805.Trigger:
invalidate({ forcePending: true })— pending beforeloadPromiseexistsWe call this from an effect after hydrating a prerendered SPA shell, to re-run the root loader on the client:
In
router-core,invalidateflips the match tostatus: "pending"in the store beforeload()runs and attaches_nonReactive.loadPromise:If React flushes a sync re-render inside that window (for us: the store update lands in a microtask and gets flushed via
flushSyncWorkAcrossRoots_implbeforeload()attaches the promise),MatchInnerImpltakes the pending branch:So this issue's title condition ("loadPromise was already cleared") has a sibling: "loadPromise not yet attached". Same
throw undefined, same boundary escape, same full-app unmount.Observations
Debugger.setPauseOnExceptions('all')+evaluateOnCallFrameat the throw site:{ routeId: '__root__', status: 'pending', errorType: 'undefined' }, with_forcePending/_displayPendingunset — i.e. it reaches thestatus === 'pending'throw, not the_forcePendingone.@tanstack/react-router1.170.18 /@tanstack/router-core1.171.15, React 19.2.4. Reproduces in Vite dev and (by mechanism) applies to production builds too.Workaround
Dropping
forcePending(a plaininvalidate()keeps the match instatus: "success"with stale loader data while it reloads) removes the pending window entirely and fixed it for us.Since #7861 was closed in favor of the #7805 loader rewrite: it would be great if the rewrite's test coverage includes the
invalidate({ forcePending: true })-during-render window as well, not just the redirect/cleared-promise race. A defense-in-depthhasErrorsentinel inCatchBoundaryImpl(as #7861 proposed) would also keep any future falsy throw from unmounting the entire app.Sheraff commentedon Aug 2, 2026
@levi i could not reproduce your described issue in a reasonable test. I do think it will be fixed by #7805 and the extremely convoluted reproduction I got does fail on
mainand succeeds on #7805. But if you have a reasonable reproduction of this issue I'd gladly add it to our tests.fix: stop blank page shown after signing in