diff --git a/experiments-rtl/src/app/__tests__/click.test.tsx b/experiments-rtl/src/app/__tests__/click.test.tsx index 376ae22f3..e4a4254c5 100644 --- a/experiments-rtl/src/app/__tests__/click.test.tsx +++ b/experiments-rtl/src/app/__tests__/click.test.tsx @@ -1,8 +1,8 @@ -import * as React from "react"; -import { render, screen, fireEvent } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; +import * as React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; -test("userEvent.click()", async () => { +test('userEvent.click()', async () => { const handleClick = jest.fn(); render( @@ -11,12 +11,12 @@ test("userEvent.click()", async () => { ); - const button = screen.getByText("Click"); + const button = screen.getByText('Click'); await userEvent.click(button); expect(handleClick).toHaveBeenCalledTimes(1); }); -test("fireEvent.click()", () => { +test('fireEvent.click()', () => { const handleClick = jest.fn(); render( @@ -25,7 +25,7 @@ test("fireEvent.click()", () => { ); - const button = screen.getByText("Click"); + const button = screen.getByText('Click'); fireEvent.click(button); expect(handleClick).toHaveBeenCalledTimes(1); }); diff --git a/src/helpers/wrap-async.ts b/src/helpers/wrap-async.ts new file mode 100644 index 000000000..c19c63250 --- /dev/null +++ b/src/helpers/wrap-async.ts @@ -0,0 +1,43 @@ +/* istanbul ignore file */ + +import { act } from 'react-test-renderer'; +import { getIsReactActEnvironment, setReactActEnvironment } from '../act'; +import { flushMicroTasksLegacy } from '../flush-micro-tasks'; +import { checkReactVersionAtLeast } from '../react-versions'; + +/** + * Run given async callback with temporarily disabled `act` environment and flushes microtasks queue. + * + * @param callback Async callback to run + * @returns Result of the callback + */ +export async function wrapAsync( + callback: () => Promise +): Promise { + if (checkReactVersionAtLeast(18, 0)) { + const previousActEnvironment = getIsReactActEnvironment(); + setReactActEnvironment(false); + + try { + const result = await callback(); + // Flush the microtask queue before restoring the `act` environment + await flushMicroTasksLegacy(); + return result; + } finally { + setReactActEnvironment(previousActEnvironment); + } + } + + if (!checkReactVersionAtLeast(16, 9)) { + return callback(); + } + + // Wrapping with act for react version 16.9 to 17.x + let result: Result; + await act(async () => { + result = await callback(); + }); + + // Either we have result or `callback` threw error + return result!; +} diff --git a/src/user-event/press/__tests__/press.test.tsx b/src/user-event/press/__tests__/press.test.tsx index 5626d9d15..96ab97cf8 100644 --- a/src/user-event/press/__tests__/press.test.tsx +++ b/src/user-event/press/__tests__/press.test.tsx @@ -454,4 +454,32 @@ describe('userEvent.press with fake timers', () => { expect(mockOnPress).toHaveBeenCalled(); }); + + test('disables act environmennt', async () => { + // In this test there is state update during await when typing + // Since wait is not wrapped by act there would be a warning + // if act environment was not disabled. + const consoleErrorSpy = jest.spyOn(console, 'error'); + jest.useFakeTimers(); + + const TestComponent = () => { + const [showText, setShowText] = React.useState(false); + + React.useEffect(() => { + setTimeout(() => setShowText(true), 100); + }, []); + + return ( + <> + + {showText && } + + ); + }; + + render(); + await userEvent.press(screen.getByTestId('pressable')); + + expect(consoleErrorSpy).not.toHaveBeenCalled(); + }); }); diff --git a/src/user-event/press/press.ts b/src/user-event/press/press.ts index 18e7fef47..e5a0f1ded 100644 --- a/src/user-event/press/press.ts +++ b/src/user-event/press/press.ts @@ -1,5 +1,4 @@ import { ReactTestInstance } from 'react-test-renderer'; -import act from '../../act'; import { getHostParent } from '../../helpers/component-tree'; import { isTextInputEditable } from '../../helpers/text-input'; import { isPointerEventEnabled } from '../../helpers/pointer-events'; @@ -83,28 +82,26 @@ const emitPressablePressEvents = async ( await wait(config); - await act(async () => { - dispatchEvent( - element, - 'responderGrant', - EventBuilder.Common.responderGrant() - ); - - await wait(config, options.duration); - - dispatchEvent( - element, - 'responderRelease', - EventBuilder.Common.responderRelease() - ); - - // React Native will wait for minimal delay of DEFAULT_MIN_PRESS_DURATION - // before emitting the `pressOut` event. We need to wait here, so that - // `press()` function does not return before that. - if (DEFAULT_MIN_PRESS_DURATION - options.duration > 0) { - await wait(config, DEFAULT_MIN_PRESS_DURATION - options.duration); - } - }); + dispatchEvent( + element, + 'responderGrant', + EventBuilder.Common.responderGrant() + ); + + await wait(config, options.duration); + + dispatchEvent( + element, + 'responderRelease', + EventBuilder.Common.responderRelease() + ); + + // React Native will wait for minimal delay of DEFAULT_MIN_PRESS_DURATION + // before emitting the `pressOut` event. We need to wait here, so that + // `press()` function does not return before that. + if (DEFAULT_MIN_PRESS_DURATION - options.duration > 0) { + await wait(config, DEFAULT_MIN_PRESS_DURATION - options.duration); + } }; const isEnabledTouchResponder = (element: ReactTestInstance) => { diff --git a/src/user-event/setup/setup.ts b/src/user-event/setup/setup.ts index 6c790a002..409e24298 100644 --- a/src/user-event/setup/setup.ts +++ b/src/user-event/setup/setup.ts @@ -1,9 +1,11 @@ import { ReactTestInstance } from 'react-test-renderer'; import { jestFakeTimersAreEnabled } from '../../helpers/timers'; -import { PressOptions, press, longPress } from '../press'; -import { TypeOptions, type } from '../type'; +import { wrapAsync } from '../../helpers/wrap-async'; import { clear } from '../clear'; +import { PressOptions, press, longPress } from '../press'; import { ScrollToOptions, scrollTo } from '../scroll'; +import { TypeOptions, type } from '../type'; +import { wait } from '../utils'; export interface UserEventSetupOptions { /** @@ -141,15 +143,42 @@ function createInstance(config: UserEventConfig): UserEventInstance { config, } as UserEventInstance; - // We need to bind these functions, as they access the config through 'this.config'. + // Bind interactions to given User Event instance. const api = { - press: press.bind(instance), - longPress: longPress.bind(instance), - type: type.bind(instance), - clear: clear.bind(instance), - scrollTo: scrollTo.bind(instance), + press: wrapAndBindImpl(instance, press), + longPress: wrapAndBindImpl(instance, longPress), + type: wrapAndBindImpl(instance, type), + clear: wrapAndBindImpl(instance, clear), + scrollTo: wrapAndBindImpl(instance, scrollTo), }; Object.assign(instance, api); return instance; } + +/** + * Wraps user interaction with `wrapAsync` (temporarily disable `act` environment while + * calling & resolving the async callback, then flush the microtask queue) + * + * This implementation is sourced from `testing-library/user-event` + * @see https://github.com/testing-library/user-event/blob/7a305dee9ab833d6f338d567fc2e862b4838b76a/src/setup/setup.ts#L121 + */ +function wrapAndBindImpl< + Args extends any[], + Impl extends (this: UserEventInstance, ...args: Args) => Promise +>(instance: UserEventInstance, impl: Impl) { + function method(...args: Args) { + return wrapAsync(() => + // eslint-disable-next-line promise/prefer-await-to-then + impl.apply(instance, args).then(async (result) => { + await wait(instance.config); + return result; + }) + ); + } + + // Copy implementation name to the returned function + Object.defineProperty(method, 'name', { get: () => impl.name }); + + return method as Impl; +} diff --git a/src/waitFor.ts b/src/waitFor.ts index d28a80c63..0cc377ed9 100644 --- a/src/waitFor.ts +++ b/src/waitFor.ts @@ -1,14 +1,13 @@ /* globals jest */ -import act, { setReactActEnvironment, getIsReactActEnvironment } from './act'; import { getConfig } from './config'; -import { flushMicroTasks, flushMicroTasksLegacy } from './flush-micro-tasks'; +import { flushMicroTasks } from './flush-micro-tasks'; import { ErrorWithStack, copyStackTrace } from './helpers/errors'; import { setTimeout, clearTimeout, jestFakeTimersAreEnabled, } from './helpers/timers'; -import { checkReactVersionAtLeast } from './react-versions'; +import { wrapAsync } from './helpers/wrap-async'; const DEFAULT_INTERVAL = 50; @@ -199,30 +198,5 @@ 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 { - const result = await waitForInternal(expectation, optionsWithStackTrace); - // Flush the microtask queue before restoring the `act` environment - await flushMicroTasksLegacy(); - return result; - } finally { - setReactActEnvironment(previousActEnvironment); - } - } - - if (!checkReactVersionAtLeast(16, 9)) { - return waitForInternal(expectation, optionsWithStackTrace); - } - - let result: T; - - await act(async () => { - result = await waitForInternal(expectation, optionsWithStackTrace); - }); - - // Either we have result or `waitFor` threw error - return result!; + return wrapAsync(() => waitForInternal(expectation, optionsWithStackTrace)); }