From 463253d095aa439a81f197c56ca04a1f1d8b5909 Mon Sep 17 00:00:00 2001 From: Maciej Jastrzebski Date: Mon, 14 Jul 2025 10:22:31 +0200 Subject: [PATCH 01/30] 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 718aaaefe4c5c830bcfcea18d4d8dda110079738 Mon Sep 17 00:00:00 2001 From: Maciej Jastrzebski Date: Mon, 14 Jul 2025 11:35:28 +0200 Subject: [PATCH 02/30] 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 ea48684d1481985786da0f2511815e7a4299a1f6 Mon Sep 17 00:00:00 2001 From: Maciej Jastrzebski Date: Mon, 14 Jul 2025 11:39:10 +0200 Subject: [PATCH 03/30] . --- 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 0441c849c2a7c8d5ee0da02d9551e03f6e3f7ef2 Mon Sep 17 00:00:00 2001 From: Maciej Jastrzebski Date: Mon, 14 Jul 2025 11:41:01 +0200 Subject: [PATCH 04/30] . --- 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 06ed3684098e8328e428248ca3fd624cfef6bcfa Mon Sep 17 00:00:00 2001 From: Maciej Jastrzebski Date: Wed, 16 Jul 2025 23:48:48 +0200 Subject: [PATCH 05/30] 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 3f774b2fcd78305677dfa8876bf991253a6ec2ef Mon Sep 17 00:00:00 2001 From: Maciej Jastrzebski Date: Mon, 21 Jul 2025 22:55:11 +0200 Subject: [PATCH 06/30] 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 24db2aa510f6cf0aa775abff5ea93c92e0c39b10 Mon Sep 17 00:00:00 2001 From: Maciej Jastrzebski Date: Mon, 21 Jul 2025 23:58:00 +0200 Subject: [PATCH 07/30] 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 9ec20f5c..3fe07e59 100644 --- a/src/fire-event.ts +++ b/src/fire-event.ts @@ -135,6 +135,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 e83d06b82b047fb9450254113dad684406ead238 Mon Sep 17 00:00:00 2001 From: Maciej Jastrzebski Date: Tue, 22 Jul 2025 00:05:16 +0200 Subject: [PATCH 08/30] 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 3fe07e59..981e6e64 100644 --- a/src/fire-event.ts +++ b/src/fire-event.ts @@ -135,7 +135,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; } @@ -148,6 +152,7 @@ async function fireEventAsync(element: ReactTestInstance, eventName: EventName, } let returnValue; + // eslint-disable-next-line require-await await act(async () => { returnValue = handler(...data); }); @@ -156,13 +161,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 5a2807806572aff6e94bdf799864b90f2a526bb8 Mon Sep 17 00:00:00 2001 From: Maciej Jastrzebski Date: Tue, 22 Jul 2025 00:06:50 +0200 Subject: [PATCH 09/30] . --- 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(); From 9a2592e29bac9398b22bb96bf920c248fba8cf8d Mon Sep 17 00:00:00 2001 From: Maciej Jastrzebski Date: Mon, 4 Aug 2025 11:22:42 +0200 Subject: [PATCH 10/30] fix async timers --- src/__tests__/suspense-fake-timers.test.tsx | 2 +- src/wait-for.ts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/__tests__/suspense-fake-timers.test.tsx b/src/__tests__/suspense-fake-timers.test.tsx index 3f879002..6e5feb04 100644 --- a/src/__tests__/suspense-fake-timers.test.tsx +++ b/src/__tests__/suspense-fake-timers.test.tsx @@ -54,7 +54,7 @@ testGateReact19('resolves timer-controlled promise', async () => { expect(screen.queryByTestId('content')).not.toBeOnTheScreen(); expect(screen.queryByTestId('sibling')).not.toBeOnTheScreen(); - await act(async () => await jest.runOnlyPendingTimersAsync()); + expect(await screen.findByTestId('content')).toBeOnTheScreen(); expect(screen.getByTestId('content')).toBeOnTheScreen(); expect(screen.getByTestId('sibling')).toBeOnTheScreen(); expect(screen.queryByText('Loading...')).not.toBeOnTheScreen(); diff --git a/src/wait-for.ts b/src/wait-for.ts index ad8abbb7..1bf96e68 100644 --- a/src/wait-for.ts +++ b/src/wait-for.ts @@ -1,4 +1,5 @@ /* globals jest */ +import act from './act'; import { getConfig } from './config'; import { flushMicroTasks } from './flush-micro-tasks'; import { copyStackTrace, ErrorWithStack } from './helpers/errors'; @@ -69,7 +70,7 @@ function waitForInternal( // third party code that's setting up recursive timers so rapidly that // the user's timer's don't get a chance to resolve. So we'll advance // by an interval instead. (We have a test for this case). - jest.advanceTimersByTime(interval); + await act(async () => await jest.advanceTimersByTime(interval)); // It's really important that checkExpectation is run *before* we flush // in-flight promises. To be honest, I'm not sure why, and I can't quite From 78150f62e9c57fbd2993c8868ce48f1f195a1f19 Mon Sep 17 00:00:00 2001 From: Maciej Jastrzebski Date: Mon, 4 Aug 2025 11:25:31 +0200 Subject: [PATCH 11/30] fix tests --- src/__tests__/fire-event-async.test.tsx | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/src/__tests__/fire-event-async.test.tsx b/src/__tests__/fire-event-async.test.tsx index 14d93b98..75240113 100644 --- a/src/__tests__/fire-event-async.test.tsx +++ b/src/__tests__/fire-event-async.test.tsx @@ -23,13 +23,6 @@ const OnPressComponent = ({ onPress, text }: OnPressComponentProps) => ( ); -type WithoutEventComponentProps = { onPress: () => void }; -const WithoutEventComponent = (_props: WithoutEventComponentProps) => ( - - Without event - -); - type CustomEventComponentProps = { onCustomEvent: () => void; }; @@ -75,16 +68,6 @@ describe('fireEventAsync', () => { 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'; From 4c0f1f5da561bbf2d771d6e5f0913b015519cb2f Mon Sep 17 00:00:00 2001 From: Maciej Jastrzebski Date: Mon, 4 Aug 2025 12:02:02 +0200 Subject: [PATCH 12/30] improve test coverage --- src/__tests__/fire-event-async.test.tsx | 18 +++ src/__tests__/fire-event.test.tsx | 44 ++++++++ src/__tests__/render-async.test.tsx | 143 ++++++++++++++++++++++++ src/__tests__/render.test.tsx | 27 +++++ 4 files changed, 232 insertions(+) create mode 100644 src/__tests__/render-async.test.tsx diff --git a/src/__tests__/fire-event-async.test.tsx b/src/__tests__/fire-event-async.test.tsx index 75240113..40089b02 100644 --- a/src/__tests__/fire-event-async.test.tsx +++ b/src/__tests__/fire-event-async.test.tsx @@ -641,3 +641,21 @@ describe('React.Suspense integration', () => { expect(screen.getByText('Loading data...')).toBeTruthy(); }); }); + +test('should handle unmounted elements gracefully in async mode', async () => { + const onPress = jest.fn(); + const result = render( + + Test + , + ); + + const element = screen.getByText('Test'); + + // Unmount the component + result.unmount(); + + // Firing async event on unmounted element should not crash + await fireEventAsync.press(element); + expect(onPress).not.toHaveBeenCalled(); +}); diff --git a/src/__tests__/fire-event.test.tsx b/src/__tests__/fire-event.test.tsx index f5e05486..c0c7c6b4 100644 --- a/src/__tests__/fire-event.test.tsx +++ b/src/__tests__/fire-event.test.tsx @@ -553,3 +553,47 @@ describe('native events', () => { expect(onMomentumScrollEndSpy).toHaveBeenCalled(); }); }); + +test('should handle unmounted elements gracefully', () => { + const onPress = jest.fn(); + const result = render( + + Test + , + ); + + const element = screen.getByText('Test'); + + // Unmount the component + result.unmount(); + + // Firing event on unmounted element should not crash + fireEvent.press(element); + expect(onPress).not.toHaveBeenCalled(); +}); + +test('should handle invalid scroll event data gracefully', () => { + const onScrollSpy = jest.fn(); + render(); + + const scrollView = screen.getByTestId('scroll-view'); + + // Test with malformed event data that would cause an error in tryGetContentOffset + fireEvent.scroll(scrollView, { malformed: 'data' }); + expect(onScrollSpy).toHaveBeenCalled(); +}); + +test('should handle scroll event with invalid contentOffset', () => { + const onScrollSpy = jest.fn(); + render(); + + const scrollView = screen.getByTestId('scroll-view'); + + // Test with event data that has invalid contentOffset structure + fireEvent.scroll(scrollView, { + nativeEvent: { + contentOffset: { x: 'invalid', y: null }, + }, + }); + expect(onScrollSpy).toHaveBeenCalled(); +}); diff --git a/src/__tests__/render-async.test.tsx b/src/__tests__/render-async.test.tsx new file mode 100644 index 00000000..ae7aaca3 --- /dev/null +++ b/src/__tests__/render-async.test.tsx @@ -0,0 +1,143 @@ +import * as React from 'react'; +import { Text, View } from 'react-native'; + +import { renderAsync, screen } from '..'; + +class Banana extends React.Component { + state = { + fresh: false, + }; + + componentDidUpdate() { + if (this.props.onUpdate) { + this.props.onUpdate(); + } + } + + componentWillUnmount() { + if (this.props.onUnmount) { + this.props.onUnmount(); + } + } + + changeFresh = () => { + this.setState((state) => ({ + fresh: !state.fresh, + })); + }; + + render() { + return ( + + Is the banana fresh? + {this.state.fresh ? 'fresh' : 'not fresh'} + + ); + } +} + +test('renderAsync renders component asynchronously', async () => { + await renderAsync(); + expect(screen.getByTestId('test')).toBeOnTheScreen(); +}); + +test('renderAsync with wrapper option', async () => { + const WrapperComponent = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + + await renderAsync(, { + wrapper: WrapperComponent, + }); + + expect(screen.getByTestId('wrapper')).toBeTruthy(); + expect(screen.getByTestId('inner')).toBeTruthy(); +}); + +test('renderAsync supports concurrent rendering option', async () => { + await renderAsync(, { concurrentRoot: true }); + expect(screen.root).toBeOnTheScreen(); +}); + +test('renderAsync supports legacy rendering option', async () => { + await renderAsync(, { concurrentRoot: false }); + expect(screen.root).toBeOnTheScreen(); +}); + +test('update function updates component synchronously', async () => { + const fn = jest.fn(); + const result = await renderAsync(); + + result.update(); + + expect(fn).toHaveBeenCalledTimes(1); +}); + +test('updateAsync function updates component asynchronously', async () => { + const fn = jest.fn(); + const result = await renderAsync(); + + await result.updateAsync(); + + expect(fn).toHaveBeenCalledTimes(1); +}); + +test('rerender is an alias for update', async () => { + const fn = jest.fn(); + const result = await renderAsync(); + + result.rerender(); + + expect(fn).toHaveBeenCalledTimes(1); +}); + +test('rerenderAsync is an alias for updateAsync', async () => { + const fn = jest.fn(); + const result = await renderAsync(); + + await result.rerenderAsync(); + + expect(fn).toHaveBeenCalledTimes(1); +}); + +test('unmount function unmounts component synchronously', async () => { + const fn = jest.fn(); + const result = await renderAsync(); + + result.unmount(); + + expect(fn).toHaveBeenCalled(); +}); + +test('unmountAsync function unmounts component asynchronously', async () => { + const fn = jest.fn(); + const result = await renderAsync(); + + await result.unmountAsync(); + + expect(fn).toHaveBeenCalled(); +}); + +test('container property displays deprecation message', async () => { + await renderAsync(); + + expect(() => (screen as any).container).toThrowErrorMatchingInlineSnapshot(` + "'container' property has been renamed to 'UNSAFE_root'. + + Consider using 'root' property which returns root host element." + `); +}); + +test('debug function handles null JSON', async () => { + const result = await renderAsync(); + + // Mock toJSON to return null to test the debug edge case + const originalToJSON = result.toJSON; + (result as any).toJSON = jest.fn().mockReturnValue(null); + + // This should not throw and handle null JSON gracefully + expect(() => result.debug()).not.toThrow(); + + // Restore original toJSON + (result as any).toJSON = originalToJSON; +}); diff --git a/src/__tests__/render.test.tsx b/src/__tests__/render.test.tsx index 6aa0769d..031e247b 100644 --- a/src/__tests__/render.test.tsx +++ b/src/__tests__/render.test.tsx @@ -243,3 +243,30 @@ test('supports concurrent rendering', () => { render(, { concurrentRoot: true }); expect(screen.root).toBeOnTheScreen(); }); + +test('updateAsync updates the component asynchronously', async () => { + const fn = jest.fn(); + const result = render(); + + await result.updateAsync(); + + expect(fn).toHaveBeenCalledTimes(1); +}); + +test('rerenderAsync is an alias for updateAsync', async () => { + const fn = jest.fn(); + const result = render(); + + await result.rerenderAsync(); + + expect(fn).toHaveBeenCalledTimes(1); +}); + +test('unmountAsync unmounts the component asynchronously', async () => { + const fn = jest.fn(); + const result = render(); + + await result.unmountAsync(); + + expect(fn).toHaveBeenCalled(); +}); From e4b4bea3468be74e5b27cad80ef538afb843f4a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Tue, 5 Aug 2025 15:54:47 +0200 Subject: [PATCH 13/30] throw on using incorrect `update`/`unmount` function --- src/render-async.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/render-async.tsx b/src/render-async.tsx index d191f329..91153f37 100644 --- a/src/render-async.tsx +++ b/src/render-async.tsx @@ -63,9 +63,9 @@ function buildRenderResult( const instance = renderer.root; const update = function (component: React.ReactElement) { - void act(() => { - renderer.update(wrap(component)); - }); + throw new Error( + '`update(...)` is not supported when using `renderAsync` use `await updateAsync(...)` instead', + ); }; const updateAsync = async function (component: React.ReactElement) { // eslint-disable-next-line require-await @@ -75,9 +75,9 @@ function buildRenderResult( }; const unmount = () => { - void act(() => { - renderer.unmount(); - }); + throw new Error( + '`unmount()` is not supported when using `renderAsync` use `await unmountAsync()` instead', + ); }; const unmountAsync = async () => { // eslint-disable-next-line require-await From 6aa17412f54ade4da0215f6631f59a25cfb08cf5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Tue, 5 Aug 2025 16:10:49 +0200 Subject: [PATCH 14/30] update docs --- .../docs/13.x/docs/api/events/fire-event.mdx | 57 ++++++++++++++++++- website/docs/13.x/docs/api/render.mdx | 40 +++++++++++++ website/docs/13.x/docs/api/screen.mdx | 51 +++++++++++++++++ 3 files changed, 147 insertions(+), 1 deletion(-) diff --git a/website/docs/13.x/docs/api/events/fire-event.mdx b/website/docs/13.x/docs/api/events/fire-event.mdx index 7f072d48..cd6d7c5b 100644 --- a/website/docs/13.x/docs/api/events/fire-event.mdx +++ b/website/docs/13.x/docs/api/events/fire-event.mdx @@ -32,7 +32,7 @@ test('fire changeText event', () => { ``` :::note -Please note that from version `7.0` `fireEvent` performs checks that should prevent events firing on disabled elements. +`fireEvent` performs checks that should prevent events firing on disabled elements. ::: An example using `fireEvent` with native events that aren't already aliased by the `fireEvent` api. @@ -156,3 +156,58 @@ fireEvent.scroll(screen.getByText('scroll-view'), eventData); Prefer using [`user.scrollTo`](docs/api/events/user-event#scrollto) over `fireEvent.scroll` for `ScrollView`, `FlatList`, and `SectionList` components. User Event provides a more realistic event simulation based on React Native runtime behavior. ::: + + +## `fireEventAsync` + +:::info RNTL minimal version + +This API requires RNTL v13.3.0 or later. + +::: + + +```ts +async function fireEventAsync(element: ReactTestInstance, eventName: string, ...data: unknown[]): Promise; +``` + +The `fireEventAsync` function is the async version of `fireEvent` designed for working with React 19 and React Suspense. It wraps event handler execution in async `act()`, making it suitable for event handlers that trigger suspense boundaries or other async behavior. + +```jsx +import { renderAsync, screen, fireEventAsync } from '@testing-library/react-native'; + +test('async fire event test', async () => { + await renderAsync(); + + // Use fireEventAsync when event handlers have async behavior + await fireEventAsync(screen.getByText('Async Button'), 'press'); + + expect(screen.getByText('Async operation completed')).toBeOnTheScreen(); +}); +``` + +Like `fireEvent`, `fireEventAsync` also provides convenience methods for common events: `fireEventAsync.press`, `fireEventAsync.changeText`, and `fireEventAsync.scroll`. + +### `fireEventAsync.press` {#async-press} + +``` +fireEventAsync.press: (element: ReactTestInstance, ...data: Array) => Promise +``` + +Async version of `fireEvent.press` designed for React 19 and React Suspense. Use when press event handlers trigger suspense boundaries or other async behavior. + +### `fireEventAsync.changeText` {#async-change-text} + +``` +fireEventAsync.changeText: (element: ReactTestInstance, ...data: Array) => Promise +``` + +Async version of `fireEvent.changeText` designed for React 19 and React Suspense. Use when changeText event handlers trigger suspense boundaries or other async behavior. + +### `fireEventAsync.scroll` {#async-scroll} + +``` +fireEventAsync.scroll: (element: ReactTestInstance, ...data: Array) => Promise +``` + +Async version of `fireEvent.scroll` designed for React 19 and React Suspense. Use when scroll event handlers trigger suspense boundaries or other async behavior. diff --git a/website/docs/13.x/docs/api/render.mdx b/website/docs/13.x/docs/api/render.mdx index 5022b87b..49af11c3 100644 --- a/website/docs/13.x/docs/api/render.mdx +++ b/website/docs/13.x/docs/api/render.mdx @@ -64,3 +64,43 @@ React Test Renderer does not enforce this check; hence, by default, React Native The `render` function returns the same queries and utilities as the [`screen`](docs/api/screen) object. We recommended using the `screen` object as more developer-friendly way. See [this article](https://kentcdodds.com/blog/common-mistakes-with-react-testing-library#not-using-screen) from Kent C. Dodds for more details. + +## `renderAsync` function + +:::info RNTL minimal version + +This API requires RNTL v13.3.0 or later. + +::: + +```jsx +async function renderAsync( + component: React.Element, + options?: RenderAsyncOptions +): Promise +``` + +The `renderAsync` function is the async version of `render` designed for working with React 19 and React Suspense. It allows components to be properly rendered when they contain suspense boundaries or async behavior that needs to complete before the render result is returned. + +```jsx +import { renderAsync, screen } from '@testing-library/react-native'; + +test('async component test', async () => { + await renderAsync(); + expect(screen.getAllByRole('button', { name: 'start' })).toBeOnTheScreen(); +}); +``` + +### Options + +`renderAsync` accepts the same options as `render`. + +### Result + +The `renderAsync` function returns a promise that resolves to the same queries and utilities as the [`screen`](docs/api/screen) object. We recommend using the `screen` object for queries and the async lifecycle methods from the render result when needed. + +:::note Async lifecycle methods + +When using `renderAsync`, you have to use correspodning lifecycle methods: `updateAsync`/`rerenderAsync` and `unmountAsync` instead of their sync versions. + +:: \ No newline at end of file diff --git a/website/docs/13.x/docs/api/screen.mdx b/website/docs/13.x/docs/api/screen.mdx index 73e99e83..2fecce1b 100644 --- a/website/docs/13.x/docs/api/screen.mdx +++ b/website/docs/13.x/docs/api/screen.mdx @@ -41,6 +41,35 @@ function rerender(element: React.Element): void; Re-render the in-memory tree with a new root element. This simulates a React update render at the root. If the new element has the same type (and `key`) as the previous element, the tree will be updated; otherwise, it will re-mount a new tree, in both cases triggering the appropriate lifecycle events. +### `rerenderAsync` + +_Also available under `updateAsync` alias_ + +:::info RNTL minimal version + +This API requires RNTL v13.3.0 or later. + +::: + +```ts +function rerenderAsync(element: React.Element): Promise; +``` + +Async versions of `rerender` designed for working with React 19 and React Suspense. These methods wait for async operations to complete during re-rendering, making them suitable for components that use suspense boundaries or other async behavior. + +```jsx +import { renderAsync, screen } from '@testing-library/react-native'; + +test('async rerender test', async () => { + await renderAsync(); + + // Use async rerender when component has suspense or async behavior + await screen.rerenderAsync(); + + expect(screen.getByText('updated')).toBeOnTheScreen(); +}); +``` + ### `unmount` ```ts @@ -50,7 +79,29 @@ function unmount(): void; Unmount the in-memory tree, triggering the appropriate lifecycle events. :::note + +Usually you should not need to call `unmount` as it is done automatically if your test runner supports `afterEach` hook (like Jest, mocha, Jasmine). + +::: + +### `unmountAsync` + +:::info RNTL minimal version + +This API requires RNTL v13.3.0 or later. + +::: + +```ts +function unmountAsync(): Promise; +``` + +Async version of `unmount` designed for working with React 19 and React Suspense. This method waits for async cleanup operations to complete during unmounting, making it suitable for components that have async cleanup behavior. + +:::note + Usually you should not need to call `unmount` as it is done automatically if your test runner supports `afterEach` hook (like Jest, mocha, Jasmine). + ::: ### `debug` From 784209277ddca9fc9f783da915e74fe34df86ee9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Tue, 5 Aug 2025 16:16:38 +0200 Subject: [PATCH 15/30] ensure `rerenderAsync` and `unmountAsync` called after `renderAsync` --- src/__tests__/act.test.tsx | 4 +-- src/__tests__/render-async.test.tsx | 48 +++++++++++++---------------- src/render-async.tsx | 16 +++++----- src/screen.ts | 4 +-- 4 files changed, 34 insertions(+), 38 deletions(-) diff --git a/src/__tests__/act.test.tsx b/src/__tests__/act.test.tsx index b398df77..f3b373df 100644 --- a/src/__tests__/act.test.tsx +++ b/src/__tests__/act.test.tsx @@ -23,10 +23,10 @@ test('render should trigger useEffect', () => { expect(effectCallback).toHaveBeenCalledTimes(1); }); -test('update should trigger useEffect', () => { +test('rerender should trigger useEffect', () => { const effectCallback = jest.fn(); render(); - screen.update(); + screen.rerender(); expect(effectCallback).toHaveBeenCalledTimes(2); }); diff --git a/src/__tests__/render-async.test.tsx b/src/__tests__/render-async.test.tsx index ae7aaca3..ba91e082 100644 --- a/src/__tests__/render-async.test.tsx +++ b/src/__tests__/render-async.test.tsx @@ -64,49 +64,46 @@ test('renderAsync supports legacy rendering option', async () => { expect(screen.root).toBeOnTheScreen(); }); -test('update function updates component synchronously', async () => { - const fn = jest.fn(); - const result = await renderAsync(); - - result.update(); +test('rerender function throws error when used with renderAsync', async () => { + const result = await renderAsync(); - expect(fn).toHaveBeenCalledTimes(1); + expect(() => result.rerender()).toThrowErrorMatchingInlineSnapshot( + `"\`rerender(...)\` is not supported when using \`renderAsync\` use \`await rerenderAsync(...)\` instead"` + ); }); -test('updateAsync function updates component asynchronously', async () => { +test('rerenderAsync function updates component asynchronously', async () => { const fn = jest.fn(); const result = await renderAsync(); - - await result.updateAsync(); - + expect(fn).toHaveBeenCalledTimes(0); + + await result.rerenderAsync(); expect(fn).toHaveBeenCalledTimes(1); }); -test('rerender is an alias for update', async () => { - const fn = jest.fn(); - const result = await renderAsync(); - - result.rerender(); +test('rerender function throws error when used with renderAsync', async () => { + const result = await renderAsync(); - expect(fn).toHaveBeenCalledTimes(1); + expect(() => result.rerender()).toThrowErrorMatchingInlineSnapshot( + `"\`rerender(...)\` is not supported when using \`renderAsync\` use \`await rerenderAsync(...)\` instead"` + ); }); -test('rerenderAsync is an alias for updateAsync', async () => { +test('rerenderAsync function updates component asynchronously', async () => { const fn = jest.fn(); const result = await renderAsync(); - + expect(fn).toHaveBeenCalledTimes(0); + await result.rerenderAsync(); - expect(fn).toHaveBeenCalledTimes(1); }); -test('unmount function unmounts component synchronously', async () => { - const fn = jest.fn(); - const result = await renderAsync(); - - result.unmount(); +test('unmount function throws error when used with renderAsync', async () => { + const result = await renderAsync(); - expect(fn).toHaveBeenCalled(); + expect(() => result.unmount()).toThrowErrorMatchingInlineSnapshot( + `"\`unmount()\` is not supported when using \`renderAsync\` use \`await unmountAsync()\` instead"` + ); }); test('unmountAsync function unmounts component asynchronously', async () => { @@ -114,7 +111,6 @@ test('unmountAsync function unmounts component asynchronously', async () => { const result = await renderAsync(); await result.unmountAsync(); - expect(fn).toHaveBeenCalled(); }); diff --git a/src/render-async.tsx b/src/render-async.tsx index 91153f37..15954fcf 100644 --- a/src/render-async.tsx +++ b/src/render-async.tsx @@ -62,12 +62,12 @@ function buildRenderResult( ) { const instance = renderer.root; - const update = function (component: React.ReactElement) { + const rerender = function (component: React.ReactElement) { throw new Error( - '`update(...)` is not supported when using `renderAsync` use `await updateAsync(...)` instead', + '`rerender(...)` is not supported when using `renderAsync` use `await rerenderAsync(...)` instead', ); }; - const updateAsync = async function (component: React.ReactElement) { + const rerenderAsync = async function (component: React.ReactElement) { // eslint-disable-next-line require-await await act(async () => { renderer.update(wrap(component)); @@ -86,14 +86,14 @@ function buildRenderResult( }); }; - addToCleanupQueue(unmount); + addToCleanupQueue(unmountAsync); const result = { ...getQueriesForElement(instance), - update, - updateAsync, - rerender: update, // alias for `update` - rerenderAsync: updateAsync, // alias for `update` + rerender, + rerenderAsync, + update: rerender, // alias for `rerender` + updateAsync: rerenderAsync, // alias for `rerenderAsync` unmount, unmountAsync, toJSON: renderer.toJSON, diff --git a/src/screen.ts b/src/screen.ts index 14173549..382aacf5 100644 --- a/src/screen.ts +++ b/src/screen.ts @@ -25,12 +25,12 @@ const defaultScreen: Screen = { throw new Error(SCREEN_ERROR); }, debug: notImplementedDebug, + rerender: notImplemented, + rerenderAsync: notImplemented, update: notImplemented, updateAsync: notImplemented, unmount: notImplemented, unmountAsync: notImplemented, - rerender: notImplemented, - rerenderAsync: notImplemented, toJSON: notImplemented, getByLabelText: notImplemented, getAllByLabelText: notImplemented, From 1aef26b53aea6bb4ce2b1c99532ba62aa5765345 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Tue, 5 Aug 2025 16:47:18 +0200 Subject: [PATCH 16/30] fix issues --- src/__tests__/render-async.test.tsx | 17 ++++------------- src/__tests__/render.test.tsx | 8 ++++---- src/render-async.tsx | 2 +- website/docs/13.x/docs/api/render.mdx | 8 ++++---- 4 files changed, 13 insertions(+), 22 deletions(-) diff --git a/src/__tests__/render-async.test.tsx b/src/__tests__/render-async.test.tsx index ba91e082..5f534a41 100644 --- a/src/__tests__/render-async.test.tsx +++ b/src/__tests__/render-async.test.tsx @@ -68,7 +68,7 @@ test('rerender function throws error when used with renderAsync', async () => { const result = await renderAsync(); expect(() => result.rerender()).toThrowErrorMatchingInlineSnapshot( - `"\`rerender(...)\` is not supported when using \`renderAsync\` use \`await rerenderAsync(...)\` instead"` + `"\`rerender(...)\` is not supported when using \`renderAsync\` use \`await rerenderAsync(...)\` instead"`, ); }); @@ -76,7 +76,7 @@ test('rerenderAsync function updates component asynchronously', async () => { const fn = jest.fn(); const result = await renderAsync(); expect(fn).toHaveBeenCalledTimes(0); - + await result.rerenderAsync(); expect(fn).toHaveBeenCalledTimes(1); }); @@ -85,24 +85,15 @@ test('rerender function throws error when used with renderAsync', async () => { const result = await renderAsync(); expect(() => result.rerender()).toThrowErrorMatchingInlineSnapshot( - `"\`rerender(...)\` is not supported when using \`renderAsync\` use \`await rerenderAsync(...)\` instead"` + `"\`rerender(...)\` is not supported when using \`renderAsync\` use \`await rerenderAsync(...)\` instead"`, ); }); -test('rerenderAsync function updates component asynchronously', async () => { - const fn = jest.fn(); - const result = await renderAsync(); - expect(fn).toHaveBeenCalledTimes(0); - - await result.rerenderAsync(); - expect(fn).toHaveBeenCalledTimes(1); -}); - test('unmount function throws error when used with renderAsync', async () => { const result = await renderAsync(); expect(() => result.unmount()).toThrowErrorMatchingInlineSnapshot( - `"\`unmount()\` is not supported when using \`renderAsync\` use \`await unmountAsync()\` instead"` + `"\`unmount()\` is not supported when using \`renderAsync\` use \`await unmountAsync()\` instead"`, ); }); diff --git a/src/__tests__/render.test.tsx b/src/__tests__/render.test.tsx index 031e247b..f220d007 100644 --- a/src/__tests__/render.test.tsx +++ b/src/__tests__/render.test.tsx @@ -110,16 +110,16 @@ test('UNSAFE_getAllByProp, UNSAFE_queryAllByProps', () => { expect(screen.UNSAFE_queryAllByProps({ type: 'inexistent' })).toHaveLength(0); }); -test('update', () => { +test('rerender', () => { const fn = jest.fn(); render(); + expect(fn).toHaveBeenCalledTimes(0); fireEvent.press(screen.getByText('Change freshness!')); + expect(fn).toHaveBeenCalledTimes(1); - screen.update(); screen.rerender(); - - expect(fn).toHaveBeenCalledTimes(3); + expect(fn).toHaveBeenCalledTimes(2); }); test('unmount', () => { diff --git a/src/render-async.tsx b/src/render-async.tsx index 15954fcf..01b387bd 100644 --- a/src/render-async.tsx +++ b/src/render-async.tsx @@ -62,7 +62,7 @@ function buildRenderResult( ) { const instance = renderer.root; - const rerender = function (component: React.ReactElement) { + const rerender = function (_component: React.ReactElement) { throw new Error( '`rerender(...)` is not supported when using `renderAsync` use `await rerenderAsync(...)` instead', ); diff --git a/website/docs/13.x/docs/api/render.mdx b/website/docs/13.x/docs/api/render.mdx index 49af11c3..9dc01942 100644 --- a/website/docs/13.x/docs/api/render.mdx +++ b/website/docs/13.x/docs/api/render.mdx @@ -97,10 +97,10 @@ test('async component test', async () => { ### Result -The `renderAsync` function returns a promise that resolves to the same queries and utilities as the [`screen`](docs/api/screen) object. We recommend using the `screen` object for queries and the async lifecycle methods from the render result when needed. +The `renderAsync` function returns a promise that resolves to the same queries and utilities as the [`screen`](docs/api/screen) object. We recommend using the `screen` object for queries and the lifecycle methods from the render result when needed. -:::note Async lifecycle methods +:::warning Async lifecycle methods -When using `renderAsync`, you have to use correspodning lifecycle methods: `updateAsync`/`rerenderAsync` and `unmountAsync` instead of their sync versions. +When using `renderAsync`, you have to use correspodning lifecycle methods: `rerenderAsync` and `unmountAsync` instead of their sync versions. -:: \ No newline at end of file +::: From 06591f3a1442d09b69ee3b5a64585e6224d605c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Tue, 5 Aug 2025 20:27:58 +0200 Subject: [PATCH 17/30] fix lint --- src/__tests__/render-async.test.tsx | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/__tests__/render-async.test.tsx b/src/__tests__/render-async.test.tsx index 5f534a41..77167508 100644 --- a/src/__tests__/render-async.test.tsx +++ b/src/__tests__/render-async.test.tsx @@ -81,14 +81,6 @@ test('rerenderAsync function updates component asynchronously', async () => { expect(fn).toHaveBeenCalledTimes(1); }); -test('rerender function throws error when used with renderAsync', async () => { - const result = await renderAsync(); - - expect(() => result.rerender()).toThrowErrorMatchingInlineSnapshot( - `"\`rerender(...)\` is not supported when using \`renderAsync\` use \`await rerenderAsync(...)\` instead"`, - ); -}); - test('unmount function throws error when used with renderAsync', async () => { const result = await renderAsync(); From 3121bd8a4ff511aa720b1c0edcb4ac03ae5923e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Wed, 6 Aug 2025 08:21:23 +0200 Subject: [PATCH 18/30] suspense tests 1 --- src/__tests__/suspense-fake-timers.test.tsx | 181 +++++++++++++++++++ src/__tests__/suspense.test.tsx | 183 ++++++++++++++++++++ 2 files changed, 364 insertions(+) diff --git a/src/__tests__/suspense-fake-timers.test.tsx b/src/__tests__/suspense-fake-timers.test.tsx index 6e5feb04..97ab0c99 100644 --- a/src/__tests__/suspense-fake-timers.test.tsx +++ b/src/__tests__/suspense-fake-timers.test.tsx @@ -59,3 +59,184 @@ testGateReact19('resolves timer-controlled promise', async () => { expect(screen.getByTestId('sibling')).toBeOnTheScreen(); expect(screen.queryByText('Loading...')).not.toBeOnTheScreen(); }); + +function DelayedSuspending({ delay }: { delay: number }) { + const promise = React.useMemo(() => + new Promise((resolve) => { + setTimeout(() => resolve(`data-${delay}`), delay); + }), [delay] + ); + + const data = React.use(promise); + return ; +} + +testGateReact19('handles multiple delays with fake timers', async () => { + await renderAsync( + + Fast Loading...}> + + + Slow Loading...}> + + + , + ); + + expect(screen.getByText('Fast Loading...')).toBeOnTheScreen(); + expect(screen.getByText('Slow Loading...')).toBeOnTheScreen(); + + // Fast timer completes first + expect(await screen.findByTestId('delayed-content-data-50')).toBeOnTheScreen(); + expect(screen.queryByText('Fast Loading...')).not.toBeOnTheScreen(); + expect(screen.getByText('Slow Loading...')).toBeOnTheScreen(); + + // Slow timer completes later + expect(await screen.findByTestId('delayed-content-data-200')).toBeOnTheScreen(); + expect(screen.queryByText('Slow Loading...')).not.toBeOnTheScreen(); +}); + +function IntervalSuspending({ interval }: { interval: number }) { + const [count, setCount] = React.useState(0); + + React.useEffect(() => { + const timer = setInterval(() => setCount(c => c + 1), interval); + return () => clearInterval(timer); + }, [interval]); + + if (count < 3) { + const promise = new Promise(() => {}); // Never resolves until count >= 3 + React.use(promise); + } + + return ; +} + +testGateReact19('handles interval-based suspense with fake timers', async () => { + await renderAsync( + Interval Loading...}> + + , + ); + + expect(screen.getByText('Interval Loading...')).toBeOnTheScreen(); + + // Should resolve after enough intervals pass + expect(await screen.findByTestId('interval-content-3')).toBeOnTheScreen(); + expect(screen.queryByText('Interval Loading...')).not.toBeOnTheScreen(); +}); + +function AnimationSuspending() { + const [progress, setProgress] = React.useState(0); + + React.useEffect(() => { + const animate = () => { + setProgress(p => { + if (p >= 100) return 100; + setTimeout(animate, 16); // 60fps + return p + 1; + }); + }; + animate(); + }, []); + + if (progress < 100) { + const promise = new Promise(() => {}); // Suspend until animation complete + React.use(promise); + } + + return ; +} + +testGateReact19('handles animation-like suspense with fake timers', async () => { + await renderAsync( + Animating...}> + + , + ); + + expect(screen.getByText('Animating...')).toBeOnTheScreen(); + expect(screen.queryByTestId('animation-complete')).not.toBeOnTheScreen(); + + // Should complete after animation finishes + expect(await screen.findByTestId('animation-complete')).toBeOnTheScreen(); + expect(screen.queryByText('Animating...')).not.toBeOnTheScreen(); +}); + +function RetryingSuspending({ maxRetries = 3 }: { maxRetries?: number }) { + const [retries, setRetries] = React.useState(0); + + const promise = React.useMemo(() => { + if (retries < maxRetries) { + // Simulate a failing request that retries + setTimeout(() => setRetries(r => r + 1), 100); + return new Promise(() => {}); // Never resolves, will retry + } + // Success case + return Promise.resolve('success'); + }, [retries, maxRetries]); + + const data = React.use(promise); + return ; +} + +testGateReact19('handles retry logic with fake timers', async () => { + await renderAsync( + Retrying...}> + + , + ); + + expect(screen.getByText('Retrying...')).toBeOnTheScreen(); + expect(screen.queryByTestId('retry-content-success')).not.toBeOnTheScreen(); + + // Should eventually succeed after retries + expect(await screen.findByTestId('retry-content-success')).toBeOnTheScreen(); + expect(screen.queryByText('Retrying...')).not.toBeOnTheScreen(); +}); + +function CascadingSuspending({ level }: { level: number }) { + const delay = level * 50; + const promise = React.useMemo(() => + new Promise((resolve) => { + setTimeout(() => resolve(level), delay); + }), [delay, level] + ); + + const data = React.use(promise); + + if (level > 1) { + return ( + + + Cascade Loading {level - 1}...}> + + + + ); + } + + return ; +} + +testGateReact19('handles cascading suspense with fake timers', async () => { + await renderAsync( + Cascade Loading 3...}> + + , + ); + + expect(screen.getByText('Cascade Loading 3...')).toBeOnTheScreen(); + + // Should resolve level by level + expect(await screen.findByTestId('cascade-3')).toBeOnTheScreen(); + expect(screen.getByText('Cascade Loading 2...')).toBeOnTheScreen(); + + expect(await screen.findByTestId('cascade-2')).toBeOnTheScreen(); + expect(screen.getByText('Cascade Loading 1...')).toBeOnTheScreen(); + + expect(await screen.findByTestId('cascade-1')).toBeOnTheScreen(); + expect(screen.queryByText('Cascade Loading 1...')).not.toBeOnTheScreen(); + expect(screen.queryByText('Cascade Loading 2...')).not.toBeOnTheScreen(); + expect(screen.queryByText('Cascade Loading 3...')).not.toBeOnTheScreen(); +}); diff --git a/src/__tests__/suspense.test.tsx b/src/__tests__/suspense.test.tsx index 50a72353..b86f48ee 100644 --- a/src/__tests__/suspense.test.tsx +++ b/src/__tests__/suspense.test.tsx @@ -56,3 +56,186 @@ testGateReact19('resolves timer-controlled promise', async () => { expect(screen.getByTestId('sibling')).toBeOnTheScreen(); expect(screen.queryByText('Loading...')).not.toBeOnTheScreen(); }); + +function SuspendingWithError({ promise }: { promise: Promise }) { + React.use(promise); + return ; +} + +class ErrorBoundary extends React.Component< + { children: React.ReactNode; fallback?: React.ReactNode }, + { hasError: boolean } +> { + constructor(props: { children: React.ReactNode; fallback?: React.ReactNode }) { + super(props); + this.state = { hasError: false }; + } + + static getDerivedStateFromError() { + return { hasError: true }; + } + + render() { + if (this.state.hasError) { + return this.props.fallback || Something went wrong.; + } + + return this.props.children; + } +} + +testGateReact19('handles promise rejection with error boundary', async () => { + let rejectPromise: (error: Error) => void; + const promise = new Promise((_, reject) => { + rejectPromise = reject; + }); + + await renderAsync( + Error occurred}> + Loading...}> + + + , + ); + + expect(screen.getByText('Loading...')).toBeOnTheScreen(); + expect(screen.queryByTestId('error-content')).not.toBeOnTheScreen(); + + // eslint-disable-next-line require-await + await act(async () => rejectPromise(new Error('Test error'))); + + expect(screen.getByText('Error occurred')).toBeOnTheScreen(); + expect(screen.queryByText('Loading...')).not.toBeOnTheScreen(); + expect(screen.queryByTestId('error-content')).not.toBeOnTheScreen(); +}); + +function NestedSuspending({ promise }: { promise: Promise }) { + React.use(promise); + return ( + Inner Loading...}> + + + ); +} + +testGateReact19('handles nested suspense boundaries', async () => { + let resolveOuterPromise: (value: unknown) => void; + const outerPromise = new Promise((resolve) => { + resolveOuterPromise = resolve; + }); + + await renderAsync( + Outer Loading...}> + + , + ); + + expect(screen.getByText('Outer Loading...')).toBeOnTheScreen(); + expect(screen.queryByText('Inner Loading...')).not.toBeOnTheScreen(); + + // eslint-disable-next-line require-await + await act(async () => resolveOuterPromise(null)); + + expect(screen.getByTestId('inner-resolved')).toBeOnTheScreen(); + expect(screen.queryByText('Outer Loading...')).not.toBeOnTheScreen(); + expect(screen.queryByText('Inner Loading...')).not.toBeOnTheScreen(); +}); + +function MultipleSuspending({ promises }: { promises: Promise[] }) { + promises.forEach((promise) => React.use(promise)); + return ; +} + +testGateReact19('handles multiple suspending promises in same boundary', async () => { + let resolvePromise1: (value: unknown) => void; + let resolvePromise2: (value: unknown) => void; + + const promise1 = new Promise((resolve) => { + resolvePromise1 = resolve; + }); + const promise2 = new Promise((resolve) => { + resolvePromise2 = resolve; + }); + + await renderAsync( + Multiple Loading...}> + + , + ); + + expect(screen.getByText('Multiple Loading...')).toBeOnTheScreen(); + expect(screen.queryByTestId('multiple-content')).not.toBeOnTheScreen(); + + // Resolve first promise - should still be loading + // eslint-disable-next-line require-await + await act(async () => resolvePromise1(null)); + expect(screen.getByText('Multiple Loading...')).toBeOnTheScreen(); + expect(screen.queryByTestId('multiple-content')).not.toBeOnTheScreen(); + + // Resolve second promise - should now render content + // eslint-disable-next-line require-await + await act(async () => resolvePromise2(null)); + expect(screen.getByTestId('multiple-content')).toBeOnTheScreen(); + expect(screen.queryByText('Multiple Loading...')).not.toBeOnTheScreen(); +}); + +function ConditionalSuspending({ shouldSuspend }: { shouldSuspend: boolean }) { + if (shouldSuspend) { + const promise = new Promise(() => {}); // Never resolves + React.use(promise); + } + return ; +} + +testGateReact19('handles conditional suspense', async () => { + const result = await renderAsync( + Conditional Loading...}> + + , + ); + + // Should render immediately when not suspending + expect(screen.getByTestId('conditional-content')).toBeOnTheScreen(); + expect(screen.queryByText('Conditional Loading...')).not.toBeOnTheScreen(); + + // Re-render with suspense + await result.rerenderAsync( + Conditional Loading...}> + + , + ); + + expect(screen.getByText('Conditional Loading...')).toBeOnTheScreen(); + expect(screen.queryByTestId('conditional-content')).not.toBeOnTheScreen(); +}); + +function SuspendingWithState() { + const [count, setCount] = React.useState(0); + + React.useEffect(() => { + const timer = setTimeout(() => setCount(1), 50); + return () => clearTimeout(timer); + }, []); + + if (count === 0) { + const promise = new Promise(() => {}); // Never resolves + React.use(promise); + } + + return ; +} + +testGateReact19('handles suspense with state updates', async () => { + await renderAsync( + State Loading...}> + + , + ); + + expect(screen.getByText('State Loading...')).toBeOnTheScreen(); + expect(screen.queryByTestId('state-content-0')).not.toBeOnTheScreen(); + + // Wait for state update to resolve suspense + expect(await screen.findByTestId('state-content-1')).toBeOnTheScreen(); + expect(screen.queryByText('State Loading...')).not.toBeOnTheScreen(); +}); From 45e7e0e86dbc689d40c58236b169379d2aef7fbf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Wed, 6 Aug 2025 08:21:30 +0200 Subject: [PATCH 19/30] suspense test 2 --- src/__tests__/suspense-fake-timers.test.tsx | 98 ++++++++++++--------- src/__tests__/suspense.test.tsx | 50 +++++++---- 2 files changed, 89 insertions(+), 59 deletions(-) diff --git a/src/__tests__/suspense-fake-timers.test.tsx b/src/__tests__/suspense-fake-timers.test.tsx index 97ab0c99..9847ae19 100644 --- a/src/__tests__/suspense-fake-timers.test.tsx +++ b/src/__tests__/suspense-fake-timers.test.tsx @@ -60,70 +60,86 @@ testGateReact19('resolves timer-controlled promise', async () => { expect(screen.queryByText('Loading...')).not.toBeOnTheScreen(); }); -function DelayedSuspending({ delay }: { delay: number }) { +function DelayedSuspending({ delay, id }: { delay: number; id: string }) { + let resolvePromise: (value: string) => void; const promise = React.useMemo(() => - new Promise((resolve) => { - setTimeout(() => resolve(`data-${delay}`), delay); - }), [delay] + new Promise((resolve) => { + resolvePromise = resolve; + setTimeout(() => resolve(`data-${id}`), delay); + }), [delay, id] ); const data = React.use(promise); return ; } -testGateReact19('handles multiple delays with fake timers', async () => { +testGateReact19('handles timer-based promises with fake timers', async () => { + let resolveManual: (value: unknown) => void; + const manualPromise = new Promise((resolve) => { + resolveManual = resolve; + }); + await renderAsync( - Fast Loading...}> - - - Slow Loading...}> - + Manual Loading...}> + + , ); - expect(screen.getByText('Fast Loading...')).toBeOnTheScreen(); - expect(screen.getByText('Slow Loading...')).toBeOnTheScreen(); + expect(screen.getByText('Manual Loading...')).toBeOnTheScreen(); + expect(screen.getByTestId('outside-suspense')).toBeOnTheScreen(); - // Fast timer completes first - expect(await screen.findByTestId('delayed-content-data-50')).toBeOnTheScreen(); - expect(screen.queryByText('Fast Loading...')).not.toBeOnTheScreen(); - expect(screen.getByText('Slow Loading...')).toBeOnTheScreen(); - - // Slow timer completes later - expect(await screen.findByTestId('delayed-content-data-200')).toBeOnTheScreen(); - expect(screen.queryByText('Slow Loading...')).not.toBeOnTheScreen(); + // eslint-disable-next-line require-await + await act(async () => resolveManual(null)); + expect(screen.getByTestId('content')).toBeOnTheScreen(); + expect(screen.queryByText('Manual Loading...')).not.toBeOnTheScreen(); }); -function IntervalSuspending({ interval }: { interval: number }) { - const [count, setCount] = React.useState(0); - - React.useEffect(() => { - const timer = setInterval(() => setCount(c => c + 1), interval); - return () => clearInterval(timer); - }, [interval]); +class ErrorBoundary extends React.Component< + { children: React.ReactNode; fallback?: React.ReactNode }, + { hasError: boolean } +> { + constructor(props: { children: React.ReactNode; fallback?: React.ReactNode }) { + super(props); + this.state = { hasError: false }; + } - if (count < 3) { - const promise = new Promise(() => {}); // Never resolves until count >= 3 - React.use(promise); + static getDerivedStateFromError() { + return { hasError: true }; + } + + render() { + if (this.state.hasError) { + return this.props.fallback || Something went wrong.; + } + + return this.props.children; } - - return ; } -testGateReact19('handles interval-based suspense with fake timers', async () => { +testGateReact19('handles suspense with error boundary in fake timers', async () => { + let rejectPromise: (error: Error) => void; + const promise = new Promise((_, reject) => { + rejectPromise = reject; + }); + await renderAsync( - Interval Loading...}> - - , + Error occurred}> + Loading...}> + + + , ); - expect(screen.getByText('Interval Loading...')).toBeOnTheScreen(); - - // Should resolve after enough intervals pass - expect(await screen.findByTestId('interval-content-3')).toBeOnTheScreen(); - expect(screen.queryByText('Interval Loading...')).not.toBeOnTheScreen(); + expect(screen.getByText('Loading...')).toBeOnTheScreen(); + + // eslint-disable-next-line require-await + await act(async () => rejectPromise(new Error('Test error'))); + + expect(screen.getByText('Error occurred')).toBeOnTheScreen(); + expect(screen.queryByText('Loading...')).not.toBeOnTheScreen(); }); function AnimationSuspending() { diff --git a/src/__tests__/suspense.test.tsx b/src/__tests__/suspense.test.tsx index b86f48ee..2922fe8c 100644 --- a/src/__tests__/suspense.test.tsx +++ b/src/__tests__/suspense.test.tsx @@ -141,9 +141,14 @@ testGateReact19('handles nested suspense boundaries', async () => { expect(screen.queryByText('Inner Loading...')).not.toBeOnTheScreen(); }); -function MultipleSuspending({ promises }: { promises: Promise[] }) { - promises.forEach((promise) => React.use(promise)); - return ; +function FirstSuspending({ promise }: { promise: Promise }) { + React.use(promise); + return ; +} + +function SecondSuspending({ promise }: { promise: Promise }) { + React.use(promise); + return ; } testGateReact19('handles multiple suspending promises in same boundary', async () => { @@ -159,29 +164,37 @@ testGateReact19('handles multiple suspending promises in same boundary', async ( await renderAsync( Multiple Loading...}> - + + , ); expect(screen.getByText('Multiple Loading...')).toBeOnTheScreen(); - expect(screen.queryByTestId('multiple-content')).not.toBeOnTheScreen(); + expect(screen.queryByTestId('first-resolved')).not.toBeOnTheScreen(); + expect(screen.queryByTestId('second-resolved')).not.toBeOnTheScreen(); - // Resolve first promise - should still be loading + // Resolve first promise - should still be loading because second is pending // eslint-disable-next-line require-await await act(async () => resolvePromise1(null)); expect(screen.getByText('Multiple Loading...')).toBeOnTheScreen(); - expect(screen.queryByTestId('multiple-content')).not.toBeOnTheScreen(); + expect(screen.queryByTestId('first-resolved')).not.toBeOnTheScreen(); + expect(screen.queryByTestId('second-resolved')).not.toBeOnTheScreen(); - // Resolve second promise - should now render content + // Resolve second promise - should now render all content // eslint-disable-next-line require-await await act(async () => resolvePromise2(null)); - expect(screen.getByTestId('multiple-content')).toBeOnTheScreen(); + expect(screen.getByTestId('first-resolved')).toBeOnTheScreen(); + expect(screen.getByTestId('second-resolved')).toBeOnTheScreen(); expect(screen.queryByText('Multiple Loading...')).not.toBeOnTheScreen(); }); -function ConditionalSuspending({ shouldSuspend }: { shouldSuspend: boolean }) { +function ConditionalSuspending({ shouldSuspend, promiseResolver }: { shouldSuspend: boolean; promiseResolver?: () => void }) { if (shouldSuspend) { - const promise = new Promise(() => {}); // Never resolves + const promise = React.useMemo(() => new Promise((resolve) => { + if (promiseResolver) { + promiseResolver = resolve; + } + }), [promiseResolver]); React.use(promise); } return ; @@ -198,31 +211,32 @@ testGateReact19('handles conditional suspense', async () => { expect(screen.getByTestId('conditional-content')).toBeOnTheScreen(); expect(screen.queryByText('Conditional Loading...')).not.toBeOnTheScreen(); - // Re-render with suspense + // Re-render with suspense - this creates a new component that will suspend await result.rerenderAsync( Conditional Loading...}> , ); + // Should now be suspended expect(screen.getByText('Conditional Loading...')).toBeOnTheScreen(); expect(screen.queryByTestId('conditional-content')).not.toBeOnTheScreen(); }); function SuspendingWithState() { - const [count, setCount] = React.useState(0); + const [isReady, setIsReady] = React.useState(false); React.useEffect(() => { - const timer = setTimeout(() => setCount(1), 50); + const timer = setTimeout(() => setIsReady(true), 100); return () => clearTimeout(timer); }, []); - if (count === 0) { + if (!isReady) { const promise = new Promise(() => {}); // Never resolves React.use(promise); } - return ; + return ; } testGateReact19('handles suspense with state updates', async () => { @@ -233,9 +247,9 @@ testGateReact19('handles suspense with state updates', async () => { ); expect(screen.getByText('State Loading...')).toBeOnTheScreen(); - expect(screen.queryByTestId('state-content-0')).not.toBeOnTheScreen(); + expect(screen.queryByTestId('state-ready-content')).not.toBeOnTheScreen(); // Wait for state update to resolve suspense - expect(await screen.findByTestId('state-content-1')).toBeOnTheScreen(); + expect(await screen.findByTestId('state-ready-content')).toBeOnTheScreen(); expect(screen.queryByText('State Loading...')).not.toBeOnTheScreen(); }); From b9dfb88763e6c132e84d73960534f7a439a5835e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Wed, 6 Aug 2025 08:21:52 +0200 Subject: [PATCH 20/30] suspense tests 3 --- src/__tests__/suspense-fake-timers.test.tsx | 133 ++++---------------- 1 file changed, 26 insertions(+), 107 deletions(-) diff --git a/src/__tests__/suspense-fake-timers.test.tsx b/src/__tests__/suspense-fake-timers.test.tsx index 9847ae19..6f943f94 100644 --- a/src/__tests__/suspense-fake-timers.test.tsx +++ b/src/__tests__/suspense-fake-timers.test.tsx @@ -142,117 +142,36 @@ testGateReact19('handles suspense with error boundary in fake timers', async () expect(screen.queryByText('Loading...')).not.toBeOnTheScreen(); }); -function AnimationSuspending() { - const [progress, setProgress] = React.useState(0); +function MultiComponentSuspense() { + let resolveFirst: (value: unknown) => void; + let resolveSecond: (value: unknown) => void; - React.useEffect(() => { - const animate = () => { - setProgress(p => { - if (p >= 100) return 100; - setTimeout(animate, 16); // 60fps - return p + 1; - }); - }; - animate(); - }, []); - - if (progress < 100) { - const promise = new Promise(() => {}); // Suspend until animation complete - React.use(promise); - } - - return ; -} - -testGateReact19('handles animation-like suspense with fake timers', async () => { - await renderAsync( - Animating...}> - - , - ); - - expect(screen.getByText('Animating...')).toBeOnTheScreen(); - expect(screen.queryByTestId('animation-complete')).not.toBeOnTheScreen(); - - // Should complete after animation finishes - expect(await screen.findByTestId('animation-complete')).toBeOnTheScreen(); - expect(screen.queryByText('Animating...')).not.toBeOnTheScreen(); -}); - -function RetryingSuspending({ maxRetries = 3 }: { maxRetries?: number }) { - const [retries, setRetries] = React.useState(0); - - const promise = React.useMemo(() => { - if (retries < maxRetries) { - // Simulate a failing request that retries - setTimeout(() => setRetries(r => r + 1), 100); - return new Promise(() => {}); // Never resolves, will retry - } - // Success case - return Promise.resolve('success'); - }, [retries, maxRetries]); + const firstPromise = new Promise((resolve) => { + resolveFirst = resolve; + }); + const secondPromise = new Promise((resolve) => { + resolveSecond = resolve; + }); - const data = React.use(promise); - return ; -} - -testGateReact19('handles retry logic with fake timers', async () => { - await renderAsync( - Retrying...}> - - , - ); - - expect(screen.getByText('Retrying...')).toBeOnTheScreen(); - expect(screen.queryByTestId('retry-content-success')).not.toBeOnTheScreen(); - - // Should eventually succeed after retries - expect(await screen.findByTestId('retry-content-success')).toBeOnTheScreen(); - expect(screen.queryByText('Retrying...')).not.toBeOnTheScreen(); -}); - -function CascadingSuspending({ level }: { level: number }) { - const delay = level * 50; - const promise = React.useMemo(() => - new Promise((resolve) => { - setTimeout(() => resolve(level), delay); - }), [delay, level] + return ( + + First Loading...}> + + + Second Loading...}> + + + + + ); - - const data = React.use(promise); - - if (level > 1) { - return ( - - - Cascade Loading {level - 1}...}> - - - - ); - } - - return ; } -testGateReact19('handles cascading suspense with fake timers', async () => { - await renderAsync( - Cascade Loading 3...}> - - , - ); - - expect(screen.getByText('Cascade Loading 3...')).toBeOnTheScreen(); - - // Should resolve level by level - expect(await screen.findByTestId('cascade-3')).toBeOnTheScreen(); - expect(screen.getByText('Cascade Loading 2...')).toBeOnTheScreen(); +testGateReact19('handles multiple independent suspense boundaries', async () => { + await renderAsync(); - expect(await screen.findByTestId('cascade-2')).toBeOnTheScreen(); - expect(screen.getByText('Cascade Loading 1...')).toBeOnTheScreen(); - - expect(await screen.findByTestId('cascade-1')).toBeOnTheScreen(); - expect(screen.queryByText('Cascade Loading 1...')).not.toBeOnTheScreen(); - expect(screen.queryByText('Cascade Loading 2...')).not.toBeOnTheScreen(); - expect(screen.queryByText('Cascade Loading 3...')).not.toBeOnTheScreen(); + expect(screen.getByText('First Loading...')).toBeOnTheScreen(); + expect(screen.getByText('Second Loading...')).toBeOnTheScreen(); + expect(screen.queryByTestId('content')).not.toBeOnTheScreen(); + expect(screen.queryByTestId('second-boundary')).not.toBeOnTheScreen(); }); From c6e46a1437dd986d96ae70708000a8e15a22d865 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Wed, 6 Aug 2025 08:23:13 +0200 Subject: [PATCH 21/30] suspense test 4 --- src/__tests__/suspense.test.tsx | 137 +++++--------------------------- 1 file changed, 22 insertions(+), 115 deletions(-) diff --git a/src/__tests__/suspense.test.tsx b/src/__tests__/suspense.test.tsx index 2922fe8c..6d909d5a 100644 --- a/src/__tests__/suspense.test.tsx +++ b/src/__tests__/suspense.test.tsx @@ -118,40 +118,7 @@ function NestedSuspending({ promise }: { promise: Promise }) { ); } -testGateReact19('handles nested suspense boundaries', async () => { - let resolveOuterPromise: (value: unknown) => void; - const outerPromise = new Promise((resolve) => { - resolveOuterPromise = resolve; - }); - - await renderAsync( - Outer Loading...}> - - , - ); - - expect(screen.getByText('Outer Loading...')).toBeOnTheScreen(); - expect(screen.queryByText('Inner Loading...')).not.toBeOnTheScreen(); - - // eslint-disable-next-line require-await - await act(async () => resolveOuterPromise(null)); - - expect(screen.getByTestId('inner-resolved')).toBeOnTheScreen(); - expect(screen.queryByText('Outer Loading...')).not.toBeOnTheScreen(); - expect(screen.queryByText('Inner Loading...')).not.toBeOnTheScreen(); -}); - -function FirstSuspending({ promise }: { promise: Promise }) { - React.use(promise); - return ; -} - -function SecondSuspending({ promise }: { promise: Promise }) { - React.use(promise); - return ; -} - -testGateReact19('handles multiple suspending promises in same boundary', async () => { +testGateReact19('handles multiple suspense boundaries independently', async () => { let resolvePromise1: (value: unknown) => void; let resolvePromise2: (value: unknown) => void; @@ -163,93 +130,33 @@ testGateReact19('handles multiple suspending promises in same boundary', async ( }); await renderAsync( - Multiple Loading...}> - - - , + + First Loading...}> + + + Second Loading...}> + + + + + ); - expect(screen.getByText('Multiple Loading...')).toBeOnTheScreen(); - expect(screen.queryByTestId('first-resolved')).not.toBeOnTheScreen(); - expect(screen.queryByTestId('second-resolved')).not.toBeOnTheScreen(); + expect(screen.getByText('First Loading...')).toBeOnTheScreen(); + expect(screen.getByText('Second Loading...')).toBeOnTheScreen(); + expect(screen.queryByTestId('content')).not.toBeOnTheScreen(); + expect(screen.queryByTestId('second-boundary')).not.toBeOnTheScreen(); - // Resolve first promise - should still be loading because second is pending + // Resolve first promise // eslint-disable-next-line require-await await act(async () => resolvePromise1(null)); - expect(screen.getByText('Multiple Loading...')).toBeOnTheScreen(); - expect(screen.queryByTestId('first-resolved')).not.toBeOnTheScreen(); - expect(screen.queryByTestId('second-resolved')).not.toBeOnTheScreen(); + expect(screen.getByTestId('content')).toBeOnTheScreen(); + expect(screen.queryByText('First Loading...')).not.toBeOnTheScreen(); + expect(screen.getByText('Second Loading...')).toBeOnTheScreen(); - // Resolve second promise - should now render all content + // Resolve second promise // eslint-disable-next-line require-await await act(async () => resolvePromise2(null)); - expect(screen.getByTestId('first-resolved')).toBeOnTheScreen(); - expect(screen.getByTestId('second-resolved')).toBeOnTheScreen(); - expect(screen.queryByText('Multiple Loading...')).not.toBeOnTheScreen(); -}); - -function ConditionalSuspending({ shouldSuspend, promiseResolver }: { shouldSuspend: boolean; promiseResolver?: () => void }) { - if (shouldSuspend) { - const promise = React.useMemo(() => new Promise((resolve) => { - if (promiseResolver) { - promiseResolver = resolve; - } - }), [promiseResolver]); - React.use(promise); - } - return ; -} - -testGateReact19('handles conditional suspense', async () => { - const result = await renderAsync( - Conditional Loading...}> - - , - ); - - // Should render immediately when not suspending - expect(screen.getByTestId('conditional-content')).toBeOnTheScreen(); - expect(screen.queryByText('Conditional Loading...')).not.toBeOnTheScreen(); - - // Re-render with suspense - this creates a new component that will suspend - await result.rerenderAsync( - Conditional Loading...}> - - , - ); - - // Should now be suspended - expect(screen.getByText('Conditional Loading...')).toBeOnTheScreen(); - expect(screen.queryByTestId('conditional-content')).not.toBeOnTheScreen(); -}); - -function SuspendingWithState() { - const [isReady, setIsReady] = React.useState(false); - - React.useEffect(() => { - const timer = setTimeout(() => setIsReady(true), 100); - return () => clearTimeout(timer); - }, []); - - if (!isReady) { - const promise = new Promise(() => {}); // Never resolves - React.use(promise); - } - - return ; -} - -testGateReact19('handles suspense with state updates', async () => { - await renderAsync( - State Loading...}> - - , - ); - - expect(screen.getByText('State Loading...')).toBeOnTheScreen(); - expect(screen.queryByTestId('state-ready-content')).not.toBeOnTheScreen(); - - // Wait for state update to resolve suspense - expect(await screen.findByTestId('state-ready-content')).toBeOnTheScreen(); - expect(screen.queryByText('State Loading...')).not.toBeOnTheScreen(); + expect(screen.getByTestId('second-boundary')).toBeOnTheScreen(); + expect(screen.queryByText('Second Loading...')).not.toBeOnTheScreen(); }); From 00c2769b31b45f9dd754864c582236294b7f4712 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Wed, 6 Aug 2025 15:43:30 +0200 Subject: [PATCH 22/30] fix tests --- src/__tests__/suspense-fake-timers.test.tsx | 140 ++++++++++---------- src/__tests__/suspense.test.tsx | 90 +++++++------ 2 files changed, 116 insertions(+), 114 deletions(-) diff --git a/src/__tests__/suspense-fake-timers.test.tsx b/src/__tests__/suspense-fake-timers.test.tsx index 6f943f94..9a5e5e85 100644 --- a/src/__tests__/suspense-fake-timers.test.tsx +++ b/src/__tests__/suspense-fake-timers.test.tsx @@ -7,9 +7,9 @@ jest.useFakeTimers(); const testGateReact19 = React.version.startsWith('19.') ? test : test.skip; -function Suspending({ promise }: { promise: Promise }) { +function Suspending({ promise, testID }: { promise: Promise; testID: string }) { React.use(promise); - return ; + return ; } testGateReact19('resolves manually-controlled promise', async () => { @@ -21,7 +21,7 @@ testGateReact19('resolves manually-controlled promise', async () => { await renderAsync( Loading...}> - + , @@ -45,7 +45,7 @@ testGateReact19('resolves timer-controlled promise', async () => { await renderAsync( Loading...}> - + , @@ -60,48 +60,11 @@ testGateReact19('resolves timer-controlled promise', async () => { expect(screen.queryByText('Loading...')).not.toBeOnTheScreen(); }); -function DelayedSuspending({ delay, id }: { delay: number; id: string }) { - let resolvePromise: (value: string) => void; - const promise = React.useMemo(() => - new Promise((resolve) => { - resolvePromise = resolve; - setTimeout(() => resolve(`data-${id}`), delay); - }), [delay, id] - ); - - const data = React.use(promise); - return ; -} - -testGateReact19('handles timer-based promises with fake timers', async () => { - let resolveManual: (value: unknown) => void; - const manualPromise = new Promise((resolve) => { - resolveManual = resolve; - }); - - await renderAsync( - - Manual Loading...}> - - - - , - ); - - expect(screen.getByText('Manual Loading...')).toBeOnTheScreen(); - expect(screen.getByTestId('outside-suspense')).toBeOnTheScreen(); - - // eslint-disable-next-line require-await - await act(async () => resolveManual(null)); - expect(screen.getByTestId('content')).toBeOnTheScreen(); - expect(screen.queryByText('Manual Loading...')).not.toBeOnTheScreen(); -}); - class ErrorBoundary extends React.Component< - { children: React.ReactNode; fallback?: React.ReactNode }, + { children: React.ReactNode; fallback: React.ReactNode }, { hasError: boolean } > { - constructor(props: { children: React.ReactNode; fallback?: React.ReactNode }) { + constructor(props: { children: React.ReactNode; fallback: React.ReactNode }) { super(props); this.state = { hasError: false }; } @@ -111,67 +74,98 @@ class ErrorBoundary extends React.Component< } render() { - if (this.state.hasError) { - return this.props.fallback || Something went wrong.; - } - - return this.props.children; + return this.state.hasError ? this.props.fallback : this.props.children; } } -testGateReact19('handles suspense with error boundary in fake timers', async () => { +testGateReact19('handles promise rejection with error boundary', async () => { let rejectPromise: (error: Error) => void; - const promise = new Promise((_, reject) => { - rejectPromise = reject; - }); + const promise = new Promise((_, reject) => { rejectPromise = reject; }); await renderAsync( Error occurred}> Loading...}> - + , ); expect(screen.getByText('Loading...')).toBeOnTheScreen(); + expect(screen.queryByTestId('content')).not.toBeOnTheScreen(); // eslint-disable-next-line require-await await act(async () => rejectPromise(new Error('Test error'))); expect(screen.getByText('Error occurred')).toBeOnTheScreen(); expect(screen.queryByText('Loading...')).not.toBeOnTheScreen(); + expect(screen.queryByTestId('error-content')).not.toBeOnTheScreen(); }); -function MultiComponentSuspense() { - let resolveFirst: (value: unknown) => void; - let resolveSecond: (value: unknown) => void; +testGateReact19('handles multiple suspending components', async () => { + let resolvePromise1: (value: unknown) => void; + let resolvePromise2: (value: unknown) => void; - const firstPromise = new Promise((resolve) => { - resolveFirst = resolve; - }); - const secondPromise = new Promise((resolve) => { - resolveSecond = resolve; - }); + const promise1 = new Promise((resolve) => { resolvePromise1 = resolve; }); + const promise2 = new Promise((resolve) => { resolvePromise2 = resolve; }); + + await renderAsync( + + Loading...}> + + + + + ); + + expect(screen.getByText('Loading...')).toBeOnTheScreen(); + expect(screen.queryByTestId('content-1')).not.toBeOnTheScreen(); + expect(screen.queryByTestId('content-2')).not.toBeOnTheScreen(); + + // eslint-disable-next-line require-await + await act(async () => resolvePromise1(null)); + expect(screen.getByText('Loading...')).toBeOnTheScreen(); + expect(screen.queryByTestId('content-1')).not.toBeOnTheScreen(); + expect(screen.queryByTestId('content-2')).not.toBeOnTheScreen(); + + // eslint-disable-next-line require-await + await act(async () => resolvePromise2(null)); + expect(screen.getByTestId('content-1')).toBeOnTheScreen(); + expect(screen.getByTestId('content-2')).toBeOnTheScreen(); + expect(screen.queryByText('Loading...')).not.toBeOnTheScreen(); +}); + + +testGateReact19('handles multiple suspense boundaries independently', async () => { + let resolvePromise1: (value: unknown) => void; + let resolvePromise2: (value: unknown) => void; - return ( + const promise1 = new Promise((resolve) => { resolvePromise1 = resolve; }); + const promise2 = new Promise((resolve) => { resolvePromise2 = resolve; }); + + await renderAsync( First Loading...}> - + Second Loading...}> - - - + ); -} -testGateReact19('handles multiple independent suspense boundaries', async () => { - await renderAsync(); - expect(screen.getByText('First Loading...')).toBeOnTheScreen(); expect(screen.getByText('Second Loading...')).toBeOnTheScreen(); - expect(screen.queryByTestId('content')).not.toBeOnTheScreen(); - expect(screen.queryByTestId('second-boundary')).not.toBeOnTheScreen(); + expect(screen.queryByTestId('content-1')).not.toBeOnTheScreen(); + expect(screen.queryByTestId('content-2')).not.toBeOnTheScreen(); + + // eslint-disable-next-line require-await + await act(async () => resolvePromise1(null)); + expect(screen.getByTestId('content-1')).toBeOnTheScreen(); + expect(screen.queryByText('First Loading...')).not.toBeOnTheScreen(); + expect(screen.getByText('Second Loading...')).toBeOnTheScreen(); + + // eslint-disable-next-line require-await + await act(async () => resolvePromise2(null)); + expect(screen.getByTestId('content-2')).toBeOnTheScreen(); + expect(screen.queryByText('Second Loading...')).not.toBeOnTheScreen(); }); diff --git a/src/__tests__/suspense.test.tsx b/src/__tests__/suspense.test.tsx index 6d909d5a..91587c01 100644 --- a/src/__tests__/suspense.test.tsx +++ b/src/__tests__/suspense.test.tsx @@ -5,9 +5,9 @@ import { act, renderAsync, screen } from '..'; const testGateReact19 = React.version.startsWith('19.') ? test : test.skip; -function Suspending({ promise }: { promise: Promise }) { +function Suspending({ promise, testID }: { promise: Promise; testID: string }) { React.use(promise); - return ; + return ; } testGateReact19('resolves manually-controlled promise', async () => { @@ -19,7 +19,7 @@ testGateReact19('resolves manually-controlled promise', async () => { await renderAsync( Loading...}> - + , @@ -43,7 +43,7 @@ testGateReact19('resolves timer-controlled promise', async () => { await renderAsync( Loading...}> - + , @@ -57,16 +57,11 @@ testGateReact19('resolves timer-controlled promise', async () => { expect(screen.queryByText('Loading...')).not.toBeOnTheScreen(); }); -function SuspendingWithError({ promise }: { promise: Promise }) { - React.use(promise); - return ; -} - class ErrorBoundary extends React.Component< - { children: React.ReactNode; fallback?: React.ReactNode }, + { children: React.ReactNode; fallback: React.ReactNode }, { hasError: boolean } > { - constructor(props: { children: React.ReactNode; fallback?: React.ReactNode }) { + constructor(props: { children: React.ReactNode; fallback: React.ReactNode }) { super(props); this.state = { hasError: false }; } @@ -76,11 +71,7 @@ class ErrorBoundary extends React.Component< } render() { - if (this.state.hasError) { - return this.props.fallback || Something went wrong.; - } - - return this.props.children; + return this.state.hasError ? this.props.fallback : this.props.children; } } @@ -93,13 +84,13 @@ testGateReact19('handles promise rejection with error boundary', async () => { await renderAsync( Error occurred}> Loading...}> - + , ); expect(screen.getByText('Loading...')).toBeOnTheScreen(); - expect(screen.queryByTestId('error-content')).not.toBeOnTheScreen(); + expect(screen.queryByTestId('content')).not.toBeOnTheScreen(); // eslint-disable-next-line require-await await act(async () => rejectPromise(new Error('Test error'))); @@ -109,54 +100,71 @@ testGateReact19('handles promise rejection with error boundary', async () => { expect(screen.queryByTestId('error-content')).not.toBeOnTheScreen(); }); -function NestedSuspending({ promise }: { promise: Promise }) { - React.use(promise); - return ( - Inner Loading...}> - - +testGateReact19('handles multiple suspending components', async () => { + let resolvePromise1: (value: unknown) => void; + let resolvePromise2: (value: unknown) => void; + + const promise1 = new Promise((resolve) => { resolvePromise1 = resolve; }); + const promise2 = new Promise((resolve) => { resolvePromise2 = resolve; }); + + await renderAsync( + + Loading...}> + + + + ); -} + + expect(screen.getByText('Loading...')).toBeOnTheScreen(); + expect(screen.queryByTestId('content-1')).not.toBeOnTheScreen(); + expect(screen.queryByTestId('content-2')).not.toBeOnTheScreen(); + + // eslint-disable-next-line require-await + await act(async () => resolvePromise1(null)); + expect(screen.getByText('Loading...')).toBeOnTheScreen(); + expect(screen.queryByTestId('content-1')).not.toBeOnTheScreen(); + expect(screen.queryByTestId('content-2')).not.toBeOnTheScreen(); + + // eslint-disable-next-line require-await + await act(async () => resolvePromise2(null)); + expect(screen.getByTestId('content-1')).toBeOnTheScreen(); + expect(screen.getByTestId('content-2')).toBeOnTheScreen(); + expect(screen.queryByText('Loading...')).not.toBeOnTheScreen(); +}); + testGateReact19('handles multiple suspense boundaries independently', async () => { let resolvePromise1: (value: unknown) => void; let resolvePromise2: (value: unknown) => void; - const promise1 = new Promise((resolve) => { - resolvePromise1 = resolve; - }); - const promise2 = new Promise((resolve) => { - resolvePromise2 = resolve; - }); + const promise1 = new Promise((resolve) => { resolvePromise1 = resolve; }); + const promise2 = new Promise((resolve) => { resolvePromise2 = resolve; }); await renderAsync( First Loading...}> - + Second Loading...}> - - - + ); expect(screen.getByText('First Loading...')).toBeOnTheScreen(); expect(screen.getByText('Second Loading...')).toBeOnTheScreen(); - expect(screen.queryByTestId('content')).not.toBeOnTheScreen(); - expect(screen.queryByTestId('second-boundary')).not.toBeOnTheScreen(); + expect(screen.queryByTestId('content-1')).not.toBeOnTheScreen(); + expect(screen.queryByTestId('content-2')).not.toBeOnTheScreen(); - // Resolve first promise // eslint-disable-next-line require-await await act(async () => resolvePromise1(null)); - expect(screen.getByTestId('content')).toBeOnTheScreen(); + expect(screen.getByTestId('content-1')).toBeOnTheScreen(); expect(screen.queryByText('First Loading...')).not.toBeOnTheScreen(); expect(screen.getByText('Second Loading...')).toBeOnTheScreen(); - // Resolve second promise // eslint-disable-next-line require-await await act(async () => resolvePromise2(null)); - expect(screen.getByTestId('second-boundary')).toBeOnTheScreen(); + expect(screen.getByTestId('content-2')).toBeOnTheScreen(); expect(screen.queryByText('Second Loading...')).not.toBeOnTheScreen(); }); From 2c649a5c0f653d1151503f8d7264787b8fc85f0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Thu, 7 Aug 2025 13:57:41 +0200 Subject: [PATCH 23/30] fix lint --- .../render-string-validation.test.tsx | 8 +--- src/__tests__/suspense-fake-timers.test.tsx | 43 ++++++++++++++----- src/__tests__/suspense.test.tsx | 41 +++++++++++++----- src/test-utils/console.ts | 12 ++++++ src/test-utils/index.ts | 1 + 5 files changed, 77 insertions(+), 28 deletions(-) create mode 100644 src/test-utils/console.ts diff --git a/src/__tests__/render-string-validation.test.tsx b/src/__tests__/render-string-validation.test.tsx index 0595c098..9ac25a01 100644 --- a/src/__tests__/render-string-validation.test.tsx +++ b/src/__tests__/render-string-validation.test.tsx @@ -2,6 +2,7 @@ import * as React from 'react'; import { Pressable, Text, View } from 'react-native'; import { fireEvent, render, screen } from '..'; +import { excludeConsoleMessage } from '../test-utils/console'; // eslint-disable-next-line no-console const originalConsoleError = console.error; @@ -12,13 +13,8 @@ const PROFILER_ERROR = 'The above error occurred in the component'; beforeEach(() => { // eslint-disable-next-line no-console - console.error = (errorMessage: string) => { - if (!errorMessage.includes(PROFILER_ERROR)) { - originalConsoleError(errorMessage); - } - }; + console.error = excludeConsoleMessage(console.error, PROFILER_ERROR); }); - afterEach(() => { // eslint-disable-next-line no-console console.error = originalConsoleError; diff --git a/src/__tests__/suspense-fake-timers.test.tsx b/src/__tests__/suspense-fake-timers.test.tsx index 9a5e5e85..ffa4ef8b 100644 --- a/src/__tests__/suspense-fake-timers.test.tsx +++ b/src/__tests__/suspense-fake-timers.test.tsx @@ -2,11 +2,19 @@ import * as React from 'react'; import { Text, View } from 'react-native'; import { act, renderAsync, screen } from '..'; +import { excludeConsoleMessage } from '../test-utils/console'; jest.useFakeTimers(); const testGateReact19 = React.version.startsWith('19.') ? test : test.skip; +// eslint-disable-next-line no-console +const originalConsoleError = console.error; +afterEach(() => { + // eslint-disable-next-line no-console + console.error = originalConsoleError; +}); + function Suspending({ promise, testID }: { promise: Promise; testID: string }) { React.use(promise); return ; @@ -79,8 +87,14 @@ class ErrorBoundary extends React.Component< } testGateReact19('handles promise rejection with error boundary', async () => { + const ERROR_MESSAGE = 'Promise Rejected In Test'; + // eslint-disable-next-line no-console + console.error = excludeConsoleMessage(console.error, ERROR_MESSAGE); + let rejectPromise: (error: Error) => void; - const promise = new Promise((_, reject) => { rejectPromise = reject; }); + const promise = new Promise((_resolve, reject) => { + rejectPromise = reject; + }); await renderAsync( Error occurred}> @@ -94,7 +108,7 @@ testGateReact19('handles promise rejection with error boundary', async () => { expect(screen.queryByTestId('content')).not.toBeOnTheScreen(); // eslint-disable-next-line require-await - await act(async () => rejectPromise(new Error('Test error'))); + await act(async () => rejectPromise(new Error(ERROR_MESSAGE))); expect(screen.getByText('Error occurred')).toBeOnTheScreen(); expect(screen.queryByText('Loading...')).not.toBeOnTheScreen(); @@ -104,9 +118,13 @@ testGateReact19('handles promise rejection with error boundary', async () => { testGateReact19('handles multiple suspending components', async () => { let resolvePromise1: (value: unknown) => void; let resolvePromise2: (value: unknown) => void; - - const promise1 = new Promise((resolve) => { resolvePromise1 = resolve; }); - const promise2 = new Promise((resolve) => { resolvePromise2 = resolve; }); + + const promise1 = new Promise((resolve) => { + resolvePromise1 = resolve; + }); + const promise2 = new Promise((resolve) => { + resolvePromise2 = resolve; + }); await renderAsync( @@ -114,7 +132,7 @@ testGateReact19('handles multiple suspending components', async () => { - + , ); expect(screen.getByText('Loading...')).toBeOnTheScreen(); @@ -134,13 +152,16 @@ testGateReact19('handles multiple suspending components', async () => { expect(screen.queryByText('Loading...')).not.toBeOnTheScreen(); }); - testGateReact19('handles multiple suspense boundaries independently', async () => { let resolvePromise1: (value: unknown) => void; let resolvePromise2: (value: unknown) => void; - - const promise1 = new Promise((resolve) => { resolvePromise1 = resolve; }); - const promise2 = new Promise((resolve) => { resolvePromise2 = resolve; }); + + const promise1 = new Promise((resolve) => { + resolvePromise1 = resolve; + }); + const promise2 = new Promise((resolve) => { + resolvePromise2 = resolve; + }); await renderAsync( @@ -150,7 +171,7 @@ testGateReact19('handles multiple suspense boundaries independently', async () = Second Loading...}> - + , ); expect(screen.getByText('First Loading...')).toBeOnTheScreen(); diff --git a/src/__tests__/suspense.test.tsx b/src/__tests__/suspense.test.tsx index 91587c01..a22ba1ed 100644 --- a/src/__tests__/suspense.test.tsx +++ b/src/__tests__/suspense.test.tsx @@ -2,9 +2,17 @@ import * as React from 'react'; import { Text, View } from 'react-native'; import { act, renderAsync, screen } from '..'; +import { excludeConsoleMessage } from '../test-utils/console'; const testGateReact19 = React.version.startsWith('19.') ? test : test.skip; +// eslint-disable-next-line no-console +const originalConsoleError = console.error; +afterEach(() => { + // eslint-disable-next-line no-console + console.error = originalConsoleError; +}); + function Suspending({ promise, testID }: { promise: Promise; testID: string }) { React.use(promise); return ; @@ -76,8 +84,12 @@ class ErrorBoundary extends React.Component< } testGateReact19('handles promise rejection with error boundary', async () => { + const ERROR_MESSAGE = 'Promise Rejected In Test'; + // eslint-disable-next-line no-console + console.error = excludeConsoleMessage(console.error, ERROR_MESSAGE); + let rejectPromise: (error: Error) => void; - const promise = new Promise((_, reject) => { + const promise = new Promise((_resolve, reject) => { rejectPromise = reject; }); @@ -93,7 +105,7 @@ testGateReact19('handles promise rejection with error boundary', async () => { expect(screen.queryByTestId('content')).not.toBeOnTheScreen(); // eslint-disable-next-line require-await - await act(async () => rejectPromise(new Error('Test error'))); + await act(async () => rejectPromise(new Error(ERROR_MESSAGE))); expect(screen.getByText('Error occurred')).toBeOnTheScreen(); expect(screen.queryByText('Loading...')).not.toBeOnTheScreen(); @@ -103,9 +115,13 @@ testGateReact19('handles promise rejection with error boundary', async () => { testGateReact19('handles multiple suspending components', async () => { let resolvePromise1: (value: unknown) => void; let resolvePromise2: (value: unknown) => void; - - const promise1 = new Promise((resolve) => { resolvePromise1 = resolve; }); - const promise2 = new Promise((resolve) => { resolvePromise2 = resolve; }); + + const promise1 = new Promise((resolve) => { + resolvePromise1 = resolve; + }); + const promise2 = new Promise((resolve) => { + resolvePromise2 = resolve; + }); await renderAsync( @@ -113,7 +129,7 @@ testGateReact19('handles multiple suspending components', async () => { - + , ); expect(screen.getByText('Loading...')).toBeOnTheScreen(); @@ -133,13 +149,16 @@ testGateReact19('handles multiple suspending components', async () => { expect(screen.queryByText('Loading...')).not.toBeOnTheScreen(); }); - testGateReact19('handles multiple suspense boundaries independently', async () => { let resolvePromise1: (value: unknown) => void; let resolvePromise2: (value: unknown) => void; - - const promise1 = new Promise((resolve) => { resolvePromise1 = resolve; }); - const promise2 = new Promise((resolve) => { resolvePromise2 = resolve; }); + + const promise1 = new Promise((resolve) => { + resolvePromise1 = resolve; + }); + const promise2 = new Promise((resolve) => { + resolvePromise2 = resolve; + }); await renderAsync( @@ -149,7 +168,7 @@ testGateReact19('handles multiple suspense boundaries independently', async () = Second Loading...}> - + , ); expect(screen.getByText('First Loading...')).toBeOnTheScreen(); diff --git a/src/test-utils/console.ts b/src/test-utils/console.ts new file mode 100644 index 00000000..428a4883 --- /dev/null +++ b/src/test-utils/console.ts @@ -0,0 +1,12 @@ +import { format } from 'util'; + +export function excludeConsoleMessage(logFn: (...args: unknown[]) => void, excludeMessage: string) { + return (...args: unknown[]) => { + const message = format(...args); + if (message.includes(excludeMessage)) { + return; + } + + logFn(...args); + }; +} diff --git a/src/test-utils/index.ts b/src/test-utils/index.ts index 7981d6b6..40ab00de 100644 --- a/src/test-utils/index.ts +++ b/src/test-utils/index.ts @@ -1 +1,2 @@ +export * from './console'; export * from './events'; From d240675f8de6803002ecf1dc41afa13c20f040f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Thu, 7 Aug 2025 14:04:28 +0200 Subject: [PATCH 24/30] improve error stack traces --- src/__tests__/render-async.test.tsx | 20 ++++++++++---------- src/render-async.tsx | 15 ++++++++------- 2 files changed, 18 insertions(+), 17 deletions(-) diff --git a/src/__tests__/render-async.test.tsx b/src/__tests__/render-async.test.tsx index 77167508..8422f737 100644 --- a/src/__tests__/render-async.test.tsx +++ b/src/__tests__/render-async.test.tsx @@ -65,35 +65,35 @@ test('renderAsync supports legacy rendering option', async () => { }); test('rerender function throws error when used with renderAsync', async () => { - const result = await renderAsync(); + await renderAsync(); - expect(() => result.rerender()).toThrowErrorMatchingInlineSnapshot( - `"\`rerender(...)\` is not supported when using \`renderAsync\` use \`await rerenderAsync(...)\` instead"`, + expect(() => screen.rerender()).toThrowErrorMatchingInlineSnapshot( + `""rerender(...)" is not supported when using "renderAsync" use "await rerenderAsync(...)" instead"`, ); }); test('rerenderAsync function updates component asynchronously', async () => { const fn = jest.fn(); - const result = await renderAsync(); + await renderAsync(); expect(fn).toHaveBeenCalledTimes(0); - await result.rerenderAsync(); + await screen.rerenderAsync(); expect(fn).toHaveBeenCalledTimes(1); }); test('unmount function throws error when used with renderAsync', async () => { - const result = await renderAsync(); + await renderAsync(); - expect(() => result.unmount()).toThrowErrorMatchingInlineSnapshot( - `"\`unmount()\` is not supported when using \`renderAsync\` use \`await unmountAsync()\` instead"`, + expect(() => screen.unmount()).toThrowErrorMatchingInlineSnapshot( + `""unmount()" is not supported when using "renderAsync" use "await unmountAsync()" instead"`, ); }); test('unmountAsync function unmounts component asynchronously', async () => { const fn = jest.fn(); - const result = await renderAsync(); + await renderAsync(); - await result.unmountAsync(); + await screen.unmountAsync(); expect(fn).toHaveBeenCalled(); }); diff --git a/src/render-async.tsx b/src/render-async.tsx index 01b387bd..7b319d6d 100644 --- a/src/render-async.tsx +++ b/src/render-async.tsx @@ -14,6 +14,7 @@ import { debug } from './helpers/debug'; import { renderWithAsyncAct } from './render-act'; import { setRenderResult } from './screen'; import { getQueriesForElement } from './within'; +import { ErrorWithStack } from './helpers/errors'; export interface RenderAsyncOptions { /** @@ -63,20 +64,20 @@ function buildRenderResult( const instance = renderer.root; const rerender = function (_component: React.ReactElement) { - throw new Error( - '`rerender(...)` is not supported when using `renderAsync` use `await rerenderAsync(...)` instead', + throw new ErrorWithStack( + '"rerender(...)" is not supported when using "renderAsync" use "await rerenderAsync(...)" instead', + rerender, ); }; const rerenderAsync = async function (component: React.ReactElement) { // eslint-disable-next-line require-await - await act(async () => { - renderer.update(wrap(component)); - }); + await act(async () => { renderer.update(wrap(component)); }); }; const unmount = () => { - throw new Error( - '`unmount()` is not supported when using `renderAsync` use `await unmountAsync()` instead', + throw new ErrorWithStack( + '"unmount()" is not supported when using "renderAsync" use "await unmountAsync()" instead', + unmount, ); }; const unmountAsync = async () => { From 9f7b04b8ae691d5013508f4426a4c6bff3ccacac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Thu, 7 Aug 2025 14:11:13 +0200 Subject: [PATCH 25/30] fix lint --- src/render-async.tsx | 6 ++++-- .../__snapshots__/scroll-to-flat-list.test.tsx.snap | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/render-async.tsx b/src/render-async.tsx index 7b319d6d..42e473fa 100644 --- a/src/render-async.tsx +++ b/src/render-async.tsx @@ -11,10 +11,10 @@ import { getConfig } from './config'; import { getHostSelves } from './helpers/component-tree'; import type { DebugOptions } from './helpers/debug'; import { debug } from './helpers/debug'; +import { ErrorWithStack } from './helpers/errors'; import { renderWithAsyncAct } from './render-act'; import { setRenderResult } from './screen'; import { getQueriesForElement } from './within'; -import { ErrorWithStack } from './helpers/errors'; export interface RenderAsyncOptions { /** @@ -71,7 +71,9 @@ function buildRenderResult( }; const rerenderAsync = async function (component: React.ReactElement) { // eslint-disable-next-line require-await - await act(async () => { renderer.update(wrap(component)); }); + await act(async () => { + renderer.update(wrap(component)); + }); }; const unmount = () => { diff --git a/src/user-event/scroll/__tests__/__snapshots__/scroll-to-flat-list.test.tsx.snap b/src/user-event/scroll/__tests__/__snapshots__/scroll-to-flat-list.test.tsx.snap index faa7ee49..e71333cf 100644 --- a/src/user-event/scroll/__tests__/__snapshots__/scroll-to-flat-list.test.tsx.snap +++ b/src/user-event/scroll/__tests__/__snapshots__/scroll-to-flat-list.test.tsx.snap @@ -1,4 +1,4 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing exports[`scrollTo() with FlatList supports vertical drag scroll: scrollTo({ y: 100 }) 1`] = ` [ From 89dc7fe073b9cd4bdeb2dce5aeaea1216a0cab2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Thu, 7 Aug 2025 14:25:34 +0200 Subject: [PATCH 26/30] cleanup --- jest.config.js | 2 +- src/__tests__/react-native-gesture-handler.test.tsx | 2 +- src/__tests__/timer-utils.ts | 7 ------- src/test-utils/index.ts | 2 -- src/user-event/__tests__/clear.test.tsx | 2 +- src/user-event/__tests__/paste.test.tsx | 2 +- .../press/__tests__/longPress.real-timers.test.tsx | 2 +- src/user-event/press/__tests__/longPress.test.tsx | 2 +- src/user-event/press/__tests__/press.real-timers.test.tsx | 2 +- .../scroll/__tests__/scroll-to-flat-list.test.tsx | 4 ++-- src/user-event/type/__tests__/type-managed.test.tsx | 2 +- src/user-event/type/__tests__/type.test.tsx | 2 +- 12 files changed, 11 insertions(+), 20 deletions(-) delete mode 100644 src/__tests__/timer-utils.ts delete mode 100644 src/test-utils/index.ts diff --git a/jest.config.js b/jest.config.js index 56f7a1ff..231c7d71 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,7 +1,7 @@ module.exports = { preset: 'react-native', setupFilesAfterEnv: ['./jest-setup.ts'], - testPathIgnorePatterns: ['build/', 'examples/', 'experiments-app/', 'timer-utils'], + testPathIgnorePatterns: ['build/', 'examples/', 'experiments-app/', 'test-utils'], testTimeout: 60000, transformIgnorePatterns: [ '/node_modules/(?!(@react-native|react-native|react-native-gesture-handler)/).*/', diff --git a/src/__tests__/react-native-gesture-handler.test.tsx b/src/__tests__/react-native-gesture-handler.test.tsx index 644090f3..989ad03c 100644 --- a/src/__tests__/react-native-gesture-handler.test.tsx +++ b/src/__tests__/react-native-gesture-handler.test.tsx @@ -4,7 +4,7 @@ import { View } from 'react-native'; import { Pressable } from 'react-native-gesture-handler'; import { fireEvent, render, screen, userEvent } from '..'; -import { createEventLogger, getEventsNames } from '../test-utils'; +import { createEventLogger, getEventsNames } from '../test-utils/events'; test('fireEvent can invoke press events for RNGH Pressable', () => { const onPress = jest.fn(); diff --git a/src/__tests__/timer-utils.ts b/src/__tests__/timer-utils.ts deleted file mode 100644 index abe13ede..00000000 --- a/src/__tests__/timer-utils.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { setTimeout } from '../helpers/timers'; - -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -export { sleep }; diff --git a/src/test-utils/index.ts b/src/test-utils/index.ts deleted file mode 100644 index 40ab00de..00000000 --- a/src/test-utils/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './console'; -export * from './events'; diff --git a/src/user-event/__tests__/clear.test.tsx b/src/user-event/__tests__/clear.test.tsx index 85d729e8..712e73df 100644 --- a/src/user-event/__tests__/clear.test.tsx +++ b/src/user-event/__tests__/clear.test.tsx @@ -3,7 +3,7 @@ import type { TextInputProps } from 'react-native'; import { TextInput, View } from 'react-native'; import { render, screen, userEvent } from '../..'; -import { createEventLogger, getEventsNames } from '../../test-utils'; +import { createEventLogger, getEventsNames } from '../../test-utils/events'; beforeEach(() => { jest.useRealTimers(); diff --git a/src/user-event/__tests__/paste.test.tsx b/src/user-event/__tests__/paste.test.tsx index 2392ac87..cf254a1a 100644 --- a/src/user-event/__tests__/paste.test.tsx +++ b/src/user-event/__tests__/paste.test.tsx @@ -3,7 +3,7 @@ import type { TextInputProps } from 'react-native'; import { TextInput, View } from 'react-native'; import { render, screen, userEvent } from '../..'; -import { createEventLogger, getEventsNames } from '../../test-utils'; +import { createEventLogger, getEventsNames } from '../../test-utils/events'; beforeEach(() => { jest.useRealTimers(); diff --git a/src/user-event/press/__tests__/longPress.real-timers.test.tsx b/src/user-event/press/__tests__/longPress.real-timers.test.tsx index 9501b94d..656fc3c0 100644 --- a/src/user-event/press/__tests__/longPress.real-timers.test.tsx +++ b/src/user-event/press/__tests__/longPress.real-timers.test.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { Pressable, Text, TouchableHighlight, TouchableOpacity } from 'react-native'; import { render, screen } from '../../..'; -import { createEventLogger, getEventsNames } from '../../../test-utils'; +import { createEventLogger, getEventsNames } from '../../../test-utils/events'; import { userEvent } from '../..'; describe('userEvent.longPress with real timers', () => { diff --git a/src/user-event/press/__tests__/longPress.test.tsx b/src/user-event/press/__tests__/longPress.test.tsx index 3dfb2914..48fe1f49 100644 --- a/src/user-event/press/__tests__/longPress.test.tsx +++ b/src/user-event/press/__tests__/longPress.test.tsx @@ -3,7 +3,7 @@ import { Pressable, Text, TouchableHighlight, TouchableOpacity, View } from 'rea import type { ReactTestInstance } from 'react-test-renderer'; import { render, screen } from '../../..'; -import { createEventLogger, getEventsNames } from '../../../test-utils'; +import { createEventLogger, getEventsNames } from '../../../test-utils/events'; import { userEvent } from '../..'; describe('userEvent.longPress with fake timers', () => { diff --git a/src/user-event/press/__tests__/press.real-timers.test.tsx b/src/user-event/press/__tests__/press.real-timers.test.tsx index 903e353c..d1095436 100644 --- a/src/user-event/press/__tests__/press.real-timers.test.tsx +++ b/src/user-event/press/__tests__/press.real-timers.test.tsx @@ -10,7 +10,7 @@ import { } from 'react-native'; import { render, screen } from '../../..'; -import { createEventLogger, getEventsNames } from '../../../test-utils'; +import { createEventLogger, getEventsNames } from '../../../test-utils/events'; import { userEvent } from '../..'; describe('userEvent.press with real timers', () => { diff --git a/src/user-event/scroll/__tests__/scroll-to-flat-list.test.tsx b/src/user-event/scroll/__tests__/scroll-to-flat-list.test.tsx index c7024af7..ea111a05 100644 --- a/src/user-event/scroll/__tests__/scroll-to-flat-list.test.tsx +++ b/src/user-event/scroll/__tests__/scroll-to-flat-list.test.tsx @@ -3,8 +3,8 @@ import type { ScrollViewProps } from 'react-native'; import { FlatList, Text, View } from 'react-native'; import { render, screen } from '../../..'; -import type { EventEntry } from '../../../test-utils'; -import { createEventLogger } from '../../../test-utils'; +import type { EventEntry } from '../../../test-utils/events'; +import { createEventLogger } from '../../../test-utils/events'; import { userEvent } from '../..'; const data = ['A', 'B', 'C', 'D', 'E', 'F', 'G']; diff --git a/src/user-event/type/__tests__/type-managed.test.tsx b/src/user-event/type/__tests__/type-managed.test.tsx index 26f8f3e9..8f567784 100644 --- a/src/user-event/type/__tests__/type-managed.test.tsx +++ b/src/user-event/type/__tests__/type-managed.test.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import { TextInput } from 'react-native'; import { render, screen } from '../../..'; -import { createEventLogger, getEventsNames } from '../../../test-utils'; +import { createEventLogger, getEventsNames } from '../../../test-utils/events'; import { userEvent } from '../..'; beforeEach(() => { diff --git a/src/user-event/type/__tests__/type.test.tsx b/src/user-event/type/__tests__/type.test.tsx index c69a41a8..7ce49900 100644 --- a/src/user-event/type/__tests__/type.test.tsx +++ b/src/user-event/type/__tests__/type.test.tsx @@ -3,7 +3,7 @@ import type { TextInputProps } from 'react-native'; import { TextInput, View } from 'react-native'; import { render, screen } from '../../..'; -import { createEventLogger, getEventsNames, lastEventPayload } from '../../../test-utils'; +import { createEventLogger, getEventsNames, lastEventPayload } from '../../../test-utils/events'; import { userEvent } from '../..'; beforeEach(() => { From 703c610d21a0c3ccf97247da15269e340eae3f4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Thu, 7 Aug 2025 14:27:07 +0200 Subject: [PATCH 27/30] . --- jest.config.js | 2 +- src/user-event/press/__tests__/press.test.tsx | 2 +- src/user-event/scroll/__tests__/scroll-to.test.tsx | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/jest.config.js b/jest.config.js index 231c7d71..d01f9710 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,7 +1,7 @@ module.exports = { preset: 'react-native', setupFilesAfterEnv: ['./jest-setup.ts'], - testPathIgnorePatterns: ['build/', 'examples/', 'experiments-app/', 'test-utils'], + testPathIgnorePatterns: ['build/', 'examples/', 'experiments-app/'], testTimeout: 60000, transformIgnorePatterns: [ '/node_modules/(?!(@react-native|react-native|react-native-gesture-handler)/).*/', diff --git a/src/user-event/press/__tests__/press.test.tsx b/src/user-event/press/__tests__/press.test.tsx index a73d9813..9ab2ef76 100644 --- a/src/user-event/press/__tests__/press.test.tsx +++ b/src/user-event/press/__tests__/press.test.tsx @@ -11,7 +11,7 @@ import { import type { ReactTestInstance } from 'react-test-renderer'; import { render, screen } from '../../..'; -import { createEventLogger, getEventsNames } from '../../../test-utils'; +import { createEventLogger, getEventsNames } from '../../../test-utils/events'; import { userEvent } from '../..'; describe('userEvent.press with fake timers', () => { diff --git a/src/user-event/scroll/__tests__/scroll-to.test.tsx b/src/user-event/scroll/__tests__/scroll-to.test.tsx index 4ad99587..29988636 100644 --- a/src/user-event/scroll/__tests__/scroll-to.test.tsx +++ b/src/user-event/scroll/__tests__/scroll-to.test.tsx @@ -3,8 +3,8 @@ import type { ScrollViewProps } from 'react-native'; import { ScrollView, View } from 'react-native'; import { fireEvent, render, screen } from '../../..'; -import type { EventEntry } from '../../../test-utils'; -import { createEventLogger } from '../../../test-utils'; +import type { EventEntry } from '../../../test-utils/events'; +import { createEventLogger } from '../../../test-utils/events'; import { userEvent } from '../..'; function mapEventsToShortForm(events: EventEntry[]) { From e9ff87e40c93618e7b0be86894ad8562a4938311 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Thu, 7 Aug 2025 15:00:43 +0200 Subject: [PATCH 28/30] tweak code cov --- jest.config.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/jest.config.js b/jest.config.js index d01f9710..ae12db2c 100644 --- a/jest.config.js +++ b/jest.config.js @@ -8,4 +8,10 @@ module.exports = { ], snapshotSerializers: ['@relmify/jest-serializer-strip-ansi/always'], clearMocks: true, + collectCoverageFrom: [ + "src/**/*.{js,jsx,ts,tsx}", + "!src/**/__tests__/**", + "!src/**/*.test.js", + "!src/test-utils/**", // Exclude setup files + ], }; From d97cab02d282864eba35367fb21d7335542b357d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Thu, 7 Aug 2025 15:19:44 +0200 Subject: [PATCH 29/30] tweaks --- src/render-async.tsx | 4 ++-- src/render.tsx | 12 ++++++------ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/render-async.tsx b/src/render-async.tsx index 42e473fa..59f916bf 100644 --- a/src/render-async.tsx +++ b/src/render-async.tsx @@ -63,13 +63,13 @@ function buildRenderResult( ) { const instance = renderer.root; - const rerender = function (_component: React.ReactElement) { + const rerender = (_component: React.ReactElement) => { throw new ErrorWithStack( '"rerender(...)" is not supported when using "renderAsync" use "await rerenderAsync(...)" instead', rerender, ); }; - const rerenderAsync = async function (component: React.ReactElement) { + const rerenderAsync = async (component: React.ReactElement) => { // eslint-disable-next-line require-await await act(async () => { renderer.update(wrap(component)); diff --git a/src/render.tsx b/src/render.tsx index f08a379f..d103e13d 100644 --- a/src/render.tsx +++ b/src/render.tsx @@ -100,12 +100,12 @@ function buildRenderResult( ) { const instance = renderer.root; - const update = function (component: React.ReactElement) { + const rerender = (component: React.ReactElement) => { void act(() => { renderer.update(wrap(component)); }); }; - const updateAsync = async function (component: React.ReactElement) { + const rerenderAsync = async (component: React.ReactElement) => { // eslint-disable-next-line require-await await act(async () => { renderer.update(wrap(component)); @@ -128,10 +128,10 @@ function buildRenderResult( const result = { ...getQueriesForElement(instance), - update, - updateAsync, - rerender: update, // alias for `update` - rerenderAsync: updateAsync, // alias for `update` + rerender, + rerenderAsync, + update: rerender, // alias for 'rerender' + updateAsync: rerenderAsync, // alias for `rerenderAsync` unmount, unmountAsync, toJSON: renderer.toJSON, From 9c714c0e5322cf4784f016d267801733b187ebf8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Jastrze=CC=A8bski?= Date: Thu, 7 Aug 2025 15:28:18 +0200 Subject: [PATCH 30/30] self code review --- src/__tests__/fire-event-async.test.tsx | 6 ++--- src/__tests__/fire-event.test.tsx | 32 ++----------------------- src/__tests__/render-async.test.tsx | 5 ---- src/__tests__/render.test.tsx | 8 +++---- website/docs/13.x/docs/api/screen.mdx | 2 +- 5 files changed, 9 insertions(+), 44 deletions(-) diff --git a/src/__tests__/fire-event-async.test.tsx b/src/__tests__/fire-event-async.test.tsx index 40089b02..49e96be9 100644 --- a/src/__tests__/fire-event-async.test.tsx +++ b/src/__tests__/fire-event-async.test.tsx @@ -644,16 +644,14 @@ describe('React.Suspense integration', () => { test('should handle unmounted elements gracefully in async mode', async () => { const onPress = jest.fn(); - const result = render( + render( Test , ); const element = screen.getByText('Test'); - - // Unmount the component - result.unmount(); + screen.unmount(); // Firing async event on unmounted element should not crash await fireEventAsync.press(element); diff --git a/src/__tests__/fire-event.test.tsx b/src/__tests__/fire-event.test.tsx index c0c7c6b4..7e3474bb 100644 --- a/src/__tests__/fire-event.test.tsx +++ b/src/__tests__/fire-event.test.tsx @@ -556,44 +556,16 @@ describe('native events', () => { test('should handle unmounted elements gracefully', () => { const onPress = jest.fn(); - const result = render( + render( Test , ); const element = screen.getByText('Test'); - - // Unmount the component - result.unmount(); + screen.unmount(); // Firing event on unmounted element should not crash fireEvent.press(element); expect(onPress).not.toHaveBeenCalled(); }); - -test('should handle invalid scroll event data gracefully', () => { - const onScrollSpy = jest.fn(); - render(); - - const scrollView = screen.getByTestId('scroll-view'); - - // Test with malformed event data that would cause an error in tryGetContentOffset - fireEvent.scroll(scrollView, { malformed: 'data' }); - expect(onScrollSpy).toHaveBeenCalled(); -}); - -test('should handle scroll event with invalid contentOffset', () => { - const onScrollSpy = jest.fn(); - render(); - - const scrollView = screen.getByTestId('scroll-view'); - - // Test with event data that has invalid contentOffset structure - fireEvent.scroll(scrollView, { - nativeEvent: { - contentOffset: { x: 'invalid', y: null }, - }, - }); - expect(onScrollSpy).toHaveBeenCalled(); -}); diff --git a/src/__tests__/render-async.test.tsx b/src/__tests__/render-async.test.tsx index 8422f737..8fad2cc3 100644 --- a/src/__tests__/render-async.test.tsx +++ b/src/__tests__/render-async.test.tsx @@ -59,11 +59,6 @@ test('renderAsync supports concurrent rendering option', async () => { expect(screen.root).toBeOnTheScreen(); }); -test('renderAsync supports legacy rendering option', async () => { - await renderAsync(, { concurrentRoot: false }); - expect(screen.root).toBeOnTheScreen(); -}); - test('rerender function throws error when used with renderAsync', async () => { await renderAsync(); diff --git a/src/__tests__/render.test.tsx b/src/__tests__/render.test.tsx index f220d007..48151662 100644 --- a/src/__tests__/render.test.tsx +++ b/src/__tests__/render.test.tsx @@ -244,20 +244,20 @@ test('supports concurrent rendering', () => { expect(screen.root).toBeOnTheScreen(); }); -test('updateAsync updates the component asynchronously', async () => { +test('rerenderAsync updates the component asynchronously', async () => { const fn = jest.fn(); const result = render(); - await result.updateAsync(); + await result.rerenderAsync(); expect(fn).toHaveBeenCalledTimes(1); }); -test('rerenderAsync is an alias for updateAsync', async () => { +test('updateAsync is an alias for rerenderAsync', async () => { const fn = jest.fn(); const result = render(); - await result.rerenderAsync(); + await result.updateAsync(); expect(fn).toHaveBeenCalledTimes(1); }); diff --git a/website/docs/13.x/docs/api/screen.mdx b/website/docs/13.x/docs/api/screen.mdx index 2fecce1b..06b448df 100644 --- a/website/docs/13.x/docs/api/screen.mdx +++ b/website/docs/13.x/docs/api/screen.mdx @@ -100,7 +100,7 @@ Async version of `unmount` designed for working with React 19 and React Suspense :::note -Usually you should not need to call `unmount` as it is done automatically if your test runner supports `afterEach` hook (like Jest, mocha, Jasmine). +Usually you should not need to call `unmountAsync` as it is done automatically if your test runner supports `afterEach` hook (like Jest, mocha, Jasmine). :::