Skip to content

CatchBoundary: falsy thrown values (undefined, null, '') bypass every errorComponent and unmount the whole app #8123

Description

@vladavoX

Which project does this relate to?

Router

Describe the bug

CatchBoundary tracks whether it has caught an error by the truthiness of the thrown value (CatchBoundary.tsx):

static getDerivedStateFromError(error: Error) {
  return { error }
}

render() {
  const error = this.state.error
  if (error) {
    // render errorComponent
  }
  return this.props.children
}

When a component throws a falsy value (throw undefined, throw null, throw ''), the boundary catches it and stores it, but if (error) fails — so it re-renders the same crashing children instead of the errorComponent. React then escalates the error past the boundary. Since every route boundary runs the same check, the error skips every route errorComponent (including the root route's) and the global catch boundary, and the entire tree unmounts. The app crashes with no error UI at all; the value surfaces at createRoot's onUncaughtError.

The reset logic in getDerivedStateFromProps has the same truthiness problem (state.error && state.resetKey !== resetKey).

Nobody throws undefined on purpose, but it happens in real apps (e.g. rethrowing a variable that is unexpectedly undefined). The expected blast radius is the nearest errorComponent; the actual result is a blank page.

Complete minimal reproducer

https://github.com/vladavoX/tsr-catchboundary-falsy-repro

Steps to Reproduce the Bug

The reproducer renders a real RouterProvider (React 19, jsdom — no browser needed) with a route like:

const indexRoute = createRoute({
  getParentRoute: () => rootRoute,
  path: '/',
  component: () => { throw undefined }, // vs. throw new Error('boom')
  errorComponent: () => <div>ROUTE ERROR BOUNDARY</div>,
})
  1. Clone the repro repo
  2. npm install
  3. npm start

Output on @tanstack/react-router 1.170.31:

=== component does: throw new Error("boom")
rendered html:   <div>ROUTE ERROR BOUNDARY</div>
onUncaughtError: (not called)

=== component does: throw undefined
rendered html:   (empty — whole tree unmounted)
onUncaughtError: called with: undefined

=== component does: throw null
rendered html:   (empty — whole tree unmounted)
onUncaughtError: called with: null

=== component does: throw '' (empty string)
rendered html:   (empty — whole tree unmounted)
onUncaughtError: called with:

Only the throw new Error(...) case renders the route's errorComponent; every falsy throw unmounts the whole tree.

Expected behavior

Any thrown value — falsy included — renders the nearest errorComponent, exactly like throw new Error(...) does. React itself delivers falsy thrown values to getDerivedStateFromError correctly; only the boundary's truthiness check loses them.

Suggested fix — track caught state with a flag instead of the error value's truthiness:

state = { hasError: false, error: null }

static getDerivedStateFromError(error: unknown) {
  return { hasError: true, error }
}

// render:  if (this.state.hasError) { ... }
// reset:   if (state.hasError && state.resetKey !== resetKey) { ... }

Happy to send a PR.

Screenshots or Videos

No response — the console output above shows the behavior.

Platform

  • Router / Start Version: @tanstack/react-router 1.170.31 (bug also present in current main)
  • OS: any (verified on macOS 15 / Node 24)
  • Browser: any (reproduced in jsdom with React 19; browser-independent)
  • Bundler: none (plain Node with --conditions=browser)

Additional context

Debugged and drafted with the help of Claude (Fable 5), verified by hand against 1.170.31 and current main.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions