Skip to content

Commit

Permalink
feat: Add support for React error handlers
Browse files Browse the repository at this point in the history
  • Loading branch information
eps1lon committed Aug 28, 2024
1 parent 7a28fa9 commit 5c899f6
Show file tree
Hide file tree
Showing 4 changed files with 222 additions and 3 deletions.
155 changes: 155 additions & 0 deletions src/__tests__/error-handlers.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import * as React from 'react'
import {render, renderHook} from '../'

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

const testGateReact19 = isReact19 ? test : test.skip

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>'
render(<div>client</div>, {
container,
hydrate: true,
onRecoverableError,
})

expect(onRecoverableError).toHaveBeenCalledWith(
expect.objectContaining({
message: expect.stringContaining('Hydration failed'),
}),
{
componentStack: expect.any(String),
},
undefined,
undefined,
undefined,
)
})

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
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

0 comments on commit 5c899f6

Please sign in to comment.