Skip to content

Commit 807898e

Browse files
authored
Switch to IS_REACT_ACT_ENVIRONMENT instead of act when needed when using react 18 (#1131)
1 parent c9ab3cf commit 807898e

File tree

5 files changed

+177
-23
lines changed

5 files changed

+177
-23
lines changed

Diff for: src/__tests__/waitFor.test.tsx

+36-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import * as React from 'react';
2-
import { Text, TouchableOpacity, View } from 'react-native';
2+
import { Text, TouchableOpacity, View, Pressable } from 'react-native';
33
import { fireEvent, render, waitFor } from '..';
44

55
class Banana extends React.Component<any> {
@@ -78,6 +78,41 @@ test('waits for element with custom interval', async () => {
7878
expect(mockFn).toHaveBeenCalledTimes(2);
7979
});
8080

81+
// this component is convoluted on purpose. It is not a good react pattern, but it is valid
82+
// react code that will run differently between different react versions (17 and 18), so we need
83+
// explicit tests for it
84+
const Comp = ({ onPress }: { onPress: () => void }) => {
85+
const [state, setState] = React.useState(false);
86+
87+
React.useEffect(() => {
88+
if (state) {
89+
onPress();
90+
}
91+
}, [state, onPress]);
92+
93+
return (
94+
<Pressable
95+
onPress={async () => {
96+
await Promise.resolve();
97+
setState(true);
98+
}}
99+
>
100+
<Text>Trigger</Text>
101+
</Pressable>
102+
);
103+
};
104+
105+
test('waits for async event with fireEvent', async () => {
106+
const spy = jest.fn();
107+
const { getByText } = render(<Comp onPress={spy} />);
108+
109+
fireEvent.press(getByText('Trigger'));
110+
111+
await waitFor(() => {
112+
expect(spy).toHaveBeenCalled();
113+
});
114+
});
115+
81116
test.each([false, true])(
82117
'waits for element until it stops throwing using fake timers (legacyFakeTimers = %s)',
83118
async (legacyFakeTimers) => {

Diff for: src/act.ts

+90-2
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,95 @@
1-
import { act } from 'react-test-renderer';
1+
// This file and the act() implementation is sourced from react-testing-library
2+
// https://github.com/testing-library/react-testing-library/blob/c80809a956b0b9f3289c4a6fa8b5e8cc72d6ef6d/src/act-compat.js
3+
import { act as reactTestRendererAct } from 'react-test-renderer';
4+
import { checkReactVersionAtLeast } from './react-versions';
25

36
const actMock = (callback: () => void) => {
47
callback();
58
};
69

7-
export default act || actMock;
10+
// See https://github.com/reactwg/react-18/discussions/102 for more context on global.IS_REACT_ACT_ENVIRONMENT
11+
declare global {
12+
var IS_REACT_ACT_ENVIRONMENT: boolean | undefined;
13+
}
14+
15+
function setIsReactActEnvironment(isReactActEnvironment: boolean | undefined) {
16+
globalThis.IS_REACT_ACT_ENVIRONMENT = isReactActEnvironment;
17+
}
18+
19+
function getIsReactActEnvironment() {
20+
return globalThis.IS_REACT_ACT_ENVIRONMENT;
21+
}
22+
23+
type Act = typeof reactTestRendererAct;
24+
25+
function withGlobalActEnvironment(actImplementation: Act) {
26+
return (callback: Parameters<Act>[0]) => {
27+
const previousActEnvironment = getIsReactActEnvironment();
28+
setIsReactActEnvironment(true);
29+
30+
// this code is riddled with eslint disabling comments because this doesn't use real promises but eslint thinks we do
31+
try {
32+
// The return value of `act` is always a thenable.
33+
let callbackNeedsToBeAwaited = false;
34+
const actResult = actImplementation(() => {
35+
const result = callback();
36+
if (
37+
result !== null &&
38+
typeof result === 'object' &&
39+
// @ts-expect-error this should be a promise or thenable
40+
// eslint-disable-next-line promise/prefer-await-to-then
41+
typeof result.then === 'function'
42+
) {
43+
callbackNeedsToBeAwaited = true;
44+
}
45+
return result;
46+
});
47+
if (callbackNeedsToBeAwaited) {
48+
const thenable = actResult;
49+
return {
50+
then: (
51+
resolve: (value: never) => never,
52+
reject: (value: never) => never
53+
) => {
54+
// eslint-disable-next-line
55+
thenable.then(
56+
// eslint-disable-next-line promise/always-return
57+
(returnValue) => {
58+
setIsReactActEnvironment(previousActEnvironment);
59+
resolve(returnValue);
60+
},
61+
(error) => {
62+
setIsReactActEnvironment(previousActEnvironment);
63+
reject(error);
64+
}
65+
);
66+
},
67+
};
68+
} else {
69+
setIsReactActEnvironment(previousActEnvironment);
70+
return actResult;
71+
}
72+
} catch (error) {
73+
// Can't be a `finally {}` block since we don't know if we have to immediately restore IS_REACT_ACT_ENVIRONMENT
74+
// or if we have to await the callback first.
75+
setIsReactActEnvironment(previousActEnvironment);
76+
throw error;
77+
}
78+
};
79+
}
80+
const getAct = () => {
81+
if (!reactTestRendererAct) {
82+
return actMock;
83+
}
84+
85+
return checkReactVersionAtLeast(18, 0)
86+
? withGlobalActEnvironment(reactTestRendererAct)
87+
: reactTestRendererAct;
88+
};
89+
const act = getAct();
90+
91+
export default act;
92+
export {
93+
setIsReactActEnvironment as setReactActEnvironment,
94+
getIsReactActEnvironment,
95+
};

Diff for: src/index.ts

+27-11
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,33 @@
11
import { cleanup } from './pure';
22
import { flushMicroTasks } from './flushMicroTasks';
3+
import { getIsReactActEnvironment, setReactActEnvironment } from './act';
34

4-
// If we're running in a test runner that supports afterEach
5-
// then we'll automatically run cleanup afterEach test
6-
// this ensures that tests run in isolation from each other
7-
// if you don't like this then either import the `pure` module
8-
// or set the RNTL_SKIP_AUTO_CLEANUP env variable to 'true'.
9-
if (typeof afterEach === 'function' && !process.env.RNTL_SKIP_AUTO_CLEANUP) {
10-
// eslint-disable-next-line no-undef
11-
afterEach(async () => {
12-
await flushMicroTasks();
13-
cleanup();
14-
});
5+
if (typeof process === 'undefined' || !process.env?.RNTL_SKIP_AUTO_CLEANUP) {
6+
// If we're running in a test runner that supports afterEach
7+
// then we'll automatically run cleanup afterEach test
8+
// this ensures that tests run in isolation from each other
9+
// if you don't like this then either import the `pure` module
10+
// or set the RNTL_SKIP_AUTO_CLEANUP env variable to 'true'.
11+
if (typeof afterEach === 'function') {
12+
// eslint-disable-next-line no-undef
13+
afterEach(async () => {
14+
await flushMicroTasks();
15+
cleanup();
16+
});
17+
}
18+
19+
if (typeof beforeAll === 'function' && typeof afterAll === 'function') {
20+
// This matches the behavior of React < 18.
21+
let previousIsReactActEnvironment = getIsReactActEnvironment();
22+
beforeAll(() => {
23+
previousIsReactActEnvironment = getIsReactActEnvironment();
24+
setReactActEnvironment(true);
25+
});
26+
27+
afterAll(() => {
28+
setReactActEnvironment(previousIsReactActEnvironment);
29+
});
30+
}
1531
}
1632

1733
export * from './pure';

Diff for: src/react-versions.ts

+11
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import * as React from 'react';
2+
3+
export function checkReactVersionAtLeast(
4+
major: number,
5+
minor: number
6+
): boolean {
7+
if (React.version === undefined) return false;
8+
const [actualMajor, actualMinor] = React.version.split('.').map(Number);
9+
10+
return actualMajor > major || (actualMajor === major && actualMinor >= minor);
11+
}

Diff for: src/waitFor.ts

+13-9
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,17 @@
11
/* globals jest */
2-
import * as React from 'react';
3-
import act from './act';
2+
import act, { setReactActEnvironment, getIsReactActEnvironment } from './act';
43
import { ErrorWithStack, copyStackTrace } from './helpers/errors';
54
import {
65
setTimeout,
76
clearTimeout,
87
setImmediate,
98
jestFakeTimersAreEnabled,
109
} from './helpers/timers';
10+
import { checkReactVersionAtLeast } from './react-versions';
1111

1212
const DEFAULT_TIMEOUT = 1000;
1313
const DEFAULT_INTERVAL = 50;
1414

15-
function checkReactVersionAtLeast(major: number, minor: number): boolean {
16-
if (React.version === undefined) return false;
17-
const [actualMajor, actualMinor] = React.version.split('.').map(Number);
18-
19-
return actualMajor > major || (actualMajor === major && actualMinor >= minor);
20-
}
21-
2215
export type WaitForOptions = {
2316
timeout?: number;
2417
interval?: number;
@@ -194,6 +187,17 @@ export default async function waitFor<T>(
194187
const stackTraceError = new ErrorWithStack('STACK_TRACE_ERROR', waitFor);
195188
const optionsWithStackTrace = { stackTraceError, ...options };
196189

190+
if (checkReactVersionAtLeast(18, 0)) {
191+
const previousActEnvironment = getIsReactActEnvironment();
192+
setReactActEnvironment(false);
193+
194+
try {
195+
return await waitForInternal(expectation, optionsWithStackTrace);
196+
} finally {
197+
setReactActEnvironment(previousActEnvironment);
198+
}
199+
}
200+
197201
if (!checkReactVersionAtLeast(16, 9)) {
198202
return waitForInternal(expectation, optionsWithStackTrace);
199203
}

0 commit comments

Comments
 (0)