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 85613475..7e18d5dd 100644 --- a/README.md +++ b/README.md @@ -97,10 +97,12 @@ primary guiding principle is: ## Installation This module is distributed via [npm][npm] which is bundled with [node][node] and -should be installed as one of your project's `devDependencies`: +should be installed as one of your project's `devDependencies`. +Starting from RTL version 16, you'll also need to install +`@testing-library/dom`: ``` -npm install --save-dev @testing-library/react +npm install --save-dev @testing-library/react @testing-library/dom ``` or @@ -108,10 +110,11 @@ or for installation via [yarn][yarn] ``` -yarn add --dev @testing-library/react +yarn add --dev @testing-library/react @testing-library/dom ``` -This library has `peerDependencies` listings for `react` and `react-dom`. +This library has `peerDependencies` listings for `react`, `react-dom` and +starting from RTL version 16 also `@testing-library/dom`. _React Testing Library versions 13+ require React v18. If your project uses an older version of React, be sure to install version 12:_ @@ -632,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

💻 📖 diff --git a/package.json b/package.json index 3c38a74e..b1bff976 100644 --- a/package.json +++ b/package.json @@ -45,25 +45,37 @@ "author": "Kent C. Dodds (https://kentcdodds.com)", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.12.5", - "@testing-library/dom": "^10.0.0", - "@types/react-dom": "^18.0.0" + "@babel/runtime": "^7.12.5" }, "devDependencies": { + "@testing-library/dom": "^10.0.0", "@testing-library/jest-dom": "^5.11.6", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", "chalk": "^4.1.2", "dotenv-cli": "^4.0.0", "jest-diff": "^29.7.0", "kcd-scripts": "^13.0.0", - "npm-run-all": "^4.1.5", - "react": "^18.3.0", - "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" }, "peerDependencies": { - "react": "^18.0.0", - "react-dom": "^18.0.0" + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } }, "eslintConfig": { "extends": "./node_modules/kcd-scripts/eslint.js", 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/setup-env.js b/tests/setup-env.js index c9b976f5..1a4401de 100644 --- a/tests/setup-env.js +++ b/tests/setup-env.js @@ -1,5 +1,9 @@ import '@testing-library/jest-dom/extend-expect' import './failOnUnexpectedConsoleCalls' import {TextEncoder} from 'util' +import {MessageChannel} from 'worker_threads' global.TextEncoder = TextEncoder +// TODO: Revisit once https://github.com/jsdom/jsdom/issues/2448 is resolved +// This isn't perfect but good enough. +global.MessageChannel = MessageChannel 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 099bbe84..bdd60567 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -7,7 +7,9 @@ import { prettyFormat, Config as ConfigDTL, } from '@testing-library/dom' -import {act as reactAct} from 'react-dom/test-utils' +import {act as reactDeprecatedAct} from 'react-dom/test-utils' +//@ts-ignore +import {act as reactAct} from 'react' export * from '@testing-library/dom' @@ -34,9 +36,10 @@ export type RenderResult< baseElement?: | RendererableContainer | HydrateableContainer - | Array, - maxLength?: number, - options?: prettyFormat.OptionsReceived, + | Array + | undefined, + maxLength?: number | undefined, + options?: prettyFormat.OptionsReceived | undefined, ) => void rerender: (ui: React.ReactNode) => void unmount: () => void @@ -95,40 +98,69 @@ export interface RenderOptions< * * @see https://testing-library.com/docs/react-testing-library/api/#container */ - container?: Container + container?: Container | undefined /** * Defaults to the container if the container is specified. Otherwise `document.body` is used for the default. This is used as * the base element for the queries as well as what is printed when you use `debug()`. * * @see https://testing-library.com/docs/react-testing-library/api/#baseelement */ - baseElement?: BaseElement + baseElement?: BaseElement | undefined /** * If `hydrate` is set to `true`, then it will render with `ReactDOM.hydrate`. This may be useful if you are using server-side * rendering and use ReactDOM.hydrate to mount your components. * * @see https://testing-library.com/docs/react-testing-library/api/#hydrate) */ - hydrate?: boolean + hydrate?: boolean | undefined /** * Only works if used with React 18. * Set to `true` if you want to force synchronous `ReactDOM.render`. * Otherwise `render` will default to concurrent React if available. */ - legacyRoot?: boolean + 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. * * @see https://testing-library.com/docs/react-testing-library/api/#queries */ - queries?: Q + queries?: Q | undefined /** * Pass a React Component as the wrapper option to have it rendered around the inner element. This is most useful for creating * reusable custom render functions for common data providers. See setup for examples. * * @see https://testing-library.com/docs/react-testing-library/api/#wrapper */ - wrapper?: React.JSXElementConstructor<{children: React.ReactNode}> + 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> @@ -146,7 +178,7 @@ export function render< ): RenderResult export function render( ui: React.ReactNode, - options?: Omit, + options?: Omit | undefined, ): RenderResult export interface RenderHookResult { @@ -221,7 +253,7 @@ export interface RenderHookOptions< * The argument passed to the renderHook callback. Can be useful if you plan * to use the rerender utility to change the values passed to your hook. */ - initialProps?: Props + initialProps?: Props | undefined } /** @@ -236,7 +268,7 @@ export function renderHook< BaseElement extends RendererableContainer | HydrateableContainer = Container, >( render: (initialProps: Props) => Result, - options?: RenderHookOptions, + options?: RenderHookOptions | undefined, ): RenderHookResult /** @@ -245,10 +277,11 @@ export function renderHook< export function cleanup(): void /** - * Simply calls ReactDOMTestUtils.act(cb) + * Simply calls React.act(cb) * If that's not available (older version of react) then it - * simply calls the given callback immediately + * simply calls the deprecated version which is ReactTestUtils.act(cb) */ -export const act: typeof reactAct extends undefined - ? (callback: () => void) => void +// IfAny from https://stackoverflow.com/a/61626123/3406963 +export const act: 0 extends 1 & typeof reactAct + ? typeof reactDeprecatedAct : typeof reactAct diff --git a/types/test.tsx b/types/test.tsx index f8cf4aad..825d5699 100644 --- a/types/test.tsx +++ b/types/test.tsx @@ -128,11 +128,20 @@ export function wrappedRender( ui: React.ReactNode, options?: pure.RenderOptions, ) { - const Wrapper = ({children}: {children: React.ReactNode}): JSX.Element => { + const Wrapper = ({ + children, + }: { + children: React.ReactNode + }): React.JSX.Element => { return
{children}
} - return pure.render(ui, {wrapper: Wrapper, ...options}) + return pure.render(ui, { + wrapper: Wrapper, + // testing exactOptionalPropertyTypes comaptibility + hydrate: options?.hydrate, + ...options, + }) } export function wrappedRenderB( @@ -254,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", diff --git a/types/tsconfig.json b/types/tsconfig.json index 4e7d649c..bad26af7 100644 --- a/types/tsconfig.json +++ b/types/tsconfig.json @@ -1,6 +1,7 @@ { "extends": "../node_modules/kcd-scripts/shared-tsconfig.json", "compilerOptions": { + "exactOptionalPropertyTypes": true, "skipLibCheck": false }, "include": ["."]