diff --git a/package.json b/package.json index 711140d1..81ee82c9 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/src/__tests__/error-handlers.js b/src/__tests__/error-handlers.js new file mode 100644 index 00000000..60db1410 --- /dev/null +++ b/src/__tests__/error-handlers.js @@ -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() + }).toThrow('Boom!') + } else { + expect(() => { + expect(() => { + render() + }).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(, { + 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( + + + , + { + 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 = '
server
' + // 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(
client
, { + container, + hydrate: true, + onRecoverableError, + }) + expect(onRecoverableError).toHaveBeenCalledTimes(1) + } else { + expect(() => { + render(
client
, { + 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, + }, + ) +}) diff --git a/src/pure.js b/src/pure.js index f546af98..fe95024a 100644 --- a/src/pure.js +++ b/src/pure.js @@ -91,7 +91,7 @@ function wrapUiIfNeeded(innerElement, wrapperComponent) { function createConcurrentRoot( container, - {hydrate, ui, wrapper: WrapperComponent}, + {hydrate, onCaughtError, onRecoverableError, ui, wrapper: WrapperComponent}, ) { let root if (hydrate) { @@ -99,10 +99,14 @@ function createConcurrentRoot( root = ReactDOMClient.hydrateRoot( container, strictModeIfNeeded(wrapUiIfNeeded(ui, WrapperComponent)), + {onCaughtError, onRecoverableError}, ) }) } else { - root = ReactDOMClient.createRoot(container) + root = ReactDOMClient.createRoot(container, { + onCaughtError, + onRecoverableError, + }) } return { @@ -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. ' + @@ -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 diff --git a/tests/toWarnDev.js b/tests/toWarnDev.js index 2aae39f0..3005125e 100644 --- a/tests/toWarnDev.js +++ b/tests/toWarnDev.js @@ -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, diff --git a/types/index.d.ts b/types/index.d.ts index 3ad8cf46..2f814a6d 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -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. * diff --git a/types/test.tsx b/types/test.tsx index 67832b23..825d5699 100644 --- a/types/test.tsx +++ b/types/test.tsx @@ -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",