Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add support for React error handlers #1354

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,16 +58,16 @@
"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"
},
"peerDependencies": {
"@testing-library/dom": "^10.0.0",
"@types/react": "^18.0.0",
"@types/react-dom": "^18.0.0",
"react": "^18.0.0",
"react-dom": "^18.0.0"
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"peerDependenciesMeta": {
"@types/react": {
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 @@ -118,6 +118,30 @@ export interface RenderOptions<
* Otherwise `render` will default to concurrent React if available.
*/
legacyRoot?: boolean
/**
* 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 @@ -254,6 +254,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