Skip to content

Commit 196680b

Browse files
feat: render hook async (#1805)
1 parent ec31ded commit 196680b

File tree

8 files changed

+383
-30
lines changed

8 files changed

+383
-30
lines changed

src/__tests__/render-async.test.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,8 +54,8 @@ test('renderAsync with wrapper option', async () => {
5454
expect(screen.getByTestId('inner')).toBeTruthy();
5555
});
5656

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

Lines changed: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,270 @@
1+
import type { ReactNode } from 'react';
2+
import * as React from 'react';
3+
4+
import { act, renderHookAsync } from '..';
5+
import { excludeConsoleMessage } from '../test-utils/console';
6+
7+
const testGateReact19 = React.version.startsWith('19.') ? test : test.skip;
8+
9+
// eslint-disable-next-line no-console
10+
const originalConsoleError = console.error;
11+
afterEach(() => {
12+
// eslint-disable-next-line no-console
13+
console.error = originalConsoleError;
14+
});
15+
16+
test('renderHookAsync renders hook asynchronously', async () => {
17+
const { result } = await renderHookAsync(() => {
18+
const [state, setState] = React.useState(1);
19+
20+
React.useEffect(() => {
21+
setState(2);
22+
}, []);
23+
24+
return state;
25+
});
26+
27+
expect(result.current).toEqual(2);
28+
});
29+
30+
test('renderHookAsync with wrapper option', async () => {
31+
const Context = React.createContext('default');
32+
33+
function useTestHook() {
34+
return React.useContext(Context);
35+
}
36+
37+
function Wrapper({ children }: { children: ReactNode }) {
38+
return <Context.Provider value="provided">{children}</Context.Provider>;
39+
}
40+
41+
const { result } = await renderHookAsync(useTestHook, { wrapper: Wrapper });
42+
expect(result.current).toEqual('provided');
43+
});
44+
45+
test('renderHookAsync supports legacy rendering option', async () => {
46+
function useTestHook() {
47+
return React.useState(42)[0];
48+
}
49+
50+
const { result } = await renderHookAsync(useTestHook, { concurrentRoot: false });
51+
expect(result.current).toEqual(42);
52+
});
53+
54+
test('rerenderAsync function updates hook asynchronously', async () => {
55+
function useTestHook(props: { value: number }) {
56+
const [state, setState] = React.useState(props.value);
57+
58+
React.useEffect(() => {
59+
setState(props.value * 2);
60+
}, [props.value]);
61+
62+
return state;
63+
}
64+
65+
const { result, rerenderAsync } = await renderHookAsync(useTestHook, {
66+
initialProps: { value: 5 },
67+
});
68+
expect(result.current).toEqual(10);
69+
70+
await rerenderAsync({ value: 10 });
71+
expect(result.current).toEqual(20);
72+
});
73+
74+
test('unmount function unmounts hook asynchronously', async () => {
75+
let cleanupCalled = false;
76+
77+
function useTestHook() {
78+
React.useEffect(() => {
79+
return () => {
80+
cleanupCalled = true;
81+
};
82+
}, []);
83+
84+
return 'test';
85+
}
86+
87+
const { unmountAsync } = await renderHookAsync(useTestHook);
88+
expect(cleanupCalled).toBe(false);
89+
90+
await unmountAsync();
91+
expect(cleanupCalled).toBe(true);
92+
});
93+
94+
test('handles hook with state updates during effects', async () => {
95+
function useTestHook() {
96+
const [count, setCount] = React.useState(0);
97+
98+
React.useEffect(() => {
99+
setCount((prev) => prev + 1);
100+
}, []);
101+
102+
return count;
103+
}
104+
105+
const { result } = await renderHookAsync(useTestHook);
106+
expect(result.current).toBe(1);
107+
});
108+
109+
test('handles multiple state updates in effects', async () => {
110+
function useTestHook() {
111+
const [first, setFirst] = React.useState(1);
112+
const [second, setSecond] = React.useState(2);
113+
114+
React.useEffect(() => {
115+
setFirst(10);
116+
setSecond(20);
117+
}, []);
118+
119+
return { first, second };
120+
}
121+
122+
const { result } = await renderHookAsync(useTestHook);
123+
expect(result.current).toEqual({ first: 10, second: 20 });
124+
});
125+
126+
testGateReact19('handles hook with suspense', async () => {
127+
function useSuspendingHook(promise: Promise<string>) {
128+
return React.use(promise);
129+
}
130+
131+
let resolvePromise: (value: string) => void;
132+
const promise = new Promise<string>((resolve) => {
133+
resolvePromise = resolve;
134+
});
135+
136+
const { result } = await renderHookAsync(useSuspendingHook, {
137+
initialProps: promise,
138+
wrapper: ({ children }) => <React.Suspense fallback="loading">{children}</React.Suspense>,
139+
});
140+
141+
// Initially suspended, result should not be available
142+
expect(result.current).toBeNull();
143+
144+
// eslint-disable-next-line require-await
145+
await act(async () => resolvePromise('resolved'));
146+
expect(result.current).toBe('resolved');
147+
});
148+
149+
class ErrorBoundary extends React.Component<
150+
{ children: React.ReactNode; fallback: string },
151+
{ hasError: boolean }
152+
> {
153+
constructor(props: { children: React.ReactNode; fallback: string }) {
154+
super(props);
155+
this.state = { hasError: false };
156+
}
157+
158+
static getDerivedStateFromError() {
159+
return { hasError: true };
160+
}
161+
162+
render() {
163+
return this.state.hasError ? this.props.fallback : this.props.children;
164+
}
165+
}
166+
167+
testGateReact19('handles hook suspense with error boundary', async () => {
168+
const ERROR_MESSAGE = 'Hook Promise Rejected In Test';
169+
// eslint-disable-next-line no-console
170+
console.error = excludeConsoleMessage(console.error, ERROR_MESSAGE);
171+
172+
function useSuspendingHook(promise: Promise<string>) {
173+
return React.use(promise);
174+
}
175+
176+
let rejectPromise: (error: Error) => void;
177+
const promise = new Promise<string>((_resolve, reject) => {
178+
rejectPromise = reject;
179+
});
180+
181+
const { result } = await renderHookAsync(useSuspendingHook, {
182+
initialProps: promise,
183+
wrapper: ({ children }) => (
184+
<ErrorBoundary fallback="error-fallback">
185+
<React.Suspense fallback="loading">{children}</React.Suspense>
186+
</ErrorBoundary>
187+
),
188+
});
189+
190+
// Initially suspended
191+
expect(result.current).toBeNull();
192+
193+
// eslint-disable-next-line require-await
194+
await act(async () => rejectPromise(new Error(ERROR_MESSAGE)));
195+
196+
// After error, result should still be null (error boundary caught it)
197+
expect(result.current).toBeNull();
198+
});
199+
200+
test('handles custom hooks with complex logic', async () => {
201+
function useCounter(initialValue: number) {
202+
const [count, setCount] = React.useState(initialValue);
203+
204+
const increment = React.useCallback(() => {
205+
setCount((prev) => prev + 1);
206+
}, []);
207+
208+
const decrement = React.useCallback(() => {
209+
setCount((prev) => prev - 1);
210+
}, []);
211+
212+
const reset = React.useCallback(() => {
213+
setCount(initialValue);
214+
}, [initialValue]);
215+
216+
return { count, increment, decrement, reset };
217+
}
218+
219+
const { result, rerenderAsync } = await renderHookAsync(useCounter, { initialProps: 5 });
220+
expect(result.current.count).toBe(5);
221+
222+
result.current.increment();
223+
await rerenderAsync(5);
224+
expect(result.current.count).toBe(6);
225+
226+
result.current.reset();
227+
await rerenderAsync(5);
228+
expect(result.current.count).toBe(5);
229+
230+
result.current.decrement();
231+
await rerenderAsync(5);
232+
expect(result.current.count).toBe(4);
233+
});
234+
235+
test('handles hook with cleanup and re-initialization', async () => {
236+
let effectCount = 0;
237+
let cleanupCount = 0;
238+
239+
function useTestHook(props: { key: string }) {
240+
const [value, setValue] = React.useState(props.key);
241+
242+
React.useEffect(() => {
243+
effectCount++;
244+
setValue(`${props.key}-effect`);
245+
246+
return () => {
247+
cleanupCount++;
248+
};
249+
}, [props.key]);
250+
251+
return value;
252+
}
253+
254+
const { result, rerenderAsync, unmountAsync } = await renderHookAsync(useTestHook, {
255+
initialProps: { key: 'initial' },
256+
});
257+
258+
expect(result.current).toBe('initial-effect');
259+
expect(effectCount).toBe(1);
260+
expect(cleanupCount).toBe(0);
261+
262+
await rerenderAsync({ key: 'updated' });
263+
expect(result.current).toBe('updated-effect');
264+
expect(effectCount).toBe(2);
265+
expect(cleanupCount).toBe(1);
266+
267+
await unmountAsync();
268+
expect(effectCount).toBe(2);
269+
expect(cleanupCount).toBe(2);
270+
});

src/pure.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ export { within, getQueriesForElement } from './within';
1010
export { configure, resetToDefaults } from './config';
1111
export { isHiddenFromAccessibility, isInaccessible } from './helpers/accessibility';
1212
export { getDefaultNormalizer } from './matches';
13-
export { renderHook } from './render-hook';
13+
export { renderHook, renderHookAsync } from './render-hook';
1414
export { screen } from './screen';
1515
export { userEvent } from './user-event';
1616

@@ -21,6 +21,6 @@ export type {
2121
DebugFunction,
2222
} from './render';
2323
export type { RenderAsyncOptions, RenderAsyncResult } from './render-async';
24-
export type { RenderHookOptions, RenderHookResult } from './render-hook';
24+
export type { RenderHookOptions, RenderHookResult, RenderHookAsyncResult } from './render-hook';
2525
export type { Config } from './config';
2626
export type { UserEventConfig } from './user-event';

src/render-hook.tsx

Lines changed: 44 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,20 @@
11
import * as React from 'react';
22

3-
import { renderInternal } from './render';
3+
import render from './render';
4+
import renderAsync from './render-async';
45

56
export type RenderHookResult<Result, Props> = {
7+
result: React.RefObject<Result>;
68
rerender: (props: Props) => void;
7-
result: React.MutableRefObject<Result>;
89
unmount: () => void;
910
};
1011

12+
export type RenderHookAsyncResult<Result, Props> = {
13+
result: React.RefObject<Result>;
14+
rerenderAsync: (props: Props) => Promise<void>;
15+
unmountAsync: () => Promise<void>;
16+
};
17+
1118
export type RenderHookOptions<Props> = {
1219
/**
1320
* The initial props to pass to the hook.
@@ -32,34 +39,59 @@ export function renderHook<Result, Props>(
3239
hookToRender: (props: Props) => Result,
3340
options?: RenderHookOptions<Props>,
3441
): RenderHookResult<Result, Props> {
42+
const result: React.RefObject<Result | null> = React.createRef();
43+
44+
function HookContainer({ hookProps }: { hookProps: Props }) {
45+
const renderResult = hookToRender(hookProps);
46+
React.useEffect(() => {
47+
result.current = renderResult;
48+
});
49+
50+
return null;
51+
}
52+
3553
const { initialProps, ...renderOptions } = options ?? {};
54+
const { rerender: rerenderComponent, unmount } = render(
55+
// @ts-expect-error since option can be undefined, initialProps can be undefined when it should'nt
56+
<HookContainer hookProps={initialProps} />,
57+
renderOptions,
58+
);
3659

37-
const result: React.MutableRefObject<Result | null> = React.createRef();
60+
return {
61+
// Result should already be set after the first render effects are run.
62+
result: result as React.RefObject<Result>,
63+
rerender: (hookProps: Props) => rerenderComponent(<HookContainer hookProps={hookProps} />),
64+
unmount,
65+
};
66+
}
67+
68+
export async function renderHookAsync<Result, Props>(
69+
hookToRender: (props: Props) => Result,
70+
options?: RenderHookOptions<Props>,
71+
): Promise<RenderHookAsyncResult<Result, Props>> {
72+
const result: React.RefObject<Result | null> = React.createRef();
3873

3974
function TestComponent({ hookProps }: { hookProps: Props }) {
4075
const renderResult = hookToRender(hookProps);
41-
4276
React.useEffect(() => {
4377
result.current = renderResult;
4478
});
4579

4680
return null;
4781
}
4882

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

55-
function rerender(hookProps: Props) {
56-
return componentRerender(<TestComponent hookProps={hookProps} />);
57-
}
58-
5990
return {
6091
// Result should already be set after the first render effects are run.
61-
result: result as React.MutableRefObject<Result>,
62-
rerender,
63-
unmount,
92+
result: result as React.RefObject<Result>,
93+
rerenderAsync: (hookProps: Props) =>
94+
rerenderComponentAsync(<TestComponent hookProps={hookProps} />),
95+
unmountAsync,
6496
};
6597
}

0 commit comments

Comments
 (0)