Skip to content

Commit

Permalink
Switch to IS_REACT_ACT_ENVIRONMENT instead of act when needed when us…
Browse files Browse the repository at this point in the history
…ing react 18 (#1131)
  • Loading branch information
AugustinLF committed Sep 22, 2022
1 parent c9ab3cf commit 807898e
Show file tree
Hide file tree
Showing 5 changed files with 177 additions and 23 deletions.
37 changes: 36 additions & 1 deletion src/__tests__/waitFor.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import * as React from 'react';
import { Text, TouchableOpacity, View } from 'react-native';
import { Text, TouchableOpacity, View, Pressable } from 'react-native';
import { fireEvent, render, waitFor } from '..';

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

// this component is convoluted on purpose. It is not a good react pattern, but it is valid
// react code that will run differently between different react versions (17 and 18), so we need
// explicit tests for it
const Comp = ({ onPress }: { onPress: () => void }) => {
const [state, setState] = React.useState(false);

React.useEffect(() => {
if (state) {
onPress();
}
}, [state, onPress]);

return (
<Pressable
onPress={async () => {
await Promise.resolve();
setState(true);
}}
>
<Text>Trigger</Text>
</Pressable>
);
};

test('waits for async event with fireEvent', async () => {
const spy = jest.fn();
const { getByText } = render(<Comp onPress={spy} />);

fireEvent.press(getByText('Trigger'));

await waitFor(() => {
expect(spy).toHaveBeenCalled();
});
});

test.each([false, true])(
'waits for element until it stops throwing using fake timers (legacyFakeTimers = %s)',
async (legacyFakeTimers) => {
Expand Down
92 changes: 90 additions & 2 deletions src/act.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,95 @@
import { act } from 'react-test-renderer';
// This file and the act() implementation is sourced from react-testing-library
// https://github.com/testing-library/react-testing-library/blob/c80809a956b0b9f3289c4a6fa8b5e8cc72d6ef6d/src/act-compat.js
import { act as reactTestRendererAct } from 'react-test-renderer';
import { checkReactVersionAtLeast } from './react-versions';

const actMock = (callback: () => void) => {
callback();
};

export default act || actMock;
// See https://github.com/reactwg/react-18/discussions/102 for more context on global.IS_REACT_ACT_ENVIRONMENT
declare global {
var IS_REACT_ACT_ENVIRONMENT: boolean | undefined;
}

function setIsReactActEnvironment(isReactActEnvironment: boolean | undefined) {
globalThis.IS_REACT_ACT_ENVIRONMENT = isReactActEnvironment;
}

function getIsReactActEnvironment() {
return globalThis.IS_REACT_ACT_ENVIRONMENT;
}

type Act = typeof reactTestRendererAct;

function withGlobalActEnvironment(actImplementation: Act) {
return (callback: Parameters<Act>[0]) => {
const previousActEnvironment = getIsReactActEnvironment();
setIsReactActEnvironment(true);

// this code is riddled with eslint disabling comments because this doesn't use real promises but eslint thinks we do
try {
// The return value of `act` is always a thenable.
let callbackNeedsToBeAwaited = false;
const actResult = actImplementation(() => {
const result = callback();
if (
result !== null &&
typeof result === 'object' &&
// @ts-expect-error this should be a promise or thenable
// eslint-disable-next-line promise/prefer-await-to-then
typeof result.then === 'function'
) {
callbackNeedsToBeAwaited = true;
}
return result;
});
if (callbackNeedsToBeAwaited) {
const thenable = actResult;
return {
then: (
resolve: (value: never) => never,
reject: (value: never) => never
) => {
// eslint-disable-next-line
thenable.then(
// eslint-disable-next-line promise/always-return
(returnValue) => {
setIsReactActEnvironment(previousActEnvironment);
resolve(returnValue);
},
(error) => {
setIsReactActEnvironment(previousActEnvironment);
reject(error);
}
);
},
};
} else {
setIsReactActEnvironment(previousActEnvironment);
return actResult;
}
} catch (error) {
// Can't be a `finally {}` block since we don't know if we have to immediately restore IS_REACT_ACT_ENVIRONMENT
// or if we have to await the callback first.
setIsReactActEnvironment(previousActEnvironment);
throw error;
}
};
}
const getAct = () => {
if (!reactTestRendererAct) {
return actMock;
}

return checkReactVersionAtLeast(18, 0)
? withGlobalActEnvironment(reactTestRendererAct)
: reactTestRendererAct;
};
const act = getAct();

export default act;
export {
setIsReactActEnvironment as setReactActEnvironment,
getIsReactActEnvironment,
};
38 changes: 27 additions & 11 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,33 @@
import { cleanup } from './pure';
import { flushMicroTasks } from './flushMicroTasks';
import { getIsReactActEnvironment, setReactActEnvironment } from './act';

// If we're running in a test runner that supports afterEach
// then we'll automatically run cleanup afterEach test
// this ensures that tests run in isolation from each other
// if you don't like this then either import the `pure` module
// or set the RNTL_SKIP_AUTO_CLEANUP env variable to 'true'.
if (typeof afterEach === 'function' && !process.env.RNTL_SKIP_AUTO_CLEANUP) {
// eslint-disable-next-line no-undef
afterEach(async () => {
await flushMicroTasks();
cleanup();
});
if (typeof process === 'undefined' || !process.env?.RNTL_SKIP_AUTO_CLEANUP) {
// If we're running in a test runner that supports afterEach
// then we'll automatically run cleanup afterEach test
// this ensures that tests run in isolation from each other
// if you don't like this then either import the `pure` module
// or set the RNTL_SKIP_AUTO_CLEANUP env variable to 'true'.
if (typeof afterEach === 'function') {
// eslint-disable-next-line no-undef
afterEach(async () => {
await flushMicroTasks();
cleanup();
});
}

if (typeof beforeAll === 'function' && typeof afterAll === 'function') {
// This matches the behavior of React < 18.
let previousIsReactActEnvironment = getIsReactActEnvironment();
beforeAll(() => {
previousIsReactActEnvironment = getIsReactActEnvironment();
setReactActEnvironment(true);
});

afterAll(() => {
setReactActEnvironment(previousIsReactActEnvironment);
});
}
}

export * from './pure';
11 changes: 11 additions & 0 deletions src/react-versions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import * as React from 'react';

export function checkReactVersionAtLeast(
major: number,
minor: number
): boolean {
if (React.version === undefined) return false;
const [actualMajor, actualMinor] = React.version.split('.').map(Number);

return actualMajor > major || (actualMajor === major && actualMinor >= minor);
}
22 changes: 13 additions & 9 deletions src/waitFor.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,17 @@
/* globals jest */
import * as React from 'react';
import act from './act';
import act, { setReactActEnvironment, getIsReactActEnvironment } from './act';
import { ErrorWithStack, copyStackTrace } from './helpers/errors';
import {
setTimeout,
clearTimeout,
setImmediate,
jestFakeTimersAreEnabled,
} from './helpers/timers';
import { checkReactVersionAtLeast } from './react-versions';

const DEFAULT_TIMEOUT = 1000;
const DEFAULT_INTERVAL = 50;

function checkReactVersionAtLeast(major: number, minor: number): boolean {
if (React.version === undefined) return false;
const [actualMajor, actualMinor] = React.version.split('.').map(Number);

return actualMajor > major || (actualMajor === major && actualMinor >= minor);
}

export type WaitForOptions = {
timeout?: number;
interval?: number;
Expand Down Expand Up @@ -194,6 +187,17 @@ export default async function waitFor<T>(
const stackTraceError = new ErrorWithStack('STACK_TRACE_ERROR', waitFor);
const optionsWithStackTrace = { stackTraceError, ...options };

if (checkReactVersionAtLeast(18, 0)) {
const previousActEnvironment = getIsReactActEnvironment();
setReactActEnvironment(false);

try {
return await waitForInternal(expectation, optionsWithStackTrace);
} finally {
setReactActEnvironment(previousActEnvironment);
}
}

if (!checkReactVersionAtLeast(16, 9)) {
return waitForInternal(expectation, optionsWithStackTrace);
}
Expand Down

0 comments on commit 807898e

Please sign in to comment.