diff --git a/.all-contributorsrc b/.all-contributorsrc index e267d285..b22c9414 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -1326,8 +1326,65 @@ "contributions": [ "code" ] + }, + { + "login": "TkDodo", + "name": "Dominik Dorfmeister", + "avatar_url": "https://avatars.githubusercontent.com/u/1021430?v=4", + "profile": "http://tkdodo.eu", + "contributions": [ + "code" + ] + }, + { + "login": "stephensauceda", + "name": "Stephen Sauceda", + "avatar_url": "https://avatars.githubusercontent.com/u/1017723?v=4", + "profile": "https://stephensauceda.com", + "contributions": [ + "doc" + ] + }, + { + "login": "cmdcolin", + "name": "Colin Diesh", + "avatar_url": "https://avatars.githubusercontent.com/u/6511937?v=4", + "profile": "http://cmdcolin.github.io", + "contributions": [ + "doc" + ] + }, + { + "login": "yinm", + "name": "Yusuke Iinuma", + "avatar_url": "https://avatars.githubusercontent.com/u/13295106?v=4", + "profile": "http://yinm.info", + "contributions": [ + "code" + ] + }, + { + "login": "trappar", + "name": "Jeff Way", + "avatar_url": "https://avatars.githubusercontent.com/u/525726?v=4", + "profile": "https://github.com/trappar", + "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, - "repoHost": "https://github.com" + "repoHost": "https://github.com", + "commitType": "docs", + "commitConvention": "angular" } diff --git a/.codesandbox/ci.json b/.codesandbox/ci.json index bf3237bb..002bafb4 100644 --- a/.codesandbox/ci.json +++ b/.codesandbox/ci.json @@ -1,5 +1,5 @@ { "installCommand": "install:csb", "sandboxes": ["new", "github/kentcdodds/react-testing-library-examples"], - "node": "12" + "node": "18" } diff --git a/.github/ISSUE_TEMPLATE/Bug_Report.md b/.github/ISSUE_TEMPLATE/Bug_Report.md index daefe8c6..c04bef38 100644 --- a/.github/ISSUE_TEMPLATE/Bug_Report.md +++ b/.github/ISSUE_TEMPLATE/Bug_Report.md @@ -60,13 +60,8 @@ https://github.com/testing-library/testing-library-docs ### Reproduction: ### Problem description: diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 9379216c..f239c717 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -11,7 +11,16 @@ on: - 'beta' - 'alpha' - '!all-contributors/**' - pull_request: {} + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + actions: write # to cancel/stop running workflows (styfle/cancel-workflow-action) + contents: read # to fetch code (actions/checkout) + jobs: main: continue-on-error: ${{ matrix.react != 'latest' }} @@ -20,18 +29,15 @@ jobs: strategy: fail-fast: false matrix: - node: [12, 14, 16] - react: [latest, next, experimental] + node: [18, 20] + react: ['18.x', latest, canary, experimental] runs-on: ubuntu-latest steps: - - name: π Cancel Previous Runs - uses: styfle/cancel-workflow-action@0.9.0 - - name: β¬οΈ Checkout repo - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: β Setup node - uses: actions/setup-node@v2 + uses: actions/setup-node@v4 with: node-version: ${{ matrix.node }} @@ -49,29 +55,39 @@ 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@v1 + uses: codecov/codecov-action@v5 with: + fail_ci_if_error: true flags: ${{ matrix.react }} + token: ${{ secrets.CODECOV_TOKEN }} release: + permissions: + actions: write # to cancel/stop running workflows (styfle/cancel-workflow-action) + contents: write # to create release tags (cycjimmy/semantic-release-action) + issues: write # to post release that resolves an issue (cycjimmy/semantic-release-action) + needs: main runs-on: ubuntu-latest if: ${{ github.repository == 'testing-library/react-testing-library' && github.event_name == 'push' }} steps: - - name: π Cancel Previous Runs - uses: styfle/cancel-workflow-action@0.9.0 - - name: β¬οΈ Checkout repo - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: β Setup node - uses: actions/setup-node@v2 + uses: actions/setup-node@v4 with: node-version: 14 diff --git a/LICENSE b/LICENSE index 4c43675b..ca399d57 100644 --- a/LICENSE +++ b/LICENSE @@ -1,5 +1,5 @@ The MIT License (MIT) -Copyright (c) 2017 Kent C. Dodds +Copyright (c) 2017-Present Kent C. Dodds Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 9992250a..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,21 @@ 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:_ + +``` +npm install --save-dev @testing-library/react@12 + + +yarn add --dev @testing-library/react@12 +``` You may also be interested in installing `@testing-library/jest-dom` so you can use [the custom jest matchers](https://github.com/testing-library/jest-dom). @@ -366,9 +379,6 @@ Some included are: - [`react-router`](https://codesandbox.io/s/github/kentcdodds/react-testing-library-examples/tree/main/?fontsize=14&module=%2Fsrc%2F__tests__%2Freact-router.js&previewwindow=tests) - [`react-context`](https://codesandbox.io/s/github/kentcdodds/react-testing-library-examples/tree/main/?fontsize=14&module=%2Fsrc%2F__tests__%2Freact-context.js&previewwindow=tests) -You can also find React Testing Library examples at -[react-testing-examples.com](https://react-testing-examples.com/jest-rtl/). - ## Hooks If you are interested in testing a custom hook, check out [React Hooks Testing @@ -440,184 +450,194 @@ Thanks goes to these people ([emoji key][emojis]):
diff --git a/src/__tests__/cleanup.js b/src/__tests__/cleanup.js
index 0dcbac12..9f17c722 100644
--- a/src/__tests__/cleanup.js
+++ b/src/__tests__/cleanup.js
@@ -51,6 +51,7 @@ describe('fake timers and missing act warnings', () => {
})
afterEach(() => {
+ jest.restoreAllMocks()
jest.useRealTimers()
})
@@ -63,7 +64,7 @@ describe('fake timers and missing act warnings', () => {
let cancelled = false
Promise.resolve().then(() => {
microTaskSpy()
- // eslint-disable-next-line jest/no-if -- false positive
+ // eslint-disable-next-line jest/no-if, jest/no-conditional-in-test -- false positive
if (!cancelled) {
setDeferredCounter(counter)
}
@@ -95,6 +96,7 @@ describe('fake timers and missing act warnings', () => {
let cancelled = false
setTimeout(() => {
deferredStateUpdateSpy()
+ // eslint-disable-next-line jest/no-conditional-in-test -- false-positive
if (!cancelled) {
setDeferredCounter(counter)
}
diff --git a/src/__tests__/config.js b/src/__tests__/config.js
new file mode 100644
index 00000000..7fdb1e00
--- /dev/null
+++ b/src/__tests__/config.js
@@ -0,0 +1,66 @@
+import {configure, getConfig} from '../'
+
+describe('configuration API', () => {
+ 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)
+ })
+
+ describe('DTL options', () => {
+ test('configure can set by a plain JS object', () => {
+ const testIdAttribute = 'not-data-testid'
+ configure({testIdAttribute})
+
+ expect(getConfig().testIdAttribute).toBe(testIdAttribute)
+ })
+
+ test('configure can set by a function', () => {
+ // setup base option
+ const baseTestIdAttribute = 'data-testid'
+ configure({testIdAttribute: baseTestIdAttribute})
+
+ const modifiedPrefix = 'modified-'
+ configure(existingConfig => ({
+ testIdAttribute: `${modifiedPrefix}${existingConfig.testIdAttribute}`,
+ }))
+
+ expect(getConfig().testIdAttribute).toBe(
+ `${modifiedPrefix}${baseTestIdAttribute}`,
+ )
+ })
+ })
+
+ describe('RTL options', () => {
+ test('configure can set by a plain JS object', () => {
+ configure({reactStrictMode: true})
+
+ expect(getConfig().reactStrictMode).toBe(true)
+ })
+
+ test('configure can set by a function', () => {
+ configure(existingConfig => ({
+ reactStrictMode: !existingConfig.reactStrictMode,
+ }))
+
+ expect(getConfig().reactStrictMode).toBe(true)
+ })
+ })
+
+ test('configure can set DTL and RTL options at once', () => {
+ const testIdAttribute = 'not-data-testid'
+ configure({testIdAttribute, reactStrictMode: true})
+
+ expect(getConfig().testIdAttribute).toBe(testIdAttribute)
+ expect(getConfig().reactStrictMode).toBe(true)
+ })
+})
diff --git a/src/__tests__/debug.js b/src/__tests__/debug.js
index f3aad595..c6a1d1fe 100644
--- a/src/__tests__/debug.js
+++ b/src/__tests__/debug.js
@@ -42,7 +42,7 @@ test('allows same arguments as prettyDOM', () => {
debug(container, 6, {highlight: false})
expect(console.log).toHaveBeenCalledTimes(1)
expect(console.log.mock.calls[0]).toMatchInlineSnapshot(`
- Array [
+ [
...,
]
@@ -52,5 +52,4 @@ test('allows same arguments as prettyDOM', () => {
/*
eslint
no-console: "off",
- testing-library/no-debug: "off",
*/
diff --git a/src/__tests__/end-to-end.js b/src/__tests__/end-to-end.js
index cf222aec..f93c23be 100644
--- a/src/__tests__/end-to-end.js
+++ b/src/__tests__/end-to-end.js
@@ -1,73 +1,234 @@
import * as React from 'react'
import {render, waitForElementToBeRemoved, screen, waitFor} from '../'
-const fetchAMessage = () =>
- new Promise(resolve => {
- // we are using random timeout here to simulate a real-time example
- // of an async operation calling a callback at a non-deterministic time
- const randomTimeout = Math.floor(Math.random() * 100)
- setTimeout(() => {
- resolve({returnedMessage: 'Hello World'})
- }, randomTimeout)
- })
-
-function ComponentWithLoader() {
- const [state, setState] = React.useState({data: undefined, loading: true})
- React.useEffect(() => {
- let cancelled = false
- fetchAMessage().then(data => {
- if (!cancelled) {
- setState({data, loading: false})
+describe.each([
+ ['real timers', () => jest.useRealTimers()],
+ ['fake legacy timers', () => jest.useFakeTimers('legacy')],
+ ['fake modern timers', () => jest.useFakeTimers('modern')],
+])(
+ 'it waits for the data to be loaded in a macrotask using %s',
+ (label, useTimers) => {
+ beforeEach(() => {
+ useTimers()
+ })
+
+ afterEach(() => {
+ jest.useRealTimers()
+ })
+
+ const fetchAMessageInAMacrotask = () =>
+ new Promise(resolve => {
+ // we are using random timeout here to simulate a real-time example
+ // of an async operation calling a callback at a non-deterministic time
+ const randomTimeout = Math.floor(Math.random() * 100)
+ setTimeout(() => {
+ resolve({returnedMessage: 'Hello World'})
+ }, randomTimeout)
+ })
+
+ function ComponentWithMacrotaskLoader() {
+ const [state, setState] = React.useState({data: undefined, loading: true})
+ React.useEffect(() => {
+ let cancelled = false
+ fetchAMessageInAMacrotask().then(data => {
+ if (!cancelled) {
+ setState({data, loading: false})
+ }
+ })
+
+ return () => {
+ cancelled = true
+ }
+ }, [])
+
+ if (state.loading) {
+ return Loading...
}
+
+ return (
+
+ Loaded this message: {state.data.returnedMessage}!
+
+ )
+ }
+
+ test('waitForElementToBeRemoved', async () => {
+ render( )
+ const loading = () => screen.getByText('Loading...')
+ await waitForElementToBeRemoved(loading)
+ expect(screen.getByTestId('message')).toHaveTextContent(/Hello World/)
+ })
+
+ test('waitFor', async () => {
+ render( )
+ await waitFor(() => screen.getByText(/Loading../))
+ await waitFor(() => screen.getByText(/Loaded this message:/))
+ expect(screen.getByTestId('message')).toHaveTextContent(/Hello World/)
+ })
+
+ test('findBy', async () => {
+ render( )
+ await expect(screen.findByTestId('message')).resolves.toHaveTextContent(
+ /Hello World/,
+ )
+ })
+ },
+)
+
+describe.each([
+ ['real timers', () => jest.useRealTimers()],
+ ['fake legacy timers', () => jest.useFakeTimers('legacy')],
+ ['fake modern timers', () => jest.useFakeTimers('modern')],
+])(
+ 'it waits for the data to be loaded in many microtask using %s',
+ (label, useTimers) => {
+ beforeEach(() => {
+ useTimers()
+ })
+
+ afterEach(() => {
+ jest.useRealTimers()
})
- return () => {
- cancelled = true
+ const fetchAMessageInAMicrotask = () =>
+ Promise.resolve({
+ status: 200,
+ json: () => Promise.resolve({title: 'Hello World'}),
+ })
+
+ function ComponentWithMicrotaskLoader() {
+ const [fetchState, setFetchState] = React.useState({fetching: true})
+
+ React.useEffect(() => {
+ if (fetchState.fetching) {
+ fetchAMessageInAMicrotask().then(res => {
+ return (
+ res
+ .json()
+ // By spec, the runtime can only yield back to the event loop once
+ // the microtask queue is empty.
+ // So we ensure that we actually wait for that as well before yielding back from `waitFor`.
+ .then(data => data)
+ .then(data => data)
+ .then(data => data)
+ .then(data => data)
+ .then(data => data)
+ .then(data => data)
+ .then(data => data)
+ .then(data => data)
+ .then(data => data)
+ .then(data => data)
+ .then(data => data)
+ .then(data => {
+ setFetchState({todo: data.title, fetching: false})
+ })
+ )
+ })
+ }
+ }, [fetchState])
+
+ if (fetchState.fetching) {
+ return Loading..
+ }
+
+ return (
+ Loaded this message: {fetchState.todo}
+ )
}
- }, [])
- if (state.loading) {
- return Loading...
- }
+ test('waitForElementToBeRemoved', async () => {
+ render( )
+ const loading = () => screen.getByText('Loading..')
+ await waitForElementToBeRemoved(loading)
+ expect(screen.getByTestId('message')).toHaveTextContent(/Hello World/)
+ })
- return (
-
- Loaded this message: {state.data.returnedMessage}!
-
- )
-}
+ test('waitFor', async () => {
+ render( )
+ await waitFor(() => {
+ screen.getByText('Loading..')
+ })
+ await waitFor(() => {
+ screen.getByText(/Loaded this message:/)
+ })
+ expect(screen.getByTestId('message')).toHaveTextContent(/Hello World/)
+ })
+
+ test('findBy', async () => {
+ render( )
+ await expect(screen.findByTestId('message')).resolves.toHaveTextContent(
+ /Hello World/,
+ )
+ })
+ },
+)
describe.each([
['real timers', () => jest.useRealTimers()],
['fake legacy timers', () => jest.useFakeTimers('legacy')],
['fake modern timers', () => jest.useFakeTimers('modern')],
-])('it waits for the data to be loaded using %s', (label, useTimers) => {
- beforeEach(() => {
- useTimers()
- })
-
- afterEach(() => {
- jest.useRealTimers()
- })
-
- test('waitForElementToBeRemoved', async () => {
- render( )
- const loading = () => screen.getByText('Loading...')
- await waitForElementToBeRemoved(loading)
- expect(screen.getByTestId('message')).toHaveTextContent(/Hello World/)
- })
-
- test('waitFor', async () => {
- render( )
- const message = () => screen.getByText(/Loaded this message:/)
- await waitFor(message)
- expect(screen.getByTestId('message')).toHaveTextContent(/Hello World/)
- })
-
- test('findBy', async () => {
- render( )
- await expect(screen.findByTestId('message')).resolves.toHaveTextContent(
- /Hello World/,
- )
- })
-})
+])(
+ 'it waits for the data to be loaded in a microtask using %s',
+ (label, useTimers) => {
+ beforeEach(() => {
+ useTimers()
+ })
+
+ afterEach(() => {
+ jest.useRealTimers()
+ })
+
+ const fetchAMessageInAMicrotask = () =>
+ Promise.resolve({
+ status: 200,
+ json: () => Promise.resolve({title: 'Hello World'}),
+ })
+
+ function ComponentWithMicrotaskLoader() {
+ const [fetchState, setFetchState] = React.useState({fetching: true})
+
+ React.useEffect(() => {
+ if (fetchState.fetching) {
+ fetchAMessageInAMicrotask().then(res => {
+ return res.json().then(data => {
+ setFetchState({todo: data.title, fetching: false})
+ })
+ })
+ }
+ }, [fetchState])
+
+ if (fetchState.fetching) {
+ return Loading..
+ }
+
+ return (
+ Loaded this message: {fetchState.todo}
+ )
+ }
+
+ test('waitForElementToBeRemoved', async () => {
+ render( )
+ const loading = () => screen.getByText('Loading..')
+ await waitForElementToBeRemoved(loading)
+ expect(screen.getByTestId('message')).toHaveTextContent(/Hello World/)
+ })
+
+ test('waitFor', async () => {
+ render( )
+ await waitFor(() => {
+ screen.getByText('Loading..')
+ })
+ await waitFor(() => {
+ screen.getByText(/Loaded this message:/)
+ })
+ expect(screen.getByTestId('message')).toHaveTextContent(/Hello World/)
+ })
+
+ test('findBy', async () => {
+ render( )
+ await expect(screen.findByTestId('message')).resolves.toHaveTextContent(
+ /Hello World/,
+ )
+ })
+ },
+)
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__/new-act.js b/src/__tests__/new-act.js
index 05f9d45a..0464ad24 100644
--- a/src/__tests__/new-act.js
+++ b/src/__tests__/new-act.js
@@ -1,10 +1,13 @@
let asyncAct
-jest.mock('react-dom/test-utils', () => ({
- act: cb => {
- return cb()
- },
-}))
+jest.mock('react', () => {
+ return {
+ ...jest.requireActual('react'),
+ act: cb => {
+ return cb()
+ },
+ }
+})
beforeEach(() => {
jest.resetModules()
@@ -13,7 +16,7 @@ beforeEach(() => {
})
afterEach(() => {
- console.error.mockRestore()
+ jest.restoreAllMocks()
})
test('async act works when it does not exist (older versions of react)', async () => {
@@ -47,8 +50,8 @@ test('async act recovers from errors', async () => {
}
expect(console.error).toHaveBeenCalledTimes(1)
expect(console.error.mock.calls).toMatchInlineSnapshot(`
- Array [
- Array [
+ [
+ [
call console.error,
],
]
@@ -65,8 +68,8 @@ test('async act recovers from sync errors', async () => {
}
expect(console.error).toHaveBeenCalledTimes(1)
expect(console.error.mock.calls).toMatchInlineSnapshot(`
- Array [
- Array [
+ [
+ [
call console.error,
],
]
diff --git a/src/__tests__/render.js b/src/__tests__/render.js
index 88e2b98d..6f5b5b39 100644
--- a/src/__tests__/render.js
+++ b/src/__tests__/render.js
@@ -1,90 +1,106 @@
import * as React from 'react'
import ReactDOM from 'react-dom'
import ReactDOMServer from 'react-dom/server'
-import {fireEvent, render, screen} from '../'
+import {fireEvent, render, screen, configure} from '../'
+
+const isReact18 = React.version.startsWith('18.')
+const isReact19 = React.version.startsWith('19.')
+
+const testGateReact18 = isReact18 ? test : test.skip
+const testGateReact19 = isReact19 ? test : test.skip
+
+describe('render API', () => {
+ 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(() => {
- if (console.error.mockRestore !== undefined) {
- console.error.mockRestore()
- }
-})
+ afterEach(() => {
+ configure(originalConfig)
+ })
-test('renders div into document', () => {
- const ref = React.createRef()
- const {container} = render()
- expect(container.firstChild).toBe(ref.current)
-})
+ test('renders div into document', () => {
+ const ref = React.createRef()
+ const {container} = render()
+ expect(container.firstChild).toBe(ref.current)
+ })
-test('works great with react portals', () => {
- class MyPortal extends React.Component {
- constructor(...args) {
- super(...args)
- this.portalNode = document.createElement('div')
- this.portalNode.dataset.testid = 'my-portal'
- }
- componentDidMount() {
- document.body.appendChild(this.portalNode)
- }
- componentWillUnmount() {
- this.portalNode.parentNode.removeChild(this.portalNode)
- }
- render() {
- return ReactDOM.createPortal(
- ,
- this.portalNode,
- )
+ test('works great with react portals', () => {
+ class MyPortal extends React.Component {
+ constructor(...args) {
+ super(...args)
+ this.portalNode = document.createElement('div')
+ this.portalNode.dataset.testid = 'my-portal'
+ }
+ componentDidMount() {
+ document.body.appendChild(this.portalNode)
+ }
+ componentWillUnmount() {
+ this.portalNode.parentNode.removeChild(this.portalNode)
+ }
+ render() {
+ return ReactDOM.createPortal(
+ ,
+ this.portalNode,
+ )
+ }
}
- }
-
- function Greet({greeting, subject}) {
- return (
-
-
- {greeting} {subject}
-
-
- )
- }
-
- const {unmount} = render( )
- expect(screen.getByText('Hello World')).toBeInTheDocument()
- const portalNode = screen.getByTestId('my-portal')
- expect(portalNode).toBeInTheDocument()
- unmount()
- expect(portalNode).not.toBeInTheDocument()
-})
-test('returns baseElement which defaults to document.body', () => {
- const {baseElement} = render()
- expect(baseElement).toBe(document.body)
-})
-
-test('supports fragments', () => {
- class Test extends React.Component {
- render() {
+ function Greet({greeting, subject}) {
return (
- DocumentFragment
is pretty cool!
+
+ {greeting} {subject}
+
)
}
- }
- const {asFragment} = render( )
- expect(asFragment()).toMatchSnapshot()
-})
+ const {unmount} = render( )
+ expect(screen.getByText('Hello World')).toBeInTheDocument()
+ const portalNode = screen.getByTestId('my-portal')
+ expect(portalNode).toBeInTheDocument()
+ unmount()
+ expect(portalNode).not.toBeInTheDocument()
+ })
-test('renders options.wrapper around node', () => {
- const WrapperComponent = ({children}) => (
- {children}
- )
+ test('returns baseElement which defaults to document.body', () => {
+ const {baseElement} = render()
+ expect(baseElement).toBe(document.body)
+ })
- const {container} = render(, {
- wrapper: WrapperComponent,
+ test('supports fragments', () => {
+ class Test extends React.Component {
+ render() {
+ return (
+
+ DocumentFragment
is pretty cool!
+
+ )
+ }
+ }
+
+ const {asFragment} = render( )
+ expect(asFragment()).toMatchSnapshot()
})
- expect(screen.getByTestId('wrapper')).toBeInTheDocument()
- expect(container.firstChild).toMatchInlineSnapshot(`
+ test('renders options.wrapper around node', () => {
+ const WrapperComponent = ({children}) => (
+ {children}
+ )
+
+ const {container} = render(, {
+ wrapper: WrapperComponent,
+ })
+
+ expect(screen.getByTestId('wrapper')).toBeInTheDocument()
+ expect(container.firstChild).toMatchInlineSnapshot(`
@@ -93,105 +109,191 @@ test('renders options.wrapper around node', () => {
/>
`)
-})
+ })
-test('flushes useEffect cleanup functions sync on unmount()', () => {
- const spy = jest.fn()
- function Component() {
- React.useEffect(() => spy, [])
- return null
- }
- const {unmount} = render( )
- expect(spy).toHaveBeenCalledTimes(0)
+ test('renders options.wrapper around node when reactStrictMode is true', () => {
+ configure({reactStrictMode: true})
- unmount()
+ const WrapperComponent = ({children}) => (
+ {children}
+ )
+ const {container} = render(, {
+ wrapper: WrapperComponent,
+ })
- expect(spy).toHaveBeenCalledTimes(1)
-})
+ expect(screen.getByTestId('wrapper')).toBeInTheDocument()
+ expect(container.firstChild).toMatchInlineSnapshot(`
+
+
+
+ `)
+ })
+
+ test('renders twice when reactStrictMode is true', () => {
+ configure({reactStrictMode: true})
+
+ const spy = jest.fn()
+ function Component() {
+ spy()
+ return null
+ }
-test('can be called multiple times on the same container', () => {
- const container = document.createElement('div')
+ render( )
+ expect(spy).toHaveBeenCalledTimes(2)
+ })
- const {unmount} = render(, {container})
+ test('flushes useEffect cleanup functions sync on unmount()', () => {
+ const spy = jest.fn()
+ function Component() {
+ React.useEffect(() => spy, [])
+ return null
+ }
+ const {unmount} = render( )
+ expect(spy).toHaveBeenCalledTimes(0)
- expect(container).toContainHTML('')
+ unmount()
- render(, {container})
+ expect(spy).toHaveBeenCalledTimes(1)
+ })
- expect(container).toContainHTML('')
+ test('can be called multiple times on the same container', () => {
+ const container = document.createElement('div')
- unmount()
+ const {unmount} = render(, {container})
- expect(container).toBeEmptyDOMElement()
-})
+ expect(container).toContainHTML('')
-test('hydrate will make the UI interactive', () => {
- jest.spyOn(console, 'error').mockImplementation(() => {})
- function App() {
- const [clicked, handleClick] = React.useReducer(n => n + 1, 0)
+ render(, {container})
- return (
-
- )
- }
- const ui =
- const container = document.createElement('div')
- document.body.appendChild(container)
- container.innerHTML = ReactDOMServer.renderToString(ui)
+ expect(container).toContainHTML('')
- expect(container).toHaveTextContent('clicked:0')
+ unmount()
- render(ui, {container, hydrate: true})
+ expect(container).toBeEmptyDOMElement()
+ })
- expect(console.error).not.toHaveBeenCalled()
+ test('hydrate will make the UI interactive', () => {
+ function App() {
+ const [clicked, handleClick] = React.useReducer(n => n + 1, 0)
- fireEvent.click(container.querySelector('button'))
+ return (
+
+ )
+ }
+ const ui =
+ const container = document.createElement('div')
+ document.body.appendChild(container)
+ container.innerHTML = ReactDOMServer.renderToString(ui)
- expect(container).toHaveTextContent('clicked:1')
-})
+ expect(container).toHaveTextContent('clicked:0')
-test('hydrate can have a wrapper', () => {
- const wrapperComponentMountEffect = jest.fn()
- function WrapperComponent({children}) {
- React.useEffect(() => {
- wrapperComponentMountEffect()
- })
+ render(ui, {container, hydrate: true})
- return children
- }
- const ui =
- const container = document.createElement('div')
- document.body.appendChild(container)
- container.innerHTML = ReactDOMServer.renderToString(ui)
+ fireEvent.click(container.querySelector('button'))
- render(ui, {container, hydrate: true, wrapper: WrapperComponent})
+ expect(container).toHaveTextContent('clicked:1')
+ })
- expect(wrapperComponentMountEffect).toHaveBeenCalledTimes(1)
-})
+ test('hydrate can have a wrapper', () => {
+ const wrapperComponentMountEffect = jest.fn()
+ function WrapperComponent({children}) {
+ React.useEffect(() => {
+ wrapperComponentMountEffect()
+ })
-test('legacyRoot uses legacy ReactDOM.render', () => {
- jest.spyOn(console, 'error').mockImplementation(() => {})
- render(, {legacyRoot: true})
+ return children
+ }
+ const ui =
+ const container = document.createElement('div')
+ document.body.appendChild(container)
+ container.innerHTML = ReactDOMServer.renderToString(ui)
- expect(console.error).toHaveBeenCalledTimes(1)
- expect(console.error).toHaveBeenNthCalledWith(
- 1,
- "Warning: ReactDOM.render is no longer supported in React 18. Use createRoot instead. Until you switch to the new API, your app will behave as if it's running React 17. Learn more: https://reactjs.org/link/switch-to-createroot",
- )
-})
+ render(ui, {container, hydrate: true, wrapper: WrapperComponent})
+
+ expect(wrapperComponentMountEffect).toHaveBeenCalledTimes(1)
+ })
+
+ testGateReact18('legacyRoot uses legacy ReactDOM.render', () => {
+ expect(() => {
+ render(, {legacyRoot: true})
+ }).toErrorDev(
+ [
+ "Warning: ReactDOM.render is no longer supported in React 18. Use createRoot instead. Until you switch to the new API, your app will behave as if it's running React 17. Learn more: https://reactjs.org/link/switch-to-createroot",
+ ],
+ {withoutStack: true},
+ )
+ })
-test('legacyRoot uses legacy ReactDOM.hydrate', () => {
- jest.spyOn(console, 'error').mockImplementation(() => {})
- const ui =
- const container = document.createElement('div')
- container.innerHTML = ReactDOMServer.renderToString(ui)
- render(ui, {container, hydrate: true, legacyRoot: true})
-
- expect(console.error).toHaveBeenCalledTimes(1)
- expect(console.error).toHaveBeenNthCalledWith(
- 1,
- "Warning: ReactDOM.hydrate is no longer supported in React 18. Use hydrateRoot instead. Until you switch to the new API, your app will behave as if it's running React 17. Learn more: https://reactjs.org/link/switch-to-createroot",
- )
+ testGateReact19('legacyRoot throws', () => {
+ expect(() => {
+ render(, {legacyRoot: true})
+ }).toThrowErrorMatchingInlineSnapshot(
+ `\`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.`,
+ )
+ })
+
+ testGateReact18('legacyRoot uses legacy ReactDOM.hydrate', () => {
+ const ui =
+ const container = document.createElement('div')
+ container.innerHTML = ReactDOMServer.renderToString(ui)
+ expect(() => {
+ render(ui, {container, hydrate: true, legacyRoot: true})
+ }).toErrorDev(
+ [
+ "Warning: ReactDOM.hydrate is no longer supported in React 18. Use hydrateRoot instead. Until you switch to the new API, your app will behave as if it's running React 17. Learn more: https://reactjs.org/link/switch-to-createroot",
+ ],
+ {withoutStack: true},
+ )
+ })
+
+ testGateReact19('legacyRoot throws even with hydrate', () => {
+ const ui =
+ const container = document.createElement('div')
+ container.innerHTML = ReactDOMServer.renderToString(ui)
+ expect(() => {
+ render(ui, {container, hydrate: true, legacyRoot: true})
+ }).toThrowErrorMatchingInlineSnapshot(
+ `\`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 fd6b95a4..f331e90e 100644
--- a/src/__tests__/renderHook.js
+++ b/src/__tests__/renderHook.js
@@ -1,7 +1,13 @@
-import React from 'react'
-import {renderHook} from '../pure'
+import React, {useEffect} from 'react'
+import {configure, renderHook} from '../pure'
-test('gives comitted result', () => {
+const isReact18 = React.version.startsWith('18.')
+const isReact19 = React.version.startsWith('19.')
+
+const testGateReact18 = isReact18 ? test : test.skip
+const testGateReact19 = isReact19 ? test : test.skip
+
+test('gives committed result', () => {
const {result} = renderHook(() => {
const [state, setState] = React.useState(1)
@@ -21,7 +27,7 @@ test('allows rerendering', () => {
const [left, setLeft] = React.useState('left')
const [right, setRight] = React.useState('right')
- // eslint-disable-next-line jest/no-if
+ // eslint-disable-next-line jest/no-if, jest/no-conditional-in-test -- false-positive
switch (branch) {
case 'left':
return [left, setLeft]
@@ -60,3 +66,76 @@ test('allows wrapper components', async () => {
expect(result.current).toEqual('provided')
})
+
+testGateReact18('legacyRoot uses legacy ReactDOM.render', () => {
+ const Context = React.createContext('default')
+ function Wrapper({children}) {
+ return {children}
+ }
+ let result
+ expect(() => {
+ result = renderHook(
+ () => {
+ return React.useContext(Context)
+ },
+ {
+ wrapper: Wrapper,
+ legacyRoot: true,
+ },
+ ).result
+ }).toErrorDev(
+ [
+ "Warning: ReactDOM.render is no longer supported in React 18. Use createRoot instead. Until you switch to the new API, your app will behave as if it's running React 17. Learn more: https://reactjs.org/link/switch-to-createroot",
+ ],
+ {withoutStack: true},
+ )
+ expect(result.current).toEqual('provided')
+})
+
+testGateReact19('legacyRoot throws', () => {
+ const Context = React.createContext('default')
+ function Wrapper({children}) {
+ return {children}
+ }
+ expect(() => {
+ renderHook(
+ () => {
+ return React.useContext(Context)
+ },
+ {
+ wrapper: Wrapper,
+ legacyRoot: true,
+ },
+ ).result
+ }).toThrowErrorMatchingInlineSnapshot(
+ `\`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/__tests__/rerender.js b/src/__tests__/rerender.js
index be3c259c..6c48c4dd 100644
--- a/src/__tests__/rerender.js
+++ b/src/__tests__/rerender.js
@@ -1,31 +1,98 @@
import * as React from 'react'
-import {render} from '../'
-
-test('rerender will re-render the element', () => {
- const Greeting = props => {props.message}
- const {container, rerender} = render( )
- expect(container.firstChild).toHaveTextContent('hi')
- rerender( )
- expect(container.firstChild).toHaveTextContent('hey')
-})
+import {render, configure} from '../'
+
+describe('rerender API', () => {
+ 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('rerender will re-render the element', () => {
+ const Greeting = props => {props.message}
+ const {container, rerender} = render( )
+ expect(container.firstChild).toHaveTextContent('hi')
+ rerender( )
+ expect(container.firstChild).toHaveTextContent('hey')
+ })
+
+ test('hydrate will not update props until next render', () => {
+ const initialInputElement = document.createElement('input')
+ const container = document.createElement('div')
+ container.appendChild(initialInputElement)
+ document.body.appendChild(container)
+
+ const firstValue = 'hello'
+ initialInputElement.value = firstValue
-test('hydrate will not update props until next render', () => {
- const initialInputElement = document.createElement('input')
- const container = document.createElement('div')
- container.appendChild(initialInputElement)
- document.body.appendChild(container)
+ const {rerender} = render( null} />, {
+ container,
+ hydrate: true,
+ })
- const firstValue = 'hello'
- initialInputElement.value = firstValue
+ expect(initialInputElement).toHaveValue(firstValue)
- const {rerender} = render( null} />, {
- container,
- hydrate: true,
+ const secondValue = 'goodbye'
+ rerender( null} />)
+ expect(initialInputElement).toHaveValue(secondValue)
})
- expect(initialInputElement).toHaveValue(firstValue)
+ test('re-renders options.wrapper around node when reactStrictMode is true', () => {
+ configure({reactStrictMode: true})
- const secondValue = 'goodbye'
- rerender( null} />)
- expect(initialInputElement).toHaveValue(secondValue)
+ const WrapperComponent = ({children}) => (
+ {children}
+ )
+ const Greeting = props => {props.message}
+ const {container, rerender} = render( , {
+ wrapper: WrapperComponent,
+ })
+
+ expect(container.firstChild).toMatchInlineSnapshot(`
+
+
+ hi
+
+
+ `)
+
+ rerender( )
+ expect(container.firstChild).toMatchInlineSnapshot(`
+
+
+ hey
+
+
+ `)
+ })
+
+ test('re-renders twice when reactStrictMode is true', () => {
+ configure({reactStrictMode: true})
+
+ const spy = jest.fn()
+ function Component() {
+ spy()
+ return null
+ }
+
+ const {rerender} = render( )
+ expect(spy).toHaveBeenCalledTimes(2)
+
+ spy.mockClear()
+ rerender( )
+ expect(spy).toHaveBeenCalledTimes(2)
+ })
})
diff --git a/src/__tests__/stopwatch.js b/src/__tests__/stopwatch.js
index eeaf395c..e3eaebbe 100644
--- a/src/__tests__/stopwatch.js
+++ b/src/__tests__/stopwatch.js
@@ -40,7 +40,6 @@ class StopWatch extends React.Component {
const sleep = t => new Promise(resolve => setTimeout(resolve, t))
test('unmounts a component', async () => {
- jest.spyOn(console, 'error').mockImplementation(() => {})
const {unmount, container} = render( )
fireEvent.click(screen.getByText('Start'))
unmount()
@@ -52,6 +51,4 @@ test('unmounts a component', async () => {
// if it's not, then we'll call setState on an unmounted component
// and get an error.
await sleep(5)
- // eslint-disable-next-line no-console
- expect(console.error).not.toHaveBeenCalled()
})
diff --git a/src/act-compat.js b/src/act-compat.js
index 86518196..6eaec0fb 100644
--- a/src/act-compat.js
+++ b/src/act-compat.js
@@ -1,6 +1,8 @@
-import * as testUtils from 'react-dom/test-utils'
+import * as React from 'react'
+import * as DeprecatedReactTestUtils from 'react-dom/test-utils'
-const domAct = testUtils.act
+const reactAct =
+ typeof React.act === 'function' ? React.act : DeprecatedReactTestUtils.act
function getGlobalThis() {
/* istanbul ignore else */
@@ -78,7 +80,7 @@ function withGlobalActEnvironment(actImplementation) {
}
}
-const act = withGlobalActEnvironment(domAct)
+const act = withGlobalActEnvironment(reactAct)
export default act
export {
diff --git a/src/config.js b/src/config.js
new file mode 100644
index 00000000..dc8a5035
--- /dev/null
+++ b/src/config.js
@@ -0,0 +1,34 @@
+import {
+ getConfig as getConfigDTL,
+ configure as configureDTL,
+} from '@testing-library/dom'
+
+let configForRTL = {
+ reactStrictMode: false,
+}
+
+function getConfig() {
+ return {
+ ...getConfigDTL(),
+ ...configForRTL,
+ }
+}
+
+function configure(newConfig) {
+ if (typeof newConfig === 'function') {
+ // Pass the existing config out to the provided function
+ // and accept a delta in return
+ newConfig = newConfig(getConfig())
+ }
+
+ const {reactStrictMode, ...configForDTL} = newConfig
+
+ configureDTL(configForDTL)
+
+ configForRTL = {
+ ...configForRTL,
+ reactStrictMode,
+ }
+}
+
+export {getConfig, configure}
diff --git a/src/pure.js b/src/pure.js
index 4c416d44..0f9c487d 100644
--- a/src/pure.js
+++ b/src/pure.js
@@ -11,6 +11,21 @@ import act, {
setReactActEnvironment,
} from './act-compat'
import {fireEvent} from './fire-event'
+import {getConfig, configure} from './config'
+
+function jestFakeTimersAreEnabled() {
+ /* istanbul ignore else */
+ if (typeof jest !== 'undefined' && jest !== null) {
+ return (
+ // legacy timers
+ setTimeout._isMockFunction === true || // modern timers
+ // eslint-disable-next-line prefer-object-has-own -- No Object.hasOwn in all target environments we support.
+ Object.prototype.hasOwnProperty.call(setTimeout, 'clock')
+ )
+ } // istanbul ignore next
+
+ return false
+}
configureDTL({
unstable_advanceTimersWrapper: cb => {
@@ -23,7 +38,21 @@ configureDTL({
const previousActEnvironment = getIsReactActEnvironment()
setReactActEnvironment(false)
try {
- return await cb()
+ const result = await cb()
+ // Drain microtask queue.
+ // Otherwise we'll restore the previous act() environment, before we resolve the `waitFor` call.
+ // The caller would have no chance to wrap the in-flight Promises in `act()`
+ await new Promise(resolve => {
+ setTimeout(() => {
+ resolve()
+ }, 0)
+
+ if (jestFakeTimersAreEnabled()) {
+ jest.advanceTimersByTime(0)
+ }
+ })
+
+ return result
} finally {
setReactActEnvironment(previousActEnvironment)
}
@@ -48,20 +77,46 @@ const mountedContainers = new Set()
*/
const mountedRootEntries = []
+function strictModeIfNeeded(innerElement, reactStrictMode) {
+ return reactStrictMode ?? getConfig().reactStrictMode
+ ? React.createElement(React.StrictMode, null, innerElement)
+ : innerElement
+}
+
+function wrapUiIfNeeded(innerElement, wrapperComponent) {
+ return wrapperComponent
+ ? React.createElement(wrapperComponent, null, innerElement)
+ : innerElement
+}
+
function createConcurrentRoot(
container,
- {hydrate, ui, wrapper: WrapperComponent},
+ {
+ hydrate,
+ onCaughtError,
+ onRecoverableError,
+ ui,
+ wrapper: WrapperComponent,
+ reactStrictMode,
+ },
) {
let root
if (hydrate) {
act(() => {
root = ReactDOMClient.hydrateRoot(
container,
- WrapperComponent ? React.createElement(WrapperComponent, null, ui) : ui,
+ strictModeIfNeeded(
+ wrapUiIfNeeded(ui, WrapperComponent),
+ reactStrictMode,
+ ),
+ {onCaughtError, onRecoverableError},
)
})
} else {
- root = ReactDOMClient.createRoot(container)
+ root = ReactDOMClient.createRoot(container, {
+ onCaughtError,
+ onRecoverableError,
+ })
}
return {
@@ -99,18 +154,33 @@ function createLegacyRoot(container) {
function renderRoot(
ui,
- {baseElement, container, hydrate, queries, root, wrapper: WrapperComponent},
+ {
+ baseElement,
+ container,
+ hydrate,
+ queries,
+ root,
+ wrapper: WrapperComponent,
+ reactStrictMode,
+ },
) {
- const wrapUiIfNeeded = innerElement =>
- WrapperComponent
- ? React.createElement(WrapperComponent, null, innerElement)
- : innerElement
-
act(() => {
if (hydrate) {
- root.hydrate(wrapUiIfNeeded(ui), container)
+ root.hydrate(
+ strictModeIfNeeded(
+ wrapUiIfNeeded(ui, WrapperComponent),
+ reactStrictMode,
+ ),
+ container,
+ )
} else {
- root.render(wrapUiIfNeeded(ui), container)
+ root.render(
+ strictModeIfNeeded(
+ wrapUiIfNeeded(ui, WrapperComponent),
+ reactStrictMode,
+ ),
+ container,
+ )
}
})
@@ -129,10 +199,12 @@ function renderRoot(
})
},
rerender: rerenderUi => {
- renderRoot(wrapUiIfNeeded(rerenderUi), {
+ renderRoot(rerenderUi, {
container,
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
@@ -159,11 +231,30 @@ 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. ' +
+ '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.',
+ )
+ Error.captureStackTrace(error, render)
+ throw error
+ }
+
if (!baseElement) {
// default to document.body instead of documentElement to avoid output of potentially-large
// head elements (such as JSS style blocks) in debug output
@@ -177,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
@@ -202,6 +300,7 @@ function render(
hydrate,
wrapper,
root,
+ reactStrictMode,
})
}
@@ -219,7 +318,18 @@ function cleanup() {
}
function renderHook(renderCallback, options = {}) {
- const {initialProps, wrapper} = options
+ const {initialProps, ...renderOptions} = options
+
+ if (renderOptions.legacyRoot && typeof ReactDOM.render !== 'function') {
+ const error = new Error(
+ '`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.',
+ )
+ Error.captureStackTrace(error, renderHook)
+ throw error
+ }
+
const result = React.createRef()
function TestComponent({renderCallbackProps}) {
@@ -234,7 +344,7 @@ function renderHook(renderCallback, options = {}) {
const {rerender: baseRerender, unmount} = render(
,
- {wrapper},
+ renderOptions,
)
function rerender(rerenderCallbackProps) {
@@ -248,6 +358,6 @@ function renderHook(renderCallback, options = {}) {
// just re-export everything from dom-testing-library
export * from '@testing-library/dom'
-export {render, renderHook, cleanup, act, fireEvent}
+export {render, renderHook, cleanup, act, fireEvent, getConfig, configure}
/* eslint func-name-matching:0 */
diff --git a/tests/failOnUnexpectedConsoleCalls.js b/tests/failOnUnexpectedConsoleCalls.js
new file mode 100644
index 00000000..83e0c641
--- /dev/null
+++ b/tests/failOnUnexpectedConsoleCalls.js
@@ -0,0 +1,129 @@
+// Fork of https://github.com/facebook/react/blob/513417d6951fa3ff5729302b7990b84604b11afa/scripts/jest/setupTests.js#L71-L161
+/**
+MIT License
+
+Copyright (c) Facebook, Inc. and its affiliates.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+ */
+/* eslint-disable prefer-template */
+/* eslint-disable func-names */
+const util = require('util')
+const chalk = require('chalk')
+const shouldIgnoreConsoleError = require('./shouldIgnoreConsoleError')
+
+const patchConsoleMethod = (methodName, unexpectedConsoleCallStacks) => {
+ const newMethod = function (format, ...args) {
+ // Ignore uncaught errors reported by jsdom
+ // and React addendums because they're too noisy.
+ if (methodName === 'error' && shouldIgnoreConsoleError(format, args)) {
+ return
+ }
+
+ // Capture the call stack now so we can warn about it later.
+ // The call stack has helpful information for the test author.
+ // Don't throw yet though b'c it might be accidentally caught and suppressed.
+ const stack = new Error().stack
+ unexpectedConsoleCallStacks.push([
+ stack.substr(stack.indexOf('\n') + 1),
+ util.format(format, ...args),
+ ])
+ }
+
+ console[methodName] = newMethod
+
+ return newMethod
+}
+
+const isSpy = spy =>
+ (spy.calls && typeof spy.calls.count === 'function') ||
+ spy._isMockFunction === true
+
+const flushUnexpectedConsoleCalls = (
+ mockMethod,
+ methodName,
+ expectedMatcher,
+ unexpectedConsoleCallStacks,
+) => {
+ if (console[methodName] !== mockMethod && !isSpy(console[methodName])) {
+ throw new Error(
+ `Test did not tear down console.${methodName} mock properly.`,
+ )
+ }
+ if (unexpectedConsoleCallStacks.length > 0) {
+ const messages = unexpectedConsoleCallStacks.map(
+ ([stack, message]) =>
+ `${chalk.red(message)}\n` +
+ `${stack
+ .split('\n')
+ .map(line => chalk.gray(line))
+ .join('\n')}`,
+ )
+
+ const message =
+ `Expected test not to call ${chalk.bold(
+ `console.${methodName}()`,
+ )}.\n\n` +
+ 'If the warning is expected, test for it explicitly by:\n' +
+ `1. Using the ${chalk.bold('.' + expectedMatcher + '()')} ` +
+ `matcher, or...\n` +
+ `2. Mock it out using ${chalk.bold(
+ 'spyOnDev',
+ )}(console, '${methodName}') or ${chalk.bold(
+ 'spyOnProd',
+ )}(console, '${methodName}'), and test that the warning occurs.`
+
+ throw new Error(`${message}\n\n${messages.join('\n\n')}`)
+ }
+}
+
+const unexpectedErrorCallStacks = []
+const unexpectedWarnCallStacks = []
+
+const errorMethod = patchConsoleMethod('error', unexpectedErrorCallStacks)
+const warnMethod = patchConsoleMethod('warn', unexpectedWarnCallStacks)
+
+const flushAllUnexpectedConsoleCalls = () => {
+ flushUnexpectedConsoleCalls(
+ errorMethod,
+ 'error',
+ 'toErrorDev',
+ unexpectedErrorCallStacks,
+ )
+ flushUnexpectedConsoleCalls(
+ warnMethod,
+ 'warn',
+ 'toWarnDev',
+ unexpectedWarnCallStacks,
+ )
+ unexpectedErrorCallStacks.length = 0
+ unexpectedWarnCallStacks.length = 0
+}
+
+const resetAllUnexpectedConsoleCalls = () => {
+ unexpectedErrorCallStacks.length = 0
+ unexpectedWarnCallStacks.length = 0
+}
+
+expect.extend({
+ ...require('./toWarnDev'),
+})
+
+beforeEach(resetAllUnexpectedConsoleCalls)
+afterEach(flushAllUnexpectedConsoleCalls)
diff --git a/tests/setup-env.js b/tests/setup-env.js
index 264828a9..1a4401de 100644
--- a/tests/setup-env.js
+++ b/tests/setup-env.js
@@ -1 +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/shouldIgnoreConsoleError.js b/tests/shouldIgnoreConsoleError.js
new file mode 100644
index 00000000..1c722ba1
--- /dev/null
+++ b/tests/shouldIgnoreConsoleError.js
@@ -0,0 +1,52 @@
+// Fork of https://github.com/facebook/react/blob/513417d6951fa3ff5729302b7990b84604b11afa/scripts/jest/shouldIgnoreConsoleError.js
+/**
+MIT License
+
+Copyright (c) Facebook, Inc. and its affiliates.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+ */
+
+module.exports = function shouldIgnoreConsoleError(format) {
+ if (process.env.NODE_ENV !== 'production') {
+ if (typeof format === 'string') {
+ if (format.indexOf('Error: Uncaught [') === 0) {
+ // This looks like an uncaught error from invokeGuardedCallback() wrapper
+ // in development that is reported by jsdom. Ignore because it's noisy.
+ return true
+ }
+ if (format.indexOf('The above error occurred') === 0) {
+ // This looks like an error addendum from ReactFiberErrorLogger.
+ // Ignore it too.
+ return true
+ }
+ if (
+ format.startsWith(
+ 'Warning: `ReactDOMTestUtils.act` is deprecated in favor of `React.act`.',
+ )
+ ) {
+ // This is a React bug in 18.3.0.
+ // Versions with `ReactDOMTestUtils.ac` being deprecated, should have `React.act`
+ return true
+ }
+ }
+ }
+ // Looks legit
+ return false
+}
diff --git a/tests/toWarnDev.js b/tests/toWarnDev.js
new file mode 100644
index 00000000..3005125e
--- /dev/null
+++ b/tests/toWarnDev.js
@@ -0,0 +1,303 @@
+// Fork of https://github.com/facebook/react/blob/513417d6951fa3ff5729302b7990b84604b11afa/scripts/jest/matchers/toWarnDev.js
+/**
+MIT License
+
+Copyright (c) Facebook, Inc. and its affiliates.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+ */
+/* eslint-disable no-unsafe-finally */
+/* eslint-disable no-negated-condition */
+/* eslint-disable no-invalid-this */
+/* eslint-disable prefer-template */
+/* eslint-disable func-names */
+/* eslint-disable complexity */
+const util = require('util')
+const jestDiff = require('jest-diff').diff
+const shouldIgnoreConsoleError = require('./shouldIgnoreConsoleError')
+
+function normalizeCodeLocInfo(str) {
+ if (typeof str !== 'string') {
+ return str
+ }
+ // This special case exists only for the special source location in
+ // ReactElementValidator. That will go away if we remove source locations.
+ str = str.replace(/Check your code at .+?:\d+/g, 'Check your code at **')
+ // V8 format:
+ // at Component (/path/filename.js:123:45)
+ // React format:
+ // in Component (at filename.js:123)
+ // eslint-disable-next-line prefer-arrow-callback
+ return str.replace(/\n +(?:at|in) ([\S]+)[^\n]*/g, function (m, name) {
+ return '\n in ' + name + ' (at **)'
+ })
+}
+
+const createMatcherFor = (consoleMethod, matcherName) =>
+ function matcher(callback, expectedMessages, options = {}) {
+ if (process.env.NODE_ENV !== 'production') {
+ // Warn about incorrect usage of matcher.
+ if (typeof expectedMessages === 'string') {
+ expectedMessages = [expectedMessages]
+ } else if (!Array.isArray(expectedMessages)) {
+ throw Error(
+ `${matcherName}() requires a parameter of type string or an array of strings ` +
+ `but was given ${typeof expectedMessages}.`,
+ )
+ }
+ if (
+ options != null &&
+ (typeof options !== 'object' || Array.isArray(options))
+ ) {
+ throw new Error(
+ `${matcherName}() second argument, when present, should be an object. ` +
+ 'Did you forget to wrap the messages into an array?',
+ )
+ }
+ if (arguments.length > 3) {
+ // `matcher` comes from Jest, so it's more than 2 in practice
+ throw new Error(
+ `${matcherName}() received more than two arguments. ` +
+ 'Did you forget to wrap the messages into an array?',
+ )
+ }
+
+ const withoutStack = options.withoutStack
+ const logAllErrors = options.logAllErrors
+ const warningsWithoutComponentStack = []
+ const warningsWithComponentStack = []
+ const unexpectedWarnings = []
+
+ let lastWarningWithMismatchingFormat = null
+ let lastWarningWithExtraComponentStack = null
+
+ // Catch errors thrown by the callback,
+ // But only rethrow them if all test expectations have been satisfied.
+ // Otherwise an Error in the callback can mask a failed expectation,
+ // and result in a test that passes when it shouldn't.
+ let caughtError
+
+ const isLikelyAComponentStack = message =>
+ typeof message === 'string' &&
+ (message.includes('\n in ') || message.includes('\n at '))
+
+ const consoleSpy = (format, ...args) => {
+ // Ignore uncaught errors reported by jsdom
+ // and React addendums because they're too noisy.
+ if (
+ !logAllErrors &&
+ consoleMethod === 'error' &&
+ shouldIgnoreConsoleError(format, args)
+ ) {
+ return
+ }
+
+ const message = util.format(format, ...args)
+ const normalizedMessage = normalizeCodeLocInfo(message)
+
+ // Remember if the number of %s interpolations
+ // doesn't match the number of arguments.
+ // We'll fail the test if it happens.
+ let argIndex = 0
+ String(format).replace(/%s/g, () => argIndex++)
+ if (argIndex !== args.length) {
+ lastWarningWithMismatchingFormat = {
+ format,
+ args,
+ expectedArgCount: argIndex,
+ }
+ }
+
+ // Protect against accidentally passing a component stack
+ // to warning() which already injects the component stack.
+ if (
+ args.length >= 2 &&
+ isLikelyAComponentStack(args[args.length - 1]) &&
+ isLikelyAComponentStack(args[args.length - 2])
+ ) {
+ lastWarningWithExtraComponentStack = {
+ format,
+ }
+ }
+
+ for (let index = 0; index < expectedMessages.length; index++) {
+ const expectedMessage = expectedMessages[index]
+ if (
+ normalizedMessage === expectedMessage ||
+ normalizedMessage.includes(expectedMessage)
+ ) {
+ if (isLikelyAComponentStack(normalizedMessage)) {
+ warningsWithComponentStack.push(normalizedMessage)
+ } else {
+ warningsWithoutComponentStack.push(normalizedMessage)
+ }
+ expectedMessages.splice(index, 1)
+ return
+ }
+ }
+
+ let errorMessage
+ if (expectedMessages.length === 0) {
+ errorMessage =
+ 'Unexpected warning recorded: ' +
+ this.utils.printReceived(normalizedMessage)
+ } else if (expectedMessages.length === 1) {
+ errorMessage =
+ 'Unexpected warning recorded: ' +
+ jestDiff(expectedMessages[0], normalizedMessage)
+ } else {
+ errorMessage =
+ 'Unexpected warning recorded: ' +
+ jestDiff(expectedMessages, [normalizedMessage])
+ }
+
+ // Record the call stack for unexpected warnings.
+ // We don't throw an Error here though,
+ // Because it might be suppressed by ReactFiberScheduler.
+ unexpectedWarnings.push(new Error(errorMessage))
+ }
+
+ // TODO Decide whether we need to support nested toWarn* expectations.
+ // If we don't need it, add a check here to see if this is already our spy,
+ // And throw an error.
+ const originalMethod = console[consoleMethod]
+
+ // Avoid using Jest's built-in spy since it can't be removed.
+ console[consoleMethod] = consoleSpy
+
+ try {
+ callback()
+ } catch (error) {
+ caughtError = error
+ } finally {
+ // Restore the unspied method so that unexpected errors fail tests.
+ console[consoleMethod] = originalMethod
+
+ // Any unexpected Errors thrown by the callback should fail the test.
+ // This should take precedence since unexpected errors could block warnings.
+ if (caughtError) {
+ throw caughtError
+ }
+
+ // Any unexpected warnings should be treated as a failure.
+ if (unexpectedWarnings.length > 0) {
+ return {
+ message: () => unexpectedWarnings[0].stack,
+ pass: false,
+ }
+ }
+
+ // Any remaining messages indicate a failed expectations.
+ if (expectedMessages.length > 0) {
+ return {
+ message: () =>
+ `Expected warning was not recorded:\n ${this.utils.printReceived(
+ expectedMessages[0],
+ )}`,
+ pass: false,
+ }
+ }
+
+ if (typeof withoutStack === 'number') {
+ // We're expecting a particular number of warnings without stacks.
+ if (withoutStack !== warningsWithoutComponentStack.length) {
+ return {
+ message: () =>
+ `Expected ${withoutStack} warnings without a component stack but received ${warningsWithoutComponentStack.length}:\n` +
+ warningsWithoutComponentStack.map(warning =>
+ this.utils.printReceived(warning),
+ ),
+ pass: false,
+ }
+ }
+ } else if (withoutStack === true) {
+ // We're expecting that all warnings won't have the stack.
+ // If some warnings have it, it's an error.
+ if (warningsWithComponentStack.length > 0) {
+ return {
+ message: () =>
+ `Received warning unexpectedly includes a component stack:\n ${this.utils.printReceived(
+ warningsWithComponentStack[0],
+ )}\nIf this warning intentionally includes the component stack, remove ` +
+ `{withoutStack: true} from the ${matcherName}() call. If you have a mix of ` +
+ `warnings with and without stack in one ${matcherName}() call, pass ` +
+ `{withoutStack: N} where N is the number of warnings without stacks.`,
+ pass: false,
+ }
+ }
+ } else if (withoutStack === false || withoutStack === undefined) {
+ // We're expecting that all warnings *do* have the stack (default).
+ // If some warnings don't have it, it's an error.
+ if (warningsWithoutComponentStack.length > 0) {
+ return {
+ message: () =>
+ `Received warning unexpectedly does not include a component stack:\n ${this.utils.printReceived(
+ warningsWithoutComponentStack[0],
+ )}\nIf this warning intentionally omits the component stack, add ` +
+ `{withoutStack: true} to the ${matcherName} call.`,
+ pass: false,
+ }
+ }
+ } else {
+ throw Error(
+ `The second argument for ${matcherName}(), when specified, must be an object. It may have a ` +
+ `property called "withoutStack" whose value may be undefined, boolean, or a number. ` +
+ `Instead received ${typeof withoutStack}.`,
+ )
+ }
+
+ if (lastWarningWithMismatchingFormat !== null) {
+ return {
+ message: () =>
+ `Received ${
+ lastWarningWithMismatchingFormat.args.length
+ } arguments for a message with ${
+ lastWarningWithMismatchingFormat.expectedArgCount
+ } placeholders:\n ${this.utils.printReceived(
+ lastWarningWithMismatchingFormat.format,
+ )}`,
+ pass: false,
+ }
+ }
+
+ if (lastWarningWithExtraComponentStack !== null) {
+ return {
+ message: () =>
+ `Received more than one component stack for a warning:\n ${this.utils.printReceived(
+ lastWarningWithExtraComponentStack.format,
+ )}\nDid you accidentally pass a stack to warning() as the last argument? ` +
+ `Don't forget warning() already injects the component stack automatically.`,
+ pass: false,
+ }
+ }
+
+ return {pass: true}
+ }
+ } else {
+ // Any uncaught errors or warnings should fail tests in production mode.
+ callback()
+
+ return {pass: true}
+ }
+ }
+
+module.exports = {
+ toWarnDev: createMatcherFor('warn', 'toWarnDev'),
+ toErrorDev: createMatcherFor('error', 'toErrorDev'),
+}
diff --git a/types/index.d.ts b/types/index.d.ts
index e3f5bc60..bdd60567 100644
--- a/types/index.d.ts
+++ b/types/index.d.ts
@@ -1,40 +1,93 @@
// TypeScript Version: 3.8
-
+import * as ReactDOMClient from 'react-dom/client'
import {
queries,
Queries,
BoundFunction,
prettyFormat,
+ Config as ConfigDTL,
} from '@testing-library/dom'
-import {Renderer} from 'react-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'
+export interface Config extends ConfigDTL {
+ reactStrictMode: boolean
+}
+
+export interface ConfigFn {
+ (existingConfig: Config): Partial
+}
+
+export function configure(configDelta: ConfigFn | Partial): void
+
+export function getConfig(): Config
+
export type RenderResult<
Q extends Queries = typeof queries,
- Container extends Element | DocumentFragment = HTMLElement,
- BaseElement extends Element | DocumentFragment = Container,
+ Container extends RendererableContainer | HydrateableContainer = HTMLElement,
+ BaseElement extends RendererableContainer | HydrateableContainer = Container,
> = {
container: Container
baseElement: BaseElement
debug: (
baseElement?:
- | Element
- | DocumentFragment
- | Array,
- maxLength?: number,
- options?: prettyFormat.OptionsReceived,
+ | RendererableContainer
+ | HydrateableContainer
+ | Array
+ | undefined,
+ maxLength?: number | undefined,
+ options?: prettyFormat.OptionsReceived | undefined,
) => void
- rerender: (ui: React.ReactElement) => void
+ rerender: (ui: React.ReactNode) => void
unmount: () => void
asFragment: () => DocumentFragment
} & {[P in keyof Q]: BoundFunction}
+/** @deprecated */
+export type BaseRenderOptions<
+ Q extends Queries,
+ Container extends RendererableContainer | HydrateableContainer,
+ BaseElement extends RendererableContainer | HydrateableContainer,
+> = RenderOptions
+
+type RendererableContainer = ReactDOMClient.Container
+type HydrateableContainer = Parameters[0]
+/** @deprecated */
+export interface ClientRenderOptions<
+ Q extends Queries,
+ Container extends RendererableContainer,
+ BaseElement extends RendererableContainer = Container,
+> extends BaseRenderOptions {
+ /**
+ * 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?: false | undefined
+}
+/** @deprecated */
+export interface HydrateOptions<
+ Q extends Queries,
+ Container extends HydrateableContainer,
+ BaseElement extends HydrateableContainer = Container,
+> extends BaseRenderOptions {
+ /**
+ * 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: true
+}
+
export interface RenderOptions<
Q extends Queries = typeof queries,
- Container extends Element | DocumentFragment = HTMLElement,
- BaseElement extends Element | DocumentFragment = Container,
+ Container extends RendererableContainer | HydrateableContainer = HTMLElement,
+ BaseElement extends RendererableContainer | HydrateableContainer = Container,
> {
/**
* By default, React Testing Library will create a div and append that div to the document.body. Your React component will be rendered in the created div. If you provide your own HTMLElement container via this option,
@@ -45,39 +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.ReactElement}>
+ 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>
@@ -87,15 +170,15 @@ type Omit = Pick>
*/
export function render<
Q extends Queries = typeof queries,
- Container extends Element | DocumentFragment = HTMLElement,
- BaseElement extends Element | DocumentFragment = Container,
+ Container extends RendererableContainer | HydrateableContainer = HTMLElement,
+ BaseElement extends RendererableContainer | HydrateableContainer = Container,
>(
- ui: React.ReactElement,
+ ui: React.ReactNode,
options: RenderOptions,
): RenderResult
export function render(
- ui: React.ReactElement,
- options?: Omit,
+ ui: React.ReactNode,
+ options?: Omit | undefined,
): RenderResult
export interface RenderHookResult {
@@ -120,28 +203,72 @@ export interface RenderHookResult {
unmount: () => void
}
-export interface RenderHookOptions {
+/** @deprecated */
+export type BaseRenderHookOptions<
+ Props,
+ Q extends Queries,
+ Container extends RendererableContainer | HydrateableContainer,
+ BaseElement extends Element | DocumentFragment,
+> = RenderHookOptions
+
+/** @deprecated */
+export interface ClientRenderHookOptions<
+ Props,
+ Q extends Queries,
+ Container extends Element | DocumentFragment,
+ BaseElement extends Element | DocumentFragment = Container,
+> extends BaseRenderHookOptions {
/**
- * 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.
+ * 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)
*/
- initialProps?: Props
+ hydrate?: false | undefined
+}
+
+/** @deprecated */
+export interface HydrateHookOptions<
+ Props,
+ Q extends Queries,
+ Container extends Element | DocumentFragment,
+ BaseElement extends Element | DocumentFragment = Container,
+> extends BaseRenderHookOptions {
/**
- * 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.
+ * 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/#wrapper
+ * @see https://testing-library.com/docs/react-testing-library/api/#hydrate)
*/
- wrapper?: React.JSXElementConstructor<{children: React.ReactElement}>
+ hydrate: true
+}
+
+export interface RenderHookOptions<
+ Props,
+ Q extends Queries = typeof queries,
+ Container extends RendererableContainer | HydrateableContainer = HTMLElement,
+ BaseElement extends RendererableContainer | HydrateableContainer = Container,
+> extends BaseRenderOptions {
+ /**
+ * 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 | undefined
}
/**
* Allows you to render a hook within a test React component without having to
* create that component yourself.
*/
-export function renderHook(
+export function renderHook<
+ Result,
+ Props,
+ Q extends Queries = typeof queries,
+ Container extends RendererableContainer | HydrateableContainer = HTMLElement,
+ BaseElement extends RendererableContainer | HydrateableContainer = Container,
+>(
render: (initialProps: Props) => Result,
- options?: RenderHookOptions,
+ options?: RenderHookOptions | undefined,
): RenderHookResult
/**
@@ -150,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 17ba7012..825d5699 100644
--- a/types/test.tsx
+++ b/types/test.tsx
@@ -45,6 +45,8 @@ export function testRenderOptions() {
const options = {container}
const {container: returnedContainer} = render(, options)
expectType(returnedContainer)
+
+ render(, {wrapper: () => null})
}
export function testSVGRenderOptions() {
@@ -62,6 +64,28 @@ export function testFireEvent() {
fireEvent.click(container)
}
+export function testConfigure() {
+ // test for DTL's config
+ pure.configure({testIdAttribute: 'foobar'})
+ pure.configure(existingConfig => ({
+ testIdAttribute: `modified-${existingConfig.testIdAttribute}`,
+ }))
+
+ // test for RTL's config
+ pure.configure({reactStrictMode: true})
+ pure.configure(existingConfig => ({
+ reactStrictMode: !existingConfig.reactStrictMode,
+ }))
+}
+
+export function testGetConfig() {
+ // test for DTL's config
+ pure.getConfig().testIdAttribute
+
+ // test for RTL's config
+ pure.getConfig().reactStrictMode
+}
+
export function testDebug() {
const {debug, getAllByTestId} = render(
<>
@@ -101,18 +125,27 @@ export function testQueries() {
}
export function wrappedRender(
- ui: React.ReactElement,
+ ui: React.ReactNode,
options?: pure.RenderOptions,
) {
- const Wrapper = ({children}: {children: React.ReactElement}): 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(
- ui: React.ReactElement,
+ ui: React.ReactNode,
options?: pure.RenderOptions,
) {
const Wrapper: React.FunctionComponent<{children?: React.ReactNode}> = ({
@@ -125,7 +158,7 @@ export function wrappedRenderB(
}
export function wrappedRenderC(
- ui: React.ReactElement,
+ ui: React.ReactNode,
options?: pure.RenderOptions,
) {
interface AppWrapperProps {
@@ -142,6 +175,24 @@ export function wrappedRenderC(
return pure.render(ui, {wrapper: AppWrapperProps, ...options})
}
+export function wrappedRenderHook(
+ hook: () => unknown,
+ options?: pure.RenderHookOptions,
+) {
+ interface AppWrapperProps {
+ children?: React.ReactNode
+ userProviderProps?: {user: string}
+ }
+ const AppWrapperProps: React.FunctionComponent = ({
+ children,
+ userProviderProps = {user: 'TypeScript'},
+ }) => {
+ return {children}
+ }
+
+ return pure.renderHook(hook, {...options})
+}
+
export function testBaseElement() {
const {baseElement: baseDefaultElement} = render()
expectType(baseDefaultElement)
@@ -169,6 +220,8 @@ export function testRenderHook() {
rerender()
unmount()
+
+ renderHook(() => null, {wrapper: () => null})
}
export function testRenderHookProps() {
@@ -184,11 +237,58 @@ export function testRenderHookProps() {
unmount()
}
+export function testContainer() {
+ render('a', {container: document.createElement('div')})
+ render('a', {container: document.createDocumentFragment()})
+ // Only allowed in React 19
+ render('a', {container: document})
+ render('a', {container: document.createElement('div'), hydrate: true})
+ // Only allowed for createRoot but typing `render` appropriately makes it harder to compose.
+ render('a', {container: document.createDocumentFragment(), hydrate: true})
+ render('a', {container: document, hydrate: true})
+
+ renderHook(() => null, {container: document.createElement('div')})
+ renderHook(() => null, {container: document.createDocumentFragment()})
+ // Only allowed in React 19
+ renderHook(() => null, {container: document})
+ renderHook(() => null, {
+ container: document.createElement('div'),
+ hydrate: true,
+ })
+ // Only allowed for createRoot but typing `render` appropriately makes it harder to compose.
+ renderHook(() => null, {
+ container: document.createDocumentFragment(),
+ hydrate: true,
+ })
+ 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",
testing-library/no-wait-for-empty-callback: "off",
- testing-library/no-debug: "off",
testing-library/prefer-screen-queries: "off"
*/
diff --git a/types/tsconfig.json b/types/tsconfig.json
index a7829065..bad26af7 100644
--- a/types/tsconfig.json
+++ b/types/tsconfig.json
@@ -1,4 +1,8 @@
{
"extends": "../node_modules/kcd-scripts/shared-tsconfig.json",
+ "compilerOptions": {
+ "exactOptionalPropertyTypes": true,
+ "skipLibCheck": false
+ },
"include": ["."]
}