Skip to content

MatchInner can throw undefined when a redirected match's loadPromise was already cleared — escapes CatchBoundary and unmounts the whole app #7753

Description

@Thiagolino8

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:

  1. SPA with createRouter (no SSR), React 19, defaultPendingComponent set.
  2. Root-ish route / whose beforeLoad awaits something slow (~300–800 ms, e.g. a token refresh request) and then does throw redirect({ to: '/somewhere' }).
  3. Cold-load the app at /.
  4. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions