From 5e741a4e4df42a73637b683e64b872e9481e126c Mon Sep 17 00:00:00 2001 From: Maciej Jastrzebski Date: Mon, 14 Jul 2025 10:22:31 +0200 Subject: [PATCH 1/9] chore: support for react suspense --- src/__tests__/react-suspense.test.tsx | 103 ++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 src/__tests__/react-suspense.test.tsx diff --git a/src/__tests__/react-suspense.test.tsx b/src/__tests__/react-suspense.test.tsx new file mode 100644 index 00000000..883806db --- /dev/null +++ b/src/__tests__/react-suspense.test.tsx @@ -0,0 +1,103 @@ +import * as React from 'react'; +import { View } from 'react-native'; +import { render, screen, within, configure } from '..'; +import TestRenderer, { type ReactTestRenderer } from 'react-test-renderer'; + +configure({ + asyncUtilTimeout: 5000, +}); + +function wait(delay: number) { + return new Promise((resolve) => + setTimeout(() => { + resolve(); + }, delay), + ); +} + +function Suspendable({ promise }: { promise: Promise }) { + React.use(promise); + return ; +} + +test('render supports components which can suspend', async () => { + render( + + }> + + + , + ); + + expect(screen.getByTestId('fallback')).toBeOnTheScreen(); + + // eslint-disable-next-line require-await + await React.act(async () => { + await wait(1000); + }); + + expect(await screen.findByTestId('test')).toBeOnTheScreen(); +}); + +test.only('react test renderer supports components which can suspend', async () => { + let renderer: ReactTestRenderer; + + // eslint-disable-next-line require-await + await React.act(async () => { + renderer = TestRenderer.create( + + }> + + + , + ); + }); + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const view = within(renderer!.root); + + expect(view.getByTestId('fallback')).toBeDefined(); + expect(await view.findByTestId('test')).toBeDefined(); +}); + +test.only('react test renderer supports components which can suspend 500', async () => { + let renderer: ReactTestRenderer; + + // eslint-disable-next-line require-await + await React.act(async () => { + renderer = TestRenderer.create( + + }> + + + , + ); + }); + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const view = within(renderer!.root); + + expect(view.getByTestId('fallback')).toBeDefined(); + expect(await view.findByTestId('test')).toBeDefined(); +}); + +test.only('react test renderer supports components which can suspend 1000ms', async () => { + let renderer: ReactTestRenderer; + + // eslint-disable-next-line require-await + await React.act(async () => { + renderer = TestRenderer.create( + + }> + + + , + ); + }); + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const view = within(renderer!.root); + + expect(view.getByTestId('fallback')).toBeDefined(); + expect(await view.findByTestId('test', undefined, { timeout: 5000 })).toBeDefined(); +}); From bb8c87cc3cf413adce9357291703e60fa5796672 Mon Sep 17 00:00:00 2001 From: Maciej Jastrzebski Date: Mon, 14 Jul 2025 11:35:28 +0200 Subject: [PATCH 2/9] basic impl --- src/__tests__/react-suspense.test.tsx | 17 ++-- src/pure.ts | 2 + src/render-act.ts | 16 ++++ src/render-async.tsx | 119 ++++++++++++++++++++++++++ 4 files changed, 143 insertions(+), 11 deletions(-) create mode 100644 src/render-async.tsx diff --git a/src/__tests__/react-suspense.test.tsx b/src/__tests__/react-suspense.test.tsx index 883806db..351e81a6 100644 --- a/src/__tests__/react-suspense.test.tsx +++ b/src/__tests__/react-suspense.test.tsx @@ -1,8 +1,9 @@ import * as React from 'react'; import { View } from 'react-native'; -import { render, screen, within, configure } from '..'; import TestRenderer, { type ReactTestRenderer } from 'react-test-renderer'; +import { configure, renderAsync, screen, within } from '..'; + configure({ asyncUtilTimeout: 5000, }); @@ -21,7 +22,7 @@ function Suspendable({ promise }: { promise: Promise }) { } test('render supports components which can suspend', async () => { - render( + await renderAsync( }> @@ -30,16 +31,10 @@ test('render supports components which can suspend', async () => { ); expect(screen.getByTestId('fallback')).toBeOnTheScreen(); - - // eslint-disable-next-line require-await - await React.act(async () => { - await wait(1000); - }); - expect(await screen.findByTestId('test')).toBeOnTheScreen(); }); -test.only('react test renderer supports components which can suspend', async () => { +test('react test renderer supports components which can suspend', async () => { let renderer: ReactTestRenderer; // eslint-disable-next-line require-await @@ -60,7 +55,7 @@ test.only('react test renderer supports components which can suspend', async () expect(await view.findByTestId('test')).toBeDefined(); }); -test.only('react test renderer supports components which can suspend 500', async () => { +test('react test renderer supports components which can suspend 500', async () => { let renderer: ReactTestRenderer; // eslint-disable-next-line require-await @@ -81,7 +76,7 @@ test.only('react test renderer supports components which can suspend 500', async expect(await view.findByTestId('test')).toBeDefined(); }); -test.only('react test renderer supports components which can suspend 1000ms', async () => { +test('react test renderer supports components which can suspend 1000ms', async () => { let renderer: ReactTestRenderer; // eslint-disable-next-line require-await diff --git a/src/pure.ts b/src/pure.ts index f4aa4f7a..60526bb3 100644 --- a/src/pure.ts +++ b/src/pure.ts @@ -2,6 +2,7 @@ export { default as act } from './act'; export { default as cleanup } from './cleanup'; export { default as fireEvent } from './fire-event'; export { default as render } from './render'; +export { default as renderAsync } from './render-async'; export { default as waitFor } from './wait-for'; export { default as waitForElementToBeRemoved } from './wait-for-element-to-be-removed'; export { within, getQueriesForElement } from './within'; @@ -19,6 +20,7 @@ export type { RenderResult as RenderAPI, DebugFunction, } from './render'; +export type { RenderAsyncOptions, RenderAsyncResult } from './render-async'; export type { RenderHookOptions, RenderHookResult } from './render-hook'; export type { Config } from './config'; export type { UserEventConfig } from './user-event'; diff --git a/src/render-act.ts b/src/render-act.ts index 3bba04ea..a463ad33 100644 --- a/src/render-act.ts +++ b/src/render-act.ts @@ -18,3 +18,19 @@ export function renderWithAct( // @ts-expect-error: `act` is synchronous, so `renderer` is already initialized here return renderer; } + +export async function renderWithAsyncAct( + component: React.ReactElement, + options?: Partial, +): Promise { + let renderer: ReactTestRenderer; + + // eslint-disable-next-line require-await + await act(async () => { + // @ts-expect-error `TestRenderer.create` is not typed correctly + renderer = TestRenderer.create(component, options); + }); + + // @ts-expect-error: `renderer` is already initialized here + return renderer; +} diff --git a/src/render-async.tsx b/src/render-async.tsx new file mode 100644 index 00000000..a22c16ee --- /dev/null +++ b/src/render-async.tsx @@ -0,0 +1,119 @@ +import * as React from 'react'; +import type { + ReactTestInstance, + ReactTestRenderer, + TestRendererOptions, +} from 'react-test-renderer'; + +import act from './act'; +import { addToCleanupQueue } from './cleanup'; +import { getConfig } from './config'; +import { getHostSelves } from './helpers/component-tree'; +import type { DebugOptions } from './helpers/debug'; +import { debug } from './helpers/debug'; +import { renderWithAsyncAct } from './render-act'; +import { setRenderResult } from './screen'; +import { getQueriesForElement } from './within'; + +export interface RenderAsyncOptions { + /** + * Pass a React Component as the wrapper option to have it rendered around the inner element. This is most useful for creating + * reusable custom render functions for common data providers. + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + wrapper?: React.ComponentType; + + /** + * Set to `false` to disable concurrent rendering. + * Otherwise `render` will default to concurrent rendering. + */ + // TODO: should we assume concurrentRoot is true for react suspense? + concurrentRoot?: boolean; + + createNodeMock?: (element: React.ReactElement) => unknown; +} + +export type RenderAsyncResult = ReturnType; + +/** + * Renders test component deeply using React Test Renderer and exposes helpers + * to assert on the output. + */ +export default async function renderAsync( + component: React.ReactElement, + options: RenderAsyncOptions = {}, +) { + const { wrapper: Wrapper, concurrentRoot, ...rest } = options || {}; + + const testRendererOptions: TestRendererOptions = { + ...rest, + // @ts-expect-error incomplete typing on RTR package + unstable_isConcurrent: concurrentRoot ?? getConfig().concurrentRoot, + }; + + const wrap = (element: React.ReactElement) => (Wrapper ? {element} : element); + const renderer = await renderWithAsyncAct(wrap(component), testRendererOptions); + return buildRenderResult(renderer, wrap); +} + +function buildRenderResult( + renderer: ReactTestRenderer, + wrap: (element: React.ReactElement) => React.JSX.Element, +) { + const update = updateWithAsyncAct(renderer, wrap); + const instance = renderer.root; + + // TODO: test this + const unmount = async () => { + // eslint-disable-next-line require-await + await act(async () => { + renderer.unmount(); + }); + }; + + addToCleanupQueue(unmount); + + const result = { + ...getQueriesForElement(instance), + update, + unmount, + rerender: update, // alias for `update` + toJSON: renderer.toJSON, + debug: makeDebug(renderer), + get root(): ReactTestInstance { + return getHostSelves(instance)[0]; + }, + UNSAFE_root: instance, + }; + + // Add as non-enumerable property, so that it's safe to enumerate + // `render` result, e.g. using destructuring rest syntax. + Object.defineProperty(result, 'container', { + enumerable: false, + get() { + throw new Error( + "'container' property has been renamed to 'UNSAFE_root'.\n\n" + + "Consider using 'root' property which returns root host element.", + ); + }, + }); + + setRenderResult(result); + + return result; +} + +// TODO: test this +function updateWithAsyncAct( + renderer: ReactTestRenderer, + wrap: (innerElement: React.ReactElement) => React.ReactElement, +) { + return async function (component: React.ReactElement) { + // eslint-disable-next-line require-await + await act(async () => { + renderer.update(wrap(component)); + }); + }; +} + +export type DebugFunction = (options?: DebugOptions) => void; From bd27572db9710a56f232a1411140d277ec637a7f Mon Sep 17 00:00:00 2001 From: Maciej Jastrzebski Date: Mon, 14 Jul 2025 11:39:10 +0200 Subject: [PATCH 3/9] . --- src/render-async.tsx | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/render-async.tsx b/src/render-async.tsx index a22c16ee..3a48a88d 100644 --- a/src/render-async.tsx +++ b/src/render-async.tsx @@ -117,3 +117,15 @@ function updateWithAsyncAct( } export type DebugFunction = (options?: DebugOptions) => void; + +function makeDebug(renderer: ReactTestRenderer): DebugFunction { + function debugImpl(options?: DebugOptions) { + const { defaultDebugOptions } = getConfig(); + const debugOptions = { ...defaultDebugOptions, ...options }; + const json = renderer.toJSON(); + if (json) { + return debug(json, debugOptions); + } + } + return debugImpl; +} From c5d1ccec98b38e0caed8fc16dabf566102557f30 Mon Sep 17 00:00:00 2001 From: Maciej Jastrzebski Date: Mon, 14 Jul 2025 11:41:01 +0200 Subject: [PATCH 4/9] . --- src/__tests__/react-suspense.test.tsx | 61 +++++---------------------- 1 file changed, 11 insertions(+), 50 deletions(-) diff --git a/src/__tests__/react-suspense.test.tsx b/src/__tests__/react-suspense.test.tsx index 351e81a6..99c9e81e 100644 --- a/src/__tests__/react-suspense.test.tsx +++ b/src/__tests__/react-suspense.test.tsx @@ -4,6 +4,9 @@ import TestRenderer, { type ReactTestRenderer } from 'react-test-renderer'; import { configure, renderAsync, screen, within } from '..'; +const isReact19 = React.version.startsWith('19.'); +const testGateReact19 = isReact19 ? test : test.skip; + configure({ asyncUtilTimeout: 5000, }); @@ -16,67 +19,25 @@ function wait(delay: number) { ); } -function Suspendable({ promise }: { promise: Promise }) { +function Suspending({ promise }: { promise: Promise }) { React.use(promise); - return ; + return ; } -test('render supports components which can suspend', async () => { +testGateReact19('render supports components which can suspend', async () => { await renderAsync( }> - + , ); expect(screen.getByTestId('fallback')).toBeOnTheScreen(); - expect(await screen.findByTestId('test')).toBeOnTheScreen(); -}); - -test('react test renderer supports components which can suspend', async () => { - let renderer: ReactTestRenderer; - - // eslint-disable-next-line require-await - await React.act(async () => { - renderer = TestRenderer.create( - - }> - - - , - ); - }); - - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const view = within(renderer!.root); - - expect(view.getByTestId('fallback')).toBeDefined(); - expect(await view.findByTestId('test')).toBeDefined(); -}); - -test('react test renderer supports components which can suspend 500', async () => { - let renderer: ReactTestRenderer; - - // eslint-disable-next-line require-await - await React.act(async () => { - renderer = TestRenderer.create( - - }> - - - , - ); - }); - - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const view = within(renderer!.root); - - expect(view.getByTestId('fallback')).toBeDefined(); - expect(await view.findByTestId('test')).toBeDefined(); + expect(await screen.findByTestId('view')).toBeOnTheScreen(); }); -test('react test renderer supports components which can suspend 1000ms', async () => { +testGateReact19('react test renderer supports components which can suspend', async () => { let renderer: ReactTestRenderer; // eslint-disable-next-line require-await @@ -84,7 +45,7 @@ test('react test renderer supports components which can suspend 1000ms', async ( renderer = TestRenderer.create( }> - + , ); @@ -94,5 +55,5 @@ test('react test renderer supports components which can suspend 1000ms', async ( const view = within(renderer!.root); expect(view.getByTestId('fallback')).toBeDefined(); - expect(await view.findByTestId('test', undefined, { timeout: 5000 })).toBeDefined(); + expect(await view.findByTestId('view')).toBeDefined(); }); From 63c5e13f1c3d2453edf1e5b8ce5c64ddb7ba8831 Mon Sep 17 00:00:00 2001 From: Maciej Jastrzebski Date: Wed, 16 Jul 2025 23:48:48 +0200 Subject: [PATCH 5/9] fake timers --- src/__tests__/render-async-fake-timers.tsx | 62 +++++++++++++++++++ ...act-suspense.test.tsx => render-async.tsx} | 3 +- 2 files changed, 64 insertions(+), 1 deletion(-) create mode 100644 src/__tests__/render-async-fake-timers.tsx rename src/__tests__/{react-suspense.test.tsx => render-async.tsx} (92%) diff --git a/src/__tests__/render-async-fake-timers.tsx b/src/__tests__/render-async-fake-timers.tsx new file mode 100644 index 00000000..caa56058 --- /dev/null +++ b/src/__tests__/render-async-fake-timers.tsx @@ -0,0 +1,62 @@ +/* eslint-disable jest/no-standalone-expect */ +import * as React from 'react'; +import { View } from 'react-native'; +import TestRenderer, { type ReactTestRenderer } from 'react-test-renderer'; + +import { configure, renderAsync, screen, within } from '..'; + +const isReact19 = React.version.startsWith('19.'); +const testGateReact19 = isReact19 ? test : test.skip; + +jest.useFakeTimers(); + +configure({ + asyncUtilTimeout: 5000, +}); + +function wait(delay: number) { + return new Promise((resolve) => + setTimeout(() => { + resolve(); + }, delay), + ); +} + +function Suspending({ promise }: { promise: Promise }) { + React.use(promise); + return ; +} + +testGateReact19('renderAsync supports components which can suspend', async () => { + await renderAsync( + + }> + + + , + ); + + expect(screen.getByTestId('fallback')).toBeOnTheScreen(); + expect(await screen.findByTestId('view')).toBeOnTheScreen(); +}); + +testGateReact19('react test renderer supports components which can suspend', async () => { + let renderer: ReactTestRenderer; + + // eslint-disable-next-line require-await + await React.act(async () => { + renderer = TestRenderer.create( + + }> + + + , + ); + }); + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const view = within(renderer!.root); + + expect(view.getByTestId('fallback')).toBeDefined(); + expect(await view.findByTestId('view')).toBeDefined(); +}); diff --git a/src/__tests__/react-suspense.test.tsx b/src/__tests__/render-async.tsx similarity index 92% rename from src/__tests__/react-suspense.test.tsx rename to src/__tests__/render-async.tsx index 99c9e81e..5b1c6529 100644 --- a/src/__tests__/react-suspense.test.tsx +++ b/src/__tests__/render-async.tsx @@ -1,3 +1,4 @@ +/* eslint-disable jest/no-standalone-expect */ import * as React from 'react'; import { View } from 'react-native'; import TestRenderer, { type ReactTestRenderer } from 'react-test-renderer'; @@ -24,7 +25,7 @@ function Suspending({ promise }: { promise: Promise }) { return ; } -testGateReact19('render supports components which can suspend', async () => { +testGateReact19('renderAsync supports components which can suspend', async () => { await renderAsync( }> From 7b84041dcc11de7c2c55c845a297dc53807b6404 Mon Sep 17 00:00:00 2001 From: Maciej Jastrzebski Date: Mon, 21 Jul 2025 22:55:11 +0200 Subject: [PATCH 6/9] update user event --- .vscode/settings.json | 1 + src/user-event/clear.ts | 8 +++---- src/user-event/paste.ts | 16 +++++++------- src/user-event/press/press.ts | 12 +++++----- src/user-event/scroll/scroll-to.ts | 14 ++++++------ src/user-event/type/type.ts | 22 +++++++++---------- .../utils/__tests__/dispatch-event.test.tsx | 14 +++++++----- src/user-event/utils/dispatch-event.ts | 8 +++---- 8 files changed, 49 insertions(+), 46 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 2be7fa33..7bf04987 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,6 +1,7 @@ { "cSpell.words": [ "labelledby", + "Pressability", "Pressable", "redent", "RNTL", diff --git a/src/user-event/clear.ts b/src/user-event/clear.ts index 20ee66f8..a006f245 100644 --- a/src/user-event/clear.ts +++ b/src/user-event/clear.ts @@ -22,7 +22,7 @@ export async function clear(this: UserEventInstance, element: ReactTestInstance) } // 1. Enter element - dispatchEvent(element, 'focus', EventBuilder.Common.focus()); + await dispatchEvent(element, 'focus', EventBuilder.Common.focus()); // 2. Select all const textToClear = getTextInputValue(element); @@ -30,7 +30,7 @@ export async function clear(this: UserEventInstance, element: ReactTestInstance) start: 0, end: textToClear.length, }; - dispatchEvent(element, 'selectionChange', EventBuilder.TextInput.selectionChange(selectionRange)); + await dispatchEvent(element, 'selectionChange', EventBuilder.TextInput.selectionChange(selectionRange)); // 3. Press backspace with selected text const emptyText = ''; @@ -42,6 +42,6 @@ export async function clear(this: UserEventInstance, element: ReactTestInstance) // 4. Exit element await wait(this.config); - dispatchEvent(element, 'endEditing', EventBuilder.TextInput.endEditing(emptyText)); - dispatchEvent(element, 'blur', EventBuilder.Common.blur()); + await dispatchEvent(element, 'endEditing', EventBuilder.TextInput.endEditing(emptyText)); + await dispatchEvent(element, 'blur', EventBuilder.Common.blur()); } diff --git a/src/user-event/paste.ts b/src/user-event/paste.ts index 9abb3f79..aceb6e56 100644 --- a/src/user-event/paste.ts +++ b/src/user-event/paste.ts @@ -26,27 +26,27 @@ export async function paste( } // 1. Enter element - dispatchEvent(element, 'focus', EventBuilder.Common.focus()); + await dispatchEvent(element, 'focus', EventBuilder.Common.focus()); // 2. Select all const textToClear = getTextInputValue(element); const rangeToClear = { start: 0, end: textToClear.length }; - dispatchEvent(element, 'selectionChange', EventBuilder.TextInput.selectionChange(rangeToClear)); + await dispatchEvent(element, 'selectionChange', EventBuilder.TextInput.selectionChange(rangeToClear)); // 3. Paste the text nativeState.valueForElement.set(element, text); - dispatchEvent(element, 'change', EventBuilder.TextInput.change(text)); - dispatchEvent(element, 'changeText', text); + await dispatchEvent(element, 'change', EventBuilder.TextInput.change(text)); + await dispatchEvent(element, 'changeText', text); const rangeAfter = { start: text.length, end: text.length }; - dispatchEvent(element, 'selectionChange', EventBuilder.TextInput.selectionChange(rangeAfter)); + await dispatchEvent(element, 'selectionChange', EventBuilder.TextInput.selectionChange(rangeAfter)); // According to the docs only multiline TextInput emits contentSizeChange event // @see: https://reactnative.dev/docs/textinput#oncontentsizechange const isMultiline = element.props.multiline === true; if (isMultiline) { const contentSize = getTextContentSize(text); - dispatchEvent( + await dispatchEvent( element, 'contentSizeChange', EventBuilder.TextInput.contentSizeChange(contentSize), @@ -55,6 +55,6 @@ export async function paste( // 4. Exit element await wait(this.config); - dispatchEvent(element, 'endEditing', EventBuilder.TextInput.endEditing(text)); - dispatchEvent(element, 'blur', EventBuilder.Common.blur()); + await dispatchEvent(element, 'endEditing', EventBuilder.TextInput.endEditing(text)); + await dispatchEvent(element, 'blur', EventBuilder.Common.blur()); } diff --git a/src/user-event/press/press.ts b/src/user-event/press/press.ts index e0f43236..e131a4fa 100644 --- a/src/user-event/press/press.ts +++ b/src/user-event/press/press.ts @@ -118,23 +118,23 @@ async function emitDirectPressEvents( options: BasePressOptions, ) { await wait(config); - dispatchEvent(element, 'pressIn', EventBuilder.Common.touch()); + await dispatchEvent(element, 'pressIn', EventBuilder.Common.touch()); await wait(config, options.duration); // Long press events are emitted before `pressOut`. if (options.type === 'longPress') { - dispatchEvent(element, 'longPress', EventBuilder.Common.touch()); + await dispatchEvent(element, 'longPress', EventBuilder.Common.touch()); } - dispatchEvent(element, 'pressOut', EventBuilder.Common.touch()); + await dispatchEvent(element, 'pressOut', EventBuilder.Common.touch()); // Regular press events are emitted after `pressOut` according to the React Native docs. // See: https://reactnative.dev/docs/pressable#onpress // Experimentally for very short presses (< 130ms) `press` events are actually emitted before `onPressOut`, but // we will ignore that as in reality most pressed would be above the 130ms threshold. if (options.type === 'press') { - dispatchEvent(element, 'press', EventBuilder.Common.touch()); + await dispatchEvent(element, 'press', EventBuilder.Common.touch()); } } @@ -145,12 +145,12 @@ async function emitPressabilityPressEvents( ) { await wait(config); - dispatchEvent(element, 'responderGrant', EventBuilder.Common.responderGrant()); + await dispatchEvent(element, 'responderGrant', EventBuilder.Common.responderGrant()); const duration = options.duration ?? DEFAULT_MIN_PRESS_DURATION; await wait(config, duration); - dispatchEvent(element, 'responderRelease', EventBuilder.Common.responderRelease()); + await 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 diff --git a/src/user-event/scroll/scroll-to.ts b/src/user-event/scroll/scroll-to.ts index 08e4534f..2708ccf8 100644 --- a/src/user-event/scroll/scroll-to.ts +++ b/src/user-event/scroll/scroll-to.ts @@ -50,7 +50,7 @@ export async function scrollTo( ensureScrollViewDirection(element, options); - dispatchEvent( + await dispatchEvent( element, 'contentSizeChange', options.contentSize?.width ?? 0, @@ -88,7 +88,7 @@ async function emitDragScrollEvents( } await wait(config); - dispatchEvent( + await dispatchEvent( element, 'scrollBeginDrag', EventBuilder.ScrollView.scroll(scrollSteps[0], scrollOptions), @@ -99,12 +99,12 @@ async function emitDragScrollEvents( // See: https://github.com/callstack/react-native-testing-library/wiki/ScrollView-Events for (let i = 1; i < scrollSteps.length - 1; i += 1) { await wait(config); - dispatchEvent(element, 'scroll', EventBuilder.ScrollView.scroll(scrollSteps[i], scrollOptions)); + await dispatchEvent(element, 'scroll', EventBuilder.ScrollView.scroll(scrollSteps[i], scrollOptions)); } await wait(config); const lastStep = scrollSteps.at(-1); - dispatchEvent(element, 'scrollEndDrag', EventBuilder.ScrollView.scroll(lastStep, scrollOptions)); + await dispatchEvent(element, 'scrollEndDrag', EventBuilder.ScrollView.scroll(lastStep, scrollOptions)); } async function emitMomentumScrollEvents( @@ -118,7 +118,7 @@ async function emitMomentumScrollEvents( } await wait(config); - dispatchEvent( + await dispatchEvent( element, 'momentumScrollBegin', EventBuilder.ScrollView.scroll(scrollSteps[0], scrollOptions), @@ -129,12 +129,12 @@ async function emitMomentumScrollEvents( // See: https://github.com/callstack/react-native-testing-library/wiki/ScrollView-Events for (let i = 1; i < scrollSteps.length; i += 1) { await wait(config); - dispatchEvent(element, 'scroll', EventBuilder.ScrollView.scroll(scrollSteps[i], scrollOptions)); + await dispatchEvent(element, 'scroll', EventBuilder.ScrollView.scroll(scrollSteps[i], scrollOptions)); } await wait(config); const lastStep = scrollSteps.at(-1); - dispatchEvent( + await dispatchEvent( element, 'momentumScrollEnd', EventBuilder.ScrollView.scroll(lastStep, scrollOptions), diff --git a/src/user-event/type/type.ts b/src/user-event/type/type.ts index 022eb6d3..19cd5d31 100644 --- a/src/user-event/type/type.ts +++ b/src/user-event/type/type.ts @@ -37,14 +37,14 @@ export async function type( const keys = parseKeys(text); if (!options?.skipPress) { - dispatchEvent(element, 'pressIn', EventBuilder.Common.touch()); + await dispatchEvent(element, 'pressIn', EventBuilder.Common.touch()); } - dispatchEvent(element, 'focus', EventBuilder.Common.focus()); + await dispatchEvent(element, 'focus', EventBuilder.Common.focus()); if (!options?.skipPress) { await wait(this.config); - dispatchEvent(element, 'pressOut', EventBuilder.Common.touch()); + await dispatchEvent(element, 'pressOut', EventBuilder.Common.touch()); } let currentText = getTextInputValue(element); @@ -66,12 +66,12 @@ export async function type( await wait(this.config); if (options?.submitEditing) { - dispatchEvent(element, 'submitEditing', EventBuilder.TextInput.submitEditing(finalText)); + await dispatchEvent(element, 'submitEditing', EventBuilder.TextInput.submitEditing(finalText)); } if (!options?.skipBlur) { - dispatchEvent(element, 'endEditing', EventBuilder.TextInput.endEditing(finalText)); - dispatchEvent(element, 'blur', EventBuilder.Common.blur()); + await dispatchEvent(element, 'endEditing', EventBuilder.TextInput.endEditing(finalText)); + await dispatchEvent(element, 'blur', EventBuilder.Common.blur()); } } @@ -89,7 +89,7 @@ export async function emitTypingEvents( const isMultiline = element.props.multiline === true; await wait(config); - dispatchEvent(element, 'keyPress', EventBuilder.TextInput.keyPress(key)); + await dispatchEvent(element, 'keyPress', EventBuilder.TextInput.keyPress(key)); // Platform difference (based on experiments): // - iOS and RN Web: TextInput emits only `keyPress` event when max length has been reached @@ -99,20 +99,20 @@ export async function emitTypingEvents( } nativeState.valueForElement.set(element, text); - dispatchEvent(element, 'change', EventBuilder.TextInput.change(text)); - dispatchEvent(element, 'changeText', text); + await dispatchEvent(element, 'change', EventBuilder.TextInput.change(text)); + await dispatchEvent(element, 'changeText', text); const selectionRange = { start: text.length, end: text.length, }; - dispatchEvent(element, 'selectionChange', EventBuilder.TextInput.selectionChange(selectionRange)); + await dispatchEvent(element, 'selectionChange', EventBuilder.TextInput.selectionChange(selectionRange)); // According to the docs only multiline TextInput emits contentSizeChange event // @see: https://reactnative.dev/docs/textinput#oncontentsizechange if (isMultiline) { const contentSize = getTextContentSize(text); - dispatchEvent( + await dispatchEvent( element, 'contentSizeChange', EventBuilder.TextInput.contentSizeChange(contentSize), diff --git a/src/user-event/utils/__tests__/dispatch-event.test.tsx b/src/user-event/utils/__tests__/dispatch-event.test.tsx index 491e83f1..573b338d 100644 --- a/src/user-event/utils/__tests__/dispatch-event.test.tsx +++ b/src/user-event/utils/__tests__/dispatch-event.test.tsx @@ -8,15 +8,15 @@ import { dispatchEvent } from '../dispatch-event'; const TOUCH_EVENT = EventBuilder.Common.touch(); describe('dispatchEvent', () => { - it('does dispatch event', () => { + it('does dispatch event', async () => { const onPress = jest.fn(); render(); - dispatchEvent(screen.getByTestId('text'), 'press', TOUCH_EVENT); + await dispatchEvent(screen.getByTestId('text'), 'press', TOUCH_EVENT); expect(onPress).toHaveBeenCalledTimes(1); }); - it('does not dispatch event to parent host component', () => { + it('does not dispatch event to parent host component', async () => { const onPressParent = jest.fn(); render( @@ -24,17 +24,19 @@ describe('dispatchEvent', () => { , ); - dispatchEvent(screen.getByTestId('text'), 'press', TOUCH_EVENT); + await dispatchEvent(screen.getByTestId('text'), 'press', TOUCH_EVENT); expect(onPressParent).not.toHaveBeenCalled(); }); - it('does NOT throw if no handler found', () => { + it('does NOT throw if no handler found', async () => { render( , ); - expect(() => dispatchEvent(screen.getByTestId('text'), 'press', TOUCH_EVENT)).not.toThrow(); + await expect( + dispatchEvent(screen.getByTestId('text'), 'press', TOUCH_EVENT), + ).resolves.not.toThrow(); }); }); diff --git a/src/user-event/utils/dispatch-event.ts b/src/user-event/utils/dispatch-event.ts index 3f04fb31..4e736d1d 100644 --- a/src/user-event/utils/dispatch-event.ts +++ b/src/user-event/utils/dispatch-event.ts @@ -11,7 +11,7 @@ import { isElementMounted } from '../../helpers/component-tree'; * @param eventName name of the event * @param event event payload(s) */ -export function dispatchEvent(element: ReactTestInstance, eventName: string, ...event: unknown[]) { +export async function dispatchEvent(element: ReactTestInstance, eventName: string, ...event: unknown[]) { if (!isElementMounted(element)) { return; } @@ -21,8 +21,8 @@ export function dispatchEvent(element: ReactTestInstance, eventName: string, ... return; } - // This will be called synchronously. - void act(() => { - handler(...event); + // React 19 support: use async act + await act(async () => { + handler(...event) }); } From 2fd90fe083e74abf8bf153453565186ab87c503d Mon Sep 17 00:00:00 2001 From: Maciej Jastrzebski Date: Mon, 21 Jul 2025 23:58:00 +0200 Subject: [PATCH 7/9] async fireEvent; async screen methods --- src/__tests__/fire-event-async.test.tsx | 668 ++++++++++++++++++++ src/__tests__/render-async-fake-timers.tsx | 62 -- src/__tests__/render-async.tsx | 60 -- src/__tests__/suspense-fake-timers.test.tsx | 35 + src/__tests__/suspense.test.tsx | 56 ++ src/fire-event.ts | 30 + src/pure.ts | 2 +- src/render-async.tsx | 26 +- src/render.tsx | 23 +- src/screen.ts | 3 + 10 files changed, 835 insertions(+), 130 deletions(-) create mode 100644 src/__tests__/fire-event-async.test.tsx delete mode 100644 src/__tests__/render-async-fake-timers.tsx delete mode 100644 src/__tests__/render-async.tsx create mode 100644 src/__tests__/suspense-fake-timers.test.tsx create mode 100644 src/__tests__/suspense.test.tsx diff --git a/src/__tests__/fire-event-async.test.tsx b/src/__tests__/fire-event-async.test.tsx new file mode 100644 index 00000000..c779bd60 --- /dev/null +++ b/src/__tests__/fire-event-async.test.tsx @@ -0,0 +1,668 @@ +import * as React from 'react'; +import { + PanResponder, + Pressable, + ScrollView, + Text, + TextInput, + TouchableOpacity, + View, +} from 'react-native'; + +import { fireEventAsync, render, screen, waitFor } from '..'; + +type OnPressComponentProps = { + onPress: () => void; + text: string; +}; +const OnPressComponent = ({ onPress, text }: OnPressComponentProps) => ( + + + {text} + + +); + +type WithoutEventComponentProps = { onPress: () => void }; +const WithoutEventComponent = (_props: WithoutEventComponentProps) => ( + + Without event + +); + +type CustomEventComponentProps = { + onCustomEvent: () => void; +}; +const CustomEventComponent = ({ onCustomEvent }: CustomEventComponentProps) => ( + + Custom event component + +); + +type MyCustomButtonProps = { + handlePress: () => void; + text: string; +}; +const MyCustomButton = ({ handlePress, text }: MyCustomButtonProps) => ( + +); + +type CustomEventComponentWithCustomNameProps = { + handlePress: () => void; +}; +const CustomEventComponentWithCustomName = ({ + handlePress, +}: CustomEventComponentWithCustomNameProps) => ( + +); + +describe('fireEventAsync', () => { + test('should invoke specified event', async () => { + const onPressMock = jest.fn(); + render(); + + await fireEventAsync(screen.getByText('Press me'), 'press'); + + expect(onPressMock).toHaveBeenCalled(); + }); + + test('should invoke specified event on parent element', async () => { + const onPressMock = jest.fn(); + const text = 'New press text'; + render(); + + await fireEventAsync(screen.getByText(text), 'press'); + expect(onPressMock).toHaveBeenCalled(); + }); + + test('should not fire if the press handler is not passed to children', async () => { + const onPressMock = jest.fn(); + render( + // TODO: this functionality is buggy, i.e. it will fail if we wrap this component with a View. + , + ); + await fireEventAsync(screen.getByText('Without event'), 'press'); + expect(onPressMock).not.toHaveBeenCalled(); + }); + + test('should invoke event with custom name', async () => { + const handlerMock = jest.fn(); + const EVENT_DATA = 'event data'; + + render( + + + , + ); + + await fireEventAsync(screen.getByText('Custom event component'), 'customEvent', EVENT_DATA); + + expect(handlerMock).toHaveBeenCalledWith(EVENT_DATA); + }); +}); + +test('fireEventAsync.press', async () => { + const onPressMock = jest.fn(); + const text = 'Fireevent press'; + const eventData = { + nativeEvent: { + pageX: 20, + pageY: 30, + }, + }; + render(); + + await fireEventAsync.press(screen.getByText(text), eventData); + + expect(onPressMock).toHaveBeenCalledWith(eventData); +}); + +test('fireEventAsync.scroll', async () => { + const onScrollMock = jest.fn(); + const eventData = { + nativeEvent: { + contentOffset: { + y: 200, + }, + }, + }; + + render( + + XD + , + ); + + await fireEventAsync.scroll(screen.getByText('XD'), eventData); + + expect(onScrollMock).toHaveBeenCalledWith(eventData); +}); + +test('fireEventAsync.changeText', async () => { + const onChangeTextMock = jest.fn(); + + render( + + + , + ); + + const input = screen.getByPlaceholderText('Customer placeholder'); + await fireEventAsync.changeText(input, 'content'); + expect(onChangeTextMock).toHaveBeenCalledWith('content'); +}); + +it('sets native state value for unmanaged text inputs', async () => { + render(); + + const input = screen.getByTestId('input'); + expect(input).toHaveDisplayValue(''); + + await fireEventAsync.changeText(input, 'abc'); + expect(input).toHaveDisplayValue('abc'); +}); + +test('custom component with custom event name', async () => { + const handlePress = jest.fn(); + + render(); + + await fireEventAsync(screen.getByText('Custom component'), 'handlePress'); + + expect(handlePress).toHaveBeenCalled(); +}); + +test('event with multiple handler parameters', async () => { + const handlePress = jest.fn(); + + render(); + + await fireEventAsync(screen.getByText('Custom component'), 'handlePress', 'param1', 'param2'); + + expect(handlePress).toHaveBeenCalledWith('param1', 'param2'); +}); + +test('should not fire on disabled TouchableOpacity', async () => { + const handlePress = jest.fn(); + render( + + + Trigger + + , + ); + + await fireEventAsync.press(screen.getByText('Trigger')); + expect(handlePress).not.toHaveBeenCalled(); +}); + +test('should not fire on disabled Pressable', async () => { + const handlePress = jest.fn(); + render( + + + Trigger + + , + ); + + await fireEventAsync.press(screen.getByText('Trigger')); + expect(handlePress).not.toHaveBeenCalled(); +}); + +test('should not fire inside View with pointerEvents="none"', async () => { + const onPress = jest.fn(); + render( + + + Trigger + + , + ); + + await fireEventAsync.press(screen.getByText('Trigger')); + await fireEventAsync(screen.getByText('Trigger'), 'onPress'); + expect(onPress).not.toHaveBeenCalled(); +}); + +test('should not fire inside View with pointerEvents="box-only"', async () => { + const onPress = jest.fn(); + render( + + + Trigger + + , + ); + + await fireEventAsync.press(screen.getByText('Trigger')); + await fireEventAsync(screen.getByText('Trigger'), 'onPress'); + expect(onPress).not.toHaveBeenCalled(); +}); + +test('should fire inside View with pointerEvents="box-none"', async () => { + const onPress = jest.fn(); + render( + + + Trigger + + , + ); + + await fireEventAsync.press(screen.getByText('Trigger')); + await fireEventAsync(screen.getByText('Trigger'), 'onPress'); + expect(onPress).toHaveBeenCalledTimes(2); +}); + +test('should fire inside View with pointerEvents="auto"', async () => { + const onPress = jest.fn(); + render( + + + Trigger + + , + ); + + await fireEventAsync.press(screen.getByText('Trigger')); + await fireEventAsync(screen.getByText('Trigger'), 'onPress'); + expect(onPress).toHaveBeenCalledTimes(2); +}); + +test('should not fire deeply inside View with pointerEvents="box-only"', async () => { + const onPress = jest.fn(); + render( + + + + Trigger + + + , + ); + + await fireEventAsync.press(screen.getByText('Trigger')); + await fireEventAsync(screen.getByText('Trigger'), 'onPress'); + expect(onPress).not.toHaveBeenCalled(); +}); + +test('should fire non-pointer events inside View with pointerEvents="box-none"', async () => { + const onTouchStart = jest.fn(); + render(); + + await fireEventAsync(screen.getByTestId('view'), 'touchStart'); + expect(onTouchStart).toHaveBeenCalled(); +}); + +test('should fire non-touch events inside View with pointerEvents="box-none"', async () => { + const onLayout = jest.fn(); + render(); + + await fireEventAsync(screen.getByTestId('view'), 'layout'); + expect(onLayout).toHaveBeenCalled(); +}); + +// This test if pointerEvents="box-only" on composite `Pressable` is blocking +// the 'press' event on host View rendered by pressable. +test('should fire on Pressable with pointerEvents="box-only', async () => { + const onPress = jest.fn(); + render(); + + await fireEventAsync.press(screen.getByTestId('pressable')); + expect(onPress).toHaveBeenCalled(); +}); + +test('should pass event up on disabled TouchableOpacity', async () => { + const handleInnerPress = jest.fn(); + const handleOuterPress = jest.fn(); + render( + + + Inner Trigger + + , + ); + + await fireEventAsync.press(screen.getByText('Inner Trigger')); + expect(handleInnerPress).not.toHaveBeenCalled(); + expect(handleOuterPress).toHaveBeenCalledTimes(1); +}); + +test('should pass event up on disabled Pressable', async () => { + const handleInnerPress = jest.fn(); + const handleOuterPress = jest.fn(); + render( + + + Inner Trigger + + , + ); + + await fireEventAsync.press(screen.getByText('Inner Trigger')); + expect(handleInnerPress).not.toHaveBeenCalled(); + expect(handleOuterPress).toHaveBeenCalledTimes(1); +}); + +type TestComponentProps = { + onPress: () => void; + disabled?: boolean; +}; +const TestComponent = ({ onPress }: TestComponentProps) => { + return ( + + Trigger Test + + ); +}; + +test('is not fooled by non-native disabled prop', async () => { + const handlePress = jest.fn(); + render(); + + await fireEventAsync.press(screen.getByText('Trigger Test')); + expect(handlePress).toHaveBeenCalledTimes(1); +}); + +type TestChildTouchableComponentProps = { + onPress: () => void; + someProp: boolean; +}; + +function TestChildTouchableComponent({ onPress, someProp }: TestChildTouchableComponentProps) { + return ( + + + Trigger + + + ); +} + +test('is not fooled by non-responder wrapping host elements', async () => { + const handlePress = jest.fn(); + + render( + + + , + ); + + await fireEventAsync.press(screen.getByText('Trigger')); + expect(handlePress).not.toHaveBeenCalled(); +}); + +type TestDraggableComponentProps = { onDrag: () => void }; + +function TestDraggableComponent({ onDrag }: TestDraggableComponentProps) { + const responderHandlers = PanResponder.create({ + onMoveShouldSetPanResponder: (_evt, _gestureState) => true, + onPanResponderMove: onDrag, + }).panHandlers; + + return ( + + Trigger + + ); +} + +test('has only onMove', async () => { + const handleDrag = jest.fn(); + + render(); + + await fireEventAsync(screen.getByText('Trigger'), 'responderMove', { + touchHistory: { mostRecentTimeStamp: '2', touchBank: [] }, + }); + expect(handleDrag).toHaveBeenCalled(); +}); + +// Those events ideally should be triggered through `fireEventAsync.scroll`, but they are handled at the +// native level, so we need to support manually triggering them +describe('native events', () => { + test('triggers onScrollBeginDrag', async () => { + const onScrollBeginDragSpy = jest.fn(); + render(); + + await fireEventAsync(screen.getByTestId('test-id'), 'onScrollBeginDrag'); + expect(onScrollBeginDragSpy).toHaveBeenCalled(); + }); + + test('triggers onScrollEndDrag', async () => { + const onScrollEndDragSpy = jest.fn(); + render(); + + await fireEventAsync(screen.getByTestId('test-id'), 'onScrollEndDrag'); + expect(onScrollEndDragSpy).toHaveBeenCalled(); + }); + + test('triggers onMomentumScrollBegin', async () => { + const onMomentumScrollBeginSpy = jest.fn(); + render(); + + await fireEventAsync(screen.getByTestId('test-id'), 'onMomentumScrollBegin'); + expect(onMomentumScrollBeginSpy).toHaveBeenCalled(); + }); + + test('triggers onMomentumScrollEnd', async () => { + const onMomentumScrollEndSpy = jest.fn(); + render(); + + await fireEventAsync(screen.getByTestId('test-id'), 'onMomentumScrollEnd'); + expect(onMomentumScrollEndSpy).toHaveBeenCalled(); + }); +}); + +describe('React.Suspense integration', () => { + let mockPromise: Promise; + let resolveMockPromise: (value: string) => void; + + beforeEach(() => { + mockPromise = new Promise((resolve) => { + resolveMockPromise = resolve; + }); + }); + + type AsyncComponentProps = { + onPress: () => void; + shouldSuspend: boolean; + }; + + function AsyncComponent({ onPress, shouldSuspend }: AsyncComponentProps) { + if (shouldSuspend) { + throw mockPromise; + } + + return ( + + Async Component Loaded + + ); + } + + function SuspenseWrapper({ children }: { children: React.ReactNode }) { + return Loading...}>{children}; + } + + test('should handle events after Suspense resolves', async () => { + const onPressMock = jest.fn(); + + render( + + + , + ); + + // Initially shows fallback + expect(screen.getByText('Loading...')).toBeTruthy(); + + // Resolve the promise + resolveMockPromise('loaded'); + await waitFor(() => { + screen.rerender( + + + , + ); + }); + + // Component should be loaded now + await waitFor(() => { + expect(screen.getByText('Async Component Loaded')).toBeTruthy(); + }); + + // fireEventAsync should work on the resolved component + await fireEventAsync.press(screen.getByText('Async Component Loaded')); + expect(onPressMock).toHaveBeenCalled(); + }); + + test('should handle events on Suspense fallback components', async () => { + const fallbackPressMock = jest.fn(); + + function InteractiveFallback() { + return ( + + Loading with button... + + ); + } + + render( + }> + + , + ); + + // Should be able to interact with fallback + expect(screen.getByText('Loading with button...')).toBeTruthy(); + + await fireEventAsync.press(screen.getByText('Loading with button...')); + expect(fallbackPressMock).toHaveBeenCalled(); + }); + + test('should work with nested Suspense boundaries', async () => { + const outerPressMock = jest.fn(); + const innerPressMock = jest.fn(); + + type NestedAsyncProps = { + onPress: () => void; + shouldSuspend: boolean; + level: string; + }; + + function NestedAsync({ onPress, shouldSuspend, level }: NestedAsyncProps) { + if (shouldSuspend) { + throw mockPromise; + } + + return ( + + {level} Component Loaded + + ); + } + + const { rerender } = render( + Outer Loading...}> + + Inner Loading...}> + + + , + ); + + // Outer component should be loaded, inner should show fallback + expect(screen.getByText('Outer Component Loaded')).toBeTruthy(); + expect(screen.getByText('Inner Loading...')).toBeTruthy(); + + // Should be able to interact with outer component + await fireEventAsync.press(screen.getByText('Outer Component Loaded')); + expect(outerPressMock).toHaveBeenCalled(); + + // Resolve inner component + resolveMockPromise('inner-loaded'); + await waitFor(() => { + rerender( + Outer Loading...}> + + Inner Loading...}> + + + , + ); + }); + + // Both components should be loaded now + await waitFor(() => { + expect(screen.getByText('Inner Component Loaded')).toBeTruthy(); + }); + + // Should be able to interact with inner component + await fireEventAsync.press(screen.getByText('Inner Component Loaded')); + expect(innerPressMock).toHaveBeenCalled(); + }); + + test('should work when events cause components to suspend', async () => { + const onPressMock = jest.fn(); + let shouldSuspend = false; + + function DataComponent() { + if (shouldSuspend) { + throw mockPromise; // This will cause suspense + } + return Data loaded; + } + + function ButtonComponent() { + return ( + { + onPressMock(); + shouldSuspend = true; // This will cause DataComponent to suspend on next render + }} + > + Load Data + + ); + } + + render( + + + Loading data...}> + + + , + ); + + // Initially data is loaded + expect(screen.getByText('Data loaded')).toBeTruthy(); + + // Click button - this triggers the state change that will cause suspension + await fireEventAsync.press(screen.getByText('Load Data')); + expect(onPressMock).toHaveBeenCalled(); + + // Rerender - now DataComponent should suspend + screen.rerender( + + + Loading data...}> + + + , + ); + + // Should show loading fallback + expect(screen.getByText('Loading data...')).toBeTruthy(); + }); +}); + +function createMockPromise(): [Promise, (value: T) => void] { + let resolve: (value: T) => void; + const promise = new Promise((res) => { + resolve = res; + }); + return [promise, resolve!]; +} diff --git a/src/__tests__/render-async-fake-timers.tsx b/src/__tests__/render-async-fake-timers.tsx deleted file mode 100644 index caa56058..00000000 --- a/src/__tests__/render-async-fake-timers.tsx +++ /dev/null @@ -1,62 +0,0 @@ -/* eslint-disable jest/no-standalone-expect */ -import * as React from 'react'; -import { View } from 'react-native'; -import TestRenderer, { type ReactTestRenderer } from 'react-test-renderer'; - -import { configure, renderAsync, screen, within } from '..'; - -const isReact19 = React.version.startsWith('19.'); -const testGateReact19 = isReact19 ? test : test.skip; - -jest.useFakeTimers(); - -configure({ - asyncUtilTimeout: 5000, -}); - -function wait(delay: number) { - return new Promise((resolve) => - setTimeout(() => { - resolve(); - }, delay), - ); -} - -function Suspending({ promise }: { promise: Promise }) { - React.use(promise); - return ; -} - -testGateReact19('renderAsync supports components which can suspend', async () => { - await renderAsync( - - }> - - - , - ); - - expect(screen.getByTestId('fallback')).toBeOnTheScreen(); - expect(await screen.findByTestId('view')).toBeOnTheScreen(); -}); - -testGateReact19('react test renderer supports components which can suspend', async () => { - let renderer: ReactTestRenderer; - - // eslint-disable-next-line require-await - await React.act(async () => { - renderer = TestRenderer.create( - - }> - - - , - ); - }); - - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const view = within(renderer!.root); - - expect(view.getByTestId('fallback')).toBeDefined(); - expect(await view.findByTestId('view')).toBeDefined(); -}); diff --git a/src/__tests__/render-async.tsx b/src/__tests__/render-async.tsx deleted file mode 100644 index 5b1c6529..00000000 --- a/src/__tests__/render-async.tsx +++ /dev/null @@ -1,60 +0,0 @@ -/* eslint-disable jest/no-standalone-expect */ -import * as React from 'react'; -import { View } from 'react-native'; -import TestRenderer, { type ReactTestRenderer } from 'react-test-renderer'; - -import { configure, renderAsync, screen, within } from '..'; - -const isReact19 = React.version.startsWith('19.'); -const testGateReact19 = isReact19 ? test : test.skip; - -configure({ - asyncUtilTimeout: 5000, -}); - -function wait(delay: number) { - return new Promise((resolve) => - setTimeout(() => { - resolve(); - }, delay), - ); -} - -function Suspending({ promise }: { promise: Promise }) { - React.use(promise); - return ; -} - -testGateReact19('renderAsync supports components which can suspend', async () => { - await renderAsync( - - }> - - - , - ); - - expect(screen.getByTestId('fallback')).toBeOnTheScreen(); - expect(await screen.findByTestId('view')).toBeOnTheScreen(); -}); - -testGateReact19('react test renderer supports components which can suspend', async () => { - let renderer: ReactTestRenderer; - - // eslint-disable-next-line require-await - await React.act(async () => { - renderer = TestRenderer.create( - - }> - - - , - ); - }); - - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const view = within(renderer!.root); - - expect(view.getByTestId('fallback')).toBeDefined(); - expect(await view.findByTestId('view')).toBeDefined(); -}); diff --git a/src/__tests__/suspense-fake-timers.test.tsx b/src/__tests__/suspense-fake-timers.test.tsx new file mode 100644 index 00000000..61fb8b21 --- /dev/null +++ b/src/__tests__/suspense-fake-timers.test.tsx @@ -0,0 +1,35 @@ +import * as React from 'react'; +import { Text, View } from 'react-native'; +import { act, renderAsync, screen } from '..'; + +jest.useFakeTimers(); + +const testGateReact19 = React.version.startsWith('19.') ? test : test.skip; + +function Suspending({ promise }: { promise: Promise }) { + React.use(promise); + return ; +} + +testGateReact19('resolves timer-controlled promise', async () => { + const promise = new Promise((resolve) => { + setTimeout(() => resolve(null), 100); + }); + + await renderAsync( + + Loading...}> + + + + , + ); + expect(screen.getByText('Loading...')).toBeOnTheScreen(); + expect(screen.queryByTestId('content')).not.toBeOnTheScreen(); + expect(screen.queryByTestId('sibling')).not.toBeOnTheScreen(); + + await act(async () => jest.runOnlyPendingTimers()); + expect(screen.getByTestId('content')).toBeOnTheScreen(); + expect(screen.getByTestId('sibling')).toBeOnTheScreen(); + expect(screen.queryByText('Loading...')).not.toBeOnTheScreen(); +}); diff --git a/src/__tests__/suspense.test.tsx b/src/__tests__/suspense.test.tsx new file mode 100644 index 00000000..d208c02e --- /dev/null +++ b/src/__tests__/suspense.test.tsx @@ -0,0 +1,56 @@ +import * as React from 'react'; +import { Text, View } from 'react-native'; +import { act, renderAsync, screen } from '..'; + +const testGateReact19 = React.version.startsWith('19.') ? test : test.skip; + +function Suspending({ promise }: { promise: Promise }) { + React.use(promise); + return ; +} + +testGateReact19('resolves manually-controlled promise', async () => { + let resolvePromise: (value: void) => void; + const promise = new Promise((resolve) => { + resolvePromise = resolve; + }); + + await renderAsync( + + Loading...}> + + + + , + ); + expect(screen.getByText('Loading...')).toBeOnTheScreen(); + expect(screen.queryByTestId('content')).not.toBeOnTheScreen(); + expect(screen.queryByTestId('sibling')).not.toBeOnTheScreen(); + + await act(async () => resolvePromise()); + expect(screen.getByTestId('content')).toBeOnTheScreen(); + expect(screen.getByTestId('sibling')).toBeOnTheScreen(); + expect(screen.queryByText('Loading...')).not.toBeOnTheScreen(); +}); + +testGateReact19('resolves timer-controlled promise', async () => { + const promise = new Promise((resolve) => { + setTimeout(() => resolve(null), 100); + }); + + await renderAsync( + + Loading...}> + + + + , + ); + expect(screen.getByText('Loading...')).toBeOnTheScreen(); + expect(screen.queryByTestId('content')).not.toBeOnTheScreen(); + expect(screen.queryByTestId('sibling')).not.toBeOnTheScreen(); + + expect(await screen.findByTestId('content')).toBeOnTheScreen(); + expect(screen.getByTestId('sibling')).toBeOnTheScreen(); + expect(screen.queryByText('Loading...')).not.toBeOnTheScreen(); +}); diff --git a/src/fire-event.ts b/src/fire-event.ts index a843fad0..4cf144c2 100644 --- a/src/fire-event.ts +++ b/src/fire-event.ts @@ -134,6 +134,36 @@ fireEvent.changeText = (element: ReactTestInstance, ...data: unknown[]) => fireEvent.scroll = (element: ReactTestInstance, ...data: unknown[]) => fireEvent(element, 'scroll', ...data); +async function fireEventAsync(element: ReactTestInstance, eventName: EventName, ...data: unknown[]) { + if (!isElementMounted(element)) { + return; + } + + setNativeStateIfNeeded(element, eventName, data[0]); + + const handler = findEventHandler(element, eventName); + if (!handler) { + return; + } + + let returnValue; + await act(async () => { + returnValue = handler(...data); + }); + + return returnValue; +} + +fireEventAsync.press = async (element: ReactTestInstance, ...data: unknown[]) => + fireEventAsync(element, 'press', ...data); + +fireEventAsync.changeText = async (element: ReactTestInstance, ...data: unknown[]) => + fireEventAsync(element, 'changeText', ...data); + +fireEventAsync.scroll = async (element: ReactTestInstance, ...data: unknown[]) => + fireEventAsync(element, 'scroll', ...data); + +export { fireEventAsync }; export default fireEvent; const scrollEventNames = new Set([ diff --git a/src/pure.ts b/src/pure.ts index 60526bb3..62be84c2 100644 --- a/src/pure.ts +++ b/src/pure.ts @@ -1,6 +1,6 @@ export { default as act } from './act'; export { default as cleanup } from './cleanup'; -export { default as fireEvent } from './fire-event'; +export { default as fireEvent, fireEventAsync } from './fire-event'; export { default as render } from './render'; export { default as renderAsync } from './render-async'; export { default as waitFor } from './wait-for'; diff --git a/src/render-async.tsx b/src/render-async.tsx index 3a48a88d..04157693 100644 --- a/src/render-async.tsx +++ b/src/render-async.tsx @@ -60,12 +60,25 @@ function buildRenderResult( renderer: ReactTestRenderer, wrap: (element: React.ReactElement) => React.JSX.Element, ) { - const update = updateWithAsyncAct(renderer, wrap); const instance = renderer.root; - // TODO: test this - const unmount = async () => { - // eslint-disable-next-line require-await + const update = function (component: React.ReactElement) { + void act(() => { + renderer.update(wrap(component)); + }); + }; + const updateAsync = async function (component: React.ReactElement) { + await act(async () => { + renderer.update(wrap(component)); + }); + }; + + const unmount = () => { + void act(() => { + renderer.unmount(); + }); + }; + const unmountAsync = async () => { await act(async () => { renderer.unmount(); }); @@ -76,8 +89,11 @@ function buildRenderResult( const result = { ...getQueriesForElement(instance), update, - unmount, + updateAsync, rerender: update, // alias for `update` + rerenderAsync: updateAsync, // alias for `update` + unmount, + unmountAsync, toJSON: renderer.toJSON, debug: makeDebug(renderer), get root(): ReactTestInstance { diff --git a/src/render.tsx b/src/render.tsx index 3555d8f4..75d22368 100644 --- a/src/render.tsx +++ b/src/render.tsx @@ -15,6 +15,7 @@ import { validateStringsRenderedWithinText } from './helpers/string-validation'; import { renderWithAct } from './render-act'; import { setRenderResult } from './screen'; import { getQueriesForElement } from './within'; +import renderAsync from './render-async'; export interface RenderOptions { /** @@ -98,22 +99,40 @@ function buildRenderResult( renderer: ReactTestRenderer, wrap: (element: React.ReactElement) => React.JSX.Element, ) { - const update = updateWithAct(renderer, wrap); const instance = renderer.root; + const update = function (component: React.ReactElement) { + void act(() => { + renderer.update(wrap(component)); + }); + }; + const updateAsync = async function (component: React.ReactElement) { + await act(async () => { + renderer.update(wrap(component)); + }); + }; + const unmount = () => { void act(() => { renderer.unmount(); }); }; + const unmountAsync = async () => { + await act(async () => { + renderer.unmount(); + }); + }; addToCleanupQueue(unmount); const result = { ...getQueriesForElement(instance), update, - unmount, + updateAsync, rerender: update, // alias for `update` + rerenderAsync: updateAsync, // alias for `update` + unmount, + unmountAsync, toJSON: renderer.toJSON, debug: makeDebug(renderer), get root(): ReactTestInstance { diff --git a/src/screen.ts b/src/screen.ts index d5edc073..14173549 100644 --- a/src/screen.ts +++ b/src/screen.ts @@ -26,8 +26,11 @@ const defaultScreen: Screen = { }, debug: notImplementedDebug, update: notImplemented, + updateAsync: notImplemented, unmount: notImplemented, + unmountAsync: notImplemented, rerender: notImplemented, + rerenderAsync: notImplemented, toJSON: notImplemented, getByLabelText: notImplemented, getAllByLabelText: notImplemented, From 804fd7b4a4d3bd10dfdb7d3be8b187c921ba8a80 Mon Sep 17 00:00:00 2001 From: Maciej Jastrzebski Date: Tue, 22 Jul 2025 00:05:16 +0200 Subject: [PATCH 8/9] fix lint --- src/__tests__/fire-event-async.test.tsx | 8 -------- src/__tests__/suspense-fake-timers.test.tsx | 3 ++- src/__tests__/suspense.test.tsx | 1 + src/fire-event.ts | 13 +++++++++---- src/render-async.tsx | 15 ++------------- src/render.tsx | 14 ++------------ src/user-event/clear.ts | 6 +++++- src/user-event/paste.ts | 12 ++++++++++-- src/user-event/scroll/scroll-to.ts | 18 +++++++++++++++--- src/user-event/type/type.ts | 6 +++++- src/user-event/utils/dispatch-event.ts | 12 ++++++++---- 11 files changed, 59 insertions(+), 49 deletions(-) diff --git a/src/__tests__/fire-event-async.test.tsx b/src/__tests__/fire-event-async.test.tsx index c779bd60..14d93b98 100644 --- a/src/__tests__/fire-event-async.test.tsx +++ b/src/__tests__/fire-event-async.test.tsx @@ -658,11 +658,3 @@ describe('React.Suspense integration', () => { expect(screen.getByText('Loading data...')).toBeTruthy(); }); }); - -function createMockPromise(): [Promise, (value: T) => void] { - let resolve: (value: T) => void; - const promise = new Promise((res) => { - resolve = res; - }); - return [promise, resolve!]; -} diff --git a/src/__tests__/suspense-fake-timers.test.tsx b/src/__tests__/suspense-fake-timers.test.tsx index 61fb8b21..37a53cc0 100644 --- a/src/__tests__/suspense-fake-timers.test.tsx +++ b/src/__tests__/suspense-fake-timers.test.tsx @@ -1,5 +1,6 @@ import * as React from 'react'; import { Text, View } from 'react-native'; + import { act, renderAsync, screen } from '..'; jest.useFakeTimers(); @@ -28,7 +29,7 @@ testGateReact19('resolves timer-controlled promise', async () => { expect(screen.queryByTestId('content')).not.toBeOnTheScreen(); expect(screen.queryByTestId('sibling')).not.toBeOnTheScreen(); - await act(async () => jest.runOnlyPendingTimers()); + await act(async () => await jest.runOnlyPendingTimersAsync()); expect(screen.getByTestId('content')).toBeOnTheScreen(); expect(screen.getByTestId('sibling')).toBeOnTheScreen(); expect(screen.queryByText('Loading...')).not.toBeOnTheScreen(); diff --git a/src/__tests__/suspense.test.tsx b/src/__tests__/suspense.test.tsx index d208c02e..fe3245ae 100644 --- a/src/__tests__/suspense.test.tsx +++ b/src/__tests__/suspense.test.tsx @@ -1,5 +1,6 @@ import * as React from 'react'; import { Text, View } from 'react-native'; + import { act, renderAsync, screen } from '..'; const testGateReact19 = React.version.startsWith('19.') ? test : test.skip; diff --git a/src/fire-event.ts b/src/fire-event.ts index 4cf144c2..8639502a 100644 --- a/src/fire-event.ts +++ b/src/fire-event.ts @@ -134,7 +134,11 @@ fireEvent.changeText = (element: ReactTestInstance, ...data: unknown[]) => fireEvent.scroll = (element: ReactTestInstance, ...data: unknown[]) => fireEvent(element, 'scroll', ...data); -async function fireEventAsync(element: ReactTestInstance, eventName: EventName, ...data: unknown[]) { +async function fireEventAsync( + element: ReactTestInstance, + eventName: EventName, + ...data: unknown[] +) { if (!isElementMounted(element)) { return; } @@ -147,6 +151,7 @@ async function fireEventAsync(element: ReactTestInstance, eventName: EventName, } let returnValue; + // eslint-disable-next-line require-await await act(async () => { returnValue = handler(...data); }); @@ -155,13 +160,13 @@ async function fireEventAsync(element: ReactTestInstance, eventName: EventName, } fireEventAsync.press = async (element: ReactTestInstance, ...data: unknown[]) => - fireEventAsync(element, 'press', ...data); + await fireEventAsync(element, 'press', ...data); fireEventAsync.changeText = async (element: ReactTestInstance, ...data: unknown[]) => - fireEventAsync(element, 'changeText', ...data); + await fireEventAsync(element, 'changeText', ...data); fireEventAsync.scroll = async (element: ReactTestInstance, ...data: unknown[]) => - fireEventAsync(element, 'scroll', ...data); + await fireEventAsync(element, 'scroll', ...data); export { fireEventAsync }; export default fireEvent; diff --git a/src/render-async.tsx b/src/render-async.tsx index 04157693..d191f329 100644 --- a/src/render-async.tsx +++ b/src/render-async.tsx @@ -68,6 +68,7 @@ function buildRenderResult( }); }; const updateAsync = async function (component: React.ReactElement) { + // eslint-disable-next-line require-await await act(async () => { renderer.update(wrap(component)); }); @@ -79,6 +80,7 @@ function buildRenderResult( }); }; const unmountAsync = async () => { + // eslint-disable-next-line require-await await act(async () => { renderer.unmount(); }); @@ -119,19 +121,6 @@ function buildRenderResult( return result; } -// TODO: test this -function updateWithAsyncAct( - renderer: ReactTestRenderer, - wrap: (innerElement: React.ReactElement) => React.ReactElement, -) { - return async function (component: React.ReactElement) { - // eslint-disable-next-line require-await - await act(async () => { - renderer.update(wrap(component)); - }); - }; -} - export type DebugFunction = (options?: DebugOptions) => void; function makeDebug(renderer: ReactTestRenderer): DebugFunction { diff --git a/src/render.tsx b/src/render.tsx index 75d22368..f08a379f 100644 --- a/src/render.tsx +++ b/src/render.tsx @@ -15,7 +15,6 @@ import { validateStringsRenderedWithinText } from './helpers/string-validation'; import { renderWithAct } from './render-act'; import { setRenderResult } from './screen'; import { getQueriesForElement } from './within'; -import renderAsync from './render-async'; export interface RenderOptions { /** @@ -107,6 +106,7 @@ function buildRenderResult( }); }; const updateAsync = async function (component: React.ReactElement) { + // eslint-disable-next-line require-await await act(async () => { renderer.update(wrap(component)); }); @@ -118,6 +118,7 @@ function buildRenderResult( }); }; const unmountAsync = async () => { + // eslint-disable-next-line require-await await act(async () => { renderer.unmount(); }); @@ -158,17 +159,6 @@ function buildRenderResult( return result; } -function updateWithAct( - renderer: ReactTestRenderer, - wrap: (innerElement: React.ReactElement) => React.ReactElement, -) { - return function (component: React.ReactElement) { - void act(() => { - renderer.update(wrap(component)); - }); - }; -} - export type DebugFunction = (options?: DebugOptions) => void; function makeDebug(renderer: ReactTestRenderer): DebugFunction { diff --git a/src/user-event/clear.ts b/src/user-event/clear.ts index a006f245..4a070187 100644 --- a/src/user-event/clear.ts +++ b/src/user-event/clear.ts @@ -30,7 +30,11 @@ export async function clear(this: UserEventInstance, element: ReactTestInstance) start: 0, end: textToClear.length, }; - await dispatchEvent(element, 'selectionChange', EventBuilder.TextInput.selectionChange(selectionRange)); + await dispatchEvent( + element, + 'selectionChange', + EventBuilder.TextInput.selectionChange(selectionRange), + ); // 3. Press backspace with selected text const emptyText = ''; diff --git a/src/user-event/paste.ts b/src/user-event/paste.ts index aceb6e56..98191d84 100644 --- a/src/user-event/paste.ts +++ b/src/user-event/paste.ts @@ -31,7 +31,11 @@ export async function paste( // 2. Select all const textToClear = getTextInputValue(element); const rangeToClear = { start: 0, end: textToClear.length }; - await dispatchEvent(element, 'selectionChange', EventBuilder.TextInput.selectionChange(rangeToClear)); + await dispatchEvent( + element, + 'selectionChange', + EventBuilder.TextInput.selectionChange(rangeToClear), + ); // 3. Paste the text nativeState.valueForElement.set(element, text); @@ -39,7 +43,11 @@ export async function paste( await dispatchEvent(element, 'changeText', text); const rangeAfter = { start: text.length, end: text.length }; - await dispatchEvent(element, 'selectionChange', EventBuilder.TextInput.selectionChange(rangeAfter)); + await dispatchEvent( + element, + 'selectionChange', + EventBuilder.TextInput.selectionChange(rangeAfter), + ); // According to the docs only multiline TextInput emits contentSizeChange event // @see: https://reactnative.dev/docs/textinput#oncontentsizechange diff --git a/src/user-event/scroll/scroll-to.ts b/src/user-event/scroll/scroll-to.ts index 2708ccf8..b019e2ba 100644 --- a/src/user-event/scroll/scroll-to.ts +++ b/src/user-event/scroll/scroll-to.ts @@ -99,12 +99,20 @@ async function emitDragScrollEvents( // See: https://github.com/callstack/react-native-testing-library/wiki/ScrollView-Events for (let i = 1; i < scrollSteps.length - 1; i += 1) { await wait(config); - await dispatchEvent(element, 'scroll', EventBuilder.ScrollView.scroll(scrollSteps[i], scrollOptions)); + await dispatchEvent( + element, + 'scroll', + EventBuilder.ScrollView.scroll(scrollSteps[i], scrollOptions), + ); } await wait(config); const lastStep = scrollSteps.at(-1); - await dispatchEvent(element, 'scrollEndDrag', EventBuilder.ScrollView.scroll(lastStep, scrollOptions)); + await dispatchEvent( + element, + 'scrollEndDrag', + EventBuilder.ScrollView.scroll(lastStep, scrollOptions), + ); } async function emitMomentumScrollEvents( @@ -129,7 +137,11 @@ async function emitMomentumScrollEvents( // See: https://github.com/callstack/react-native-testing-library/wiki/ScrollView-Events for (let i = 1; i < scrollSteps.length; i += 1) { await wait(config); - await dispatchEvent(element, 'scroll', EventBuilder.ScrollView.scroll(scrollSteps[i], scrollOptions)); + await dispatchEvent( + element, + 'scroll', + EventBuilder.ScrollView.scroll(scrollSteps[i], scrollOptions), + ); } await wait(config); diff --git a/src/user-event/type/type.ts b/src/user-event/type/type.ts index 19cd5d31..8607ef87 100644 --- a/src/user-event/type/type.ts +++ b/src/user-event/type/type.ts @@ -106,7 +106,11 @@ export async function emitTypingEvents( start: text.length, end: text.length, }; - await dispatchEvent(element, 'selectionChange', EventBuilder.TextInput.selectionChange(selectionRange)); + await dispatchEvent( + element, + 'selectionChange', + EventBuilder.TextInput.selectionChange(selectionRange), + ); // According to the docs only multiline TextInput emits contentSizeChange event // @see: https://reactnative.dev/docs/textinput#oncontentsizechange diff --git a/src/user-event/utils/dispatch-event.ts b/src/user-event/utils/dispatch-event.ts index 4e736d1d..161d4cfa 100644 --- a/src/user-event/utils/dispatch-event.ts +++ b/src/user-event/utils/dispatch-event.ts @@ -11,7 +11,11 @@ import { isElementMounted } from '../../helpers/component-tree'; * @param eventName name of the event * @param event event payload(s) */ -export async function dispatchEvent(element: ReactTestInstance, eventName: string, ...event: unknown[]) { +export async function dispatchEvent( + element: ReactTestInstance, + eventName: string, + ...event: unknown[] +) { if (!isElementMounted(element)) { return; } @@ -21,8 +25,8 @@ export async function dispatchEvent(element: ReactTestInstance, eventName: strin return; } - // React 19 support: use async act - await act(async () => { - handler(...event) + // eslint-disable-next-line require-await + await act(async () => { + handler(...event); }); } From f62bebe5da9de34611d7476b4bfd4d160092f463 Mon Sep 17 00:00:00 2001 From: Maciej Jastrzebski Date: Tue, 22 Jul 2025 00:06:50 +0200 Subject: [PATCH 9/9] . --- src/__tests__/suspense-fake-timers.test.tsx | 25 +++++++++++++++++++++ src/__tests__/suspense.test.tsx | 5 +++-- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/src/__tests__/suspense-fake-timers.test.tsx b/src/__tests__/suspense-fake-timers.test.tsx index 37a53cc0..3f879002 100644 --- a/src/__tests__/suspense-fake-timers.test.tsx +++ b/src/__tests__/suspense-fake-timers.test.tsx @@ -12,6 +12,31 @@ function Suspending({ promise }: { promise: Promise }) { return ; } +testGateReact19('resolves manually-controlled promise', async () => { + let resolvePromise: (value: unknown) => void; + const promise = new Promise((resolve) => { + resolvePromise = resolve; + }); + + await renderAsync( + + Loading...}> + + + + , + ); + expect(screen.getByText('Loading...')).toBeOnTheScreen(); + expect(screen.queryByTestId('content')).not.toBeOnTheScreen(); + expect(screen.queryByTestId('sibling')).not.toBeOnTheScreen(); + + // eslint-disable-next-line require-await + await act(async () => resolvePromise(null)); + expect(screen.getByTestId('content')).toBeOnTheScreen(); + expect(screen.getByTestId('sibling')).toBeOnTheScreen(); + expect(screen.queryByText('Loading...')).not.toBeOnTheScreen(); +}); + testGateReact19('resolves timer-controlled promise', async () => { const promise = new Promise((resolve) => { setTimeout(() => resolve(null), 100); diff --git a/src/__tests__/suspense.test.tsx b/src/__tests__/suspense.test.tsx index fe3245ae..50a72353 100644 --- a/src/__tests__/suspense.test.tsx +++ b/src/__tests__/suspense.test.tsx @@ -11,7 +11,7 @@ function Suspending({ promise }: { promise: Promise }) { } testGateReact19('resolves manually-controlled promise', async () => { - let resolvePromise: (value: void) => void; + let resolvePromise: (value: unknown) => void; const promise = new Promise((resolve) => { resolvePromise = resolve; }); @@ -28,7 +28,8 @@ testGateReact19('resolves manually-controlled promise', async () => { expect(screen.queryByTestId('content')).not.toBeOnTheScreen(); expect(screen.queryByTestId('sibling')).not.toBeOnTheScreen(); - await act(async () => resolvePromise()); + // eslint-disable-next-line require-await + await act(async () => resolvePromise(null)); expect(screen.getByTestId('content')).toBeOnTheScreen(); expect(screen.getByTestId('sibling')).toBeOnTheScreen(); expect(screen.queryByText('Loading...')).not.toBeOnTheScreen();