diff --git a/packages/react-router/src/CatchBoundary.tsx b/packages/react-router/src/CatchBoundary.tsx index f55a250ab1..64fd936074 100644 --- a/packages/react-router/src/CatchBoundary.tsx +++ b/packages/react-router/src/CatchBoundary.tsx @@ -11,36 +11,41 @@ export class CatchBoundary extends React.Component<{ errorComponent?: ErrorRouteComponent onCatch?: (error: Error, errorInfo: ErrorInfo) => void }> { - state = { error: null } as { error: Error | null; resetKey?: unknown } + // Tracked separately from the value so thrown falsy values still render the boundary + 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, + error: error as Error, reset: this.reset, }, ) @@ -88,7 +93,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..a5f5e7f17c --- /dev/null +++ b/packages/react-router/tests/issue-8098-falsy-error-boundary.test.tsx @@ -0,0 +1,64 @@ +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: ['/'] }), + }) +} + +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() + + const errorEl = await screen.findByTestId('route-error') + expect(errorEl.textContent).toBe(String(thrownValue)) + }, +) + +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') +})