Skip to content

feat: render hook async #1805

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

Merged
merged 6 commits into from
Aug 11, 2025
Merged
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
4 changes: 2 additions & 2 deletions src/__tests__/render-async.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,8 @@ test('renderAsync with wrapper option', async () => {
expect(screen.getByTestId('inner')).toBeTruthy();
});

test('renderAsync supports concurrent rendering option', async () => {
await renderAsync(<View testID="test" />, { concurrentRoot: true });
test('renderAsync supports legacy rendering option', async () => {
await renderAsync(<View testID="test" />, { concurrentRoot: false });
expect(screen.root).toBeOnTheScreen();
});

Expand Down
270 changes: 270 additions & 0 deletions src/__tests__/render-hook-async.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,270 @@
import type { ReactNode } from 'react';
import * as React from 'react';

import { act, renderHookAsync } 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;
});

test('renderHookAsync renders hook asynchronously', async () => {
const { result } = await renderHookAsync(() => {
const [state, setState] = React.useState(1);

React.useEffect(() => {
setState(2);
}, []);

return state;
});

expect(result.current).toEqual(2);
});

test('renderHookAsync with wrapper option', async () => {
const Context = React.createContext('default');

function useTestHook() {
return React.useContext(Context);
}

function Wrapper({ children }: { children: ReactNode }) {
return <Context.Provider value="provided">{children}</Context.Provider>;
}

const { result } = await renderHookAsync(useTestHook, { wrapper: Wrapper });
expect(result.current).toEqual('provided');
});

test('renderHookAsync supports legacy rendering option', async () => {
function useTestHook() {
return React.useState(42)[0];
}

const { result } = await renderHookAsync(useTestHook, { concurrentRoot: false });
expect(result.current).toEqual(42);
});

test('rerenderAsync function updates hook asynchronously', async () => {
function useTestHook(props: { value: number }) {
const [state, setState] = React.useState(props.value);

React.useEffect(() => {
setState(props.value * 2);
}, [props.value]);

return state;
}

const { result, rerenderAsync } = await renderHookAsync(useTestHook, {
initialProps: { value: 5 },
});
expect(result.current).toEqual(10);

await rerenderAsync({ value: 10 });
expect(result.current).toEqual(20);
});

test('unmount function unmounts hook asynchronously', async () => {
let cleanupCalled = false;

function useTestHook() {
React.useEffect(() => {
return () => {
cleanupCalled = true;
};
}, []);

return 'test';
}

const { unmountAsync } = await renderHookAsync(useTestHook);
expect(cleanupCalled).toBe(false);

await unmountAsync();
expect(cleanupCalled).toBe(true);
});

test('handles hook with state updates during effects', async () => {
function useTestHook() {
const [count, setCount] = React.useState(0);

React.useEffect(() => {
setCount((prev) => prev + 1);
}, []);

return count;
}

const { result } = await renderHookAsync(useTestHook);
expect(result.current).toBe(1);
});

test('handles multiple state updates in effects', async () => {
function useTestHook() {
const [first, setFirst] = React.useState(1);
const [second, setSecond] = React.useState(2);

React.useEffect(() => {
setFirst(10);
setSecond(20);
}, []);

return { first, second };
}

const { result } = await renderHookAsync(useTestHook);
expect(result.current).toEqual({ first: 10, second: 20 });
});

testGateReact19('handles hook with suspense', async () => {
function useSuspendingHook(promise: Promise<string>) {
return React.use(promise);
}

let resolvePromise: (value: string) => void;
const promise = new Promise<string>((resolve) => {
resolvePromise = resolve;
});

const { result } = await renderHookAsync(useSuspendingHook, {
initialProps: promise,
wrapper: ({ children }) => <React.Suspense fallback="loading">{children}</React.Suspense>,
});

// Initially suspended, result should not be available
expect(result.current).toBeNull();

// eslint-disable-next-line require-await
await act(async () => resolvePromise('resolved'));
expect(result.current).toBe('resolved');
});

class ErrorBoundary extends React.Component<
{ children: React.ReactNode; fallback: string },
{ hasError: boolean }
> {
constructor(props: { children: React.ReactNode; fallback: string }) {
super(props);
this.state = { hasError: false };
}

static getDerivedStateFromError() {
return { hasError: true };
}

render() {
return this.state.hasError ? this.props.fallback : this.props.children;
}
}

testGateReact19('handles hook suspense with error boundary', async () => {
const ERROR_MESSAGE = 'Hook Promise Rejected In Test';
// eslint-disable-next-line no-console
console.error = excludeConsoleMessage(console.error, ERROR_MESSAGE);

function useSuspendingHook(promise: Promise<string>) {
return React.use(promise);
}

let rejectPromise: (error: Error) => void;
const promise = new Promise<string>((_resolve, reject) => {
rejectPromise = reject;
});

const { result } = await renderHookAsync(useSuspendingHook, {
initialProps: promise,
wrapper: ({ children }) => (
<ErrorBoundary fallback="error-fallback">
<React.Suspense fallback="loading">{children}</React.Suspense>
</ErrorBoundary>
),
});

// Initially suspended
expect(result.current).toBeNull();

// eslint-disable-next-line require-await
await act(async () => rejectPromise(new Error(ERROR_MESSAGE)));

// After error, result should still be null (error boundary caught it)
expect(result.current).toBeNull();
});

