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 b4485f3c..4d40baf0 100644 --- a/src/__tests__/act.js +++ b/src/__tests__/act.js @@ -1,98 +1,26 @@ import * as React from 'react' -import {act, render, fireEvent, screen} from '../' +import {act, render} from '../' 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(() => - act(() => { - 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(() => - act(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]) +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 } - const {container} = await render() - - await act(() => { - triggerStateUpdateFromMicrotask() - }) + await render() - expect(container).toHaveTextContent('2') + global.IS_REACT_ACT_ENVIRONMENT = false + await expect(async () => { + await act(() => { + setState(1) + }) + }).toErrorDev( + 'Warning: 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 b1c88d26..89a99e9b 100644 --- a/src/__tests__/auto-cleanup-skip.js +++ b/src/__tests__/auto-cleanup-skip.js @@ -3,6 +3,7 @@ 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 }) diff --git a/src/__tests__/end-to-end.js b/src/__tests__/end-to-end.js index da3b2640..825edc9d 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() }) @@ -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() }) @@ -167,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() }) @@ -218,3 +262,78 @@ describe.each([ }) }, ) + +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') + ;({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() + + await waitFor(() => { + expect(screen.getByText('Loading..')).toBeInTheDocument() + }) + + await waitFor(() => { + expect(screen.getByText(/Loaded this message:/)).toBeInTheDocument() + }) + + expect(screen.getByTestId('message')).toHaveTextContent(/Hello World/) + }) + + test('findBy', async () => { + await render() + + await screen.findByText('Loading..') + + await screen.findByText(/Loaded this message:/) + + expect(screen.getByTestId('message')).toHaveTextContent(/Hello World/) + }) +}) diff --git a/src/__tests__/new-act.js b/src/__tests__/new-act.js index 0412a8a3..025a6967 100644 --- a/src/__tests__/new-act.js +++ b/src/__tests__/new-act.js @@ -1,4 +1,4 @@ -let asyncAct +let actIfEnabled jest.mock('react-dom/test-utils', () => ({ act: cb => { @@ -8,7 +8,7 @@ jest.mock('react-dom/test-utils', () => ({ beforeEach(() => { jest.resetModules() - asyncAct = require('../act-compat').default + actIfEnabled = require('../act-compat').actIfEnabled jest.spyOn(console, 'error').mockImplementation(() => {}) }) @@ -18,7 +18,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() }) @@ -28,7 +28,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() }) @@ -38,7 +38,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') }) @@ -57,7 +57,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__/renderHook.js b/src/__tests__/renderHook.js index 99489077..b5c23de4 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 '../' afterEach(() => { jest.resetAllMocks() diff --git a/src/act-compat.js b/src/act-compat.js index 6fd6dfca..5d36dcbb 100644 --- a/src/act-compat.js +++ b/src/act-compat.js @@ -31,26 +31,21 @@ function getIsReactActEnvironment() { return getGlobalThis().IS_REACT_ACT_ENVIRONMENT } -async function act(scope) { - const previousActEnvironment = getIsReactActEnvironment() - setIsReactActEnvironment(true) - try { +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. - const result = await domAct(async () => { + return domAct(async () => { return scope() }) - return result - } finally { - setIsReactActEnvironment(previousActEnvironment) + } else { + // We wrap everything in act internally. + // But a userspace call might not want that so we respect global config here. + return scope() } } -export default act -export { - setIsReactActEnvironment as setReactActEnvironment, - getIsReactActEnvironment, -} +export {actIfEnabled, setIsReactActEnvironment, getIsReactActEnvironment} /* eslint no-console:0 */ diff --git a/src/index.js b/src/index.js index 4637ebf6..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 @@ -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 b9d219bf..ece21925 100644 --- a/src/pure.js +++ b/src/pure.js @@ -1,42 +1,34 @@ import * as React from 'react' import ReactDOM from 'react-dom' +import {act as domAct} 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' configureDTL({ - unstable_advanceTimersWrapper: cb => { - // Only needed to support test environments that enable fake timers after modules are loaded. - // React's scheduler will detect fake timers when it's initialized and use them. - // So if we change the timers after that, we need to re-initialize the scheduler. - // But not every test runner supports module reset. - // It's not even clear how modules should be reset in ESM. - // So for this brief period we go back to using the act queue. - return act(cb) - }, + unstable_advanceTimersWrapper: actIfEnabled, // 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 { return await cb() } finally { - setReactActEnvironment(previousActEnvironment) + setIsReactActEnvironment(previousActEnvironment) } }, - eventWrapper: cb => { - return act(cb) - }, + eventWrapper: actIfEnabled, }) // Ideally we'd just use a WeakMap where containers are keys and roots are values. @@ -56,7 +48,7 @@ async function createConcurrentRoot( ) { let root if (hydrate) { - await act(() => { + await actIfEnabled(() => { root = ReactDOMClient.hydrateRoot( container, WrapperComponent ? React.createElement(WrapperComponent, null, ui) : ui, @@ -77,12 +69,12 @@ async function createConcurrentRoot( // Nothing to do since hydration happens when creating the root object. }, render(element) { - return act(() => { + return actIfEnabled(() => { root.render(element) }) }, unmount() { - return act(() => { + return actIfEnabled(() => { root.unmount() }) }, @@ -92,17 +84,17 @@ async function createConcurrentRoot( async function createLegacyRoot(container) { return { hydrate(element) { - return act(() => { + return actIfEnabled(() => { ReactDOM.hydrate(element, container) }) }, render(element) { - return act(() => { + return actIfEnabled(() => { ReactDOM.render(element, container) }) }, unmount() { - return act(() => { + return actIfEnabled(() => { ReactDOM.unmountComponentAtNode(container) }) }, @@ -254,8 +246,17 @@ async function renderHook(renderCallback, options = {}) { return {result, rerender, unmount} } +function compatAct(scope) { + // 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 domAct(async () => { + return scope() + }) +} + // just re-export everything from dom-testing-library export * from '@testing-library/dom' -export {render, renderHook, cleanup, act, fireEvent} +export {render, renderHook, cleanup, compatAct as act, fireEvent} /* eslint func-name-matching:0 */ diff --git a/tests/toWarnDev.js b/tests/toWarnDev.js index 18f68419..7d64ff6a 100644 --- a/tests/toWarnDev.js +++ b/tests/toWarnDev.js @@ -29,7 +29,7 @@ SOFTWARE. /* eslint-disable func-names */ /* eslint-disable complexity */ const util = require('util') -const jestDiff = require('jest-diff').default +const jestDiff = require('jest-diff').diff const shouldIgnoreConsoleError = require('./shouldIgnoreConsoleError') function normalizeCodeLocInfo(str) {