diff --git a/src/__tests__/waitFor.test.tsx b/src/__tests__/waitFor.test.tsx index a7ae4add9..534b43bdd 100644 --- a/src/__tests__/waitFor.test.tsx +++ b/src/__tests__/waitFor.test.tsx @@ -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 { @@ -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 ( + { + await Promise.resolve(); + setState(true); + }} + > + Trigger + + ); +}; + +test('waits for async event with fireEvent', async () => { + const spy = jest.fn(); + const { getByText } = render(); + + 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) => { diff --git a/src/act.ts b/src/act.ts index 2d4bdb13e..10d55d073 100644 --- a/src/act.ts +++ b/src/act.ts @@ -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[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, +}; diff --git a/src/index.ts b/src/index.ts index a8bc65b9f..f2f2f342c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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'; diff --git a/src/react-versions.ts b/src/react-versions.ts new file mode 100644 index 000000000..3f66f6494 --- /dev/null +++ b/src/react-versions.ts @@ -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); +} diff --git a/src/waitFor.ts b/src/waitFor.ts index 35d42c7ad..69d3295db 100644 --- a/src/waitFor.ts +++ b/src/waitFor.ts @@ -1,6 +1,5 @@ /* 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, @@ -8,17 +7,11 @@ import { 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; @@ -194,6 +187,17 @@ export default async function waitFor( 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); }