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