Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 14 additions & 9 deletions packages/react-router/src/CatchBoundary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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,
},
)
Expand Down Expand Up @@ -88,7 +93,7 @@ export function ErrorComponent({ error }: { error: any }) {
overflow: 'auto',
}}
>
{error.message ? <code>{error.message}</code> : null}
{error?.message ? <code>{error.message}</code> : null}
</pre>
</div>
) : null}
Expand Down
Original file line number Diff line number Diff line change
@@ -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 }) => (
<div data-testid="route-error">{String(error)}</div>
),
})
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(<RouterProvider router={router} />)

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(<RouterProvider router={router} />)

const errorEl = await screen.findByTestId('route-error')
expect(errorEl.textContent).toContain('real failure')
})