Skip to content

feat: support for react suspense #1788

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 4 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions src/__tests__/react-suspense.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
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<void>((resolve) =>
setTimeout(() => {
resolve();
}, delay),
);
}

function Suspending<T>({ promise }: { promise: Promise<T> }) {
React.use(promise);
return <View testID="view" />;
}

testGateReact19('render supports components which can suspend', async () => {
await renderAsync(
<View>
<React.Suspense fallback={<View testID="fallback" />}>
<Suspending promise={wait(100)} />
</React.Suspense>
</View>,
);

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(
<View>
<React.Suspense fallback={<View testID="fallback" />}>
<Suspending promise={wait(100)} />
</React.Suspense>
</View>,
);
});

// 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();
});
2 changes: 2 additions & 0 deletions src/pure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
16 changes: 16 additions & 0 deletions src/render-act.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<TestRendererOptions>,
): Promise<ReactTestRenderer> {
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;
}
131 changes: 131 additions & 0 deletions src/render-async.tsx
Original file line number Diff line number Diff line change
@@ -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<any>;

/**
* 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<typeof renderAsync>;

/**
* Renders test component deeply using React Test Renderer and exposes helpers
* to assert on the output.
*/
export default async function renderAsync<T>(
component: React.ReactElement<T>,
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 ? <Wrapper>{element}</Wrapper> : 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.",
);
},

Check warning on line 98 in src/render-async.tsx

View check run for this annotation

Codecov / codecov/patch

src/render-async.tsx#L94-L98

Added lines #L94 - L98 were not covered by tests
});

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));
});

Check warning on line 115 in src/render-async.tsx

View check run for this annotation

Codecov / codecov/patch

src/render-async.tsx#L112-L115

Added lines #L112 - L115 were not covered by tests
};
}

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);
}
}

Check warning on line 129 in src/render-async.tsx

View check run for this annotation

Codecov / codecov/patch

src/render-async.tsx#L123-L129

Added lines #L123 - L129 were not covered by tests
return debugImpl;
}