Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit f676301

Browse files
author
Sebastian Silbermann
committedOct 5, 2023
Add support for testing updates granularly
1 parent d78b532 commit f676301

File tree

10 files changed

+283
-140
lines changed

10 files changed

+283
-140
lines changed
 

‎src/__tests__/act-compat.js

+99
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import * as React from 'react'
2+
import {render, fireEvent, screen} from '../'
3+
import {actIfEnabled} from '../act-compat'
4+
5+
beforeEach(() => {
6+
global.IS_REACT_ACT_ENVIRONMENT = true
7+
})
8+
9+
test('render calls useEffect immediately', async () => {
10+
const effectCb = jest.fn()
11+
function MyUselessComponent() {
12+
React.useEffect(effectCb)
13+
return null
14+
}
15+
await render(<MyUselessComponent />)
16+
expect(effectCb).toHaveBeenCalledTimes(1)
17+
})
18+
19+
test('findByTestId returns the element', async () => {
20+
const ref = React.createRef()
21+
await render(<div ref={ref} data-testid="foo" />)
22+
expect(await screen.findByTestId('foo')).toBe(ref.current)
23+
})
24+
25+
test('fireEvent triggers useEffect calls', async () => {
26+
const effectCb = jest.fn()
27+
function Counter() {
28+
React.useEffect(effectCb)
29+
const [count, setCount] = React.useState(0)
30+
return <button onClick={() => setCount(count + 1)}>{count}</button>
31+
}
32+
const {
33+
container: {firstChild: buttonNode},
34+
} = await render(<Counter />)
35+
36+
effectCb.mockClear()
37+
// eslint-disable-next-line testing-library/no-await-sync-events -- TODO: Remove lint rule.
38+
await fireEvent.click(buttonNode)
39+
expect(buttonNode).toHaveTextContent('1')
40+
expect(effectCb).toHaveBeenCalledTimes(1)
41+
})
42+
43+
test('calls to hydrate will run useEffects', async () => {
44+
const effectCb = jest.fn()
45+
function MyUselessComponent() {
46+
React.useEffect(effectCb)
47+
return null
48+
}
49+
await render(<MyUselessComponent />, {hydrate: true})
50+
expect(effectCb).toHaveBeenCalledTimes(1)
51+
})
52+
53+
test('cleans up IS_REACT_ACT_ENVIRONMENT if its callback throws', async () => {
54+
global.IS_REACT_ACT_ENVIRONMENT = false
55+
56+
await expect(() =>
57+
actIfEnabled(() => {
58+
throw new Error('threw')
59+
}),
60+
).rejects.toThrow('threw')
61+
62+
expect(global.IS_REACT_ACT_ENVIRONMENT).toEqual(false)
63+
})
64+
65+
test('cleans up IS_REACT_ACT_ENVIRONMENT if its async callback throws', async () => {
66+
global.IS_REACT_ACT_ENVIRONMENT = false
67+
68+
await expect(() =>
69+
actIfEnabled(async () => {
70+
throw new Error('thenable threw')
71+
}),
72+
).rejects.toThrow('thenable threw')
73+
74+
expect(global.IS_REACT_ACT_ENVIRONMENT).toEqual(false)
75+
})
76+
77+
test('state update from microtask does not trigger "missing act" warning', async () => {
78+
let triggerStateUpdateFromMicrotask
79+
function App() {
80+
const [state, setState] = React.useState(0)
81+
triggerStateUpdateFromMicrotask = () => setState(1)
82+
React.useEffect(() => {
83+
// eslint-disable-next-line jest/no-conditional-in-test
84+
if (state === 1) {
85+
Promise.resolve().then(() => {
86+
setState(2)
87+
})
88+
}
89+
}, [state])
90+
return state
91+
}
92+
const {container} = await render(<App />)
93+
94+
await actIfEnabled(() => {
95+
triggerStateUpdateFromMicrotask()
96+
})
97+
98+
expect(container).toHaveTextContent('2')
99+
})

‎src/__tests__/act.js

+16-88
Original file line numberDiff line numberDiff line change
@@ -1,98 +1,26 @@
11
import * as React from 'react'
2-
import {act, render, fireEvent, screen} from '../'
2+
import {act, render} from '../'
33

44
beforeEach(() => {
55
global.IS_REACT_ACT_ENVIRONMENT = true
66
})
77

