Skip to content

Commit 9618c51

Browse files
authoredJan 15, 2025··
feat: Add support for React error handlers (#1354)
1 parent eab6e67 commit 9618c51

File tree

6 files changed

+252
-5
lines changed

6 files changed

+252
-5
lines changed
 

‎package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@
5858
"kcd-scripts": "^13.0.0",
5959
"npm-run-all": "^4.1.5",
6060
"react": "^18.3.1",
61-
"react-dom": "^18.3.0",
61+
"react-dom": "^18.3.1",
6262
"rimraf": "^3.0.2",
6363
"typescript": "^4.1.2"
6464
},

‎src/__tests__/error-handlers.js

+183
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
/* eslint-disable jest/no-if */
2+
/* eslint-disable jest/no-conditional-in-test */
3+
/* eslint-disable jest/no-conditional-expect */
4+
import * as React from 'react'
5+
import {render, renderHook} from '../'
6+
7+
const isReact19 = React.version.startsWith('19.')
8+
9+
const testGateReact19 = isReact19 ? test : test.skip
10+
11+
test('render errors', () => {
12+
function Thrower() {
13+
throw new Error('Boom!')
14+
}
15+
16+
if (isReact19) {
17+
expect(() => {
18+
render(<Thrower />)
19+
}).toThrow('Boom!')
20+
} else {
21+
expect(() => {
22+
expect(() => {
23+
render(<Thrower />)
24+
}).toThrow('Boom!')
25+
}).toErrorDev([
26+
'Error: Uncaught [Error: Boom!]',
27+
// React retries on error
28+
'Error: Uncaught [Error: Boom!]',
29+
])
30+
}
31+
})
32+
33+
test('onUncaughtError is not supported in render', () => {
34+
function Thrower() {
35+
throw new Error('Boom!')
36+
}
37+
const onUncaughtError = jest.fn(() => {})
38+
39+
expect(() => {
40+
render(<Thrower />, {
41+
onUncaughtError(error, errorInfo) {
42+
console.log({error, errorInfo})
43+
},
44+
})
45+
}).toThrow(
46+
'onUncaughtError is not supported. The `render` call will already throw on uncaught errors.',
47+
)
48+
49+
expect(onUncaughtError).toHaveBeenCalledTimes(0)
50+
})
51+
52+
testGateReact19('onCaughtError is supported in render', () => {
53+
const thrownError = new Error('Boom!')
54+
const handleComponentDidCatch = jest.fn()
55+
const onCaughtError = jest.fn()
56+
class ErrorBoundary extends React.Component {
57+
state = {error: null}
58+
static getDerivedStateFromError(error) {
59+
return {error}
60+
}
61+
componentDidCatch(error, errorInfo) {
62+
handleComponentDidCatch(error, errorInfo)
63+
}
64+
render() {
65+
if (this.state.error) {
66+
return null
67+
}
68+
return this.props.children
69+
}
70+
}
71+
function Thrower() {
72+
throw thrownError
73+
}
74+
75+
render(
76+
<ErrorBoundary>
77+
<Thrower />
78+
</ErrorBoundary>,
79+
{
80+
onCaughtError,
81+
},
82+
)
83+
84+
expect(onCaughtError).toHaveBeenCalledWith(thrownError, {
85+
componentStack: expect.any(String),
86+
errorBoundary: expect.any(Object),
87+
})
88+
})
89+
90+
test('onRecoverableError is supported in render', () => {
91+
const onRecoverableError = jest.fn()
92+
93+
const container = document.createElement('div')
94+
container.innerHTML = '<div>server</div>'
95+
// We just hope we forwarded the callback correctly (which is guaranteed since we just pass it along)
96+
// Frankly, I'm too lazy to assert on React 18 hydration errors since they're a mess.
97+
// eslint-disable-next-line jest/no-conditional-in-test
98+
if (isReact19) {
99+
render(<div>client</div>, {
100+
container,
101+
hydrate: true,
102+
onRecoverableError,
103+
})
104+
expect(onRecoverableError).toHaveBeenCalledTimes(1)
105+
} else {
106+
expect(() => {
107+
render(<div>client</div>, {
108+
container,
109+
hydrate: true,
110+
onRecoverableError,
111+
})
112+
}).toErrorDev(['', ''], {withoutStack: 1})
113+
expect(onRecoverableError).toHaveBeenCalledTimes(2)
114+
}
115+
})
116+
117+
test('onUncaughtError is not supported in renderHook', () => {
118+
function useThrower() {
119+
throw new Error('Boom!')
120+
}
121+
const onUncaughtError = jest.fn(() => {})
122+
123+
expect(() => {
124+
renderHook(useThrower, {
125+
onUncaughtError(error, errorInfo) {
126+
console.log({error, errorInfo})
127+
},
128+
})
129+
}).toThrow(
130+
'onUncaughtError is not supported. The `render` call will already throw on uncaught errors.',
131+
)
132+
133+
expect(onUncaughtError).toHaveBeenCalledTimes(0)
134+
})
135+
136+
testGateReact19('onCaughtError is supported in renderHook', () => {
137+
const thrownError = new Error('Boom!')
138+
const handleComponentDidCatch = jest.fn()
139+
const onCaughtError = jest.fn()
140+
class ErrorBoundary extends React.Component {
141+
state = {error: null}
142+
static getDerivedStateFromError(error) {
143+
return {error}
144+
}
145+
componentDidCatch(error, errorInfo) {
146+
handleComponentDidCatch(error, errorInfo)
147+
}
148+
render() {
149+
if (this.state.error) {
150+
return null
151+
}
152+
return this.props.children
153+
}
154+
}
155+
function useThrower() {
156+
throw thrownError
157+
}
158+
159+
renderHook(useThrower, {
160+
onCaughtError,
161+
wrapper: ErrorBoundary,
162+
})
163+
164+
expect(onCaughtError).toHaveBeenCalledWith(thrownError, {
165+
componentStack: expect.any(String),
166+
errorBoundary: expect.any(Object),
167+
})
168+
})
169+
170+
// Currently, there's no recoverable error without hydration.
171+
// The option is still supported though.
172+
test('onRecoverableError is supported in renderHook', () => {
173+
const onRecoverableError = jest.fn()
174+
175+
renderHook(
176+
() => {
177+
// TODO: trigger recoverable error
178+
},
179+
{
180+
onRecoverableError,
181+
},
182+
)
183+
})

