diff --git a/.all-contributorsrc b/.all-contributorsrc
index c3b86064..b22c9414 100644
--- a/.all-contributorsrc
+++ b/.all-contributorsrc
@@ -1371,6 +1371,16 @@
"contributions": [
"code"
]
+ },
+ {
+ "login": "bernardobelchior",
+ "name": "Bernardo Belchior",
+ "avatar_url": "https://avatars.githubusercontent.com/u/12778398?v=4",
+ "profile": "http://belchior.me",
+ "contributions": [
+ "code",
+ "doc"
+ ]
}
],
"contributorsPerLine": 7,
diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml
index aa4eeed7..f239c717 100644
--- a/.github/workflows/validate.yml
+++ b/.github/workflows/validate.yml
@@ -55,11 +55,17 @@ jobs:
- name: ⚛️ Setup react
run: npm install react@${{ matrix.react }} react-dom@${{ matrix.react }}
+ - name: ⚛️ Setup react types
+ if: ${{ matrix.react != 'canary' && matrix.react != 'experimental' }}
+ run:
+ npm install @types/react@${{ matrix.react }} @types/react-dom@${{
+ matrix.react }}
+
- name: ▶️ Run validate script
run: npm run validate
- name: ⬆️ Upload coverage report
- uses: codecov/codecov-action@v4
+ uses: codecov/codecov-action@v5
with:
fail_ci_if_error: true
flags: ${{ matrix.react }}
diff --git a/README.md b/README.md
index bffa75a5..7e18d5dd 100644
--- a/README.md
+++ b/README.md
@@ -635,6 +635,7 @@ Thanks goes to these people ([emoji key][emojis]):
 Colin Diesh 📖 |
 Yusuke Iinuma 💻 |
 Jeff Way 💻 |