8-
test('render calls useEffect immediately', async () => {
9-
const effectCb = jest.fn()
10-
function MyUselessComponent() {
11-
React.useEffect(effectCb)
12-
return null
13-
}
14-
await render(<MyUselessComponent />)
15-
expect(effectCb).toHaveBeenCalledTimes(1)
16-
})
17-
18-
test('findByTestId returns the element', async () => {
19-
const ref = React.createRef()
20-
await render(<div ref={ref} data-testid="foo" />)
21-
expect(await screen.findByTestId('foo')).toBe(ref.current)
22-
})
23-
24-
test('fireEvent triggers useEffect calls', async () => {
25-
const effectCb = jest.fn()
26-
function Counter() {
27-
React.useEffect(effectCb)
28-
const [count, setCount] = React.useState(0)
29-
return <button onClick={() => setCount(count + 1)}>{count}</button>
30-
}
31-
const {
32-
container: {firstChild: buttonNode},
33-
} = await render(<Counter />)
34-
35-
effectCb.mockClear()
36-
// eslint-disable-next-line testing-library/no-await-sync-events -- TODO: Remove lint rule.
37-
await fireEvent.click(buttonNode)
38-
expect(buttonNode).toHaveTextContent('1')
39-
expect(effectCb).toHaveBeenCalledTimes(1)
40-
})
41-
42-
test('calls to hydrate will run useEffects', async () => {
43-
const effectCb = jest.fn()
44-
function MyUselessComponent() {
45-
React.useEffect(effectCb)
46-
return null
47-
}
48-
await render(<MyUselessComponent />, {hydrate: true})
49-
expect(effectCb).toHaveBeenCalledTimes(1)
50-
})
51-
52-
test('cleans up IS_REACT_ACT_ENVIRONMENT if its callback throws', async () => {
53-
global.IS_REACT_ACT_ENVIRONMENT = false
54-
55-
await expect(() =>
56-
act(() => {
57-
throw new Error('threw')
58-
}),
59-
).rejects.toThrow('threw')
60-
61-
expect(global.IS_REACT_ACT_ENVIRONMENT).toEqual(false)
62-
})
63-
64-
test('cleans up IS_REACT_ACT_ENVIRONMENT if its async callback throws', async () => {
65-
global.IS_REACT_ACT_ENVIRONMENT = false
66-
67-
await expect(() =>
68-
act(async () => {
69-
throw new Error('thenable threw')
70-
}),
71-
).rejects.toThrow('thenable threw')
72-
73-
expect(global.IS_REACT_ACT_ENVIRONMENT).toEqual(false)
74-
})
75-
76-
test('state update from microtask does not trigger "missing act" warning', async () => {
77-
let triggerStateUpdateFromMicrotask
78-
function App() {
79-
const [state, setState] = React.useState(0)
80-
triggerStateUpdateFromMicrotask = () => setState(1)
81-
React.useEffect(() => {
82-
// eslint-disable-next-line jest/no-conditional-in-test
83-
if (state === 1) {
84-
Promise.resolve().then(() => {
85-
setState(2)
86-
})
87-
}
88-
}, [state])
8+
test('does not work outside IS_REACT_ENVIRONMENT like React.act', async () => {
9+
let setState
10+
function Component() {
11+
const [state, _setState] = React.useState(0)
12+
setState = _setState
8913
return state
9014
}
91-
const {container} = await render(<App />)
92-
93-
await act(() => {
94-
triggerStateUpdateFromMicrotask()
95-
})
15+
await render(<Component />)
9616

97-
expect(container).toHaveTextContent('2')
17+
global.IS_REACT_ACT_ENVIRONMENT = false
18+
await expect(async () => {
19+
await act(() => {
20+
setState(1)
21+
})
22+
}).toErrorDev(
23+
'Warning: The current testing environment is not configured to support act(...)',
24+
{withoutStack: true},
25+
)
9826
})

‎src/__tests__/auto-cleanup-skip.js

+1
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import * as React from 'react'
33
let render
44
beforeAll(() => {
55
process.env.RTL_SKIP_AUTO_CLEANUP = 'true'
6+
globalThis.IS_REACT_ACT_ENVIRONMENT = true
67
const rtl = require('../')
78
render = rtl.render
89
})

‎src/__tests__/end-to-end.js

+124-5
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
1-
import * as React from 'react'
2-
import {render, waitForElementToBeRemoved, screen, waitFor} from '../'
1+
let React, cleanup, render, screen, waitFor, waitForElementToBeRemoved
32

43
describe.each([
54
['real timers', () => jest.useRealTimers()],
@@ -9,10 +8,25 @@ describe.each([
98
'it waits for the data to be loaded in a macrotask using %s',
109
(label, useTimers) => {
1110
beforeEach(() => {
11+
jest.resetModules()
12+
global.IS_REACT_ACT_ENVIRONMENT = true
13+
process.env.RTL_SKIP_AUTO_CLEANUP = '0'
14+
1215
useTimers()
16+
17+
React = require('react')
18+
;({
19+
cleanup,
20+
render,
21+
screen,
22+
waitFor,
23+
waitForElementToBeRemoved,
24+
} = require('..'))
1325
})
1426

15-
afterEach(() => {
27+
afterEach(async () => {
28+
await cleanup()
29+
global.IS_REACT_ACT_ENVIRONMENT = false
1630
jest.useRealTimers()
1731
})
1832

@@ -83,10 +97,25 @@ describe.each([
8397
'it waits for the data to be loaded in many microtask using %s',
8498
(label, useTimers) => {
8599
beforeEach(() => {
100+
jest.resetModules()
101+
global.IS_REACT_ACT_ENVIRONMENT = true
102+
process.env.RTL_SKIP_AUTO_CLEANUP = '0'
103+
86104
useTimers()
105+
106+
React = require('react')
107+
;({
108+
cleanup,
109+
render,
110+
screen,
111+
waitFor,
112+
waitForElementToBeRemoved,
113+
} = require('..'))
87114
})
88115

89-
afterEach(() => {
116+
afterEach(async () => {
117+
await cleanup()
118+
global.IS_REACT_ACT_ENVIRONMENT = false
90119
jest.useRealTimers()
91120
})
92121

@@ -167,10 +196,25 @@ describe.each([
167196
'it waits for the data to be loaded in a microtask using %s',
168197
(label, useTimers) => {
169198
beforeEach(() => {
199+
jest.resetModules()
200+
global.IS_REACT_ACT_ENVIRONMENT = true
201+
process.env.RTL_SKIP_AUTO_CLEANUP = '0'
202+
170203
useTimers()
204+
205+
React = require('react')
206+
;({
207+
cleanup,
208+
render,
209+
screen,
210+
waitFor,
211+
waitForElementToBeRemoved,
212+
} = require('..'))
171213
})
172214

173-
afterEach(() => {
215+
afterEach(async () => {
216+
await cleanup()
217+
global.IS_REACT_ACT_ENVIRONMENT = false
174218
jest.useRealTimers()
175219
})
176220

@@ -218,3 +262,78 @@ describe.each([
218262
})
219263
},
220264
)
265+
266+
describe.each([
267+
['real timers', () => jest.useRealTimers()],
268+
['fake legacy timers', () => jest.useFakeTimers('legacy')],
269+
['fake modern timers', () => jest.useFakeTimers('modern')],
270+
])('testing intermediate states using %s', (label, useTimers) => {
271+
beforeEach(() => {
272+
jest.resetModules()
273+
global.IS_REACT_ACT_ENVIRONMENT = false
274+
process.env.RTL_SKIP_AUTO_CLEANUP = '0'
275+
276+
useTimers()
277+
278+
React = require('react')
279+
;({render, screen, waitFor, waitForElementToBeRemoved} = require('..'))
280+
})
281+
282+
afterEach(async () => {
283+
await cleanup()
284+
jest.useRealTimers()
285+
global.IS_REACT_ACT_ENVIRONMENT = true
286+
})
287+
288+
const fetchAMessageInAMicrotask = () =>
289+
Promise.resolve({
290+
status: 200,
291+
json: () => Promise.resolve({title: 'Hello World'}),
292+
})
293+
294+
function ComponentWithMicrotaskLoader() {
295+
const [fetchState, setFetchState] = React.useState({fetching: true})
296+
297+
React.useEffect(() => {
298+
if (fetchState.fetching) {
299+
fetchAMessageInAMicrotask().then(res => {
300+
return res.json().then(data => {
301+
setFetchState({todo: data.title, fetching: false})
302+
})
303+
})
304+
}
305+
}, [fetchState])
306+
307+
if (fetchState.fetching) {
308+
return <p>Loading..</p>
309+
}
310+
311+
return (
312+
<div data-testid="message">Loaded this message: {fetchState.todo}</div>
313+
)
314+
}
315+
316+
test('waitFor', async () => {
317+
await render(<ComponentWithMicrotaskLoader />)
318+
319+
await waitFor(() => {
320+
expect(screen.getByText('Loading..')).toBeInTheDocument()
321+
})
322+
323+
await waitFor(() => {
324+
expect(screen.getByText(/Loaded this message:/)).toBeInTheDocument()
325+
})
326+
327+
expect(screen.getByTestId('message')).toHaveTextContent(/Hello World/)
328+
})
329+
330+
test('findBy', async () => {
331+
await render(<ComponentWithMicrotaskLoader />)
332+
333+
await screen.findByText('Loading..')
334+
335+
await screen.findByText(/Loaded this message:/)
336+
337+
expect(screen.getByTestId('message')).toHaveTextContent(/Hello World/)
338+
})
339+
})

‎src/__tests__/new-act.js

+6-6
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
let asyncAct
1+
let actIfEnabled
22

33
jest.mock('react-dom/test-utils', () => ({
44
act: cb => {
@@ -8,7 +8,7 @@ jest.mock('react-dom/test-utils', () => ({
88

99
beforeEach(() => {
1010
jest.resetModules()
11-
asyncAct = require('../act-compat').default
11+
actIfEnabled = require('../act-compat').actIfEnabled
1212
jest.spyOn(console, 'error').mockImplementation(() => {})
1313
})
1414

@@ -18,7 +18,7 @@ afterEach(() => {
1818

1919
test('async act works when it does not exist (older versions of react)', async () => {
2020
const callback = jest.fn()
21-
await asyncAct(async () => {
21+
await actIfEnabled(async () => {
2222
await Promise.resolve()
2323
await callback()
2424
})
@@ -28,7 +28,7 @@ test('async act works when it does not exist (older versions of react)', async (
2828
callback.mockClear()
2929
console.error.mockClear()
3030

31-
await asyncAct(async () => {
31+
await actIfEnabled(async () => {
3232
await Promise.resolve()
3333
await callback()
3434
})
@@ -38,7 +38,7 @@ test('async act works when it does not exist (older versions of react)', async (
3838

3939
test('async act recovers from errors', async () => {
4040
try {
41-
await asyncAct(async () => {
41+
await actIfEnabled(async () => {
4242
await null
4343
throw new Error('test error')
4444
})
@@ -57,7 +57,7 @@ test('async act recovers from errors', async () => {
5757

5858
test('async act recovers from sync errors', async () => {
5959
try {
60-
await asyncAct(() => {
60+
await actIfEnabled(() => {
6161
throw new Error('test error')
6262
})
6363
} catch (err) {

‎src/__tests__/renderHook.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import React from 'react'
2-
import {renderHook} from '../pure'
2+
import {renderHook} from '../'
33

44
afterEach(() => {
55
jest.resetAllMocks()

‎src/act-compat.js

+8-13
Original file line numberDiff line numberDiff line change
@@ -31,26 +31,21 @@ function getIsReactActEnvironment() {
3131
return getGlobalThis().IS_REACT_ACT_ENVIRONMENT
3232
}
3333

34-
async function act(scope) {
35-
const previousActEnvironment = getIsReactActEnvironment()
36-
setIsReactActEnvironment(true)
37-
try {
34+
async function actIfEnabled(scope) {
35+
if (getIsReactActEnvironment()) {
3836
// scope passed to domAct needs to be `async` until React.act treats every scope as async.
3937
// We already enforce `await act()` (regardless of scope) to flush microtasks
4038
// inside the act scope.
41-
const result = await domAct(async () => {
39+
return domAct(async () => {
4240
return scope()
4341
})
44-
return result
45-
} finally {
46-
setIsReactActEnvironment(previousActEnvironment)
42+
} else {
43+
// We wrap everything in act internally.
44+
// But a userspace call might not want that so we respect global config here.
45+
return scope()
4746
}
4847
}
4948

50-
export default act
51-
export {
52-
setIsReactActEnvironment as setReactActEnvironment,
53-
getIsReactActEnvironment,
54-
}
49+
export {actIfEnabled, setIsReactActEnvironment, getIsReactActEnvironment}
5550

5651
/* eslint no-console:0 */

‎src/index.js

+3-3
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {getIsReactActEnvironment, setReactActEnvironment} from './act-compat'
1+
import {getIsReactActEnvironment, setIsReactActEnvironment} from './act-compat'
22
import {cleanup} from './pure'
33

44
// 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) {
2929
let previousIsReactActEnvironment = getIsReactActEnvironment()
3030
beforeAll(() => {
3131
previousIsReactActEnvironment = getIsReactActEnvironment()
32-
setReactActEnvironment(true)
32+
setIsReactActEnvironment(true)
3333
})
3434

3535
afterAll(() => {
36-
setReactActEnvironment(previousIsReactActEnvironment)
36+
setIsReactActEnvironment(previousIsReactActEnvironment)
3737
})
3838
}
3939
}

‎src/pure.js

+24-23
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,34 @@
11
import * as React from 'react'
22
import ReactDOM from 'react-dom'
3+
import {act as domAct} from 'react-dom/test-utils'
34
import * as ReactDOMClient from 'react-dom/client'
45
import {
56
getQueriesForElement,
67
prettyDOM,
78
configure as configureDTL,
89
} from '@testing-library/dom'
9-
import act, {
10+
import {
11+
actIfEnabled,
1012
getIsReactActEnvironment,
11-
setReactActEnvironment,
13+
setIsReactActEnvironment,
1214
} from './act-compat'
1315
import {fireEvent} from './fire-event'
1416

1517
configureDTL({
16-
unstable_advanceTimersWrapper: cb => {
17-
// Only needed to support test environments that enable fake timers after modules are loaded.
18-
// React's scheduler will detect fake timers when it's initialized and use them.
19-
// So if we change the timers after that, we need to re-initialize the scheduler.
20-
// But not every test runner supports module reset.
21-
// It's not even clear how modules should be reset in ESM.
22-
// So for this brief period we go back to using the act queue.
23-
return act(cb)
24-
},
18+
unstable_advanceTimersWrapper: actIfEnabled,
2519
// We just want to run `waitFor` without IS_REACT_ACT_ENVIRONMENT
2620
// But that's not necessarily how `asyncWrapper` is used since it's a public method.
2721
// Let's just hope nobody else is using it.
2822
asyncWrapper: async cb => {
2923
const previousActEnvironment = getIsReactActEnvironment()
30-
setReactActEnvironment(false)
24+
setIsReactActEnvironment(false)
3125
try {
3226
return await cb()
3327
} finally {
34-
setReactActEnvironment(previousActEnvironment)
28+
setIsReactActEnvironment(previousActEnvironment)
3529
}
3630
},
37-
eventWrapper: cb => {
38-
return act(cb)
39-
},
31+
eventWrapper: actIfEnabled,
4032
})
4133

4234
// Ideally we'd just use a WeakMap where containers are keys and roots are values.
@@ -56,7 +48,7 @@ async function createConcurrentRoot(
5648
) {
5749
let root
5850
if (hydrate) {
59-
await act(() => {
51+
await actIfEnabled(() => {
6052
root = ReactDOMClient.hydrateRoot(
6153
container,
6254
WrapperComponent ? React.createElement(WrapperComponent, null, ui) : ui,
@@ -77,12 +69,12 @@ async function createConcurrentRoot(
7769
// Nothing to do since hydration happens when creating the root object.
7870
},
7971
render(element) {
80-
return act(() => {
72+
return actIfEnabled(() => {
8173
root.render(element)
8274
})
8375
},
8476
unmount() {
85-
return act(() => {
77+
return actIfEnabled(() => {
8678
root.unmount()
8779
})
8880
},
@@ -92,17 +84,17 @@ async function createConcurrentRoot(
9284
async function createLegacyRoot(container) {
9385
return {
9486
hydrate(element) {
95-
return act(() => {
87+
return actIfEnabled(() => {
9688
ReactDOM.hydrate(element, container)
9789
})
9890
},
9991
render(element) {
100-
return act(() => {
92+
return actIfEnabled(() => {
10193
ReactDOM.render(element, container)
10294
})
10395
},
10496
unmount() {
105-
return act(() => {
97+
return actIfEnabled(() => {
10698
ReactDOM.unmountComponentAtNode(container)
10799
})
108100
},
@@ -254,8 +246,17 @@ async function renderHook(renderCallback, options = {}) {
254246
return {result, rerender, unmount}
255247
}
256248

249+
function compatAct(scope) {
250+
// scope passed to domAct needs to be `async` until React.act treats every scope as async.
251+
// We already enforce `await act()` (regardless of scope) to flush microtasks
252+
// inside the act scope.
253+
return domAct(async () => {
254+
return scope()
255+
})
256+
}
257+
257258
// just re-export everything from dom-testing-library
258259
export * from '@testing-library/dom'
259-
export {render, renderHook, cleanup, act, fireEvent}
260+
export {render, renderHook, cleanup, compatAct as act, fireEvent}
260261

261262
/* eslint func-name-matching:0 */

‎tests/toWarnDev.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ SOFTWARE.
2929
/* eslint-disable func-names */
3030
/* eslint-disable complexity */
3131
const util = require('util')
32-
const jestDiff = require('jest-diff').default
32+
const jestDiff = require('jest-diff').diff
3333
const shouldIgnoreConsoleError = require('./shouldIgnoreConsoleError')
3434

3535
function normalizeCodeLocInfo(str) {

0 commit comments

Comments
 (0)
Please sign in to comment.