Skip to content

Commit

Permalink
feat: Add support for React error handlers (#1354)
Browse files Browse the repository at this point in the history
  • Loading branch information
eps1lon authored Jan 15, 2025
1 parent eab6e67 commit 9618c51
Show file tree
Hide file tree
Showing 6 changed files with 252 additions and 5 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@
"kcd-scripts": "^13.0.0",
"npm-run-all": "^4.1.5",
"react": "^18.3.1",
"react-dom": "^18.3.0",
"react-dom": "^18.3.1",
"rimraf": "^3.0.2",
"typescript": "^4.1.2"
},
Expand Down
183 changes: 183 additions & 0 deletions src/__tests__/error-handlers.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
/* eslint-disable jest/no-if */
/* eslint-disable jest/no-conditional-in-test */
/* eslint-disable jest/no-conditional-expect */
import * as React from 'react'
import {render, renderHook} from '../'

const isReact19 = React.version.startsWith('19.')

const testGateReact19 = isReact19 ? test : test.skip

test('render errors', () => {
function Thrower() {
throw new Error('Boom!')
}

if (isReact19) {
expect(() => {
render(<Thrower />)
}).toThrow('Boom!')
} else {
expect(() => {
expect(() => {
render(<Thrower />)
}).toThrow('Boom!')
}).toErrorDev([
'Error: Uncaught [Error: Boom!]',
// React retries on error
'Error: Uncaught [Error: Boom!]',
])
}
})

test('onUncaughtError is not supported in render', () => {
function Thrower() {
throw new Error('Boom!')
}
const onUncaughtError = jest.fn(() => {})

expect(() => {
render(<Thrower />, {
onUncaughtError(error, errorInfo) {
console.log({error, errorInfo})
},
})
}).toThrow(
'onUncaughtError is not supported. The `render` call will already throw on uncaught errors.',
)

expect(onUncaughtError).toHaveBeenCalledTimes(0)
})

testGateReact19('onCaughtError is supported in render', () => {
const thrownError = new Error('Boom!')
const handleComponentDidCatch = jest.fn()
const onCaughtError = jest.fn()
class ErrorBoundary extends React.Component {
state = {error: null}
static getDerivedStateFromError(error) {
return {error}
}
componentDidCatch(error, errorInfo) {
handleComponentDidCatch(error, errorInfo)
}
render() {
if (this.state.error) {
return null
}
return this.props.children
}
}
function Thrower() {
throw thrownError
}

render(
<ErrorBoundary>
<Thrower />
</ErrorBoundary>,
{
onCaughtError,
},
)

expect(onCaughtError).toHaveBeenCalledWith(thrownError, {
componentStack: expect.any(String),
errorBoundary: expect.any(Object),
})
})

test('onRecoverableError is supported in render', () => {
const onRecoverableError = jest.fn()

const container = document.createElement('div')
container.innerHTML = '<div>server</div>'
// We just hope we forwarded the callback correctly (which is guaranteed since we just pass it along)
// Frankly, I'm too lazy to assert on React 18 hydration errors since they're a mess.
// eslint-disable-next-line jest/no-conditional-in-test
if (isReact19) {
render(<div>client</div>, {
container,
hydrate: true,
onRecoverableError,
})
expect(onRecoverableError).toHaveBeenCalledTimes(1)
} else {
expect(() => {
render(<div>client</div>, {
container,
hydrate: true,
onRecoverableError,
})
}).toErrorDev(['', ''], {withoutStack: 1})
expect(onRecoverableError).toHaveBeenCalledTimes(2)
}
})

test('onUncaughtError is not supported in renderHook', () => {
function useThrower() {
throw new Error('Boom!')
}
const onUncaughtError = jest.fn(() => {})

expect(() => {
renderHook(useThrower, {
onUncaughtError(error, errorInfo) {
console.log({error, errorInfo})
},
})
}).toThrow(
'onUncaughtError is not supported. The `render` call will already throw on uncaught errors.',
)

expect(onUncaughtError).toHaveBeenCalledTimes(0)
})

testGateReact19('onCaughtError is supported in renderHook', () => {
const thrownError = new Error('Boom!')
const handleComponentDidCatch = jest.fn()
const onCaughtError = jest.fn()
class ErrorBoundary extends React.Component {
state = {error: null}
static getDerivedStateFromError(error) {
return {error}
}
componentDidCatch(error, errorInfo) {
handleComponentDidCatch(error, errorInfo)
}
render() {
if (this.state.error) {
return null
}
return this.props.children
}
}
function useThrower() {
throw thrownError
}

renderHook(useThrower, {
onCaughtError,
wrapper: ErrorBoundary,
})

expect(onCaughtError).toHaveBeenCalledWith(thrownError, {
componentStack: expect.any(String),
errorBoundary: expect.any(Object),
})
})

