Skip to content

Commit a045bc3

Browse files
authored
fix: Don't trigger "missing act" warnings when using waitFor+real timers (#980)
1 parent c888cb6 commit a045bc3

File tree

6 files changed

+126
-9
lines changed

6 files changed

+126
-9
lines changed

jest.config.js

+2-2
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@ module.exports = Object.assign(jestConfig, {
88
// minimum coverage of jobs using React 17 and 18
99
branches: 80,
1010
functions: 78,
11-
lines: 84,
12-
statements: 84,
11+
lines: 79,
12+
statements: 79,
1313
},
1414
},
1515
})

src/__tests__/act.js

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

44
test('render calls useEffect immediately', () => {
55
const effectCb = jest.fn()
@@ -43,3 +43,27 @@ test('calls to hydrate will run useEffects', () => {
4343
render(<MyUselessComponent />, {hydrate: true})
4444
expect(effectCb).toHaveBeenCalledTimes(1)
4545
})
46+
47+
test('cleans up IS_REACT_ACT_ENVIRONMENT if its callback throws', () => {
48+
global.IS_REACT_ACT_ENVIRONMENT = false
49+
50+
expect(() =>
51+
act(() => {
52+
throw new Error('threw')
53+
}),
54+
).toThrow('threw')
55+
56+
expect(global.IS_REACT_ACT_ENVIRONMENT).toEqual(false)
57+
})
58+
59+
test('cleans up IS_REACT_ACT_ENVIRONMENT if its async callback throws', async () => {
60+
global.IS_REACT_ACT_ENVIRONMENT = false
61+
62+
await expect(() =>
63+
act(async () => {
64+
throw new Error('thenable threw')
65+
}),
66+
).rejects.toThrow('thenable threw')
67+
68+
expect(global.IS_REACT_ACT_ENVIRONMENT).toEqual(false)
69+
})

src/__tests__/end-to-end.js

-2
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,6 @@ function ComponentWithLoader() {
1717
let cancelled = false
1818
fetchAMessage().then(data => {
1919
if (!cancelled) {
20-
// Will trigger "missing act" warnings in React 18 with real timers
21-
// Need to wait for an action on https://github.com/reactwg/react-18/discussions/23#discussioncomment-1087897
2220
setState({data, loading: false})
2321
}
2422
})

src/act-compat.js

+74-2
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,79 @@ function actPolyfill(cb) {
1515
ReactDOM.render(<div />, document.createElement('div'))
1616
}
1717

18-
const act = isomorphicAct || domAct || actPolyfill
18+
function getGlobalThis() {
19+
/* istanbul ignore else */
20+
if (typeof self !== 'undefined') {
21+
return self
22+
}
23+
/* istanbul ignore next */
24+
if (typeof window !== 'undefined') {
25+
return window
26+
}
27+
/* istanbul ignore next */
28+
if (typeof global !== 'undefined') {
29+
return global
30+
}
31+
/* istanbul ignore next */
32+
throw new Error('unable to locate global object')
33+
}
34+
35+
function setReactActEnvironment(isReactActEnvironment) {
36+
getGlobalThis().IS_REACT_ACT_ENVIRONMENT = isReactActEnvironment
37+
}
38+
39+
function getIsReactActEnvironment() {
40+
return getGlobalThis().IS_REACT_ACT_ENVIRONMENT
41+
}
42+
43+
function withGlobalActEnvironment(actImplementation) {
44+
return callback => {
45+
const previousActEnvironment = getIsReactActEnvironment()
46+
setReactActEnvironment(true)
47+
try {
48+
// The return value of `act` is always a thenable.
49+
let callbackNeedsToBeAwaited = false
50+
const actResult = actImplementation(() => {
51+
const result = callback()
52+
if (
53+
result !== null &&
54+
typeof result === 'object' &&
55+
typeof result.then === 'function'
56+
) {
57+
callbackNeedsToBeAwaited = true
58+
}
59+
return result
60+
})
61+
if (callbackNeedsToBeAwaited) {
62+
const thenable = actResult
63+
return {
64+
then: (resolve, reject) => {
65+
thenable.then(
66+
returnValue => {
67+
setReactActEnvironment(previousActEnvironment)
68+
resolve(returnValue)
69+
},
70+
error => {
71+
setReactActEnvironment(previousActEnvironment)
72+
reject(error)
73+
},
74+
)
75+
},
76+
}
77+
} else {
78+
setReactActEnvironment(previousActEnvironment)
79+
return actResult
80+
}
81+
} catch (error) {
82+
// Can't be a `finally {}` block since we don't know if we have to immediately restore IS_REACT_ACT_ENVIRONMENT
83+
// or if we have to await the callback first.
84+
setReactActEnvironment(previousActEnvironment)
85+
throw error
86+
}
87+
}
88+
}
89+
90+
const act = withGlobalActEnvironment(isomorphicAct || domAct || actPolyfill)
1991

2092
let youHaveBeenWarned = false
2193
let isAsyncActSupported = null
@@ -131,6 +203,6 @@ function asyncAct(cb) {
131203
}
132204

133205
export default act
134-
export {asyncAct}
206+
export {asyncAct, setReactActEnvironment, getIsReactActEnvironment}
135207

136208
/* eslint no-console:0 */

src/pure.js

+17-2
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,11 @@ import {
55
prettyDOM,
66
configure as configureDTL,
77
} from '@testing-library/dom'
8-
import act, {asyncAct} from './act-compat'
8+
import act, {
9+
asyncAct,
10+
getIsReactActEnvironment,
11+
setReactActEnvironment,
12+
} from './act-compat'
913
import {fireEvent} from './fire-event'
1014

1115
configureDTL({
@@ -30,7 +34,18 @@ if (React.startTransition !== undefined) {
3034
unstable_advanceTimersWrapper: cb => {
3135
return act(cb)
3236
},
33-
asyncWrapper: cb => cb(),
37+
// We just want to run `waitFor` without IS_REACT_ACT_ENVIRONMENT
38+
// But that's not necessarily how `asyncWrapper` is used since it's a public method.
39+
// Let's just hope nobody else is using it.
40+
asyncWrapper: async cb => {
41+
const previousActEnvironment = getIsReactActEnvironment()
42+
setReactActEnvironment(false)
43+
try {
44+
return await cb()
45+
} finally {
46+
setReactActEnvironment(previousActEnvironment)
47+
}
48+
},
3449
})
3550
}
3651

tests/setup-env.js

+8
Original file line numberDiff line numberDiff line change
@@ -1 +1,9 @@
11
import '@testing-library/jest-dom/extend-expect'
2+
3+
beforeEach(() => {
4+
global.IS_REACT_ACT_ENVIRONMENT = true
5+
})
6+
7+
afterEach(() => {
8+
global.IS_REACT_ACT_ENVIRONMENT = false
9+
})

0 commit comments

Comments
 (0)