Skip to content

Commit 668f4d0

Browse files
committed
feat: Add support for React error handlers
1 parent 7a28fa9 commit 668f4d0

File tree

4 files changed

+226
-3
lines changed

4 files changed

+226
-3
lines changed

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+
})

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

types/index.d.ts

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

types/test.tsx

+22
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,28 @@ export function testContainer() {
254254
renderHook(() => null, {container: document, hydrate: true})
255255
}
256256

257+
export function testErrorHandlers() {
258+
// React 19 types are not used in tests. Verify manually if this works with `"@types/react": "npm:types-react@rc"`
259+
render(null, {
260+
// Should work with React 19 types
261+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
262+
// @ts-expect-error
263+
onCaughtError: () => {},
264+
})
265+
render(null, {
266+
// Should never work as it's not supported yet.
267+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
268+
// @ts-expect-error
269+
onUncaughtError: () => {},
270+
})
271+
render(null, {
272+
onRecoverableError: (error, errorInfo) => {
273+
console.error(error)
274+
console.log(errorInfo.componentStack)
275+
},
276+
})
277+
}
278+
257279
/*
258280
eslint
259281
testing-library/prefer-explicit-assert: "off",

0 commit comments

Comments
 (0)