From 64402204793ad56fd0b4c9c8860ae80ca48724ce Mon Sep 17 00:00:00 2001 From: eps1lon Date: Sun, 23 Apr 2023 11:38:36 +0200 Subject: [PATCH] Use `async` `act` --- jest.config.js | 10 +- package.json | 1 + src/__tests__/act-compat.js | 99 ++++++++++++++++ src/__tests__/act.js | 79 +++---------- src/__tests__/auto-cleanup-skip.js | 5 +- src/__tests__/auto-cleanup.js | 4 +- src/__tests__/cleanup.js | 49 +++++--- src/__tests__/debug.js | 12 +- src/__tests__/end-to-end.js | 179 +++++++++++++++++++++++------ src/__tests__/error-handlers.js | 52 ++++----- src/__tests__/events.js | 32 +++--- src/__tests__/multi-base.js | 6 +- src/__tests__/new-act.js | 12 +- src/__tests__/render.js | 82 ++++++------- src/__tests__/renderHook.js | 47 ++++---- src/__tests__/rerender.js | 24 ++-- src/__tests__/stopwatch.js | 6 +- src/act-compat.js | 64 +++-------- src/fire-event.js | 30 ++--- src/index.js | 14 +-- src/pure.js | 159 +++++++++++++------------ tests/toWarnDev.js | 55 +++++++-- types/index.d.ts | 43 ++++--- types/test.tsx | 103 +++++++++-------- 24 files changed, 690 insertions(+), 477 deletions(-) create mode 100644 src/__tests__/act-compat.js diff --git a/jest.config.js b/jest.config.js index 860358cd..fd76e2f2 100644 --- a/jest.config.js +++ b/jest.config.js @@ -6,14 +6,14 @@ module.exports = Object.assign(jestConfig, { // Full coverage across the build matrix (React 18, 19) but not in a single job // Ful coverage is checked via codecov './src/act-compat': { - branches: 90, + branches: 80, }, './src/pure': { // minimum coverage of jobs using React 18 and 19 - branches: 95, - functions: 88, - lines: 92, - statements: 92, + branches: 90, + functions: 81, + lines: 91, + statements: 91, }, }, }) diff --git a/package.json b/package.json index 146c7d02..03f0629b 100644 --- a/package.json +++ b/package.json @@ -90,6 +90,7 @@ "react/no-adjacent-inline-elements": "off", "import/no-unassigned-import": "off", "import/named": "off", + "testing-library/no-await-sync-events": "off", "testing-library/no-container": "off", "testing-library/no-debugging-utils": "off", "testing-library/no-dom-import": "off", diff --git a/src/__tests__/act-compat.js b/src/__tests__/act-compat.js new file mode 100644 index 00000000..89aacbc9 --- /dev/null +++ b/src/__tests__/act-compat.js @@ -0,0 +1,99 @@ +import * as React from 'react' +import {render, fireEvent, screen} from '../' +import {actIfEnabled} from '../act-compat' + +beforeEach(() => { + global.IS_REACT_ACT_ENVIRONMENT = true +}) + +test('render calls useEffect immediately', async () => { + const effectCb = jest.fn() + function MyUselessComponent() { + React.useEffect(effectCb) + return null + } + await render() + expect(effectCb).toHaveBeenCalledTimes(1) +}) + +test('findByTestId returns the element', async () => { + const ref = React.createRef() + await render(
) + expect(await screen.findByTestId('foo')).toBe(ref.current) +}) + +test('fireEvent triggers useEffect calls', async () => { + const effectCb = jest.fn() + function Counter() { + React.useEffect(effectCb) + const [count, setCount] = React.useState(0) + return + } + const { + container: {firstChild: buttonNode}, + } = await render() + + effectCb.mockClear() + // eslint-disable-next-line testing-library/no-await-sync-events -- TODO: Remove lint rule. + await fireEvent.click(buttonNode) + expect(buttonNode).toHaveTextContent('1') + expect(effectCb).toHaveBeenCalledTimes(1) +}) + +test('calls to hydrate will run useEffects', async () => { + const effectCb = jest.fn() + function MyUselessComponent() { + React.useEffect(effectCb) + return null + } + await render(, {hydrate: true}) + expect(effectCb).toHaveBeenCalledTimes(1) +}) + +test('cleans up IS_REACT_ACT_ENVIRONMENT if its callback throws', async () => { + global.IS_REACT_ACT_ENVIRONMENT = false + + await expect(() => + actIfEnabled(() => { + throw new Error('threw') + }), + ).rejects.toThrow('threw') + + expect(global.IS_REACT_ACT_ENVIRONMENT).toEqual(false) +}) + +test('cleans up IS_REACT_ACT_ENVIRONMENT if its async callback throws', async () => { + global.IS_REACT_ACT_ENVIRONMENT = false + + await expect(() => + actIfEnabled(async () => { + throw new Error('thenable threw') + }), + ).rejects.toThrow('thenable threw') + + expect(global.IS_REACT_ACT_ENVIRONMENT).toEqual(false) +}) + +test('state update from microtask does not trigger "missing act" warning', async () => { + let triggerStateUpdateFromMicrotask + function App() { + const [state, setState] = React.useState(0) + triggerStateUpdateFromMicrotask = () => setState(1) + React.useEffect(() => { + // eslint-disable-next-line jest/no-conditional-in-test + if (state === 1) { + Promise.resolve().then(() => { + setState(2) + }) + } + }, [state]) + return state + } + const {container} = await render() + + await actIfEnabled(() => { + triggerStateUpdateFromMicrotask() + }) + + expect(container).toHaveTextContent('2') +}) diff --git a/src/__tests__/act.js b/src/__tests__/act.js index 5430f28b..95803a39 100644 --- a/src/__tests__/act.js +++ b/src/__tests__/act.js @@ -1,69 +1,26 @@ import * as React from 'react' -import {act, render, fireEvent, screen} from '../' +import {act, render} from '../' -test('render calls useEffect immediately', () => { - const effectCb = jest.fn() - function MyUselessComponent() { - React.useEffect(effectCb) - return null - } - render() - expect(effectCb).toHaveBeenCalledTimes(1) -}) - -test('findByTestId returns the element', async () => { - const ref = React.createRef() - render(
) - expect(await screen.findByTestId('foo')).toBe(ref.current) -}) - -test('fireEvent triggers useEffect calls', () => { - const effectCb = jest.fn() - function Counter() { - React.useEffect(effectCb) - const [count, setCount] = React.useState(0) - return - } - const { - container: {firstChild: buttonNode}, - } = render() - - effectCb.mockClear() - fireEvent.click(buttonNode) - expect(buttonNode).toHaveTextContent('1') - expect(effectCb).toHaveBeenCalledTimes(1) +beforeEach(() => { + global.IS_REACT_ACT_ENVIRONMENT = true }) -test('calls to hydrate will run useEffects', () => { - const effectCb = jest.fn() - function MyUselessComponent() { - React.useEffect(effectCb) - return null +test('does not work outside IS_REACT_ENVIRONMENT like React.act', async () => { + let setState + function Component() { + const [state, _setState] = React.useState(0) + setState = _setState + return state } - render(, {hydrate: true}) - expect(effectCb).toHaveBeenCalledTimes(1) -}) + await render() -test('cleans up IS_REACT_ACT_ENVIRONMENT if its callback throws', () => { global.IS_REACT_ACT_ENVIRONMENT = false - - expect(() => - act(() => { - throw new Error('threw') - }), - ).toThrow('threw') - - expect(global.IS_REACT_ACT_ENVIRONMENT).toEqual(false) -}) - -test('cleans up IS_REACT_ACT_ENVIRONMENT if its async callback throws', async () => { - global.IS_REACT_ACT_ENVIRONMENT = false - - await expect(() => - act(async () => { - throw new Error('thenable threw') - }), - ).rejects.toThrow('thenable threw') - - expect(global.IS_REACT_ACT_ENVIRONMENT).toEqual(false) + await expect(async () => { + await act(() => { + setState(1) + }) + }).toErrorDev( + 'The current testing environment is not configured to support act(...)', + {withoutStack: true}, + ) }) diff --git a/src/__tests__/auto-cleanup-skip.js b/src/__tests__/auto-cleanup-skip.js index 5696d4e3..89a99e9b 100644 --- a/src/__tests__/auto-cleanup-skip.js +++ b/src/__tests__/auto-cleanup-skip.js @@ -3,14 +3,15 @@ import * as React from 'react' let render beforeAll(() => { process.env.RTL_SKIP_AUTO_CLEANUP = 'true' + globalThis.IS_REACT_ACT_ENVIRONMENT = true const rtl = require('../') render = rtl.render }) // This one verifies that if RTL_SKIP_AUTO_CLEANUP is set // then we DON'T auto-wire up the afterEach for folks -test('first', () => { - render(
hi
) +test('first', async () => { + await render(
hi
) }) test('second', () => { diff --git a/src/__tests__/auto-cleanup.js b/src/__tests__/auto-cleanup.js index 450a6136..b796fa00 100644 --- a/src/__tests__/auto-cleanup.js +++ b/src/__tests__/auto-cleanup.js @@ -4,8 +4,8 @@ import {render} from '../' // This just verifies that by importing RTL in an // environment which supports afterEach (like jest) // we'll get automatic cleanup between tests. -test('first', () => { - render(
hi
) +test('first', async () => { + await render(
hi
) }) test('second', () => { diff --git a/src/__tests__/cleanup.js b/src/__tests__/cleanup.js index 9f17c722..20e298f2 100644 --- a/src/__tests__/cleanup.js +++ b/src/__tests__/cleanup.js @@ -1,7 +1,7 @@ import * as React from 'react' import {render, cleanup} from '../' -test('cleans up the document', () => { +test('cleans up the document', async () => { const spy = jest.fn() const divId = 'my-div' @@ -16,18 +16,18 @@ test('cleans up the document', () => { } } - render() - cleanup() + await render() + await cleanup() expect(document.body).toBeEmptyDOMElement() expect(spy).toHaveBeenCalledTimes(1) }) -test('cleanup does not error when an element is not a child', () => { - render(
, {container: document.createElement('div')}) - cleanup() +test('cleanup does not error when an element is not a child', async () => { + await render(
, {container: document.createElement('div')}) + await cleanup() }) -test('cleanup runs effect cleanup functions', () => { +test('cleanup runs effect cleanup functions', async () => { const spy = jest.fn() const Test = () => { @@ -36,11 +36,23 @@ test('cleanup runs effect cleanup functions', () => { return null } - render() - cleanup() + await render() + await cleanup() expect(spy).toHaveBeenCalledTimes(1) }) +test('cleanup cleans up every root and disconnects containers', async () => { + const {container: container1} = await render(
) + const {container: container2} = await render() + + await cleanup() + + expect(container1).toBeEmptyDOMElement() + expect(container1.isConnected).toBe(false) + expect(container2).toBeEmptyDOMElement() + expect(container2.isConnected).toBe(false) +}) + describe('fake timers and missing act warnings', () => { beforeEach(() => { jest.resetAllMocks() @@ -55,7 +67,7 @@ describe('fake timers and missing act warnings', () => { jest.useRealTimers() }) - test('cleanup does not flush microtasks', () => { + test('cleanup does flush microtasks', async () => { const microTaskSpy = jest.fn() function Test() { const counter = 1 @@ -72,22 +84,25 @@ describe('fake timers and missing act warnings', () => { return () => { cancelled = true + Promise.resolve().then(() => { + microTaskSpy() + }) } }, [counter]) return null } - render() - - cleanup() + await render() + expect(microTaskSpy).toHaveBeenCalledTimes(1) - expect(microTaskSpy).toHaveBeenCalledTimes(0) + await cleanup() + expect(microTaskSpy).toHaveBeenCalledTimes(2) // console.error is mocked // eslint-disable-next-line no-console expect(console.error).toHaveBeenCalledTimes(0) }) - test('cleanup does not swallow missing act warnings', () => { + test('cleanup does not swallow missing act warnings', async () => { const deferredStateUpdateSpy = jest.fn() function Test() { const counter = 1 @@ -109,10 +124,10 @@ describe('fake timers and missing act warnings', () => { return null } - render() + await render() jest.runAllTimers() - cleanup() + await cleanup() expect(deferredStateUpdateSpy).toHaveBeenCalledTimes(1) // console.error is mocked diff --git a/src/__tests__/debug.js b/src/__tests__/debug.js index c6a1d1fe..4c5ec311 100644 --- a/src/__tests__/debug.js +++ b/src/__tests__/debug.js @@ -9,9 +9,9 @@ afterEach(() => { console.log.mockRestore() }) -test('debug pretty prints the container', () => { +test('debug pretty prints the container', async () => { const HelloWorld = () =>

Hello World

- const {debug} = render() + const {debug} = await render() debug() expect(console.log).toHaveBeenCalledTimes(1) expect(console.log).toHaveBeenCalledWith( @@ -19,14 +19,14 @@ test('debug pretty prints the container', () => { ) }) -test('debug pretty prints multiple containers', () => { +test('debug pretty prints multiple containers', async () => { const HelloWorld = () => ( <>

Hello World

Hello World

) - const {debug} = render() + const {debug} = await render() const multipleElements = screen.getAllByTestId('testId') debug(multipleElements) @@ -36,9 +36,9 @@ test('debug pretty prints multiple containers', () => { ) }) -test('allows same arguments as prettyDOM', () => { +test('allows same arguments as prettyDOM', async () => { const HelloWorld = () =>

Hello World

- const {debug, container} = render() + const {debug, container} = await render() debug(container, 6, {highlight: false}) expect(console.log).toHaveBeenCalledTimes(1) expect(console.log.mock.calls[0]).toMatchInlineSnapshot(` diff --git a/src/__tests__/end-to-end.js b/src/__tests__/end-to-end.js index f93c23be..0c93b7f8 100644 --- a/src/__tests__/end-to-end.js +++ b/src/__tests__/end-to-end.js @@ -1,5 +1,4 @@ -import * as React from 'react' -import {render, waitForElementToBeRemoved, screen, waitFor} from '../' +let React, cleanup, render, screen, waitFor, waitForElementToBeRemoved describe.each([ ['real timers', () => jest.useRealTimers()], @@ -9,10 +8,25 @@ describe.each([ 'it waits for the data to be loaded in a macrotask using %s', (label, useTimers) => { beforeEach(() => { + jest.resetModules() + global.IS_REACT_ACT_ENVIRONMENT = true + process.env.RTL_SKIP_AUTO_CLEANUP = '0' + useTimers() + + React = require('react') + ;({ + cleanup, + render, + screen, + waitFor, + waitForElementToBeRemoved, + } = require('..')) }) - afterEach(() => { + afterEach(async () => { + await cleanup() + global.IS_REACT_ACT_ENVIRONMENT = false jest.useRealTimers() }) @@ -53,21 +67,21 @@ describe.each([ } test('waitForElementToBeRemoved', async () => { - render() + await render() const loading = () => screen.getByText('Loading...') await waitForElementToBeRemoved(loading) expect(screen.getByTestId('message')).toHaveTextContent(/Hello World/) }) test('waitFor', async () => { - render() + await 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 render() await expect(screen.findByTestId('message')).resolves.toHaveTextContent( /Hello World/, ) @@ -83,10 +97,25 @@ describe.each([ 'it waits for the data to be loaded in many microtask using %s', (label, useTimers) => { beforeEach(() => { + jest.resetModules() + global.IS_REACT_ACT_ENVIRONMENT = true + process.env.RTL_SKIP_AUTO_CLEANUP = '0' + useTimers() + + React = require('react') + ;({ + cleanup, + render, + screen, + waitFor, + waitForElementToBeRemoved, + } = require('..')) }) - afterEach(() => { + afterEach(async () => { + await cleanup() + global.IS_REACT_ACT_ENVIRONMENT = false jest.useRealTimers() }) @@ -137,25 +166,21 @@ describe.each([ } test('waitForElementToBeRemoved', async () => { - render() + await render() const loading = () => screen.getByText('Loading..') - await waitForElementToBeRemoved(loading) + // Already flushed microtasks so we'll never see the loading state in a test. + expect(loading).toThrowError(/Unable to find an element with the text/) expect(screen.getByTestId('message')).toHaveTextContent(/Hello World/) }) - test('waitFor', async () => { - render() - await waitFor(() => { - screen.getByText('Loading..') - }) - await waitFor(() => { - screen.getByText(/Loaded this message:/) - }) + test('waitFor is not needed since microtasks are flushed', async () => { + await render() + expect(screen.getByTestId('message')).toHaveTextContent(/Hello World/) }) test('findBy', async () => { - render() + await render() await expect(screen.findByTestId('message')).resolves.toHaveTextContent( /Hello World/, ) @@ -171,10 +196,25 @@ describe.each([ 'it waits for the data to be loaded in a microtask using %s', (label, useTimers) => { beforeEach(() => { + jest.resetModules() + global.IS_REACT_ACT_ENVIRONMENT = true + process.env.RTL_SKIP_AUTO_CLEANUP = '0' + useTimers() + + React = require('react') + ;({ + cleanup, + render, + screen, + waitFor, + waitForElementToBeRemoved, + } = require('..')) }) - afterEach(() => { + afterEach(async () => { + await cleanup() + global.IS_REACT_ACT_ENVIRONMENT = false jest.useRealTimers() }) @@ -206,29 +246,100 @@ describe.each([ ) } - 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:/) - }) + await render() + // Already flushed microtasks from `ComponentWithMicrotaskLoader` here. + expect(screen.queryByText('Loading..')).not.toBeInTheDocument() + expect(screen.getByText(/Loaded this message:/)).toBeInTheDocument() expect(screen.getByTestId('message')).toHaveTextContent(/Hello World/) }) test('findBy', async () => { - render() + await 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')], +])('testing intermediate states using %s', (label, useTimers) => { + beforeEach(() => { + jest.resetModules() + global.IS_REACT_ACT_ENVIRONMENT = false + process.env.RTL_SKIP_AUTO_CLEANUP = '0' + + useTimers() + + React = require('react') + ;({ + cleanup, + render, + screen, + waitFor, + waitForElementToBeRemoved, + } = require('..')) + }) + + afterEach(async () => { + await cleanup() + jest.useRealTimers() + global.IS_REACT_ACT_ENVIRONMENT = 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().then(data => { + setFetchState({todo: data.title, fetching: false}) + }) + }) + } + }, [fetchState]) + + if (fetchState.fetching) { + return

Loading..

+ } + + return ( +
Loaded this message: {fetchState.todo}
+ ) + } + + test('waitFor', async () => { + await render() + + // TODO: How to assert on the intermediate state? + await expect( + waitFor(() => { + expect(screen.getByText('Loading..')).toBeInTheDocument() + }), + ).rejects.toThrowError(/Unable to find an element/) + + expect(screen.getByTestId('message')).toHaveTextContent(/Hello World/) + }) + + test('findBy', async () => { + await render() + + // TODO: How to assert on the intermediate state? + await expect(screen.findByText('Loading..')).rejects.toThrowError( + /Unable to find an element/, + ) + + expect(screen.getByTestId('message')).toHaveTextContent(/Hello World/) + }) +}) diff --git a/src/__tests__/error-handlers.js b/src/__tests__/error-handlers.js index 60db1410..476002ba 100644 --- a/src/__tests__/error-handlers.js +++ b/src/__tests__/error-handlers.js @@ -8,20 +8,20 @@ const isReact19 = React.version.startsWith('19.') const testGateReact19 = isReact19 ? test : test.skip -test('render errors', () => { +test('render errors', async () => { function Thrower() { throw new Error('Boom!') } if (isReact19) { - expect(() => { - render() - }).toThrow('Boom!') + await expect(async () => { + await render() + }).rejects.toThrow('Boom!') } else { - expect(() => { - expect(() => { - render() - }).toThrow('Boom!') + await expect(async () => { + await expect(async () => { + await render() + }).rejects.toThrow('Boom!') }).toErrorDev([ 'Error: Uncaught [Error: Boom!]', // React retries on error @@ -30,26 +30,26 @@ test('render errors', () => { } }) -test('onUncaughtError is not supported in render', () => { +test('onUncaughtError is not supported in render', async () => { function Thrower() { throw new Error('Boom!') } const onUncaughtError = jest.fn(() => {}) - expect(() => { - render(, { + await expect(async () => { + await render(, { onUncaughtError(error, errorInfo) { console.log({error, errorInfo}) }, }) - }).toThrow( + }).rejects.toThrow( 'onUncaughtError is not supported. The `render` call will already throw on uncaught errors.', ) expect(onUncaughtError).toHaveBeenCalledTimes(0) }) -testGateReact19('onCaughtError is supported in render', () => { +testGateReact19('onCaughtError is supported in render', async () => { const thrownError = new Error('Boom!') const handleComponentDidCatch = jest.fn() const onCaughtError = jest.fn() @@ -72,7 +72,7 @@ testGateReact19('onCaughtError is supported in render', () => { throw thrownError } - render( + await render( , @@ -87,7 +87,7 @@ testGateReact19('onCaughtError is supported in render', () => { }) }) -test('onRecoverableError is supported in render', () => { +test('onRecoverableError is supported in render', async () => { const onRecoverableError = jest.fn() const container = document.createElement('div') @@ -96,15 +96,15 @@ test('onRecoverableError is supported in render', () => { // 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
, { + await render(
client
, { container, hydrate: true, onRecoverableError, }) expect(onRecoverableError).toHaveBeenCalledTimes(1) } else { - expect(() => { - render(
client
, { + await expect(async () => { + await render(
client
, { container, hydrate: true, onRecoverableError, @@ -114,26 +114,26 @@ test('onRecoverableError is supported in render', () => { } }) -test('onUncaughtError is not supported in renderHook', () => { +test('onUncaughtError is not supported in renderHook', async () => { function useThrower() { throw new Error('Boom!') } const onUncaughtError = jest.fn(() => {}) - expect(() => { - renderHook(useThrower, { + await expect(async () => { + await renderHook(useThrower, { onUncaughtError(error, errorInfo) { console.log({error, errorInfo}) }, }) - }).toThrow( + }).rejects.toThrow( 'onUncaughtError is not supported. The `render` call will already throw on uncaught errors.', ) expect(onUncaughtError).toHaveBeenCalledTimes(0) }) -testGateReact19('onCaughtError is supported in renderHook', () => { +testGateReact19('onCaughtError is supported in renderHook', async () => { const thrownError = new Error('Boom!') const handleComponentDidCatch = jest.fn() const onCaughtError = jest.fn() @@ -156,7 +156,7 @@ testGateReact19('onCaughtError is supported in renderHook', () => { throw thrownError } - renderHook(useThrower, { + await renderHook(useThrower, { onCaughtError, wrapper: ErrorBoundary, }) @@ -169,10 +169,10 @@ testGateReact19('onCaughtError is supported in renderHook', () => { // Currently, there's no recoverable error without hydration. // The option is still supported though. -test('onRecoverableError is supported in renderHook', () => { +test('onRecoverableError is supported in renderHook', async () => { const onRecoverableError = jest.fn() - renderHook( + await renderHook( () => { // TODO: trigger recoverable error }, diff --git a/src/__tests__/events.js b/src/__tests__/events.js index 587bfdae..569ec9db 100644 --- a/src/__tests__/events.js +++ b/src/__tests__/events.js @@ -151,18 +151,18 @@ eventTypes.forEach(({type, events, elementType, init}) => { 1, )}` - it(`triggers ${propName}`, () => { + it(`triggers ${propName}`, async () => { const ref = React.createRef() const spy = jest.fn() - render( + await render( React.createElement(elementType, { [propName]: spy, ref, }), ) - fireEvent[eventName](ref.current, init) + await fireEvent[eventName](ref.current, init) expect(spy).toHaveBeenCalledTimes(1) }) }) @@ -179,7 +179,7 @@ eventTypes.forEach(({type, events, elementType, init}) => { nativeEventName = 'dblclick' } - it(`triggers native ${nativeEventName}`, () => { + it(`triggers native ${nativeEventName}`, async () => { const ref = React.createRef() const spy = jest.fn() const Element = elementType @@ -195,30 +195,30 @@ eventTypes.forEach(({type, events, elementType, init}) => { return } - render() + await render() - fireEvent[eventName](ref.current, init) + await fireEvent[eventName](ref.current, init) expect(spy).toHaveBeenCalledTimes(1) }) }) }) }) -test('onChange works', () => { +test('onChange works', async () => { const handleChange = jest.fn() const { container: {firstChild: input}, - } = render() - fireEvent.change(input, {target: {value: 'a'}}) + } = await render() + await fireEvent.change(input, {target: {value: 'a'}}) expect(handleChange).toHaveBeenCalledTimes(1) }) -test('calling `fireEvent` directly works too', () => { +test('calling `fireEvent` directly works too', async () => { const handleEvent = jest.fn() const { container: {firstChild: button}, - } = render(
, ) const button = container.firstChild.firstChild - fireEvent.focus(button) + await fireEvent.focus(button) expect(handleBlur).toHaveBeenCalledTimes(0) expect(handleBubbledBlur).toHaveBeenCalledTimes(0) expect(handleFocus).toHaveBeenCalledTimes(1) expect(handleBubbledFocus).toHaveBeenCalledTimes(1) - fireEvent.blur(button) + await fireEvent.blur(button) expect(handleBlur).toHaveBeenCalledTimes(1) expect(handleBubbledBlur).toHaveBeenCalledTimes(1) diff --git a/src/__tests__/multi-base.js b/src/__tests__/multi-base.js index ef5a7e11..27cba42b 100644 --- a/src/__tests__/multi-base.js +++ b/src/__tests__/multi-base.js @@ -15,11 +15,11 @@ afterAll(() => { treeB.parentNode.removeChild(treeB) }) -test('baseElement isolates trees from one another', () => { - const {getByText: getByTextInA} = render(
Jekyll
, { +test('baseElement isolates trees from one another', async () => { + const {getByText: getByTextInA} = await render(
Jekyll
, { baseElement: treeA, }) - const {getByText: getByTextInB} = render(
Hyde
, { + const {getByText: getByTextInB} = await render(
Hyde
, { baseElement: treeB, }) diff --git a/src/__tests__/new-act.js b/src/__tests__/new-act.js index 0464ad24..2bb671b6 100644 --- a/src/__tests__/new-act.js +++ b/src/__tests__/new-act.js @@ -1,4 +1,4 @@ -let asyncAct +let actIfEnabled jest.mock('react', () => { return { @@ -11,7 +11,7 @@ jest.mock('react', () => { beforeEach(() => { jest.resetModules() - asyncAct = require('../act-compat').default + actIfEnabled = require('../act-compat').actIfEnabled jest.spyOn(console, 'error').mockImplementation(() => {}) }) @@ -21,7 +21,7 @@ afterEach(() => { test('async act works when it does not exist (older versions of react)', async () => { const callback = jest.fn() - await asyncAct(async () => { + await actIfEnabled(async () => { await Promise.resolve() await callback() }) @@ -31,7 +31,7 @@ test('async act works when it does not exist (older versions of react)', async ( callback.mockClear() console.error.mockClear() - await asyncAct(async () => { + await actIfEnabled(async () => { await Promise.resolve() await callback() }) @@ -41,7 +41,7 @@ test('async act works when it does not exist (older versions of react)', async ( test('async act recovers from errors', async () => { try { - await asyncAct(async () => { + await actIfEnabled(async () => { await null throw new Error('test error') }) @@ -60,7 +60,7 @@ test('async act recovers from errors', async () => { test('async act recovers from sync errors', async () => { try { - await asyncAct(() => { + await actIfEnabled(() => { throw new Error('test error') }) } catch (err) { diff --git a/src/__tests__/render.js b/src/__tests__/render.js index f00410b4..302e5a4b 100644 --- a/src/__tests__/render.js +++ b/src/__tests__/render.js @@ -25,13 +25,13 @@ describe('render API', () => { configure(originalConfig) }) - test('renders div into document', () => { + test('renders div into document', async () => { const ref = React.createRef() - const {container} = render(
) + const {container} = await render(
) expect(container.firstChild).toBe(ref.current) }) - test('works great with react portals', () => { + test('works great with react portals', async () => { class MyPortal extends React.Component { constructor(...args) { super(...args) @@ -62,20 +62,20 @@ describe('render API', () => { ) } - const {unmount} = render() + const {unmount} = await render() expect(screen.getByText('Hello World')).toBeInTheDocument() const portalNode = screen.getByTestId('my-portal') expect(portalNode).toBeInTheDocument() - unmount() + await unmount() expect(portalNode).not.toBeInTheDocument() }) - test('returns baseElement which defaults to document.body', () => { - const {baseElement} = render(
) + test('returns baseElement which defaults to document.body', async () => { + const {baseElement} = await render(
) expect(baseElement).toBe(document.body) }) - test('supports fragments', () => { + test('supports fragments', async () => { class Test extends React.Component { render() { return ( @@ -86,16 +86,16 @@ describe('render API', () => { } } - const {asFragment} = render() + const {asFragment} = await render() expect(asFragment()).toMatchSnapshot() }) - test('renders options.wrapper around node', () => { + test('renders options.wrapper around node', async () => { const WrapperComponent = ({children}) => (
{children}
) - const {container} = render(
, { + const {container} = await render(
, { wrapper: WrapperComponent, }) @@ -111,13 +111,13 @@ describe('render API', () => { `) }) - test('renders options.wrapper around node when reactStrictMode is true', () => { + test('renders options.wrapper around node when reactStrictMode is true', async () => { configure({reactStrictMode: true}) const WrapperComponent = ({children}) => (
{children}
) - const {container} = render(
, { + const {container} = await render(
, { wrapper: WrapperComponent, }) @@ -133,7 +133,7 @@ describe('render API', () => { `) }) - test('renders twice when reactStrictMode is true', () => { + test('renders twice when reactStrictMode is true', async () => { configure({reactStrictMode: true}) const spy = jest.fn() @@ -142,41 +142,41 @@ describe('render API', () => { return null } - render() + await render() expect(spy).toHaveBeenCalledTimes(2) }) - test('flushes useEffect cleanup functions sync on unmount()', () => { + test('flushes useEffect cleanup functions sync on unmount()', async () => { const spy = jest.fn() function Component() { React.useEffect(() => spy, []) return null } - const {unmount} = render() + const {unmount} = await render() expect(spy).toHaveBeenCalledTimes(0) - unmount() + await unmount() expect(spy).toHaveBeenCalledTimes(1) }) - test('can be called multiple times on the same container', () => { + test('can be called multiple times on the same container', async () => { const container = document.createElement('div') - const {unmount} = render(, {container}) + const {unmount} = await render(, {container}) expect(container).toContainHTML('') - render(, {container}) + await render(, {container}) expect(container).toContainHTML('') - unmount() + await unmount() expect(container).toBeEmptyDOMElement() }) - test('hydrate will make the UI interactive', () => { + test('hydrate will make the UI interactive', async () => { function App() { const [clicked, handleClick] = React.useReducer(n => n + 1, 0) @@ -193,14 +193,14 @@ describe('render API', () => { expect(container).toHaveTextContent('clicked:0') - render(ui, {container, hydrate: true}) + await render(ui, {container, hydrate: true}) - fireEvent.click(container.querySelector('button')) + await fireEvent.click(container.querySelector('button')) expect(container).toHaveTextContent('clicked:1') }) - test('hydrate can have a wrapper', () => { + test('hydrate can have a wrapper', async () => { const wrapperComponentMountEffect = jest.fn() function WrapperComponent({children}) { React.useEffect(() => { @@ -214,14 +214,14 @@ describe('render API', () => { document.body.appendChild(container) container.innerHTML = ReactDOMServer.renderToString(ui) - render(ui, {container, hydrate: true, wrapper: WrapperComponent}) + await render(ui, {container, hydrate: true, wrapper: WrapperComponent}) expect(wrapperComponentMountEffect).toHaveBeenCalledTimes(1) }) - testGateReact18('legacyRoot uses legacy ReactDOM.render', () => { - expect(() => { - render(
, {legacyRoot: true}) + testGateReact18('legacyRoot uses legacy ReactDOM.render', async () => { + await expect(async () => { + await 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", @@ -230,20 +230,20 @@ describe('render API', () => { ) }) - testGateReact19('legacyRoot throws', () => { - expect(() => { - render(
, {legacyRoot: true}) - }).toThrowErrorMatchingInlineSnapshot( + testGateReact19('legacyRoot throws', async () => { + await expect(async () => { + await render(
, {legacyRoot: true}) + }).rejects.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', () => { + testGateReact18('legacyRoot uses legacy ReactDOM.hydrate', async () => { const ui =
const container = document.createElement('div') container.innerHTML = ReactDOMServer.renderToString(ui) - expect(() => { - render(ui, {container, hydrate: true, legacyRoot: true}) + await expect(async () => { + await 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", @@ -252,13 +252,13 @@ describe('render API', () => { ) }) - testGateReact19('legacyRoot throws even with hydrate', () => { + testGateReact19('legacyRoot throws even with hydrate', async () => { const ui =
const container = document.createElement('div') container.innerHTML = ReactDOMServer.renderToString(ui) - expect(() => { - render(ui, {container, hydrate: true, legacyRoot: true}) - }).toThrowErrorMatchingInlineSnapshot( + await expect( + render(ui, {container, hydrate: true, legacyRoot: true}), + ).rejects.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.`, ) }) diff --git a/src/__tests__/renderHook.js b/src/__tests__/renderHook.js index fe7551a2..9a24482a 100644 --- a/src/__tests__/renderHook.js +++ b/src/__tests__/renderHook.js @@ -1,5 +1,5 @@ import React from 'react' -import {renderHook} from '../pure' +import {renderHook} from '../' const isReact18 = React.version.startsWith('18.') const isReact19 = React.version.startsWith('19.') @@ -7,8 +7,8 @@ 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(() => { +test('gives committed result', async () => { + const {result} = await renderHook(() => { const [state, setState] = React.useState(1) React.useEffect(() => { @@ -21,8 +21,8 @@ test('gives committed result', () => { expect(result.current).toEqual([2, expect.any(Function)]) }) -test('allows rerendering', () => { - const {result, rerender} = renderHook( +test('allows rerendering', async () => { + const {result, rerender} = await renderHook( ({branch}) => { const [left, setLeft] = React.useState('left') const [right, setRight] = React.useState('right') @@ -45,7 +45,7 @@ test('allows rerendering', () => { expect(result.current).toEqual(['left', expect.any(Function)]) - rerender({branch: 'right'}) + await rerender({branch: 'right'}) expect(result.current).toEqual(['right', expect.any(Function)]) }) @@ -55,7 +55,7 @@ test('allows wrapper components', async () => { function Wrapper({children}) { return {children} } - const {result} = renderHook( + const {result} = await renderHook( () => { return React.useContext(Context) }, @@ -67,21 +67,23 @@ test('allows wrapper components', async () => { expect(result.current).toEqual('provided') }) -testGateReact18('legacyRoot uses legacy ReactDOM.render', () => { +testGateReact18('legacyRoot uses legacy ReactDOM.render', async () => { const Context = React.createContext('default') function Wrapper({children}) { return {children} } let result - expect(() => { - result = renderHook( - () => { - return React.useContext(Context) - }, - { - wrapper: Wrapper, - legacyRoot: true, - }, + await expect(async () => { + result = ( + await renderHook( + () => { + return React.useContext(Context) + }, + { + wrapper: Wrapper, + legacyRoot: true, + }, + ) ).result }).toErrorDev( [ @@ -89,16 +91,17 @@ testGateReact18('legacyRoot uses legacy ReactDOM.render', () => { ], {withoutStack: true}, ) + expect(result.current).toEqual('provided') }) -testGateReact19('legacyRoot throws', () => { +testGateReact19('legacyRoot throws', async () => { const Context = React.createContext('default') function Wrapper({children}) { return {children} } - expect(() => { - renderHook( + await expect(async () => { + await renderHook( () => { return React.useContext(Context) }, @@ -106,8 +109,8 @@ testGateReact19('legacyRoot throws', () => { wrapper: Wrapper, legacyRoot: true, }, - ).result - }).toThrowErrorMatchingInlineSnapshot( + ) + }).rejects.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.`, ) }) diff --git a/src/__tests__/rerender.js b/src/__tests__/rerender.js index 6c48c4dd..632c4ed2 100644 --- a/src/__tests__/rerender.js +++ b/src/__tests__/rerender.js @@ -17,15 +17,15 @@ describe('rerender API', () => { configure(originalConfig) }) - test('rerender will re-render the element', () => { + test('rerender will re-render the element', async () => { const Greeting = props =>
{props.message}
- const {container, rerender} = render() + const {container, rerender} = await render() expect(container.firstChild).toHaveTextContent('hi') - rerender() + await rerender() expect(container.firstChild).toHaveTextContent('hey') }) - test('hydrate will not update props until next render', () => { + test('hydrate will not update props until next render', async () => { const initialInputElement = document.createElement('input') const container = document.createElement('div') container.appendChild(initialInputElement) @@ -34,7 +34,7 @@ describe('rerender API', () => { const firstValue = 'hello' initialInputElement.value = firstValue - const {rerender} = render( null} />, { + const {rerender} = await render( null} />, { container, hydrate: true, }) @@ -42,18 +42,18 @@ describe('rerender API', () => { expect(initialInputElement).toHaveValue(firstValue) const secondValue = 'goodbye' - rerender( null} />) + await rerender( null} />) expect(initialInputElement).toHaveValue(secondValue) }) - test('re-renders options.wrapper around node when reactStrictMode is true', () => { + test('re-renders options.wrapper around node when reactStrictMode is true', async () => { configure({reactStrictMode: true}) const WrapperComponent = ({children}) => (
{children}
) const Greeting = props =>
{props.message}
- const {container, rerender} = render(, { + const {container, rerender} = await render(, { wrapper: WrapperComponent, }) @@ -67,7 +67,7 @@ describe('rerender API', () => {
`) - rerender() + await rerender() expect(container.firstChild).toMatchInlineSnapshot(`
{ `) }) - test('re-renders twice when reactStrictMode is true', () => { + test('re-renders twice when reactStrictMode is true', async () => { configure({reactStrictMode: true}) const spy = jest.fn() @@ -88,11 +88,11 @@ describe('rerender API', () => { return null } - const {rerender} = render() + const {rerender} = await render() expect(spy).toHaveBeenCalledTimes(2) spy.mockClear() - rerender() + await rerender() expect(spy).toHaveBeenCalledTimes(2) }) }) diff --git a/src/__tests__/stopwatch.js b/src/__tests__/stopwatch.js index e3eaebbe..08d4cb02 100644 --- a/src/__tests__/stopwatch.js +++ b/src/__tests__/stopwatch.js @@ -40,9 +40,9 @@ class StopWatch extends React.Component { const sleep = t => new Promise(resolve => setTimeout(resolve, t)) test('unmounts a component', async () => { - const {unmount, container} = render() - fireEvent.click(screen.getByText('Start')) - unmount() + const {unmount, container} = await render() + await fireEvent.click(screen.getByText('Start')) + await unmount() // hey there reader! You don't need to have an assertion like this one // this is just me making sure that the unmount function works. // You don't need to do this in your apps. Just rely on the fact that this works. diff --git a/src/act-compat.js b/src/act-compat.js index 6eaec0fb..f209659b 100644 --- a/src/act-compat.js +++ b/src/act-compat.js @@ -33,59 +33,21 @@ function getIsReactActEnvironment() { return getGlobalThis().IS_REACT_ACT_ENVIRONMENT } -function withGlobalActEnvironment(actImplementation) { - return callback => { - const previousActEnvironment = getIsReactActEnvironment() - setIsReactActEnvironment(true) - try { - // The return value of `act` is always a thenable. - let callbackNeedsToBeAwaited = false - const actResult = actImplementation(() => { - const result = callback() - if ( - result !== null && - typeof result === 'object' && - typeof result.then === 'function' - ) { - callbackNeedsToBeAwaited = true - } - return result - }) - if (callbackNeedsToBeAwaited) { - const thenable = actResult - return { - then: (resolve, reject) => { - thenable.then( - returnValue => { - setIsReactActEnvironment(previousActEnvironment) - resolve(returnValue) - }, - error => { - setIsReactActEnvironment(previousActEnvironment) - reject(error) - }, - ) - }, - } - } else { - setIsReactActEnvironment(previousActEnvironment) - return actResult - } - } catch (error) { - // Can't be a `finally {}` block since we don't know if we have to immediately restore IS_REACT_ACT_ENVIRONMENT - // or if we have to await the callback first. - setIsReactActEnvironment(previousActEnvironment) - throw error - } +async function actIfEnabled(scope) { + if (getIsReactActEnvironment()) { + // scope passed to domAct needs to be `async` until React.act treats every scope as async. + // We already enforce `await act()` (regardless of scope) to flush microtasks + // inside the act scope. + return reactAct(async () => { + return scope() + }) + } else { + // We wrap everything in act internally. + // But a userspace call might not want that so we respect global config here. + return scope() } } -const act = withGlobalActEnvironment(reactAct) - -export default act -export { - setIsReactActEnvironment as setReactActEnvironment, - getIsReactActEnvironment, -} +export {actIfEnabled, setIsReactActEnvironment, getIsReactActEnvironment} /* eslint no-console:0 */ diff --git a/src/fire-event.js b/src/fire-event.js index cb790c7f..733e60cf 100644 --- a/src/fire-event.js +++ b/src/fire-event.js @@ -15,29 +15,29 @@ Object.keys(dtlFireEvent).forEach(key => { // @link https://github.com/facebook/react/blob/b87aabdfe1b7461e7331abb3601d9e6bb27544bc/packages/react-dom/src/events/EnterLeaveEventPlugin.js#L24-L31 const mouseEnter = fireEvent.mouseEnter const mouseLeave = fireEvent.mouseLeave -fireEvent.mouseEnter = (...args) => { - mouseEnter(...args) +fireEvent.mouseEnter = async (...args) => { + await mouseEnter(...args) return fireEvent.mouseOver(...args) } -fireEvent.mouseLeave = (...args) => { - mouseLeave(...args) +fireEvent.mouseLeave = async (...args) => { + await mouseLeave(...args) return fireEvent.mouseOut(...args) } const pointerEnter = fireEvent.pointerEnter const pointerLeave = fireEvent.pointerLeave -fireEvent.pointerEnter = (...args) => { - pointerEnter(...args) +fireEvent.pointerEnter = async (...args) => { + await pointerEnter(...args) return fireEvent.pointerOver(...args) } -fireEvent.pointerLeave = (...args) => { - pointerLeave(...args) +fireEvent.pointerLeave = async (...args) => { + await pointerLeave(...args) return fireEvent.pointerOut(...args) } const select = fireEvent.select -fireEvent.select = (node, init) => { - select(node, init) +fireEvent.select = async (node, init) => { + await select(node, init) // React tracks this event only on focused inputs node.focus() @@ -49,7 +49,7 @@ fireEvent.select = (node, init) => { // - keyDown // so we can use any here // @link https://github.com/facebook/react/blob/b87aabdfe1b7461e7331abb3601d9e6bb27544bc/packages/react-dom/src/events/SelectEventPlugin.js#L203-L224 - fireEvent.keyUp(node, init) + await fireEvent.keyUp(node, init) } // React event system tracks native focusout/focusin events for @@ -57,12 +57,12 @@ fireEvent.select = (node, init) => { // @link https://github.com/facebook/react/pull/19186 const blur = fireEvent.blur const focus = fireEvent.focus -fireEvent.blur = (...args) => { - fireEvent.focusOut(...args) +fireEvent.blur = async (...args) => { + await fireEvent.focusOut(...args) return blur(...args) } -fireEvent.focus = (...args) => { - fireEvent.focusIn(...args) +fireEvent.focus = async (...args) => { + await fireEvent.focusIn(...args) return focus(...args) } diff --git a/src/index.js b/src/index.js index bb0d0270..ac8b36f9 100644 --- a/src/index.js +++ b/src/index.js @@ -1,4 +1,4 @@ -import {getIsReactActEnvironment, setReactActEnvironment} from './act-compat' +import {getIsReactActEnvironment, setIsReactActEnvironment} from './act-compat' import {cleanup} from './pure' // if we're running in a test runner that supports afterEach @@ -10,15 +10,15 @@ if (typeof process === 'undefined' || !process.env?.RTL_SKIP_AUTO_CLEANUP) { // ignore teardown() in code coverage because Jest does not support it /* istanbul ignore else */ if (typeof afterEach === 'function') { - afterEach(() => { - cleanup() + afterEach(async () => { + await cleanup() }) } else if (typeof teardown === 'function') { // Block is guarded by `typeof` check. // eslint does not support `typeof` guards. // eslint-disable-next-line no-undef - teardown(() => { - cleanup() + teardown(async () => { + await cleanup() }) } @@ -29,11 +29,11 @@ if (typeof process === 'undefined' || !process.env?.RTL_SKIP_AUTO_CLEANUP) { let previousIsReactActEnvironment = getIsReactActEnvironment() beforeAll(() => { previousIsReactActEnvironment = getIsReactActEnvironment() - setReactActEnvironment(true) + setIsReactActEnvironment(true) }) afterAll(() => { - setReactActEnvironment(previousIsReactActEnvironment) + setIsReactActEnvironment(previousIsReactActEnvironment) }) } } diff --git a/src/pure.js b/src/pure.js index fe95024a..30db0e68 100644 --- a/src/pure.js +++ b/src/pure.js @@ -1,69 +1,58 @@ import * as React from 'react' import ReactDOM from 'react-dom' +import * as DeprecatedReactTestUtils from 'react-dom/test-utils' import * as ReactDOMClient from 'react-dom/client' import { getQueriesForElement, prettyDOM, configure as configureDTL, } from '@testing-library/dom' -import act, { +import { + actIfEnabled, getIsReactActEnvironment, - setReactActEnvironment, + setIsReactActEnvironment, } 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 +function enqueueTask(task) { + const channel = new MessageChannel() + channel.port1.onmessage = () => { + channel.port1.close() + task() + } + channel.port2.postMessage(undefined) +} - return false +async function waitForMicrotasks() { + return new Promise(resolve => { + enqueueTask(() => resolve()) + }) } configureDTL({ - unstable_advanceTimersWrapper: cb => { - return act(cb) + unstable_advanceTimersWrapper: async scope => { + if (getIsReactActEnvironment()) { + return actIfEnabled(scope) + } else { + const result = await scope() + await waitForMicrotasks() + return result + } }, // We just want to run `waitFor` without IS_REACT_ACT_ENVIRONMENT // But that's not necessarily how `asyncWrapper` is used since it's a public method. // Let's just hope nobody else is using it. asyncWrapper: async cb => { const previousActEnvironment = getIsReactActEnvironment() - setReactActEnvironment(false) + setIsReactActEnvironment(false) try { - 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 + return await cb() } finally { - setReactActEnvironment(previousActEnvironment) + setIsReactActEnvironment(previousActEnvironment) } }, - eventWrapper: cb => { - let result - act(() => { - result = cb() - }) - return result - }, + eventWrapper: actIfEnabled, }) // Ideally we'd just use a WeakMap where containers are keys and roots are values. @@ -89,13 +78,13 @@ function wrapUiIfNeeded(innerElement, wrapperComponent) { : innerElement } -function createConcurrentRoot( +async function createConcurrentRoot( container, {hydrate, onCaughtError, onRecoverableError, ui, wrapper: WrapperComponent}, ) { let root if (hydrate) { - act(() => { + await actIfEnabled(() => { root = ReactDOMClient.hydrateRoot( container, strictModeIfNeeded(wrapUiIfNeeded(ui, WrapperComponent)), @@ -120,45 +109,53 @@ function createConcurrentRoot( // Nothing to do since hydration happens when creating the root object. }, render(element) { - root.render(element) + return actIfEnabled(() => { + root.render(element) + }) }, unmount() { - root.unmount() + return actIfEnabled(() => { + root.unmount() + }) }, } } -function createLegacyRoot(container) { +async function createLegacyRoot(container) { return { hydrate(element) { - ReactDOM.hydrate(element, container) + return actIfEnabled(() => { + ReactDOM.hydrate(element, container) + }) }, render(element) { - ReactDOM.render(element, container) + return actIfEnabled(() => { + ReactDOM.render(element, container) + }) }, unmount() { - ReactDOM.unmountComponentAtNode(container) + return actIfEnabled(() => { + ReactDOM.unmountComponentAtNode(container) + }) }, } } -function renderRoot( +async function renderRoot( ui, {baseElement, container, hydrate, queries, root, wrapper: WrapperComponent}, ) { - act(() => { - if (hydrate) { - root.hydrate( - strictModeIfNeeded(wrapUiIfNeeded(ui, WrapperComponent)), - container, - ) - } else { - root.render( - strictModeIfNeeded(wrapUiIfNeeded(ui, WrapperComponent)), - container, - ) - } - }) + if (hydrate) { + await root.hydrate( + strictModeIfNeeded(wrapUiIfNeeded(ui, WrapperComponent)), + container, + ) + } else { + await root.render( + strictModeIfNeeded(wrapUiIfNeeded(ui, WrapperComponent)), + container, + ) + } return { container, @@ -170,12 +167,10 @@ function renderRoot( : // eslint-disable-next-line no-console, console.log(prettyDOM(el, maxLength, options)), unmount: () => { - act(() => { - root.unmount() - }) + return root.unmount() }, - rerender: rerenderUi => { - renderRoot(rerenderUi, { + rerender: async rerenderUi => { + await renderRoot(rerenderUi, { container, baseElement, root, @@ -200,7 +195,7 @@ function renderRoot( } } -function render( +async function render( ui, { container, @@ -242,7 +237,7 @@ 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, { + root = await createRootImpl(container, { hydrate, onCaughtError, onRecoverableError, @@ -276,20 +271,20 @@ function render( }) } -function cleanup() { - mountedRootEntries.forEach(({root, container}) => { - act(() => { - root.unmount() - }) +async function cleanup() { + for (const {container, root} of mountedRootEntries) { + // eslint-disable-next-line no-await-in-loop -- Overlapping act calls are not allowed. + await root.unmount() if (container.parentNode === document.body) { document.body.removeChild(container) } - }) + } + mountedRootEntries.length = 0 mountedContainers.clear() } -function renderHook(renderCallback, options = {}) { +async function renderHook(renderCallback, options = {}) { const {initialProps, ...renderOptions} = options if (renderOptions.legacyRoot && typeof ReactDOM.render !== 'function') { @@ -314,7 +309,7 @@ function renderHook(renderCallback, options = {}) { return null } - const {rerender: baseRerender, unmount} = render( + const {rerender: baseRerender, unmount} = await render( , renderOptions, ) @@ -328,6 +323,18 @@ function renderHook(renderCallback, options = {}) { return {result, rerender, unmount} } +const reactAct = + typeof React.act === 'function' ? React.act : DeprecatedReactTestUtils.act + +function act(scope) { + // scope passed to reactAct needs to be `async` until React.act treats every scope as async. + // We already enforce `await act()` (regardless of scope) to flush microtasks + // inside the act scope. + return reactAct(async () => { + return scope() + }) +} + // just re-export everything from dom-testing-library export * from '@testing-library/dom' export {render, renderHook, cleanup, act, fireEvent, getConfig, configure} diff --git a/tests/toWarnDev.js b/tests/toWarnDev.js index 3005125e..914711bd 100644 --- a/tests/toWarnDev.js +++ b/tests/toWarnDev.js @@ -181,11 +181,7 @@ const createMatcherFor = (consoleMethod, matcherName) => // Avoid using Jest's built-in spy since it can't be removed. console[consoleMethod] = consoleSpy - try { - callback() - } catch (error) { - caughtError = error - } finally { + const onFinally = () => { // Restore the unspied method so that unexpected errors fail tests. console[consoleMethod] = originalMethod @@ -289,11 +285,56 @@ const createMatcherFor = (consoleMethod, matcherName) => return {pass: true} } + + let returnPromise = null + try { + const result = callback() + + if ( + typeof result === 'object' && + result !== null && + typeof result.then === 'function' + ) { + // `act` returns a thenable that can't be chained. + // Once `act(async () => {}).then(() => {}).then(() => {})` works + // we can just return `result.then(onFinally, error => ...)` + returnPromise = new Promise((resolve, reject) => { + result + .then( + () => { + resolve(onFinally()) + }, + error => { + caughtError = error + return resolve(onFinally()) + }, + ) + // In case onFinally throws we need to reject from this matcher + .catch(error => { + reject(error) + }) + }) + } + } catch (error) { + caughtError = error + } finally { + return returnPromise === null ? onFinally() : returnPromise + } } else { // Any uncaught errors or warnings should fail tests in production mode. - callback() + const result = callback() - return {pass: true} + if ( + typeof result === 'object' && + result !== null && + typeof result.then === 'function' + ) { + return result.then(() => { + return {pass: true} + }) + } else { + return {pass: true} + } } } diff --git a/types/index.d.ts b/types/index.d.ts index 2f814a6d..e2422a75 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -6,13 +6,13 @@ import { BoundFunction, prettyFormat, Config as ConfigDTL, + FireFunction as DTLFireFunction, + FireObject as DTLFireObject, } from '@testing-library/dom' 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 } @@ -25,6 +25,20 @@ export function configure(configDelta: ConfigFn | Partial): void export function getConfig(): Config +export * from '@testing-library/dom' + +export type FireFunction = ( + ...parameters: Parameters +) => Promise> + +export type FireObject = { + [K in keyof DTLFireObject]: ( + ...parameters: Parameters + ) => Promise> +} + +export const fireEvent: FireFunction & FireObject + export type RenderResult< Q extends Queries = typeof queries, Container extends RendererableContainer | HydrateableContainer = HTMLElement, @@ -41,8 +55,8 @@ export type RenderResult< maxLength?: number | undefined, options?: prettyFormat.OptionsReceived | undefined, ) => void - rerender: (ui: React.ReactNode) => void - unmount: () => void + rerender: (ui: React.ReactNode) => Promise + unmount: () => Promise asFragment: () => DocumentFragment } & {[P in keyof Q]: BoundFunction} @@ -170,17 +184,17 @@ export function render< >( ui: React.ReactNode, options: RenderOptions, -): RenderResult +): Promise> export function render( ui: React.ReactNode, options?: Omit | undefined, -): RenderResult +): Promise export interface RenderHookResult { /** * Triggers a re-render. The props will be passed to your renderHook callback. */ - rerender: (props?: Props) => void + rerender: (props?: Props) => Promise /** * This is a stable reference to the latest value returned by your renderHook * callback @@ -195,7 +209,7 @@ export interface RenderHookResult { * Unmounts the test component. This is useful for when you need to test * any cleanup your useEffects have. */ - unmount: () => void + unmount: () => Promise } /** @deprecated */ @@ -264,19 +278,14 @@ export function renderHook< >( render: (initialProps: Props) => Result, options?: RenderHookOptions | undefined, -): RenderHookResult +): Promise> /** * Unmounts React trees that were mounted with render. */ -export function cleanup(): void +export function cleanup(): Promise /** - * Simply calls React.act(cb) - * If that's not available (older version of react) then it - * simply calls the deprecated version which is ReactTestUtils.act(cb) + * `act` for the DOM renderer */ -// IfAny from https://stackoverflow.com/a/61626123/3406963 -export const act: 0 extends 1 & typeof reactAct - ? typeof reactDeprecatedAct - : typeof reactAct +export function act(scope: () => T): Promise diff --git a/types/test.tsx b/types/test.tsx index 825d5699..bb11d112 100644 --- a/types/test.tsx +++ b/types/test.tsx @@ -3,7 +3,7 @@ import {render, fireEvent, screen, waitFor, renderHook} from '.' import * as pure from './pure' export async function testRender() { - const view = render(