From bf63ab2337161f13654a0e08c0219d64a48609ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Augustin=20Le=20F=C3=A8vre?= Date: Tue, 20 Sep 2022 16:56:55 +0200 Subject: [PATCH 1/5] Switch to IS_REACT_ACT_ENVIRONMENT instead of act when needed when used with react 18 --- src/__tests__/waitFor.test.tsx | 34 +++++++++++- src/act.ts | 98 ++++++++++++++++++++++++++++++++- src/checkReactVersionAtLeast.ts | 11 ++++ src/index.ts | 18 ++++++ src/waitFor.ts | 22 +++++--- 5 files changed, 171 insertions(+), 12 deletions(-) create mode 100644 src/checkReactVersionAtLeast.ts diff --git a/src/__tests__/waitFor.test.tsx b/src/__tests__/waitFor.test.tsx index a7ae4add9..af6bdc728 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,38 @@ test('waits for element with custom interval', async () => { expect(mockFn).toHaveBeenCalledTimes(2); }); +test('waits for async event with fireEvent', async () => { + 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 + + ); + }; + + 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..6ce1dd739 100644 --- a/src/act.ts +++ b/src/act.ts @@ -1,7 +1,101 @@ -import { act } from 'react-test-renderer'; +import { act as reactTestRendererAct } from 'react-test-renderer'; +import { checkReactVersionAtLeast } from './checkReactVersionAtLeast'; const actMock = (callback: () => void) => { callback(); }; -export default act || actMock; +type GlobalWithReactActEnvironment = { + IS_REACT_ACT_ENVIRONMENT?: boolean; +} & typeof globalThis; +function getGlobalThis(): GlobalWithReactActEnvironment { + // eslint-disable-next-line no-restricted-globals + if (typeof self !== 'undefined') { + // eslint-disable-next-line no-restricted-globals + return self as GlobalWithReactActEnvironment; + } + if (typeof window !== 'undefined') { + return window; + } + if (typeof global !== 'undefined') { + return global; + } + + throw new Error('unable to locate global object'); +} + +function setIsReactActEnvironment(isReactActEnvironment: boolean | undefined) { + getGlobalThis().IS_REACT_ACT_ENVIRONMENT = isReactActEnvironment; +} + +function getIsReactActEnvironment() { + return getGlobalThis().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' && + // eslint-disable-next-line promise/prefer-await-to-then + typeof (result as any).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 act = reactTestRendererAct + ? checkReactVersionAtLeast(18, 0) + ? withGlobalActEnvironment(reactTestRendererAct) + : reactTestRendererAct + : actMock; + +export default act; +export { + setIsReactActEnvironment as setReactActEnvironment, + getIsReactActEnvironment, +}; diff --git a/src/checkReactVersionAtLeast.ts b/src/checkReactVersionAtLeast.ts new file mode 100644 index 000000000..3f66f6494 --- /dev/null +++ b/src/checkReactVersionAtLeast.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/index.ts b/src/index.ts index a8bc65b9f..f6a6742ee 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,6 @@ 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 @@ -14,4 +15,21 @@ if (typeof afterEach === 'function' && !process.env.RNTL_SKIP_AUTO_CLEANUP) { }); } +if ( + typeof beforeAll === 'function' && + typeof afterAll === 'function' && + !process.env.RNTL_SKIP_AUTO_CLEANUP +) { + // 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/waitFor.ts b/src/waitFor.ts index 35d42c7ad..9abd2baa3 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 './checkReactVersionAtLeast'; 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; @@ -198,6 +191,17 @@ export default async function waitFor( return waitForInternal(expectation, optionsWithStackTrace); } + if (checkReactVersionAtLeast(18, 0)) { + const previousActEnvironment = getIsReactActEnvironment(); + setReactActEnvironment(false); + + try { + return await waitForInternal(expectation, optionsWithStackTrace); + } finally { + setReactActEnvironment(previousActEnvironment); + } + } + let result: T; await act(async () => { From 32b928851b6869f7a5ba6d730742532824072c0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Augustin=20Le=20F=C3=A8vre?= Date: Wed, 21 Sep 2022 16:51:15 +0200 Subject: [PATCH 2/5] Improve react 18 act support readability --- src/act.ts | 44 ++++++++----------- ...actVersionAtLeast.ts => react-versions.ts} | 0 src/waitFor.ts | 10 ++--- 3 files changed, 24 insertions(+), 30 deletions(-) rename src/{checkReactVersionAtLeast.ts => react-versions.ts} (100%) diff --git a/src/act.ts b/src/act.ts index 6ce1dd739..10d55d073 100644 --- a/src/act.ts +++ b/src/act.ts @@ -1,38 +1,27 @@ +// 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 './checkReactVersionAtLeast'; +import { checkReactVersionAtLeast } from './react-versions'; const actMock = (callback: () => void) => { callback(); }; -type GlobalWithReactActEnvironment = { - IS_REACT_ACT_ENVIRONMENT?: boolean; -} & typeof globalThis; -function getGlobalThis(): GlobalWithReactActEnvironment { - // eslint-disable-next-line no-restricted-globals - if (typeof self !== 'undefined') { - // eslint-disable-next-line no-restricted-globals - return self as GlobalWithReactActEnvironment; - } - if (typeof window !== 'undefined') { - return window; - } - if (typeof global !== 'undefined') { - return global; - } - - throw new Error('unable to locate global object'); +// 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) { - getGlobalThis().IS_REACT_ACT_ENVIRONMENT = isReactActEnvironment; + globalThis.IS_REACT_ACT_ENVIRONMENT = isReactActEnvironment; } function getIsReactActEnvironment() { - return getGlobalThis().IS_REACT_ACT_ENVIRONMENT; + return globalThis.IS_REACT_ACT_ENVIRONMENT; } type Act = typeof reactTestRendererAct; + function withGlobalActEnvironment(actImplementation: Act) { return (callback: Parameters[0]) => { const previousActEnvironment = getIsReactActEnvironment(); @@ -47,8 +36,9 @@ function withGlobalActEnvironment(actImplementation: Act) { 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 as any).then === 'function' + typeof result.then === 'function' ) { callbackNeedsToBeAwaited = true; } @@ -87,12 +77,16 @@ function withGlobalActEnvironment(actImplementation: Act) { } }; } +const getAct = () => { + if (!reactTestRendererAct) { + return actMock; + } -const act = reactTestRendererAct - ? checkReactVersionAtLeast(18, 0) + return checkReactVersionAtLeast(18, 0) ? withGlobalActEnvironment(reactTestRendererAct) - : reactTestRendererAct - : actMock; + : reactTestRendererAct; +}; +const act = getAct(); export default act; export { diff --git a/src/checkReactVersionAtLeast.ts b/src/react-versions.ts similarity index 100% rename from src/checkReactVersionAtLeast.ts rename to src/react-versions.ts diff --git a/src/waitFor.ts b/src/waitFor.ts index 9abd2baa3..69d3295db 100644 --- a/src/waitFor.ts +++ b/src/waitFor.ts @@ -7,7 +7,7 @@ import { setImmediate, jestFakeTimersAreEnabled, } from './helpers/timers'; -import { checkReactVersionAtLeast } from './checkReactVersionAtLeast'; +import { checkReactVersionAtLeast } from './react-versions'; const DEFAULT_TIMEOUT = 1000; const DEFAULT_INTERVAL = 50; @@ -187,10 +187,6 @@ export default async function waitFor( const stackTraceError = new ErrorWithStack('STACK_TRACE_ERROR', waitFor); const optionsWithStackTrace = { stackTraceError, ...options }; - if (!checkReactVersionAtLeast(16, 9)) { - return waitForInternal(expectation, optionsWithStackTrace); - } - if (checkReactVersionAtLeast(18, 0)) { const previousActEnvironment = getIsReactActEnvironment(); setReactActEnvironment(false); @@ -202,6 +198,10 @@ export default async function waitFor( } } + if (!checkReactVersionAtLeast(16, 9)) { + return waitForInternal(expectation, optionsWithStackTrace); + } + let result: T; await act(async () => { From 729626a8f23a1dfd79ee77ef13a22f9d2dfac348 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Augustin=20Le=20F=C3=A8vre?= Date: Thu, 22 Sep 2022 14:28:48 +0200 Subject: [PATCH 3/5] Improve readability --- src/__tests__/waitFor.test.tsx | 44 ++++++++++++++++-------------- src/index.ts | 50 ++++++++++++++++------------------ 2 files changed, 47 insertions(+), 47 deletions(-) diff --git a/src/__tests__/waitFor.test.tsx b/src/__tests__/waitFor.test.tsx index af6bdc728..c6d515b2f 100644 --- a/src/__tests__/waitFor.test.tsx +++ b/src/__tests__/waitFor.test.tsx @@ -78,28 +78,30 @@ 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 Comp = ({ onPress }: { onPress: () => void }) => { - const [state, setState] = React.useState(false); - - React.useEffect(() => { - if (state) { - onPress(); - } - }, [state, onPress]); - - return ( - { - await Promise.resolve(); - setState(true); - }} - > - Trigger - - ); - }; - const spy = jest.fn(); const { getByText } = render(); diff --git a/src/index.ts b/src/index.ts index f6a6742ee..f2f2f342c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,34 +2,32 @@ 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' && - !process.env.RNTL_SKIP_AUTO_CLEANUP -) { - // This matches the behavior of React < 18. - let previousIsReactActEnvironment = getIsReactActEnvironment(); - beforeAll(() => { - previousIsReactActEnvironment = getIsReactActEnvironment(); - setReactActEnvironment(true); - }); + 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); - }); + afterAll(() => { + setReactActEnvironment(previousIsReactActEnvironment); + }); + } } export * from './pure'; From 8b6c16a0c0cd864179116ed7bc2cb203a1a19b92 Mon Sep 17 00:00:00 2001 From: Maciej Jastrzebski Date: Thu, 22 Sep 2022 17:57:09 +0200 Subject: [PATCH 4/5] Update src/__tests__/waitFor.test.tsx --- src/__tests__/waitFor.test.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/__tests__/waitFor.test.tsx b/src/__tests__/waitFor.test.tsx index c6d515b2f..534b43bdd 100644 --- a/src/__tests__/waitFor.test.tsx +++ b/src/__tests__/waitFor.test.tsx @@ -101,6 +101,7 @@ const Comp = ({ onPress }: { onPress: () => void }) => { ); }; + test('waits for async event with fireEvent', async () => { const spy = jest.fn(); const { getByText } = render(); From 18549979770e3061beca59c91d77452c1ed07124 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Augustin=20Le=20F=C3=A8vre?= Date: Thu, 22 Sep 2022 23:20:59 +0200 Subject: [PATCH 5/5] Trigger new build