Skip to content

Commit f78839b

Browse files
authoredFeb 16, 2023
fix: Prevent "missing act" warning for queued microtasks (#1137)
* Add intended behavior * fix: Prevent "missing act" warning for in-flight promises * Disable TL lint rules in tests * Implementation without macrotask * Now I member
1 parent 6653c23 commit f78839b

File tree

3 files changed

+182
-61
lines changed

3 files changed

+182
-61
lines changed
 

‎package.json

+2
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,8 @@
8282
"testing-library/no-debugging-utils": "off",
8383
"testing-library/no-dom-import": "off",
8484
"testing-library/no-unnecessary-act": "off",
85+
"testing-library/prefer-explicit-assert": "off",
86+
"testing-library/prefer-find-by": "off",
8587
"testing-library/prefer-user-event": "off"
8688
}
8789
},

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

+151-60
Original file line numberDiff line numberDiff line change
@@ -1,73 +1,164 @@
11
import * as React from 'react'
22
import {render, waitForElementToBeRemoved, screen, waitFor} from '../'
33

4-
const fetchAMessage = () =>
5-
new Promise(resolve => {
6-
// we are using random timeout here to simulate a real-time example
7-
// of an async operation calling a callback at a non-deterministic time
8-
const randomTimeout = Math.floor(Math.random() * 100)
9-
setTimeout(() => {
10-
resolve({returnedMessage: 'Hello World'})
11-
}, randomTimeout)
12-
})
13-
14-
function ComponentWithLoader() {
15-
const [state, setState] = React.useState({data: undefined, loading: true})
16-
React.useEffect(() => {
17-
let cancelled = false
18-
fetchAMessage().then(data => {
19-
if (!cancelled) {
20-
setState({data, loading: false})
21-
}
4+
describe.each([
5+
['real timers', () => jest.useRealTimers()],
6+
['fake legacy timers', () => jest.useFakeTimers('legacy')],
7+
['fake modern timers', () => jest.useFakeTimers('modern')],
8+
])(
9+
'it waits for the data to be loaded in a macrotask using %s',
10+
(label, useTimers) => {
11+
beforeEach(() => {
12+
useTimers()
13+
})
14+
15+
afterEach(() => {
16+
jest.useRealTimers()
2217
})
2318

24-
return () => {
25-
cancelled = true
19+
const fetchAMessageInAMacrotask = () =>
20+
new Promise(resolve => {
21+
// we are using random timeout here to simulate a real-time example
22+
// of an async operation calling a callback at a non-deterministic time
23+
const randomTimeout = Math.floor(Math.random() * 100)
24+
setTimeout(() => {
25+
resolve({returnedMessage: 'Hello World'})
26+
}, randomTimeout)
27+
})
28+
29+
function ComponentWithMacrotaskLoader() {
30+
const [state, setState] = React.useState({data: undefined, loading: true})
31+
React.useEffect(() => {
32+
let cancelled = false
33+
fetchAMessageInAMacrotask().then(data => {
34+
if (!cancelled) {
35+
setState({data, loading: false})
36+
}
37+
})
38+
39+
return () => {
40+
cancelled = true
41+
}
42+
}, [])
43+
44+
if (state.loading) {
45+
return <div>Loading...</div>
46+
}
47+
48+
return (
49+
<div data-testid="message">
50+
Loaded this message: {state.data.returnedMessage}!
51+
</div>
52+
)
2653
}
27-
}, [])
2854

29-
if (state.loading) {
30-
return <div>Loading...</div>
31-
}
55+
test('waitForElementToBeRemoved', async () => {
56+
render(<ComponentWithMacrotaskLoader />)
57+
const loading = () => screen.getByText('Loading...')
58+
await waitForElementToBeRemoved(loading)
59+
expect(screen.getByTestId('message')).toHaveTextContent(/Hello World/)
60+
})
61+
62+
test('waitFor', async () => {
63+
render(<ComponentWithMacrotaskLoader />)
64+
await waitFor(() => screen.getByText(/Loading../))
65+
await waitFor(() => screen.getByText(/Loaded this message:/))
66+
expect(screen.getByTestId('message')).toHaveTextContent(/Hello World/)
67+
})
3268

33-
return (
34-
<div data-testid="message">
35-
Loaded this message: {state.data.returnedMessage}!
36-
</div>
37-
)
38-
}
69+
test('findBy', async () => {
70+
render(<ComponentWithMacrotaskLoader />)
71+
await expect(screen.findByTestId('message')).resolves.toHaveTextContent(
72+
/Hello World/,
73+
)
74+
})
75+
},
76+
)
3977

4078
describe.each([
4179
['real timers', () => jest.useRealTimers()],
4280
['fake legacy timers', () => jest.useFakeTimers('legacy')],
4381
['fake modern timers', () => jest.useFakeTimers('modern')],
44-
])('it waits for the data to be loaded using %s', (label, useTimers) => {
45-
beforeEach(() => {
46-
useTimers()
47-
})
48-
49-
afterEach(() => {
50-
jest.useRealTimers()
51-
})
52-
53-
test('waitForElementToBeRemoved', async () => {
54-
render(<ComponentWithLoader />)
55-
const loading = () => screen.getByText('Loading...')
56-
await waitForElementToBeRemoved(loading)
57-
expect(screen.getByTestId('message')).toHaveTextContent(/Hello World/)
58-
})
59-
60-
test('waitFor', async () => {
61-
render(<ComponentWithLoader />)
62-
const message = () => screen.getByText(/Loaded this message:/)
63-
await waitFor(message)
64-
expect(screen.getByTestId('message')).toHaveTextContent(/Hello World/)
65-
})
66-
67-
test('findBy', async () => {
68-
render(<ComponentWithLoader />)
69-
await expect(screen.findByTestId('message')).resolves.toHaveTextContent(
70-
/Hello World/,
71-
)
72-
})
73-
})
82+
])(
83+
'it waits for the data to be loaded in a microtask using %s',
84+
(label, useTimers) => {
85+
beforeEach(() => {
86+
useTimers()
87+
})
88+
89+
afterEach(() => {
90+
jest.useRealTimers()
91+
})
92+
93+
const fetchAMessageInAMicrotask = () =>
94+
Promise.resolve({
95+
status: 200,
96+
json: () => Promise.resolve({title: 'Hello World'}),
97+
})
98+
99+
function ComponentWithMicrotaskLoader() {
100+
const [fetchState, setFetchState] = React.useState({fetching: true})
101+
102+
React.useEffect(() => {
103+
if (fetchState.fetching) {
104+
fetchAMessageInAMicrotask().then(res => {
105+
return (
106+
res
107+
.json()
108+
// By spec, the runtime can only yield back to the event loop once
109+
// the microtask queue is empty.
110+
// So we ensure that we actually wait for that as well before yielding back from `waitFor`.
111+
.then(data => data)
112+
.then(data => data)
113+
.then(data => data)
114+
.then(data => data)
115+
.then(data => data)
116+
.then(data => data)
117+
.then(data => data)
118+
.then(data => data)
119+
.then(data => data)
120+
.then(data => data)
121+
.then(data => data)
122+
.then(data => {
123+
setFetchState({todo: data.title, fetching: false})
124+
})
125+
)
126+
})
127+
}
128+
}, [fetchState])
129+
130+
if (fetchState.fetching) {
131+
return <p>Loading..</p>
132+
}
133+
134+
return (
135+
<div data-testid="message">Loaded this message: {fetchState.todo}</div>
136+
)
137+
}
138+
139+
test('waitForElementToBeRemoved', async () => {
140+
render(<ComponentWithMicrotaskLoader />)
141+
const loading = () => screen.getByText('Loading..')
142+
await waitForElementToBeRemoved(loading)
143+
expect(screen.getByTestId('message')).toHaveTextContent(/Hello World/)
144+
})
145+
146+
test('waitFor', async () => {
147+
render(<ComponentWithMicrotaskLoader />)
148+
await waitFor(() => {
149+
screen.getByText('Loading..')
150+
})
151+
await waitFor(() => {
152+
screen.getByText(/Loaded this message:/)
153+
})
154+
expect(screen.getByTestId('message')).toHaveTextContent(/Hello World/)
155+
})
156+
157+
test('findBy', async () => {
158+
render(<ComponentWithMicrotaskLoader />)
159+
await expect(screen.findByTestId('message')).resolves.toHaveTextContent(
160+
/Hello World/,
161+
)
162+
})
163+
},
164+
)

‎src/pure.js

+29-1
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,20 @@ import act, {
1212
} from './act-compat'
1313
import {fireEvent} from './fire-event'
1414

15+
function jestFakeTimersAreEnabled() {
16+
/* istanbul ignore else */
17+
if (typeof jest !== 'undefined' && jest !== null) {
18+
return (
19+
// legacy timers
20+
setTimeout._isMockFunction === true || // modern timers
21+
// eslint-disable-next-line prefer-object-has-own -- No Object.hasOwn in all target environments we support.
22+
Object.prototype.hasOwnProperty.call(setTimeout, 'clock')
23+
)
24+
} // istanbul ignore next
25+
26+
return false
27+
}
28+
1529
configureDTL({
1630
unstable_advanceTimersWrapper: cb => {
1731
return act(cb)
@@ -23,7 +37,21 @@ configureDTL({
2337
const previousActEnvironment = getIsReactActEnvironment()
2438
setReactActEnvironment(false)
2539
try {
26-
return await cb()
40+
const result = await cb()
41+
// Drain microtask queue.
42+
// Otherwise we'll restore the previous act() environment, before we resolve the `waitFor` call.
43+
// The caller would have no chance to wrap the in-flight Promises in `act()`
44+
await new Promise(resolve => {
45+
setTimeout(() => {
46+
resolve()
47+
}, 0)
48+
49+
if (jestFakeTimersAreEnabled()) {
50+
jest.advanceTimersByTime(0)
51+
}
52+
})
53+
54+
return result
2755
} finally {
2856
setReactActEnvironment(previousActEnvironment)
2957
}

0 commit comments

Comments
 (0)
Please sign in to comment.