test('handles custom hooks with complex logic', async () => {
function useCounter(initialValue: number) {
const [count, setCount] = React.useState(initialValue);

const increment = React.useCallback(() => {
setCount((prev) => prev + 1);
}, []);

const decrement = React.useCallback(() => {
setCount((prev) => prev - 1);
}, []);

const reset = React.useCallback(() => {
setCount(initialValue);
}, [initialValue]);

return { count, increment, decrement, reset };
}

const { result, rerenderAsync } = await renderHookAsync(useCounter, { initialProps: 5 });
expect(result.current.count).toBe(5);

result.current.increment();
await rerenderAsync(5);
expect(result.current.count).toBe(6);

result.current.reset();
await rerenderAsync(5);
expect(result.current.count).toBe(5);

result.current.decrement();
await rerenderAsync(5);
expect(result.current.count).toBe(4);
});

test('handles hook with cleanup and re-initialization', async () => {
let effectCount = 0;
let cleanupCount = 0;

function useTestHook(props: { key: string }) {
const [value, setValue] = React.useState(props.key);

React.useEffect(() => {
effectCount++;
setValue(`${props.key}-effect`);

return () => {
cleanupCount++;
};
}, [props.key]);

return value;
}

const { result, rerenderAsync, unmountAsync } = await renderHookAsync(useTestHook, {
initialProps: { key: 'initial' },
});

expect(result.current).toBe('initial-effect');
expect(effectCount).toBe(1);
expect(cleanupCount).toBe(0);

await rerenderAsync({ key: 'updated' });
expect(result.current).toBe('updated-effect');
expect(effectCount).toBe(2);
expect(cleanupCount).toBe(1);

await unmountAsync();
expect(effectCount).toBe(2);
expect(cleanupCount).toBe(2);
});
4 changes: 2 additions & 2 deletions src/pure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export { within, getQueriesForElement } from './within';
export { configure, resetToDefaults } from './config';
export { isHiddenFromAccessibility, isInaccessible } from './helpers/accessibility';
export { getDefaultNormalizer } from './matches';
export { renderHook } from './render-hook';
export { renderHook, renderHookAsync } from './render-hook';
export { screen } from './screen';
export { userEvent } from './user-event';

Expand All @@ -21,6 +21,6 @@ export type {
DebugFunction,
} from './render';
export type { RenderAsyncOptions, RenderAsyncResult } from './render-async';
export type { RenderHookOptions, RenderHookResult } from './render-hook';
export type { RenderHookOptions, RenderHookResult, RenderHookAsyncResult } from './render-hook';
export type { Config } from './config';
export type { UserEventConfig } from './user-event';
56 changes: 44 additions & 12 deletions src/render-hook.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,20 @@
import * as React from 'react';

import { renderInternal } from './render';
import render from './render';
import renderAsync from './render-async';

export type RenderHookResult<Result, Props> = {
result: React.RefObject<Result>;
rerender: (props: Props) => void;
result: React.MutableRefObject<Result>;
unmount: () => void;
};

export type RenderHookAsyncResult<Result, Props> = {
result: React.RefObject<Result>;
rerenderAsync: (props: Props) => Promise<void>;
unmountAsync: () => Promise<void>;
};

export type RenderHookOptions<Props> = {
/**
* The initial props to pass to the hook.
Expand All @@ -32,34 +39,59 @@ export function renderHook<Result, Props>(
hookToRender: (props: Props) => Result,
options?: RenderHookOptions<Props>,
): RenderHookResult<Result, Props> {
const result: React.RefObject<Result | null> = React.createRef();

function HookContainer({ hookProps }: { hookProps: Props }) {
const renderResult = hookToRender(hookProps);
React.useEffect(() => {
result.current = renderResult;
});

return null;
}

const { initialProps, ...renderOptions } = options ?? {};
const { rerender: rerenderComponent, unmount } = render(
// @ts-expect-error since option can be undefined, initialProps can be undefined when it should'nt
<HookContainer hookProps={initialProps} />,
renderOptions,
);

const result: React.MutableRefObject<Result | null> = React.createRef();
return {
// Result should already be set after the first render effects are run.
result: result as React.RefObject<Result>,
rerender: (hookProps: Props) => rerenderComponent(<HookContainer hookProps={hookProps} />),
unmount,
};
}

export async function renderHookAsync<Result, Props>(
hookToRender: (props: Props) => Result,
options?: RenderHookOptions<Props>,
): Promise<RenderHookAsyncResult<Result, Props>> {
const result: React.RefObject<Result | null> = React.createRef();

function TestComponent({ hookProps }: { hookProps: Props }) {
const renderResult = hookToRender(hookProps);

React.useEffect(() => {
result.current = renderResult;
});

return null;
}

const { rerender: componentRerender, unmount } = renderInternal(
const { initialProps, ...renderOptions } = options ?? {};
const { rerenderAsync: rerenderComponentAsync, unmountAsync } = await renderAsync(
// @ts-expect-error since option can be undefined, initialProps can be undefined when it should'nt
<TestComponent hookProps={initialProps} />,
renderOptions,
);

function rerender(hookProps: Props) {
return componentRerender(<TestComponent hookProps={hookProps} />);
}

return {
// Result should already be set after the first render effects are run.
result: result as React.MutableRefObject<Result>,
rerender,
unmount,
result: result as React.RefObject<Result>,
rerenderAsync: (hookProps: Props) =>
rerenderComponentAsync(<TestComponent hookProps={hookProps} />),
unmountAsync,
};
}
Loading