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__/render-async.tsx b/src/__tests__/render-async.tsx new file mode 100644 index 00000000..5b1c6529 --- /dev/null +++ b/src/__tests__/render-async.tsx @@ -0,0 +1,60 @@ +/* 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/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..3a48a88d --- /dev/null +++ b/src/render-async.tsx @@ -0,0 +1,131 @@ +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; + +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; +}