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) {