From 5e741a4e4df42a73637b683e64b872e9481e126c Mon Sep 17 00:00:00 2001 From: Maciej Jastrzebski Date: Mon, 14 Jul 2025 10:22:31 +0200 Subject: [PATCH 1/4] chore: support for react suspense --- src/__tests__/react-suspense.test.tsx | 103 ++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 src/__tests__/react-suspense.test.tsx diff --git a/src/__tests__/react-suspense.test.tsx b/src/__tests__/react-suspense.test.tsx new file mode 100644 index 00000000..883806db --- /dev/null +++ b/src/__tests__/react-suspense.test.tsx @@ -0,0 +1,103 @@ +import * as React from 'react'; +import { View } from 'react-native'; +import { render, screen, within, configure } from '..'; +import TestRenderer, { type ReactTestRenderer } from 'react-test-renderer'; + +configure({ + asyncUtilTimeout: 5000, +}); + +function wait(delay: number) { + return new Promise((resolve) => + setTimeout(() => { + resolve(); + }, delay), + ); +} + +function Suspendable({ promise }: { promise: Promise }) { + React.use(promise); + return ; +} + +test('render supports components which can suspend', async () => { + render( + + }> + + + , + ); + + expect(screen.getByTestId('fallback')).toBeOnTheScreen(); + + // eslint-disable-next-line require-await + await React.act(async () => { + await wait(1000); + }); + + expect(await screen.findByTestId('test')).toBeOnTheScreen(); +}); + +test.only('react test renderer supports components which can suspend', async () => { + let renderer: ReactTestRenderer; + + // eslint-disable-next-line require-await + await React.act(async () => { + renderer = TestRenderer.create( + + }> + + + , + ); + }); + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const view = within(renderer!.root); + + expect(view.getByTestId('fallback')).toBeDefined(); + expect(await view.findByTestId('test')).toBeDefined(); +}); + +test.only('react test renderer supports components which can suspend 500', async () => { + let renderer: ReactTestRenderer; + + // eslint-disable-next-line require-await + await React.act(async () => { + renderer = TestRenderer.create( + + }> + + + , + ); + }); + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const view = within(renderer!.root); + + expect(view.getByTestId('fallback')).toBeDefined(); + expect(await view.findByTestId('test')).toBeDefined(); +}); + +test.only('react test renderer supports components which can suspend 1000ms', async () => { + let renderer: ReactTestRenderer; + + // eslint-disable-next-line require-await + await React.act(async () => { + renderer = TestRenderer.create( + + }> + + + , + ); + }); + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const view = within(renderer!.root); + + expect(view.getByTestId('fallback')).toBeDefined(); + expect(await view.findByTestId('test', undefined, { timeout: 5000 })).toBeDefined(); +}); From bb8c87cc3cf413adce9357291703e60fa5796672 Mon Sep 17 00:00:00 2001 From: Maciej Jastrzebski Date: Mon, 14 Jul 2025 11:35:28 +0200 Subject: [PATCH 2/4] basic impl --- src/__tests__/react-suspense.test.tsx | 17 ++-- src/pure.ts | 2 + src/render-act.ts | 16 ++++ src/render-async.tsx | 119 ++++++++++++++++++++++++++ 4 files changed, 143 insertions(+), 11 deletions(-) create mode 100644 src/render-async.tsx diff --git a/src/__tests__/react-suspense.test.tsx b/src/__tests__/react-suspense.test.tsx index 883806db..351e81a6 100644 --- a/src/__tests__/react-suspense.test.tsx +++ b/src/__tests__/react-suspense.test.tsx @@ -1,8 +1,9 @@ import * as React from 'react'; import { View } from 'react-native'; -import { render, screen, within, configure } from '..'; import TestRenderer, { type ReactTestRenderer } from 'react-test-renderer'; +import { configure, renderAsync, screen, within } from '..'; + configure({ asyncUtilTimeout: 5000, }); @@ -21,7 +22,7 @@ function Suspendable({ promise }: { promise: Promise }) { } test('render supports components which can suspend', async () => { - render( + await renderAsync( }> @@ -30,16 +31,10 @@ test('render supports components which can suspend', async () => { ); expect(screen.getByTestId('fallback')).toBeOnTheScreen(); - - // eslint-disable-next-line require-await - await React.act(async () => { - await wait(1000); - }); - expect(await screen.findByTestId('test')).toBeOnTheScreen(); }); -test.only('react test renderer supports components which can suspend', async () => { +test('react test renderer supports components which can suspend', async () => { let renderer: ReactTestRenderer; // eslint-disable-next-line require-await @@ -60,7 +55,7 @@ test.only('react test renderer supports components which can suspend', async () expect(await view.findByTestId('test')).toBeDefined(); }); -test.only('react test renderer supports components which can suspend 500', async () => { +test('react test renderer supports components which can suspend 500', async () => { let renderer: ReactTestRenderer; // eslint-disable-next-line require-await @@ -81,7 +76,7 @@ test.only('react test renderer supports components which can suspend 500', async expect(await view.findByTestId('test')).toBeDefined(); }); -test.only('react test renderer supports components which can suspend 1000ms', async () => { +test('react test renderer supports components which can suspend 1000ms', async () => { let renderer: ReactTestRenderer; // eslint-disable-next-line require-await diff --git a/src/pure.ts b/src/pure.ts index f4aa4f7a..60526bb3 100644 --- a/src/pure.ts +++ b/src/pure.ts @@ -2,6 +2,7 @@ export { default as act } from './act'; export { default as cleanup } from './cleanup'; export { default as fireEvent } from './fire-event'; export { default as render } from './render'; +export { default as renderAsync } from './render-async'; export { default as waitFor } from './wait-for'; export { default as waitForElementToBeRemoved } from './wait-for-element-to-be-removed'; export { within, getQueriesForElement } from './within'; @@ -19,6 +20,7 @@ export type { RenderResult as RenderAPI, DebugFunction, } from './render'; +export type { RenderAsyncOptions, RenderAsyncResult } from './render-async'; export type { RenderHookOptions, RenderHookResult } from './render-hook'; export type { Config } from './config'; export type { UserEventConfig } from './user-event'; diff --git a/src/render-act.ts b/src/render-act.ts index 3bba04ea..a463ad33 100644 --- a/src/render-act.ts +++ b/src/render-act.ts @@ -18,3 +18,19 @@ export function renderWithAct( // @ts-expect-error: `act` is synchronous, so `renderer` is already initialized here return renderer; } + +export async function renderWithAsyncAct( + component: React.ReactElement, + options?: Partial, +): Promise { + let renderer: ReactTestRenderer; + + // eslint-disable-next-line require-await + await act(async () => { + // @ts-expect-error `TestRenderer.create` is not typed correctly + renderer = TestRenderer.create(component, options); + }); + + // @ts-expect-error: `renderer` is already initialized here + return renderer; +} diff --git a/src/render-async.tsx b/src/render-async.tsx new file mode 100644 index 00000000..a22c16ee --- /dev/null +++ b/src/render-async.tsx @@ -0,0 +1,119 @@ +import * as React from 'react'; +import type { + ReactTestInstance, + ReactTestRenderer, + TestRendererOptions, +} from 'react-test-renderer'; + +import act from './act'; +import { addToCleanupQueue } from './cleanup'; +import { getConfig } from './config'; +import { getHostSelves } from './helpers/component-tree'; +import type { DebugOptions } from './helpers/debug'; +import { debug } from './helpers/debug'; +import { renderWithAsyncAct } from './render-act'; +import { setRenderResult } from './screen'; +import { getQueriesForElement } from './within'; + +export interface RenderAsyncOptions { + /** + * Pass a React Component as the wrapper option to have it rendered around the inner element. This is most useful for creating + * reusable custom render functions for common data providers. + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + wrapper?: React.ComponentType; + + /** + * Set to `false` to disable concurrent rendering. + * Otherwise `render` will default to concurrent rendering. + */ + // TODO: should we assume concurrentRoot is true for react suspense? + concurrentRoot?: boolean; + + createNodeMock?: (element: React.ReactElement) => unknown; +} + +export type RenderAsyncResult = ReturnType; + +/** + * Renders test component deeply using React Test Renderer and exposes helpers + * to assert on the output. + */ +export default async function renderAsync( + component: React.ReactElement, + options: RenderAsyncOptions = {}, +) { + const { wrapper: Wrapper, concurrentRoot, ...rest } = options || {}; + + const testRendererOptions: TestRendererOptions = { + ...rest, + // @ts-expect-error incomplete typing on RTR package + unstable_isConcurrent: concurrentRoot ?? getConfig().concurrentRoot, + }; + + const wrap = (element: React.ReactElement) => (Wrapper ? {element} : element); + const renderer = await renderWithAsyncAct(wrap(component), testRendererOptions); + return buildRenderResult(renderer, wrap); +} + +function buildRenderResult( + renderer: ReactTestRenderer, + wrap: (element: React.ReactElement) => React.JSX.Element, +) { + const update = updateWithAsyncAct(renderer, wrap); + const instance = renderer.root; + + // TODO: test this + const unmount = async () => { + // eslint-disable-next-line require-await + await act(async () => { + renderer.unmount(); + }); + }; + + addToCleanupQueue(unmount); + + const result = { + ...getQueriesForElement(instance), + update, + unmount, + rerender: update, // alias for `update` + toJSON: renderer.toJSON, + debug: makeDebug(renderer), + get root(): ReactTestInstance { + return getHostSelves(instance)[0]; + }, + UNSAFE_root: instance, + }; + + // Add as non-enumerable property, so that it's safe to enumerate + // `render` result, e.g. using destructuring rest syntax. + Object.defineProperty(result, 'container', { + enumerable: false, + get() { + throw new Error( + "'container' property has been renamed to 'UNSAFE_root'.\n\n" + + "Consider using 'root' property which returns root host element.", + ); + }, + }); + + setRenderResult(result); + + return result; +} + +// TODO: test this +function updateWithAsyncAct( + renderer: ReactTestRenderer, + wrap: (innerElement: React.ReactElement) => React.ReactElement, +) { + return async function (component: React.ReactElement) { + // eslint-disable-next-line require-await + await act(async () => { + renderer.update(wrap(component)); + }); + }; +} + +export type DebugFunction = (options?: DebugOptions) => void; From bd27572db9710a56f232a1411140d277ec637a7f Mon Sep 17 00:00:00 2001 From: Maciej Jastrzebski Date: Mon, 14 Jul 2025 11:39:10 +0200 Subject: [PATCH 3/4] . --- src/render-async.tsx | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/render-async.tsx b/src/render-async.tsx index a22c16ee..3a48a88d 100644 --- a/src/render-async.tsx +++ b/src/render-async.tsx @@ -117,3 +117,15 @@ function updateWithAsyncAct( } export type DebugFunction = (options?: DebugOptions) => void; + +function makeDebug(renderer: ReactTestRenderer): DebugFunction { + function debugImpl(options?: DebugOptions) { + const { defaultDebugOptions } = getConfig(); + const debugOptions = { ...defaultDebugOptions, ...options }; + const json = renderer.toJSON(); + if (json) { + return debug(json, debugOptions); + } + } + return debugImpl; +} From c5d1ccec98b38e0caed8fc16dabf566102557f30 Mon Sep 17 00:00:00 2001 From: Maciej Jastrzebski Date: Mon, 14 Jul 2025 11:41:01 +0200 Subject: [PATCH 4/4] . --- 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(); });