+  Bernardo Belchior 💻 📖 |
diff --git a/package.json b/package.json
index 711140d1..b1bff976 100644
--- a/package.json
+++ b/package.json
@@ -56,9 +56,9 @@
"dotenv-cli": "^4.0.0",
"jest-diff": "^29.7.0",
"kcd-scripts": "^13.0.0",
- "npm-run-all": "^4.1.5",
- "react": "^18.3.1",
- "react-dom": "^18.3.0",
+ "npm-run-all2": "^6.2.6",
+ "react": "^19.0.0",
+ "react-dom": "^19.0.0",
"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/__tests__/render.js b/src/__tests__/render.js
index f00410b4..6f5b5b39 100644
--- a/src/__tests__/render.js
+++ b/src/__tests__/render.js
@@ -262,4 +262,38 @@ describe('render API', () => {
`\`legacyRoot: true\` is not supported in this version of React. If your app runs React 19 or later, you should remove this flag. If your app runs React 18 or earlier, visit https://react.dev/blog/2022/03/08/react-18-upgrade-guide for upgrade instructions.`,
)
})
+
+ test('reactStrictMode in renderOptions has precedence over config when rendering', () => {
+ const wrapperComponentMountEffect = jest.fn()
+ function WrapperComponent({children}) {
+ React.useEffect(() => {
+ wrapperComponentMountEffect()
+ })
+
+ return children
+ }
+ const ui =
+ configure({reactStrictMode: false})
+
+ render(ui, {wrapper: WrapperComponent, reactStrictMode: true})
+
+ expect(wrapperComponentMountEffect).toHaveBeenCalledTimes(2)
+ })
+
+ test('reactStrictMode in config is used when renderOptions does not specify reactStrictMode', () => {
+ const wrapperComponentMountEffect = jest.fn()
+ function WrapperComponent({children}) {
+ React.useEffect(() => {
+ wrapperComponentMountEffect()
+ })
+
+ return children
+ }
+ const ui =
+ configure({reactStrictMode: true})
+
+ render(ui, {wrapper: WrapperComponent})
+
+ expect(wrapperComponentMountEffect).toHaveBeenCalledTimes(2)
+ })
})
diff --git a/src/__tests__/renderHook.js b/src/__tests__/renderHook.js
index fe7551a2..f331e90e 100644
--- a/src/__tests__/renderHook.js
+++ b/src/__tests__/renderHook.js
@@ -1,5 +1,5 @@
-import React from 'react'
-import {renderHook} from '../pure'
+import React, {useEffect} from 'react'
+import {configure, renderHook} from '../pure'
const isReact18 = React.version.startsWith('18.')
const isReact19 = React.version.startsWith('19.')
@@ -111,3 +111,31 @@ testGateReact19('legacyRoot throws', () => {
`\`legacyRoot: true\` is not supported in this version of React. If your app runs React 19 or later, you should remove this flag. If your app runs React 18 or earlier, visit https://react.dev/blog/2022/03/08/react-18-upgrade-guide for upgrade instructions.`,
)
})
+
+describe('reactStrictMode', () => {
+ let originalConfig
+ beforeEach(() => {
+ // Grab the existing configuration so we can restore
+ // it at the end of the test
+ configure(existingConfig => {
+ originalConfig = existingConfig
+ // Don't change the existing config
+ return {}
+ })
+ })
+
+ afterEach(() => {
+ configure(originalConfig)
+ })
+
+ test('reactStrictMode in renderOptions has precedence over config when rendering', () => {
+ const hookMountEffect = jest.fn()
+ configure({reactStrictMode: false})
+
+ renderHook(() => useEffect(() => hookMountEffect()), {
+ reactStrictMode: true,
+ })
+
+ expect(hookMountEffect).toHaveBeenCalledTimes(2)
+ })
+})
diff --git a/src/pure.js b/src/pure.js
index f546af98..0f9c487d 100644
--- a/src/pure.js
+++ b/src/pure.js
@@ -77,8 +77,8 @@ const mountedContainers = new Set()
*/
const mountedRootEntries = []
-function strictModeIfNeeded(innerElement) {
- return getConfig().reactStrictMode
+function strictModeIfNeeded(innerElement, reactStrictMode) {
+ return reactStrictMode ?? getConfig().reactStrictMode
? React.createElement(React.StrictMode, null, innerElement)
: innerElement
}
@@ -91,18 +91,32 @@ function wrapUiIfNeeded(innerElement, wrapperComponent) {
function createConcurrentRoot(
container,
- {hydrate, ui, wrapper: WrapperComponent},
+ {
+ hydrate,
+ onCaughtError,
+ onRecoverableError,
+ ui,
+ wrapper: WrapperComponent,
+ reactStrictMode,
+ },
) {
let root
if (hydrate) {
act(() => {
root = ReactDOMClient.hydrateRoot(
container,
- strictModeIfNeeded(wrapUiIfNeeded(ui, WrapperComponent)),
+ strictModeIfNeeded(
+ wrapUiIfNeeded(ui, WrapperComponent),
+ reactStrictMode,
+ ),
+ {onCaughtError, onRecoverableError},
)
})
} else {
- root = ReactDOMClient.createRoot(container)
+ root = ReactDOMClient.createRoot(container, {
+ onCaughtError,
+ onRecoverableError,
+ })
}
return {
@@ -140,17 +154,31 @@ function createLegacyRoot(container) {
function renderRoot(
ui,
- {baseElement, container, hydrate, queries, root, wrapper: WrapperComponent},
+ {
+ baseElement,
+ container,
+ hydrate,
+ queries,
+ root,
+ wrapper: WrapperComponent,
+ reactStrictMode,
+ },
) {
act(() => {
if (hydrate) {
root.hydrate(
- strictModeIfNeeded(wrapUiIfNeeded(ui, WrapperComponent)),
+ strictModeIfNeeded(
+ wrapUiIfNeeded(ui, WrapperComponent),
+ reactStrictMode,
+ ),
container,
)
} else {
root.render(
- strictModeIfNeeded(wrapUiIfNeeded(ui, WrapperComponent)),
+ strictModeIfNeeded(
+ wrapUiIfNeeded(ui, WrapperComponent),
+ reactStrictMode,
+ ),
container,
)
}
@@ -176,6 +204,7 @@ function renderRoot(
baseElement,
root,
wrapper: WrapperComponent,
+ reactStrictMode,
})
// Intentionally do not return anything to avoid unnecessarily complicating the API.
// folks can use all the same utilities we return in the first place that are bound to the container
@@ -202,11 +231,20 @@ function render(
container,
baseElement = container,
legacyRoot = false,
+ onCaughtError,
+ onUncaughtError,
+ onRecoverableError,
queries,
hydrate = false,
wrapper,
+ reactStrictMode,
} = {},
) {
+ 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 +268,14 @@ 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,
+ reactStrictMode,
+ })
mountedRootEntries.push({container, root})
// we'll add it to the mounted containers regardless of whether it's actually
@@ -255,6 +300,7 @@ function render(
hydrate,
wrapper,
root,
+ reactStrictMode,
})
}
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..bdd60567 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.
*
@@ -132,6 +156,11 @@ export interface RenderOptions<
* @see https://testing-library.com/docs/react-testing-library/api/#wrapper
*/
wrapper?: React.JSXElementConstructor<{children: React.ReactNode}> | undefined
+ /**
+ * When enabled, is rendered around the inner element.
+ * If defined, overrides the value of `reactStrictMode` set in `configure`.
+ */
+ reactStrictMode?: boolean
}
type Omit = Pick>
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",