From cecf2946be69a2f8f59f4b976d42eb8ce41b2228 Mon Sep 17 00:00:00 2001 From: pierrezimmermann Date: Thu, 7 Sep 2023 12:06:13 +0200 Subject: [PATCH 01/14] fix: use act in wait util --- .../__tests__/{wait.test.ts => wait.test.tsx} | 29 +++++++++++++++++++ src/user-event/utils/wait.ts | 15 ++++++---- 2 files changed, 38 insertions(+), 6 deletions(-) rename src/user-event/utils/__tests__/{wait.test.ts => wait.test.tsx} (73%) diff --git a/src/user-event/utils/__tests__/wait.test.ts b/src/user-event/utils/__tests__/wait.test.tsx similarity index 73% rename from src/user-event/utils/__tests__/wait.test.ts rename to src/user-event/utils/__tests__/wait.test.tsx index ac89070fc..ef39ead67 100644 --- a/src/user-event/utils/__tests__/wait.test.ts +++ b/src/user-event/utils/__tests__/wait.test.tsx @@ -1,4 +1,8 @@ +import React, { useEffect, useState } from 'react'; +import { Text } from 'react-native'; import { wait } from '../wait'; +import render from '../../../render'; +import { screen } from '../../../screen'; beforeEach(() => { jest.useRealTimers(); @@ -59,4 +63,29 @@ describe('wait()', () => { expect(advanceTimers).not.toHaveBeenCalled(); } ); + + it('is wrapped by act', async () => { + jest.useFakeTimers(); + const TestComponent = () => { + const [isVisible, setIsVisible] = useState(false); + + useEffect(() => { + setTimeout(() => { + setIsVisible(true); + }, 100); + }, []); + + if (isVisible) { + return Visible; + } + + return null; + }; + + render(); + + await wait({ delay: 100, advanceTimers: jest.advanceTimersByTime }); + + expect(screen.getByText('Visible')).toBeTruthy(); + }); }); diff --git a/src/user-event/utils/wait.ts b/src/user-event/utils/wait.ts index 4c34e6a8b..9c3da30d6 100644 --- a/src/user-event/utils/wait.ts +++ b/src/user-event/utils/wait.ts @@ -1,3 +1,4 @@ +import act from '../../act'; import { UserEventConfig } from '../setup'; export function wait(config: UserEventConfig, durationInMs?: number) { @@ -6,10 +7,12 @@ export function wait(config: UserEventConfig, durationInMs?: number) { return; } - return Promise.all([ - new Promise((resolve) => - globalThis.setTimeout(() => resolve(), delay) - ), - config.advanceTimers(delay), - ]); + return act(async () => { + await Promise.all([ + new Promise((resolve) => + globalThis.setTimeout(() => resolve(), delay) + ), + config.advanceTimers(delay), + ]); + }); } From 3c96432686696b2d2c6016433f910ff11faf815b Mon Sep 17 00:00:00 2001 From: pierrezimmermann Date: Thu, 7 Sep 2023 12:07:16 +0200 Subject: [PATCH 02/14] refactor: remove useless usage of act in press implem --- src/user-event/press/press.ts | 43 ++++++++++++++++------------------- 1 file changed, 20 insertions(+), 23 deletions(-) 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) => { From e1516c285bea56dd3a2e2ac641095f79fb498879 Mon Sep 17 00:00:00 2001 From: pierrezimmermann Date: Thu, 7 Sep 2023 12:12:23 +0200 Subject: [PATCH 03/14] refactor: make test fail by checking console.error is not called --- src/user-event/utils/__tests__/wait.test.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/user-event/utils/__tests__/wait.test.tsx b/src/user-event/utils/__tests__/wait.test.tsx index ef39ead67..e2a0ef69c 100644 --- a/src/user-event/utils/__tests__/wait.test.tsx +++ b/src/user-event/utils/__tests__/wait.test.tsx @@ -65,6 +65,7 @@ describe('wait()', () => { ); it('is wrapped by act', async () => { + const consoleErrorSpy = jest.spyOn(console, 'error'); jest.useFakeTimers(); const TestComponent = () => { const [isVisible, setIsVisible] = useState(false); @@ -87,5 +88,6 @@ describe('wait()', () => { await wait({ delay: 100, advanceTimers: jest.advanceTimersByTime }); expect(screen.getByText('Visible')).toBeTruthy(); + expect(consoleErrorSpy).not.toHaveBeenCalled(); }); }); From 8686fc8d820d7e29780052954b4f0efd853b8794 Mon Sep 17 00:00:00 2001 From: pierrezimmermann Date: Mon, 25 Sep 2023 20:47:09 +0200 Subject: [PATCH 04/14] refactor: extract async wrapper from waitFor implem --- src/user-event/utils/asyncWrapper.ts | 35 ++++++++++++++++++++++++++++ src/user-event/utils/wait.ts | 15 +++++------- 2 files changed, 41 insertions(+), 9 deletions(-) create mode 100644 src/user-event/utils/asyncWrapper.ts diff --git a/src/user-event/utils/asyncWrapper.ts b/src/user-event/utils/asyncWrapper.ts new file mode 100644 index 000000000..c4d5d708e --- /dev/null +++ b/src/user-event/utils/asyncWrapper.ts @@ -0,0 +1,35 @@ +import { act } from 'react-test-renderer'; +import { getIsReactActEnvironment, setReactActEnvironment } from '../../act'; +import { flushMicroTasksLegacy } from '../../flush-micro-tasks'; +import { checkReactVersionAtLeast } from '../../react-versions'; + +export const asyncWrapper = async ( + 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(); + } + + let result: T; + + await act(async () => { + result = await callback(); + }); + + // Either we have result or `waitFor` threw error + return result!; +}; diff --git a/src/user-event/utils/wait.ts b/src/user-event/utils/wait.ts index 9c3da30d6..4c34e6a8b 100644 --- a/src/user-event/utils/wait.ts +++ b/src/user-event/utils/wait.ts @@ -1,4 +1,3 @@ -import act from '../../act'; import { UserEventConfig } from '../setup'; export function wait(config: UserEventConfig, durationInMs?: number) { @@ -7,12 +6,10 @@ export function wait(config: UserEventConfig, durationInMs?: number) { return; } - return act(async () => { - await Promise.all([ - new Promise((resolve) => - globalThis.setTimeout(() => resolve(), delay) - ), - config.advanceTimers(delay), - ]); - }); + return Promise.all([ + new Promise((resolve) => + globalThis.setTimeout(() => resolve(), delay) + ), + config.advanceTimers(delay), + ]); } From c371dd3d05e7db290431c20b2cf1d83034551338 Mon Sep 17 00:00:00 2001 From: pierrezimmermann Date: Mon, 25 Sep 2023 21:18:12 +0200 Subject: [PATCH 05/14] feat: use asyncWrapper for userEvent to prevent act warnings --- .../src/app/__tests__/click.test.tsx | 14 ++++---- src/user-event/__tests__/act.test.tsx | 32 +++++++++++++++++ src/user-event/setup/setup.ts | 32 ++++++++++++++--- src/user-event/utils/__tests__/wait.test.tsx | 31 ----------------- src/waitFor.ts | 34 +++---------------- 5 files changed, 71 insertions(+), 72 deletions(-) create mode 100644 src/user-event/__tests__/act.test.tsx 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/user-event/__tests__/act.test.tsx b/src/user-event/__tests__/act.test.tsx new file mode 100644 index 000000000..8ce9a4dec --- /dev/null +++ b/src/user-event/__tests__/act.test.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import { Pressable, Text } from 'react-native'; +import render from '../../render'; +import { userEvent } from '..'; +import { screen } from '../../screen'; + +test('user event disables act environmennt', async () => { + const consoleErrorSpy = jest.spyOn(console, 'error'); + jest.useFakeTimers(); + const TestComponent = () => { + const [isVisible, setIsVisible] = React.useState(false); + + React.useEffect(() => { + setTimeout(() => { + setIsVisible(true); + }, 100); + }, []); + + return ( + <> + + {isVisible && } + + ); + }; + + render(); + + await userEvent.press(screen.getByTestId('pressable')); + + expect(consoleErrorSpy).not.toHaveBeenCalled(); +}); diff --git a/src/user-event/setup/setup.ts b/src/user-event/setup/setup.ts index 6c790a002..c40b023fb 100644 --- a/src/user-event/setup/setup.ts +++ b/src/user-event/setup/setup.ts @@ -4,6 +4,8 @@ import { PressOptions, press, longPress } from '../press'; import { TypeOptions, type } from '../type'; import { clear } from '../clear'; import { ScrollToOptions, scrollTo } from '../scroll'; +import { wait } from '../utils'; +import { asyncWrapper } from '../utils/asyncWrapper'; export interface UserEventSetupOptions { /** @@ -143,13 +145,33 @@ function createInstance(config: UserEventConfig): UserEventInstance { // We need to bind these functions, as they access the config through 'this.config'. 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; } + +// This implementation is sourced from testing-library/user-event +// https://github.com/testing-library/user-event/blob/7a305dee9ab833d6f338d567fc2e862b4838b76a/src/setup/setup.ts#L121 +function wrapAndBindImpl Promise>( + instance: UserEventInstance, + impl: Impl +): Impl { + const method = ((...args: any[]) => { + return asyncWrapper(() => + // eslint-disable-next-line promise/prefer-await-to-then + impl.apply(instance, args).then(async (ret) => { + await wait(instance.config); + return ret; + }) + ); + }) as Impl; + + Object.defineProperty(method, 'name', { get: () => impl.name }); + return method; +} diff --git a/src/user-event/utils/__tests__/wait.test.tsx b/src/user-event/utils/__tests__/wait.test.tsx index e2a0ef69c..ac89070fc 100644 --- a/src/user-event/utils/__tests__/wait.test.tsx +++ b/src/user-event/utils/__tests__/wait.test.tsx @@ -1,8 +1,4 @@ -import React, { useEffect, useState } from 'react'; -import { Text } from 'react-native'; import { wait } from '../wait'; -import render from '../../../render'; -import { screen } from '../../../screen'; beforeEach(() => { jest.useRealTimers(); @@ -63,31 +59,4 @@ describe('wait()', () => { expect(advanceTimers).not.toHaveBeenCalled(); } ); - - it('is wrapped by act', async () => { - const consoleErrorSpy = jest.spyOn(console, 'error'); - jest.useFakeTimers(); - const TestComponent = () => { - const [isVisible, setIsVisible] = useState(false); - - useEffect(() => { - setTimeout(() => { - setIsVisible(true); - }, 100); - }, []); - - if (isVisible) { - return Visible; - } - - return null; - }; - - render(); - - await wait({ delay: 100, advanceTimers: jest.advanceTimersByTime }); - - expect(screen.getByText('Visible')).toBeTruthy(); - expect(consoleErrorSpy).not.toHaveBeenCalled(); - }); }); diff --git a/src/waitFor.ts b/src/waitFor.ts index d28a80c63..81ccf35dc 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 { asyncWrapper } from './user-event/utils/asyncWrapper'; const DEFAULT_INTERVAL = 50; @@ -199,30 +198,7 @@ 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 asyncWrapper(() => + waitForInternal(expectation, optionsWithStackTrace) + ); } From c3ed7d919ef47a66a650b1c1317b44f5d2e68cf2 Mon Sep 17 00:00:00 2001 From: pierrezimmermann Date: Tue, 26 Sep 2023 22:08:04 +0200 Subject: [PATCH 06/14] refactor: add comment making test on act environment more explicit --- src/user-event/__tests__/act.test.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/user-event/__tests__/act.test.tsx b/src/user-event/__tests__/act.test.tsx index 8ce9a4dec..a8cb89d70 100644 --- a/src/user-event/__tests__/act.test.tsx +++ b/src/user-event/__tests__/act.test.tsx @@ -5,6 +5,9 @@ import { userEvent } from '..'; import { screen } from '../../screen'; test('user event disables act environmennt', async () => { + // In this There is state update during a wait 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 = () => { From c9b176c69372e31f9c2248aa002cf8038a7ff4af Mon Sep 17 00:00:00 2001 From: pierrezimmermann Date: Tue, 26 Sep 2023 22:11:21 +0200 Subject: [PATCH 07/14] refactor: move asyncWrapper to helper folder cause its not direclty tied to userevent --- src/{user-event/utils => helpers}/asyncWrapper.ts | 6 +++--- src/user-event/setup/setup.ts | 2 +- src/waitFor.ts | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) rename src/{user-event/utils => helpers}/asyncWrapper.ts (85%) diff --git a/src/user-event/utils/asyncWrapper.ts b/src/helpers/asyncWrapper.ts similarity index 85% rename from src/user-event/utils/asyncWrapper.ts rename to src/helpers/asyncWrapper.ts index c4d5d708e..05030a6c7 100644 --- a/src/user-event/utils/asyncWrapper.ts +++ b/src/helpers/asyncWrapper.ts @@ -1,7 +1,7 @@ import { act } from 'react-test-renderer'; -import { getIsReactActEnvironment, setReactActEnvironment } from '../../act'; -import { flushMicroTasksLegacy } from '../../flush-micro-tasks'; -import { checkReactVersionAtLeast } from '../../react-versions'; +import { getIsReactActEnvironment, setReactActEnvironment } from '../act'; +import { flushMicroTasksLegacy } from '../flush-micro-tasks'; +import { checkReactVersionAtLeast } from '../react-versions'; export const asyncWrapper = async ( callback: () => Promise diff --git a/src/user-event/setup/setup.ts b/src/user-event/setup/setup.ts index c40b023fb..1c1eaaaa2 100644 --- a/src/user-event/setup/setup.ts +++ b/src/user-event/setup/setup.ts @@ -5,7 +5,7 @@ import { TypeOptions, type } from '../type'; import { clear } from '../clear'; import { ScrollToOptions, scrollTo } from '../scroll'; import { wait } from '../utils'; -import { asyncWrapper } from '../utils/asyncWrapper'; +import { asyncWrapper } from '../../helpers/asyncWrapper'; export interface UserEventSetupOptions { /** diff --git a/src/waitFor.ts b/src/waitFor.ts index 81ccf35dc..100259c8e 100644 --- a/src/waitFor.ts +++ b/src/waitFor.ts @@ -7,7 +7,7 @@ import { clearTimeout, jestFakeTimersAreEnabled, } from './helpers/timers'; -import { asyncWrapper } from './user-event/utils/asyncWrapper'; +import { asyncWrapper } from './helpers/asyncWrapper'; const DEFAULT_INTERVAL = 50; From 9617a43249e651d04e29855e89d5dd7370f56202 Mon Sep 17 00:00:00 2001 From: pierrezimmermann Date: Tue, 26 Sep 2023 22:14:59 +0200 Subject: [PATCH 08/14] refactor: add comment in asyncWrapper --- src/helpers/asyncWrapper.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/helpers/asyncWrapper.ts b/src/helpers/asyncWrapper.ts index 05030a6c7..f42e077be 100644 --- a/src/helpers/asyncWrapper.ts +++ b/src/helpers/asyncWrapper.ts @@ -26,6 +26,7 @@ export const asyncWrapper = async ( let result: T; + // Wrapping with act for react version 16.9 to 17.x await act(async () => { result = await callback(); }); From dd7d2f9804be98c8bff938938f8c514af08d7bae Mon Sep 17 00:00:00 2001 From: Maciej Jastrzebski Date: Mon, 23 Oct 2023 10:25:34 +0200 Subject: [PATCH 09/14] refactor: move UE `act` test to UE `press` tests --- src/user-event/__tests__/act.test.tsx | 35 ------------------- src/user-event/press/__tests__/press.test.tsx | 28 +++++++++++++++ 2 files changed, 28 insertions(+), 35 deletions(-) delete mode 100644 src/user-event/__tests__/act.test.tsx diff --git a/src/user-event/__tests__/act.test.tsx b/src/user-event/__tests__/act.test.tsx deleted file mode 100644 index a8cb89d70..000000000 --- a/src/user-event/__tests__/act.test.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import React from 'react'; -import { Pressable, Text } from 'react-native'; -import render from '../../render'; -import { userEvent } from '..'; -import { screen } from '../../screen'; - -test('user event disables act environmennt', async () => { - // In this There is state update during a wait 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 [isVisible, setIsVisible] = React.useState(false); - - React.useEffect(() => { - setTimeout(() => { - setIsVisible(true); - }, 100); - }, []); - - return ( - <> - - {isVisible && } - - ); - }; - - render(); - - await userEvent.press(screen.getByTestId('pressable')); - - expect(consoleErrorSpy).not.toHaveBeenCalled(); -}); 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(); + }); }); From 7b2eca683b7070c1fa71afebba9c34117501472c Mon Sep 17 00:00:00 2001 From: Maciej Jastrzebski Date: Mon, 23 Oct 2023 11:01:42 +0200 Subject: [PATCH 10/14] refactor: tweaks --- src/helpers/asyncWrapper.ts | 19 ++++++++++++------- src/user-event/setup/setup.ts | 31 +++++++++++++++++++------------ 2 files changed, 31 insertions(+), 19 deletions(-) diff --git a/src/helpers/asyncWrapper.ts b/src/helpers/asyncWrapper.ts index f42e077be..52582b504 100644 --- a/src/helpers/asyncWrapper.ts +++ b/src/helpers/asyncWrapper.ts @@ -3,9 +3,15 @@ import { getIsReactActEnvironment, setReactActEnvironment } from '../act'; import { flushMicroTasksLegacy } from '../flush-micro-tasks'; import { checkReactVersionAtLeast } from '../react-versions'; -export const asyncWrapper = async ( - callback: () => Promise -): Promise => { +/** + * 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 asyncWrapper( + callback: () => Promise +): Promise { if (checkReactVersionAtLeast(18, 0)) { const previousActEnvironment = getIsReactActEnvironment(); setReactActEnvironment(false); @@ -24,13 +30,12 @@ export const asyncWrapper = async ( return callback(); } - let result: T; - // 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 `waitFor` threw error + // Either we have result or `callback` threw error return result!; -}; +} diff --git a/src/user-event/setup/setup.ts b/src/user-event/setup/setup.ts index 1c1eaaaa2..af3f353fc 100644 --- a/src/user-event/setup/setup.ts +++ b/src/user-event/setup/setup.ts @@ -143,7 +143,7 @@ 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: wrapAndBindImpl(instance, press), longPress: wrapAndBindImpl(instance, longPress), @@ -156,22 +156,29 @@ function createInstance(config: UserEventConfig): UserEventInstance { return instance; } -// This implementation is sourced from testing-library/user-event -// https://github.com/testing-library/user-event/blob/7a305dee9ab833d6f338d567fc2e862b4838b76a/src/setup/setup.ts#L121 -function wrapAndBindImpl Promise>( - instance: UserEventInstance, - impl: Impl -): Impl { - const method = ((...args: any[]) => { +/** + * 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 asyncWrapper(() => // eslint-disable-next-line promise/prefer-await-to-then - impl.apply(instance, args).then(async (ret) => { + impl.apply(instance, args).then(async (result) => { await wait(instance.config); - return ret; + return result; }) ); - }) as Impl; + } + // Copy implementation name to the returned function Object.defineProperty(method, 'name', { get: () => impl.name }); - return method; + + return method as Impl; } From 6423a27d7f5db9643b232db982b1a1f4019213a4 Mon Sep 17 00:00:00 2001 From: Maciej Jastrzebski Date: Mon, 23 Oct 2023 11:03:24 +0200 Subject: [PATCH 11/14] refactor: naming tweaks --- src/helpers/{asyncWrapper.ts => wrap-async.ts} | 2 +- src/user-event/setup/setup.ts | 8 ++++---- src/waitFor.ts | 6 ++---- 3 files changed, 7 insertions(+), 9 deletions(-) rename src/helpers/{asyncWrapper.ts => wrap-async.ts} (96%) diff --git a/src/helpers/asyncWrapper.ts b/src/helpers/wrap-async.ts similarity index 96% rename from src/helpers/asyncWrapper.ts rename to src/helpers/wrap-async.ts index 52582b504..ee45b86d6 100644 --- a/src/helpers/asyncWrapper.ts +++ b/src/helpers/wrap-async.ts @@ -9,7 +9,7 @@ import { checkReactVersionAtLeast } from '../react-versions'; * @param callback Async callback to run * @returns Result of the callback */ -export async function asyncWrapper( +export async function wrapAsync( callback: () => Promise ): Promise { if (checkReactVersionAtLeast(18, 0)) { diff --git a/src/user-event/setup/setup.ts b/src/user-event/setup/setup.ts index af3f353fc..409e24298 100644 --- a/src/user-event/setup/setup.ts +++ b/src/user-event/setup/setup.ts @@ -1,11 +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'; -import { asyncWrapper } from '../../helpers/asyncWrapper'; export interface UserEventSetupOptions { /** @@ -168,7 +168,7 @@ function wrapAndBindImpl< Impl extends (this: UserEventInstance, ...args: Args) => Promise >(instance: UserEventInstance, impl: Impl) { function method(...args: Args) { - return asyncWrapper(() => + return wrapAsync(() => // eslint-disable-next-line promise/prefer-await-to-then impl.apply(instance, args).then(async (result) => { await wait(instance.config); diff --git a/src/waitFor.ts b/src/waitFor.ts index 100259c8e..0cc377ed9 100644 --- a/src/waitFor.ts +++ b/src/waitFor.ts @@ -7,7 +7,7 @@ import { clearTimeout, jestFakeTimersAreEnabled, } from './helpers/timers'; -import { asyncWrapper } from './helpers/asyncWrapper'; +import { wrapAsync } from './helpers/wrap-async'; const DEFAULT_INTERVAL = 50; @@ -198,7 +198,5 @@ export default async function waitFor( const stackTraceError = new ErrorWithStack('STACK_TRACE_ERROR', waitFor); const optionsWithStackTrace = { stackTraceError, ...options }; - return asyncWrapper(() => - waitForInternal(expectation, optionsWithStackTrace) - ); + return wrapAsync(() => waitForInternal(expectation, optionsWithStackTrace)); } From d9777d2cc0ee9a72c111f790d803993fdd4e13a7 Mon Sep 17 00:00:00 2001 From: Maciej Jastrzebski Date: Mon, 23 Oct 2023 11:06:14 +0200 Subject: [PATCH 12/14] chore: revert unnecessary filename change --- src/user-event/utils/__tests__/{wait.test.tsx => wait.test.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/user-event/utils/__tests__/{wait.test.tsx => wait.test.ts} (100%) diff --git a/src/user-event/utils/__tests__/wait.test.tsx b/src/user-event/utils/__tests__/wait.test.ts similarity index 100% rename from src/user-event/utils/__tests__/wait.test.tsx rename to src/user-event/utils/__tests__/wait.test.ts From 0794b44d8ba6cae3511235c88f9ea76ed6f67e74 Mon Sep 17 00:00:00 2001 From: Maciej Jastrzebski Date: Mon, 23 Oct 2023 11:22:02 +0200 Subject: [PATCH 13/14] chore: exclude code cov for React <= 17 --- src/helpers/wrap-async.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/helpers/wrap-async.ts b/src/helpers/wrap-async.ts index ee45b86d6..90caf6f83 100644 --- a/src/helpers/wrap-async.ts +++ b/src/helpers/wrap-async.ts @@ -26,7 +26,9 @@ export async function wrapAsync( } } + /* istanbul ignore else */ if (!checkReactVersionAtLeast(16, 9)) { + /* istanbul ignore next */ return callback(); } From 59d0c14166a11d954ce7d92ac88e4d81107c4bcb Mon Sep 17 00:00:00 2001 From: Maciej Jastrzebski Date: Mon, 23 Oct 2023 11:41:47 +0200 Subject: [PATCH 14/14] chore: disable codecov for wrap-async --- src/helpers/wrap-async.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/helpers/wrap-async.ts b/src/helpers/wrap-async.ts index 90caf6f83..c19c63250 100644 --- a/src/helpers/wrap-async.ts +++ b/src/helpers/wrap-async.ts @@ -1,3 +1,5 @@ +/* istanbul ignore file */ + import { act } from 'react-test-renderer'; import { getIsReactActEnvironment, setReactActEnvironment } from '../act'; import { flushMicroTasksLegacy } from '../flush-micro-tasks'; @@ -26,9 +28,7 @@ export async function wrapAsync( } } - /* istanbul ignore else */ if (!checkReactVersionAtLeast(16, 9)) { - /* istanbul ignore next */ return callback(); }