From 3a1c198d323ab242f01f12cf7c1010d045220034 Mon Sep 17 00:00:00 2001 From: Andreas Turku Date: Tue, 18 Aug 2026 10:05:08 +0200 Subject: [PATCH 1/3] fix(react-router): render errorComponent for thrown falsy values --- packages/react-router/src/CatchBoundary.tsx | 27 +++++--- .../issue-8098-falsy-error-boundary.test.tsx | 66 +++++++++++++++++++ 2 files changed, 84 insertions(+), 9 deletions(-) create mode 100644 packages/react-router/tests/issue-8098-falsy-error-boundary.test.tsx diff --git a/packages/react-router/src/CatchBoundary.tsx b/packages/react-router/src/CatchBoundary.tsx index f55a250ab1..1332002e65 100644 --- a/packages/react-router/src/CatchBoundary.tsx +++ b/packages/react-router/src/CatchBoundary.tsx @@ -11,36 +11,45 @@ export class CatchBoundary extends React.Component<{ errorComponent?: ErrorRouteComponent onCatch?: (error: Error, errorInfo: ErrorInfo) => void }> { - state = { error: null } as { error: Error | null; resetKey?: unknown } + // hasError tracks the caught state separately from the error value: a thrown + // falsy value (undefined, null, 0, '') would otherwise re-render the crashing + // children and escalate to an uncaught error at the root. + state = { error: null, hasError: false } as { + error: Error | null + hasError: boolean + resetKey?: unknown + } static getDerivedStateFromProps( props: { getResetKey: () => unknown }, - state: { resetKey?: unknown; error: Error | null }, + state: { resetKey?: unknown; error: Error | null; hasError: boolean }, ) { const resetKey = props.getResetKey() - if (state.error && state.resetKey !== resetKey) { - return { resetKey, error: null } + if (state.hasError && state.resetKey !== resetKey) { + return { resetKey, error: null, hasError: false } } return { resetKey } } static getDerivedStateFromError(error: Error) { - return { error } + return { error, hasError: true } } reset = () => { - this.setState({ error: null }) + this.setState({ error: null, hasError: false }) } componentDidCatch(error: Error, errorInfo: ErrorInfo) { this.props.onCatch?.(error, errorInfo) } render() { const error = this.state.error - if (error) { + if (this.state.hasError) { const element = React.createElement( this.props.errorComponent ?? ErrorComponent, { - error, + // The value passes through as thrown; non-Error throws already reached + // errorComponent under the previous truthy gate with this same typing. + error: error as Error, reset: this.reset, }, ) @@ -88,7 +97,7 @@ export function ErrorComponent({ error }: { error: any }) { overflow: 'auto', }} > - {error.message ? {error.message} : null} + {error?.message ? {error.message} : null} ) : null} diff --git a/packages/react-router/tests/issue-8098-falsy-error-boundary.test.tsx b/packages/react-router/tests/issue-8098-falsy-error-boundary.test.tsx new file mode 100644 index 0000000000..659053df53 --- /dev/null +++ b/packages/react-router/tests/issue-8098-falsy-error-boundary.test.tsx @@ -0,0 +1,66 @@ +import { afterEach, expect, test, vi } from 'vitest' +import { cleanup, render, screen } from '@testing-library/react' +import { createMemoryHistory } from '@tanstack/history' +import { + RouterProvider, + createRootRoute, + createRoute, + createRouter, +} from '../src' + +afterEach(() => { + cleanup() + vi.restoreAllMocks() +}) + +function setupThrowingRoute(thrownValue: unknown) { + const rootRoute = createRootRoute({ + errorComponent: ({ error }) => ( +
{String(error)}
+ ), + }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: function Boom(): never { + // eslint-disable-next-line @typescript-eslint/only-throw-error + throw thrownValue + }, + }) + return createRouter({ + routeTree: rootRoute.addChildren([indexRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) +} + +// issue #8098: the boundary gated on the error value's truthiness, so a thrown +// falsy value re-rendered the crashing children and escalated to an uncaught +// root error instead of rendering the errorComponent. +test.each([ + ['undefined', undefined], + ['null', null], + ['zero', 0], + ['empty string', ''], +])( + 'renders the errorComponent when a component throws %s', + async (_label, thrownValue) => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const router = setupThrowingRoute(thrownValue) + render() + + expect(await screen.findByTestId('route-error')).toBeInTheDocument() + }, +) + +test('passes real errors through to the errorComponent unchanged', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const router = setupThrowingRoute(new Error('real failure')) + render() + + const errorEl = await screen.findByTestId('route-error') + expect(errorEl.textContent).toContain('real failure') +}) From 056c87188a9f78dad13809143fdd2a0d369d0d8f Mon Sep 17 00:00:00 2001 From: Andreas Turku Date: Tue, 18 Aug 2026 11:12:02 +0200 Subject: [PATCH 2/3] match repo comment style --- packages/react-router/src/CatchBoundary.tsx | 6 +----- .../tests/issue-8098-falsy-error-boundary.test.tsx | 3 --- 2 files changed, 1 insertion(+), 8 deletions(-) diff --git a/packages/react-router/src/CatchBoundary.tsx b/packages/react-router/src/CatchBoundary.tsx index 1332002e65..64fd936074 100644 --- a/packages/react-router/src/CatchBoundary.tsx +++ b/packages/react-router/src/CatchBoundary.tsx @@ -11,9 +11,7 @@ export class CatchBoundary extends React.Component<{ errorComponent?: ErrorRouteComponent onCatch?: (error: Error, errorInfo: ErrorInfo) => void }> { - // hasError tracks the caught state separately from the error value: a thrown - // falsy value (undefined, null, 0, '') would otherwise re-render the crashing - // children and escalate to an uncaught error at the root. + // Tracked separately from the value so thrown falsy values still render the boundary state = { error: null, hasError: false } as { error: Error | null hasError: boolean @@ -47,8 +45,6 @@ export class CatchBoundary extends React.Component<{ const element = React.createElement( this.props.errorComponent ?? ErrorComponent, { - // The value passes through as thrown; non-Error throws already reached - // errorComponent under the previous truthy gate with this same typing. error: error as Error, reset: this.reset, }, diff --git a/packages/react-router/tests/issue-8098-falsy-error-boundary.test.tsx b/packages/react-router/tests/issue-8098-falsy-error-boundary.test.tsx index 659053df53..3c2dd4cb9a 100644 --- a/packages/react-router/tests/issue-8098-falsy-error-boundary.test.tsx +++ b/packages/react-router/tests/issue-8098-falsy-error-boundary.test.tsx @@ -33,9 +33,6 @@ function setupThrowingRoute(thrownValue: unknown) { }) } -// issue #8098: the boundary gated on the error value's truthiness, so a thrown -// falsy value re-rendered the crashing children and escalated to an uncaught -// root error instead of rendering the errorComponent. test.each([ ['undefined', undefined], ['null', null], From e7be37c08fd5e1f7127eb6b5aa9da974e6f972bf Mon Sep 17 00:00:00 2001 From: Andreas Turku Date: Tue, 18 Aug 2026 11:16:01 +0200 Subject: [PATCH 3/3] test: assert the value forwarded to errorComponent --- .../tests/issue-8098-falsy-error-boundary.test.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/react-router/tests/issue-8098-falsy-error-boundary.test.tsx b/packages/react-router/tests/issue-8098-falsy-error-boundary.test.tsx index 3c2dd4cb9a..a5f5e7f17c 100644 --- a/packages/react-router/tests/issue-8098-falsy-error-boundary.test.tsx +++ b/packages/react-router/tests/issue-8098-falsy-error-boundary.test.tsx @@ -47,7 +47,8 @@ test.each([ const router = setupThrowingRoute(thrownValue) render() - expect(await screen.findByTestId('route-error')).toBeInTheDocument() + const errorEl = await screen.findByTestId('route-error') + expect(errorEl.textContent).toBe(String(thrownValue)) }, )