// Currently, there's no recoverable error without hydration.
// The option is still supported though.
test('onRecoverableError is supported in renderHook', () => {
const onRecoverableError = jest.fn()

renderHook(
() => {
// TODO: trigger recoverable error
},
{
onRecoverableError,
},
)
})
24 changes: 21 additions & 3 deletions src/pure.js
Original file line number Diff line number Diff line change
Expand Up @@ -91,18 +91,22 @@ function wrapUiIfNeeded(innerElement, wrapperComponent) {

function createConcurrentRoot(
container,
{hydrate, ui, wrapper: WrapperComponent},
{hydrate, onCaughtError, onRecoverableError, ui, wrapper: WrapperComponent},
) {
let root
if (hydrate) {
act(() => {
root = ReactDOMClient.hydrateRoot(
container,
strictModeIfNeeded(wrapUiIfNeeded(ui, WrapperComponent)),
{onCaughtError, onRecoverableError},
)
})
} else {
root = ReactDOMClient.createRoot(container)
root = ReactDOMClient.createRoot(container, {
onCaughtError,
onRecoverableError,
})
}

return {
Expand Down Expand Up @@ -202,11 +206,19 @@ function render(
container,
baseElement = container,
legacyRoot = false,
onCaughtError,
onUncaughtError,
onRecoverableError,
queries,
hydrate = false,
wrapper,
} = {},
) {
if (onUncaughtError !== undefined) {
throw new Error(
'onUncaughtError is not supported. The `render` call will already throw on uncaught errors.',
)
}
if (legacyRoot && typeof ReactDOM.render !== 'function') {
const error = new Error(
'`legacyRoot: true` is not supported in this version of React. ' +
Expand All @@ -230,7 +242,13 @@ function render(
// 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.
if (!mountedContainers.has(container)) {
const createRootImpl = legacyRoot ? createLegacyRoot : createConcurrentRoot
root = createRootImpl(container, {hydrate, ui, wrapper})
root = createRootImpl(container, {
hydrate,
onCaughtError,
onRecoverableError,
ui,
wrapper,
})

mountedRootEntries.push({container, root})
// we'll add it to the mounted containers regardless of whether it's actually
Expand Down
2 changes: 1 addition & 1 deletion tests/toWarnDev.js
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ const createMatcherFor = (consoleMethod, matcherName) =>
// doesn't match the number of arguments.
// We'll fail the test if it happens.
let argIndex = 0
format.replace(/%s/g, () => argIndex++)
String(format).replace(/%s/g, () => argIndex++)
if (argIndex !== args.length) {
lastWarningWithMismatchingFormat = {
format,
Expand Down
24 changes: 24 additions & 0 deletions types/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,30 @@ export interface RenderOptions<
* Otherwise `render` will default to concurrent React if available.
*/
legacyRoot?: boolean | undefined
/**
* Only supported in React 19.
* Callback called when React catches an error in an Error Boundary.
* Called with the error caught by the Error Boundary, and an `errorInfo` object containing the `componentStack`.
*
* @see {@link https://react.dev/reference/react-dom/client/createRoot#parameters createRoot#options}
*/
onCaughtError?: ReactDOMClient.RootOptions extends {
onCaughtError: infer OnCaughtError
}
? OnCaughtError
: never
/**
* Callback called when React automatically recovers from errors.
* Called with an error React throws, and an `errorInfo` object containing the `componentStack`.
* Some recoverable errors may include the original error cause as `error.cause`.
*
* @see {@link https://react.dev/reference/react-dom/client/createRoot#parameters createRoot#options}
*/
onRecoverableError?: ReactDOMClient.RootOptions['onRecoverableError']
/**
* Not supported at the moment
*/
onUncaughtError?: never
/**
* Queries to bind. Overrides the default set from DOM Testing Library unless merged.
*
Expand Down
22 changes: 22 additions & 0 deletions types/test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,28 @@ export function testContainer() {
renderHook(() => null, {container: document, hydrate: true})
}

export function testErrorHandlers() {
// React 19 types are not used in tests. Verify manually if this works with `"@types/react": "npm:types-react@rc"`
render(null, {
// Should work with React 19 types
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
onCaughtError: () => {},
})
render(null, {
// Should never work as it's not supported yet.
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
onUncaughtError: () => {},
})
render(null, {
onRecoverableError: (error, errorInfo) => {
console.error(error)
console.log(errorInfo.componentStack)
},
})
}

/*
eslint
testing-library/prefer-explicit-assert: "off",
Expand Down

0 comments on commit 9618c51

Please sign in to comment.