Skip to content

Commit e831993

Browse files
jaensautofix-ci[bot]schiller-manuel
authored
feat(react-router): add disableGlobalCatchBoundary option (#4765)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Manuel Schiller <[email protected]>
1 parent b39b891 commit e831993

File tree

6 files changed

+241
-26
lines changed

6 files changed

+241
-26
lines changed

docs/router/framework/react/api/router/RouterOptionsType.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,14 @@ The `RouterOptions` type accepts an object with the following properties and met
136136
- Optional
137137
- The default `onCatch` handler for errors caught by the Router ErrorBoundary
138138

139+
### `disableGlobalCatchBoundary` property
140+
141+
- Type: `boolean`
142+
- Optional
143+
- Defaults to `false`
144+
- When `true`, disables the global catch boundary that normally wraps all route matches. This allows unhandled errors to bubble up to top-level error handlers in the browser.
145+
- Useful for testing tools, error reporting services, and debugging scenarios.
146+
139147
### `defaultViewTransition` property
140148

141149
- Type: `boolean | ViewTransitionOptions`

packages/react-router/src/Matches.tsx

Lines changed: 20 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ export function Matches() {
6767
}
6868

6969
function MatchesInner() {
70+
const router = useRouter()
7071
const matchId = useRouterState({
7172
select: (s) => {
7273
return s.matches[0]?.id
@@ -77,21 +78,27 @@ function MatchesInner() {
7778
select: (s) => s.loadedAt,
7879
})
7980

81+
const matchComponent = matchId ? <Match matchId={matchId} /> : null
82+
8083
return (
8184
<matchContext.Provider value={matchId}>
82-
<CatchBoundary
83-
getResetKey={() => resetKey}
84-
errorComponent={ErrorComponent}
85-
onCatch={(error) => {
86-
warning(
87-
false,
88-
`The following error wasn't caught by any route! At the very least, consider setting an 'errorComponent' in your RootRoute!`,
89-
)
90-
warning(false, error.message || error.toString())
91-
}}
92-
>
93-
{matchId ? <Match matchId={matchId} /> : null}
94-
</CatchBoundary>
85+
{router.options.disableGlobalCatchBoundary ? (
86+
matchComponent
87+
) : (
88+
<CatchBoundary
89+
getResetKey={() => resetKey}
90+
errorComponent={ErrorComponent}
91+
onCatch={(error) => {
92+
warning(
93+
false,
94+
`The following error wasn't caught by any route! At the very least, consider setting an 'errorComponent' in your RootRoute!`,
95+
)
96+
warning(false, error.message || error.toString())
97+
}}
98+
>
99+
{matchComponent}
100+
</CatchBoundary>
101+
)}
95102
</matchContext.Provider>
96103
)
97104
}
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
2+
import { cleanup, render, screen } from '@testing-library/react'
3+
import { Component } from 'react'
4+
import {
5+
RouterProvider,
6+
createBrowserHistory,
7+
createRootRoute,
8+
createRoute,
9+
createRouter,
10+
} from '../src'
11+
import type { ReactNode } from 'react'
12+
13+
import type { RouterHistory } from '../src'
14+
15+
function ThrowingComponent() {
16+
throw new Error('Test error')
17+
}
18+
19+
// Custom error boundary to catch errors that bubble up
20+
class TestErrorBoundary extends Component<
21+
{ children: ReactNode },
22+
{ hasError: boolean; error?: Error }
23+
> {
24+
constructor(props: { children: ReactNode }) {
25+
super(props)
26+
this.state = { hasError: false }
27+
}
28+
29+
static getDerivedStateFromError(error: Error) {
30+
return { hasError: true, error }
31+
}
32+
33+
render() {
34+
if (this.state.hasError) {
35+
return (
36+
<div>External Error Boundary Caught: {this.state.error?.message}</div>
37+
)
38+
}
39+
40+
return this.props.children
41+
}
42+
}
43+
44+
let history: RouterHistory
45+
let originalOnError: typeof window.onerror
46+
47+
beforeEach(() => {
48+
history = createBrowserHistory()
49+
originalOnError = window.onerror
50+
expect(window.location.pathname).toBe('/')
51+
})
52+
53+
afterEach(() => {
54+
history.destroy()
55+
window.onerror = originalOnError
56+
vi.resetAllMocks()
57+
window.history.replaceState(null, 'root', '/')
58+
cleanup()
59+
})
60+
61+
function createTestRouter(disableGlobalCatchBoundary: boolean) {
62+
const rootRoute = createRootRoute()
63+
const indexRoute = createRoute({
64+
getParentRoute: () => rootRoute,
65+
path: '/',
66+
component: ThrowingComponent,
67+
})
68+
69+
const routeTree = rootRoute.addChildren([indexRoute])
70+
return createRouter({
71+
routeTree,
72+
history,
73+
disableGlobalCatchBoundary,
74+
})
75+
}
76+
77+
describe('disableGlobalCatchBoundary option', () => {
78+
test('catches errors in global boundary when disableGlobalCatchBoundary is false', async () => {
79+
const router = createTestRouter(false)
80+
81+
render(<RouterProvider router={router} />)
82+
83+
// The global CatchBoundary shows "Something went wrong!" by default
84+
const errorElement = await screen.findByText('Something went wrong!')
85+
expect(errorElement).toBeInTheDocument()
86+
})
87+
88+
test('errors bubble up to external error boundary when disableGlobalCatchBoundary is true', async () => {
89+
const router = createTestRouter(true)
90+
91+
// Wrap RouterProvider in an external error boundary
92+
render(
93+
<TestErrorBoundary>
94+
<RouterProvider router={router} />
95+
</TestErrorBoundary>,
96+
)
97+
98+
// Error should bubble up and be caught by the external error boundary
99+
const externalErrorElement = await screen.findByText(
100+
'External Error Boundary Caught: Test error',
101+
)
102+
expect(externalErrorElement).toBeInTheDocument()
103+
})
104+
})

packages/router-core/src/router.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -407,6 +407,17 @@ export interface RouterOptions<
407407
* @default ['window']
408408
*/
409409
scrollToTopSelectors?: Array<string | (() => Element | null | undefined)>
410+
411+
/**
412+
* When `true`, disables the global catch boundary that normally wraps all route matches.
413+
* This allows unhandled errors to bubble up to top-level error handlers in the browser.
414+
*
415+
* Useful for testing tools (like Storybook Test Runner), error reporting services,
416+
* and debugging scenarios where you want errors to reach the browser's global error handlers.
417+
*
418+
* @default false
419+
*/
420+
disableGlobalCatchBoundary?: boolean
410421
}
411422

412423
export interface RouterState<

packages/solid-router/src/Matches.tsx

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ export function Matches() {
6363
}
6464

6565
function MatchesInner() {
66+
const router = useRouter()
6667
const matchId = useRouterState({
6768
select: (s) => {
6869
return s.matches[0]?.id
@@ -73,21 +74,28 @@ function MatchesInner() {
7374
select: (s) => s.loadedAt,
7475
})
7576

77+
const matchComponent = () =>
78+
matchId() ? <Match matchId={matchId()!} /> : null
79+
7680
return (
7781
<matchContext.Provider value={matchId}>
78-
<CatchBoundary
79-
getResetKey={() => resetKey()}
80-
errorComponent={ErrorComponent}
81-
onCatch={(error) => {
82-
warning(
83-
false,
84-
`The following error wasn't caught by any route! At the very least, consider setting an 'errorComponent' in your RootRoute!`,
85-
)
86-
warning(false, error.message || error.toString())
87-
}}
88-
>
89-
{matchId() ? <Match matchId={matchId()!} /> : null}
90-
</CatchBoundary>
82+
{router.options.disableGlobalCatchBoundary ? (
83+
matchComponent()
84+
) : (
85+
<CatchBoundary
86+
getResetKey={() => resetKey()}
87+
errorComponent={ErrorComponent}
88+
onCatch={(error) => {
89+
warning(
90+
false,
91+
`The following error wasn't caught by any route! At the very least, consider setting an 'errorComponent' in your RootRoute!`,
92+
)
93+
warning(false, error.message || error.toString())
94+
}}
95+
>
96+
{matchComponent()}
97+
</CatchBoundary>
98+
)}
9199
</matchContext.Provider>
92100
)
93101
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { afterEach, describe, expect, test, vi } from 'vitest'
2+
import { cleanup, render, screen } from '@solidjs/testing-library'
3+
import { ErrorBoundary } from 'solid-js'
4+
import {
5+
RouterProvider,
6+
createRootRoute,
7+
createRoute,
8+
createRouter,
9+
} from '../src'
10+
import type { JSX } from 'solid-js'
11+
12+
function ThrowingComponent() {
13+
throw new Error('Test error')
14+
}
15+
16+
// Custom error boundary to catch errors that bubble up
17+
function TestErrorBoundary(props: { children: JSX.Element }) {
18+
return (
19+
<ErrorBoundary
20+
fallback={(err) => (
21+
<div>External Error Boundary Caught: {err.message}</div>
22+
)}
23+
>
24+
{props.children}
25+
</ErrorBoundary>
26+
)
27+
}
28+
29+
function createTestRouter(disableGlobalCatchBoundary: boolean) {
30+
const rootRoute = createRootRoute({})
31+
const indexRoute = createRoute({
32+
getParentRoute: () => rootRoute,
33+
path: '/',
34+
component: ThrowingComponent,
35+
})
36+
37+
const routeTree = rootRoute.addChildren([indexRoute])
38+
return createRouter({
39+
routeTree,
40+
disableGlobalCatchBoundary,
41+
})
42+
}
43+
44+
afterEach(() => {
45+
vi.resetAllMocks()
46+
window.history.replaceState(null, 'root', '/')
47+
cleanup()
48+
})
49+
50+
describe('disableGlobalCatchBoundary option', () => {
51+
test('catches errors in global boundary when disableGlobalCatchBoundary is false', async () => {
52+
const router = createTestRouter(false)
53+
54+
render(() => <RouterProvider router={router} />)
55+
56+
// The global CatchBoundary shows "Something went wrong!" by default
57+
const errorElement = await screen.findByText('Something went wrong!')
58+
expect(errorElement).toBeInTheDocument()
59+
})
60+
61+
test('errors bubble up to external error boundary when disableGlobalCatchBoundary is true', async () => {
62+
const router = createTestRouter(true)
63+
64+
// Wrap RouterProvider in an external error boundary
65+
render(() => (
66+
<TestErrorBoundary>
67+
<RouterProvider router={router} />
68+
</TestErrorBoundary>
69+
))
70+
71+
// Error should bubble up and be caught by the external error boundary
72+
const externalErrorElement = await screen.findByText(
73+
'External Error Boundary Caught: Test error',
74+
)
75+
expect(externalErrorElement).toBeInTheDocument()
76+
})
77+
})

0 commit comments

Comments
 (0)