From 7134e5d7adc9f10b6ddaf6efdca376fac2f0b521 Mon Sep 17 00:00:00 2001 From: "Sebastian \"Sebbie\" Silbermann" Date: Thu, 16 Jan 2025 00:09:31 +0100 Subject: [PATCH 1/8] [ci] Fix codecov action (#1376) --- .github/workflows/validate.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index aa4eeed7..fe69085a 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -59,11 +59,10 @@ jobs: 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 }} - token: ${{ secrets.CODECOV_TOKEN }} release: permissions: From eab6e679a30eb87c1019717bb1fe1b57e5207c39 Mon Sep 17 00:00:00 2001 From: Sebastian Sebbie Silbermann Date: Thu, 16 Jan 2025 00:11:07 +0100 Subject: [PATCH 2/8] [ci] Codecov fix follow-up Accidentally removed the token which is still required for protected branches. --- .github/workflows/validate.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index fe69085a..488c633b 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -63,6 +63,7 @@ jobs: with: fail_ci_if_error: true flags: ${{ matrix.react }} + token: ${{ secrets.CODECOV_TOKEN }} release: permissions: From 9618c5133706ec964f649e60a777cc384db58a3f Mon Sep 17 00:00:00 2001 From: "Sebastian \"Sebbie\" Silbermann" Date: Thu, 16 Jan 2025 00:16:35 +0100 Subject: [PATCH 3/8] feat: Add support for React error handlers (#1354) --- package.json | 2 +- src/__tests__/error-handlers.js | 183 ++++++++++++++++++++++++++++++++ src/pure.js | 24 ++++- tests/toWarnDev.js | 2 +- types/index.d.ts | 24 +++++ types/test.tsx | 22 ++++ 6 files changed, 252 insertions(+), 5 deletions(-) create mode 100644 src/__tests__/error-handlers.js 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", From c3e3d9027c325ef169f139d449dcd65ffe444ac4 Mon Sep 17 00:00:00 2001 From: "Sebastian \"Sebbie\" Silbermann" Date: Thu, 16 Jan 2025 00:51:40 +0100 Subject: [PATCH 4/8] test: Use React 19 by default (#1377) --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 81ee82c9..146c7d02 100644 --- a/package.json +++ b/package.json @@ -57,8 +57,8 @@ "jest-diff": "^29.7.0", "kcd-scripts": "^13.0.0", "npm-run-all": "^4.1.5", - "react": "^18.3.1", - "react-dom": "^18.3.1", + "react": "^19.0.0", + "react-dom": "^19.0.0", "rimraf": "^3.0.2", "typescript": "^4.1.2" }, From 65bc994e7d4c1c388c51826f5352cf0320abb008 Mon Sep 17 00:00:00 2001 From: "Sebastian \"Sebbie\" Silbermann" Date: Mon, 17 Mar 2025 22:24:31 +0100 Subject: [PATCH 5/8] test: Run with relevant React stable types (#1352) --- .github/workflows/validate.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 488c633b..f239c717 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -55,6 +55,12 @@ 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 From 8782f3be71eb2384df6c546dddab515867de3d7a Mon Sep 17 00:00:00 2001 From: Bernardo Belchior Date: Wed, 2 Apr 2025 17:57:28 +0100 Subject: [PATCH 6/8] Add `reactStrictMode` as an option to `render` (#1390) --- src/__tests__/render.js | 34 ++++++++++++++++++++++++++++++ src/__tests__/renderHook.js | 32 ++++++++++++++++++++++++++-- src/pure.js | 42 ++++++++++++++++++++++++++++++------- types/index.d.ts | 5 +++++ 4 files changed, 104 insertions(+), 9 deletions(-) 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 fe95024a..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,14 +91,24 @@ function wrapUiIfNeeded(innerElement, wrapperComponent) { function createConcurrentRoot( container, - {hydrate, onCaughtError, onRecoverableError, 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}, ) }) @@ -144,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, ) } @@ -180,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 @@ -212,6 +237,7 @@ function render( queries, hydrate = false, wrapper, + reactStrictMode, } = {}, ) { if (onUncaughtError !== undefined) { @@ -248,6 +274,7 @@ function render( onRecoverableError, ui, wrapper, + reactStrictMode, }) mountedRootEntries.push({container, root}) @@ -273,6 +300,7 @@ function render( hydrate, wrapper, root, + reactStrictMode, }) } diff --git a/types/index.d.ts b/types/index.d.ts index 2f814a6d..bdd60567 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -156,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> From 9fc6a75d74bb8e03a48d3339efde4dd83cd5328b Mon Sep 17 00:00:00 2001 From: "allcontributors[bot]" <46447321+allcontributors[bot]@users.noreply.github.com> Date: Wed, 2 Apr 2025 19:01:06 +0200 Subject: [PATCH 7/8] feat: add bernardobelchior as a contributor for code, and doc (#1391) Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com> --- .all-contributorsrc | 10 ++++++++++ README.md | 1 + 2 files changed, 11 insertions(+) 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/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
Colin Diesh

📖 Yusuke Iinuma
Yusuke Iinuma

💻 Jeff Way
Jeff Way

💻 + Bernardo Belchior
Bernardo Belchior

💻 📖 From 1c931a6c03091d725eccee7767d9ec696d5d33c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20De=20Boey?= Date: Thu, 7 Aug 2025 20:13:48 +0200 Subject: [PATCH 8/8] chore(deps): use `npm-run-all2` (#1411) --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 146c7d02..b1bff976 100644 --- a/package.json +++ b/package.json @@ -56,7 +56,7 @@ "dotenv-cli": "^4.0.0", "jest-diff": "^29.7.0", "kcd-scripts": "^13.0.0", - "npm-run-all": "^4.1.5", + "npm-run-all2": "^6.2.6", "react": "^19.0.0", "react-dom": "^19.0.0", "rimraf": "^3.0.2",