Skip to content

Commit da81419

Browse files
committed
feat: Add support for React error handlers
1 parent 85ac253 commit da81419

File tree

4 files changed

+226
-3
lines changed

4 files changed

+226
-3
lines changed

Diff for: src/__tests__/error-handlers.js

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

Diff for: 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

Diff for: 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
*

Diff for: 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)