‎src/pure.js

+21-3
Original file line numberDiff line numberDiff line change
@@ -91,18 +91,22 @@ function wrapUiIfNeeded(innerElement, wrapperComponent) {
9191

9292
function createConcurrentRoot(
9393
container,
94-
{hydrate, ui, wrapper: WrapperComponent},
94+
{hydrate, onCaughtError, onRecoverableError, ui, wrapper: WrapperComponent},
9595
) {
9696
let root
9797
if (hydrate) {
9898
act(() => {
9999
root = ReactDOMClient.hydrateRoot(
100100
container,
101101
strictModeIfNeeded(wrapUiIfNeeded(ui, WrapperComponent)),
102+
{onCaughtError, onRecoverableError},
102103
)
103104
})
104105
} else {
105-
root = ReactDOMClient.createRoot(container)
106+
root = ReactDOMClient.createRoot(container, {
107+
onCaughtError,
108+
onRecoverableError,
109+
})
106110
}
107111

108112
return {
@@ -202,11 +206,19 @@ function render(
202206
container,
203207
baseElement = container,
204208
legacyRoot = false,
209+
onCaughtError,
210+
onUncaughtError,
211+
onRecoverableError,
205212
queries,
206213
hydrate = false,
207214
wrapper,
208215
} = {},
209216
) {
217+
if (onUncaughtError !== undefined) {
218+
throw new Error(
219+
'onUncaughtError is not supported. The `render` call will already throw on uncaught errors.',
220+
)
221+
}
210222
if (legacyRoot && typeof ReactDOM.render !== 'function') {
211223
const error = new Error(
212224
'`legacyRoot: true` is not supported in this version of React. ' +
@@ -230,7 +242,13 @@ function render(
230242
// eslint-disable-next-line no-negated-condition -- we want to map the evolution of this over time. The root is created first. Only later is it re-used so we don't want to read the case that happens later first.
231243
if (!mountedContainers.has(container)) {
232244
const createRootImpl = legacyRoot ? createLegacyRoot : createConcurrentRoot
233-
root = createRootImpl(container, {hydrate, ui, wrapper})
245+
root = createRootImpl(container, {
246+
hydrate,
247+
onCaughtError,
248+
onRecoverableError,
249+
ui,
250+
wrapper,
251+
})
234252

235253
mountedRootEntries.push({container, root})
236254
// we'll add it to the mounted containers regardless of whether it's actually

‎tests/toWarnDev.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ const createMatcherFor = (consoleMethod, matcherName) =>
115115
// doesn't match the number of arguments.
116116
// We'll fail the test if it happens.
117117
let argIndex = 0
118-
format.replace(/%s/g, () => argIndex++)
118+
String(format).replace(/%s/g, () => argIndex++)
119119
if (argIndex !== args.length) {
120120
lastWarningWithMismatchingFormat = {
121121
format,

‎types/index.d.ts

+24
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,30 @@ export interface RenderOptions<
119119
* Otherwise `render` will default to concurrent React if available.
120120
*/
121121
legacyRoot?: boolean | undefined
122+
/**
123+
* Only supported in React 19.
124+
* Callback called when React catches an error in an Error Boundary.
125+
* Called with the error caught by the Error Boundary, and an `errorInfo` object containing the `componentStack`.
126+
*
127+
* @see {@link https://react.dev/reference/react-dom/client/createRoot#parameters createRoot#options}
128+
*/
129+
onCaughtError?: ReactDOMClient.RootOptions extends {
130+
onCaughtError: infer OnCaughtError
131+
}
132+
? OnCaughtError
133+
: never
134+
/**
135+
* Callback called when React automatically recovers from errors.
136+
* Called with an error React throws, and an `errorInfo` object containing the `componentStack`.
137+
* Some recoverable errors may include the original error cause as `error.cause`.
138+
*
139+
* @see {@link https://react.dev/reference/react-dom/client/createRoot#parameters createRoot#options}
140+
*/
141+
onRecoverableError?: ReactDOMClient.RootOptions['onRecoverableError']
142+
/**
143+
* Not supported at the moment
144+
*/
145+
onUncaughtError?: never
122146
/**
123147
* Queries to bind. Overrides the default set from DOM Testing Library unless merged.
124148
*

‎types/test.tsx

+22
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,28 @@ export function testContainer() {
263263
renderHook(() => null, {container: document, hydrate: true})
264264
}
265265

266+
export function testErrorHandlers() {
267+
// React 19 types are not used in tests. Verify manually if this works with `"@types/react": "npm:types-react@rc"`
268+
render(null, {
269+
// Should work with React 19 types
270+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
271+
// @ts-expect-error
272+
onCaughtError: () => {},
273+
})
274+
render(null, {
275+
// Should never work as it's not supported yet.
276+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
277+
// @ts-expect-error
278+
onUncaughtError: () => {},
279+
})
280+
render(null, {
281+
onRecoverableError: (error, errorInfo) => {
282+
console.error(error)
283+
console.log(errorInfo.componentStack)
284+
},
285+
})
286+
}
287+
266288
/*
267289
eslint
268290
testing-library/prefer-explicit-assert: "off",

0 commit comments

Comments
 (0)
Please sign in to comment.