Skip to content

Commit ab13864

Browse files
committed
fix: Prevent "missing act" warning for in-flight promises
1 parent 4d76a4a commit ab13864

File tree

2 files changed

+167
-63
lines changed

2 files changed

+167
-63
lines changed

src/__tests__/end-to-end.js

+139-62
Original file line numberDiff line numberDiff line change
@@ -1,73 +1,150 @@
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()
2213
})
2314

24-
return () => {
25-
cancelled = true
15+
afterEach(() => {
16+
jest.useRealTimers()
17+
})
18+
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+
// eslint-disable-next-line testing-library/prefer-find-by -- Sir, this is a test.
65+
await waitFor(() => screen.getByText(/Loading../))
66+
// eslint-disable-next-line testing-library/prefer-find-by -- Sir, this is a test.
67+
await waitFor(() => screen.getByText(/Loaded this message:/))
68+
expect(screen.getByTestId('message')).toHaveTextContent(/Hello World/)
69+
})
3270

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

4080
describe.each([
41-
['real timers', () => jest.useRealTimers()],
42-
['fake legacy timers', () => jest.useFakeTimers('legacy')],
81+
// ['real timers', () => jest.useRealTimers()],
82+
// ['fake legacy timers', () => jest.useFakeTimers('legacy')],
4383
['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-
})
84+
])(
85+
'it waits for the data to be loaded in a microtask using %s',
86+
(label, useTimers) => {
87+
beforeEach(() => {
88+
useTimers()
89+
})
90+
91+
afterEach(() => {
92+
jest.useRealTimers()
93+
})
94+
95+
const fetchAMessageInAMicrotask = () =>
96+
Promise.resolve({
97+
status: 200,
98+
json: () => Promise.resolve({title: 'Hello World'}),
99+
})
100+
101+
function ComponentWithMicrotaskLoader() {
102+
const [fetchState, setFetchState] = React.useState({fetching: true})
103+
104+
React.useEffect(() => {
105+
if (fetchState.fetching) {
106+
fetchAMessageInAMicrotask().then(res => {
107+
return res.json().then(data => {
108+
setFetchState({todo: data.title, fetching: false})
109+
})
110+
})
111+
}
112+
}, [fetchState])
113+
114+
if (fetchState.fetching) {
115+
return <p>Loading..</p>
116+
}
117+
118+
return (
119+
<div data-testid="message">Loaded this message: {fetchState.todo}</div>
120+
)
121+
}
122+
123+
test('waitForElementToBeRemoved', async () => {
124+
render(<ComponentWithMicrotaskLoader />)
125+
const loading = () => screen.getByText('Loading..')
126+
await waitForElementToBeRemoved(loading)
127+
expect(screen.getByTestId('message')).toHaveTextContent(/Hello World/)
128+
})
129+
130+
test('waitFor', async () => {
131+
render(<ComponentWithMicrotaskLoader />)
132+
await waitFor(() => {
133+
// eslint-disable-next-line testing-library/prefer-explicit-assert -- Sir, this is a test.
134+
screen.getByText('Loading..')
135+
})
136+
await waitFor(() => {
137+
// eslint-disable-next-line testing-library/prefer-explicit-assert -- Sir, this is a test.
138+
screen.getByText(/Loaded this message:/)
139+
})
140+
expect(screen.getByTestId('message')).toHaveTextContent(/Hello World/)
141+
})
142+
143+
test('findBy', async () => {
144+
render(<ComponentWithMicrotaskLoader />)
145+
await expect(screen.findByTestId('message')).resolves.toHaveTextContent(
146+
/Hello World/,
147+
)
148+
})
149+
},
150+
)

src/pure.js

+28-1
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,19 @@ 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+
Object.prototype.hasOwnProperty.call(setTimeout, 'clock')
22+
)
23+
} // istanbul ignore next
24+
25+
return false
26+
}
27+
1528
configureDTL({
1629
unstable_advanceTimersWrapper: cb => {
1730
return act(cb)
@@ -23,7 +36,21 @@ configureDTL({
2336
const previousActEnvironment = getIsReactActEnvironment()
2437
setReactActEnvironment(false)
2538
try {
26-
return await cb()
39+
const result = await cb()
40+
// Drain microtask queue.
41+
// Otherwise we'll restore the previous act() environment, before we resolve the `waitFor` call.
42+
// The caller would have no chance to wrap the in-flight Promises in `act()`
43+
await new Promise(resolve => {
44+
setTimeout(() => {
45+
resolve()
46+
}, 0)
47+
48+
if (jestFakeTimersAreEnabled()) {
49+
jest.advanceTimersByTime(0)
50+
}
51+
})
52+
53+
return result
2754
} finally {
2855
setReactActEnvironment(previousActEnvironment)
2956
}

0 commit comments

Comments
 (0)