Skip to content

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

Closed

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.

Activity

  1. levi commented on Jul 31, 2026

    @levi

    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:

    router.invalidate({
      filter: (match) => match.routeId === rootRouteId,
      forcePending: true,
      sync: true,
    })

    In router-core, invalidate flips the match to status: "pending" in the store before load() runs and attaches _nonReactive.loadPromise:

    // router-core/src/router.ts
    this.invalidate = (opts) => {
      const invalidate = (d) => ({
        ...d,
        invalid: true,
        ...(opts?.forcePending || ... ? { status: 'pending', error: void 0 } : void 0),
      })
      this.batch(() => { /* setMatches / setCached / setPending with status: 'pending' */ })
      return this.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:

    if (match.status === 'pending') throw getMatchPromise(match, 'loadPromise') // undefined

    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.

  2. Sheraff commented on Aug 2, 2026

    @Sheraff
    Collaborator

    @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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions