refactor: make renderHook async be default (v2) (#1843)

This commit is contained in:
Maciej Jastrzebski
2026-01-04 16:10:25 +01:00
committed by GitHub
parent c4cedd8533
commit aba244fc77
9 changed files with 700 additions and 419 deletions
-266
View File
@@ -1,266 +0,0 @@
import type { ReactNode } from 'react';
import * as React from 'react';
import { Text } from 'react-native';
import { act, renderHookAsync } from '..';
import { excludeConsoleMessage } from '../test-utils/console';
// eslint-disable-next-line no-console
const originalConsoleError = console.error;
afterEach(() => {
// eslint-disable-next-line no-console
console.error = originalConsoleError;
});
function useSuspendingHook(promise: Promise<string>) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: React 18 does not have `use` hook
return React.use(promise);
}
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('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 });
});
test('handles hook with suspense', async () => {
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={<Text>Loading...</Text>}>{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;
}
}
test('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);
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={<Text>Loading...</Text>}>{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 } = await renderHookAsync(useCounter, { initialProps: 5 });
expect(result.current.count).toBe(5);
// eslint-disable-next-line require-await
await act(async () => {
result.current.increment();
});
expect(result.current.count).toBe(6);
// eslint-disable-next-line require-await
await act(async () => {
result.current.reset();
});
expect(result.current.count).toBe(5);
// eslint-disable-next-line require-await
await act(async () => {
result.current.decrement();
});
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);
});
+138
View File
@@ -0,0 +1,138 @@
import type { ReactNode } from 'react';
import * as React from 'react';
import { deprecated_renderHookSync } from '../pure';
test('renders hook and returns committed result', () => {
const { result } = deprecated_renderHookSync(() => {
const [state, setState] = React.useState(1);
React.useEffect(() => {
setState(2);
}, []);
return [state, setState];
});
expect(result.current).toEqual([2, expect.any(Function)]);
});
test('works with wrapper option', () => {
const Context = React.createContext('default');
function Wrapper({ children }: { children: ReactNode }) {
return <Context.Provider value="provided">{children}</Context.Provider>;
}
const { result } = deprecated_renderHookSync(
() => {
return React.useContext(Context);
},
{
wrapper: Wrapper,
},
);
expect(result.current).toEqual('provided');
});
test('works with initialProps option', () => {
const { result } = deprecated_renderHookSync(
(props: { branch: 'left' | 'right' }) => {
const [left, setLeft] = React.useState('left');
const [right, setRight] = React.useState('right');
switch (props.branch) {
case 'left':
return [left, setLeft];
case 'right':
return [right, setRight];
default:
throw new Error('No Props passed. This is a bug in the implementation');
}
},
{ initialProps: { branch: 'left' } },
);
expect(result.current).toEqual(['left', expect.any(Function)]);
});
test('works without initialProps option', () => {
function useTestHook() {
const [count, setCount] = React.useState(0);
return { count, setCount };
}
const { result } = deprecated_renderHookSync(useTestHook);
expect(result.current.count).toBe(0);
});
test('rerender updates hook with new props', () => {
const { result, rerender } = deprecated_renderHookSync(
(props: { branch: 'left' | 'right' }) => {
const [left, setLeft] = React.useState('left');
const [right, setRight] = React.useState('right');
switch (props.branch) {
case 'left':
return [left, setLeft];
case 'right':
return [right, setRight];
default:
throw new Error('No Props passed. This is a bug in the implementation');
}
},
{ initialProps: { branch: 'left' } },
);
expect(result.current).toEqual(['left', expect.any(Function)]);
rerender({ branch: 'right' });
expect(result.current).toEqual(['right', expect.any(Function)]);
});
test('unmount triggers cleanup effects', () => {
let cleanupCalled = false;
function useTestHook() {
React.useEffect(() => {
return () => {
cleanupCalled = true;
};
}, []);
return 'test';
}
const { unmount } = deprecated_renderHookSync(useTestHook);
expect(cleanupCalled).toBe(false);
unmount();
expect(cleanupCalled).toBe(true);
});
function useMyHook<T>(param: T) {
return { param };
}
test('props type is inferred correctly when initial props is defined', () => {
const { result, rerender } = deprecated_renderHookSync((num: number) => useMyHook(num), {
initialProps: 5,
});
expect(result.current.param).toBe(5);
rerender(6);
expect(result.current.param).toBe(6);
});
test('props type is inferred correctly when initial props is explicitly undefined', () => {
const { result, rerender } = deprecated_renderHookSync(
(num: number | undefined) => useMyHook(num),
{
initialProps: undefined,
},
);
expect(result.current.param).toBeUndefined();
rerender(6);
expect(result.current.param).toBe(6);
});
+257 -51
View File
@@ -1,85 +1,291 @@
import type { ReactNode } from 'react';
import * as React from 'react';
import { Text } from 'react-native';
import { renderHook } from '../pure';
import { act, renderHook } from '..';
import { excludeConsoleMessage } from '../test-utils/console';
test('gives committed result', () => {
const { result } = renderHook(() => {
// eslint-disable-next-line no-console
const originalConsoleError = console.error;
afterEach(() => {
// eslint-disable-next-line no-console
console.error = originalConsoleError;
});
function useSuspendingHook(promise: Promise<string>) {
return React.use(promise);
}
test('renders hook and returns committed result', async () => {
const { result } = await renderHook(() => {
const [state, setState] = React.useState(1);
React.useEffect(() => {
setState(2);
}, []);
return [state, setState];
return state;
});
expect(result.current).toEqual([2, expect.any(Function)]);
expect(result.current).toEqual(2);
});
test('allows rerendering', () => {
const { result, rerender } = renderHook(
(props: { branch: 'left' | 'right' }) => {
const [left, setLeft] = React.useState('left');
const [right, setRight] = React.useState('right');
test('handles hook with state updates during effects', async () => {
function useTestHook() {
const [count, setCount] = React.useState(0);
switch (props.branch) {
case 'left':
return [left, setLeft];
case 'right':
return [right, setRight];
default:
throw new Error('No Props passed. This is a bug in the implementation');
}
},
{ initialProps: { branch: 'left' } },
);
React.useEffect(() => {
setCount((prev) => prev + 1);
}, []);
expect(result.current).toEqual(['left', expect.any(Function)]);
return count;
}
rerender({ branch: 'right' });
expect(result.current).toEqual(['right', expect.any(Function)]);
const { result } = await renderHook(useTestHook);
expect(result.current).toBe(1);
});
test('allows wrapper components', () => {
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 renderHook(useTestHook);
expect(result.current).toEqual({ first: 10, second: 20 });
});
test('works 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 } = renderHook(
() => {
return React.useContext(Context);
},
{
wrapper: Wrapper,
},
);
const { result } = await renderHook(useTestHook, { wrapper: Wrapper });
expect(result.current).toEqual('provided');
});
function useMyHook<T>(param: T) {
return { param };
test('works with initialProps option', 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 } = await renderHook(useTestHook, {
initialProps: { value: 5 },
});
expect(result.current).toEqual(10);
});
test('works without initialProps option', async () => {
function useTestHook() {
const [count, setCount] = React.useState(0);
return { count, setCount };
}
const { result } = await renderHook(useTestHook);
expect(result.current.count).toBe(0);
});
test('rerender updates hook with new props', 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, rerender } = await renderHook(useTestHook, {
initialProps: { value: 5 },
});
expect(result.current).toEqual(10);
await rerender({ value: 10 });
expect(result.current).toEqual(20);
});
test('unmount triggers cleanup effects', async () => {
let cleanupCalled = false;
function useTestHook() {
React.useEffect(() => {
return () => {
cleanupCalled = true;
};
}, []);
return 'test';
}
const { unmount } = await renderHook(useTestHook);
expect(cleanupCalled).toBe(false);
await unmount();
expect(cleanupCalled).toBe(true);
});
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, rerender, unmount } = await renderHook(useTestHook, {
initialProps: { key: 'initial' },
});
expect(result.current).toBe('initial-effect');
expect(effectCount).toBe(1);
expect(cleanupCount).toBe(0);
await rerender({ key: 'updated' });
expect(result.current).toBe('updated-effect');
expect(effectCount).toBe(2);
expect(cleanupCount).toBe(1);
await unmount();
expect(effectCount).toBe(2);
expect(cleanupCount).toBe(2);
});
test('handles hook with suspense', async () => {
let resolvePromise: (value: string) => void;
const promise = new Promise<string>((resolve) => {
resolvePromise = resolve;
});
const { result } = await renderHook(useSuspendingHook, {
initialProps: promise,
wrapper: ({ children }) => (
<React.Suspense fallback={<Text>Loading...</Text>}>{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;
}
}
test('props type is inferred correctly when initial props is defined', () => {
const { result, rerender } = renderHook((num: number) => useMyHook(num), {
initialProps: 5,
});
expect(result.current.param).toBe(5);
test('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);
rerender(6);
expect(result.current.param).toBe(6);
});
test('props type is inferred correctly when initial props is explicitly undefined', () => {
const { result, rerender } = renderHook((num: number | undefined) => useMyHook(num), {
initialProps: undefined,
let rejectPromise: (error: Error) => void;
const promise = new Promise<string>((_resolve, reject) => {
rejectPromise = reject;
});
expect(result.current.param).toBeUndefined();
const { result } = await renderHook(useSuspendingHook, {
initialProps: promise,
wrapper: ({ children }) => (
<ErrorBoundary fallback="error-fallback">
<React.Suspense fallback={<Text>Loading...</Text>}>{children}</React.Suspense>
</ErrorBoundary>
),
});
rerender(6);
expect(result.current.param).toBe(6);
// 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 } = await renderHook(useCounter, { initialProps: 5 });
expect(result.current.count).toBe(5);
// eslint-disable-next-line require-await
await act(async () => {
result.current.increment();
});
expect(result.current.count).toBe(6);
// eslint-disable-next-line require-await
await act(async () => {
result.current.reset();
});
expect(result.current.count).toBe(5);
// eslint-disable-next-line require-await
await act(async () => {
result.current.decrement();
});
expect(result.current.count).toBe(4);
});
+2 -2
View File
@@ -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, renderHookAsync } from './render-hook';
export { renderHook, deprecated_renderHookSync } from './render-hook';
export { screen } from './screen';
export { userEvent } from './user-event';
@@ -21,6 +21,6 @@ export type {
DebugFunction,
} from './render';
export type { RenderAsyncOptions, RenderAsyncResult } from './render-async';
export type { RenderHookOptions, RenderHookResult, RenderHookAsyncResult } from './render-hook';
export type { RenderHookOptions, RenderHookResult, RenderHookSyncResult } from './render-hook';
export type { Config } from './config';
export type { UserEventConfig } from './user-event';
+38 -37
View File
@@ -6,14 +6,15 @@ import type { RefObject } from './types';
export type RenderHookResult<Result, Props> = {
result: RefObject<Result>;
rerender: (props: Props) => void;
unmount: () => void;
rerender: (props: Props) => Promise<void>;
unmount: () => Promise<void>;
};
export type RenderHookAsyncResult<Result, Props> = {
/** @deprecated - Use `renderHook` and `RenderHookResult` instead. */
export type RenderHookSyncResult<Result, Props> = {
result: RefObject<Result>;
rerenderAsync: (props: Props) => Promise<void>;
unmountAsync: () => Promise<void>;
rerender: (props: Props) => void;
unmount: () => void;
};
export type RenderHookOptions<Props> = {
@@ -30,10 +31,40 @@ export type RenderHookOptions<Props> = {
wrapper?: React.ComponentType<any>;
};
export function renderHook<Result, Props>(
export async function renderHook<Result, Props>(
hookToRender: (props: Props) => Result,
options?: RenderHookOptions<NoInfer<Props>>,
): RenderHookResult<Result, Props> {
): Promise<RenderHookResult<Result, Props>> {
const result = React.createRef<Result>() as RefObject<Result>;
function HookContainer({ hookProps }: { hookProps: Props }) {
const renderResult = hookToRender(hookProps);
React.useEffect(() => {
result.current = renderResult;
});
return null;
}
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
<HookContainer hookProps={initialProps} />,
renderOptions,
);
return {
result: result,
rerender: (hookProps: Props) => rerenderComponentAsync(<HookContainer hookProps={hookProps} />),
unmount: unmountAsync,
};
}
/** @deprecated - Use async `renderHook` instead. */
export function deprecated_renderHookSync<Result, Props>(
hookToRender: (props: Props) => Result,
options?: RenderHookOptions<NoInfer<Props>>,
): RenderHookSyncResult<Result, Props> {
const result = React.createRef<Result>() as RefObject<Result>;
function HookContainer({ hookProps }: { hookProps: Props }) {
@@ -58,33 +89,3 @@ export function renderHook<Result, Props>(
unmount,
};
}
export async function renderHookAsync<Result, Props>(
hookToRender: (props: Props) => Result,
options?: RenderHookOptions<NoInfer<Props>>,
): Promise<RenderHookAsyncResult<Result, Props>> {
const result = React.createRef<Result>() as RefObject<Result>;
function TestComponent({ hookProps }: { hookProps: Props }) {
const renderResult = hookToRender(hookProps);
React.useEffect(() => {
result.current = renderResult;
});
return null;
}
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,
);
return {
result: result,
rerenderAsync: (hookProps: Props) =>
rerenderComponentAsync(<TestComponent hookProps={hookProps} />),
unmountAsync,
};
}
+57 -62
View File
@@ -3,23 +3,29 @@
## `renderHook`
```ts
function renderHook<Result, Props>(
async function renderHook<Result, Props>(
hookFn: (props?: Props) => Result,
options?: RenderHookOptions<Props>
): RenderHookResult<Result, Props>;
): Promise<RenderHookResult<Result, Props>>;
```
Renders a test component that will call the provided `callback`, including any hooks it calls, every time it renders. Returns [`RenderHookResult`](#renderhookresult) object, which you can interact with.
Renders a test component that will call the provided `callback`, including any hooks it calls, every time it renders. Returns a Promise that resolves to a [`RenderHookResult`](#renderhookresult) object, which you can interact with.
**This is the recommended default API** for testing hooks. It uses async `act` internally to ensure all pending React updates are executed during rendering, making it compatible with React 19, React Suspense, and `React.use()`.
- **Returns a Promise**: Should be awaited
- **Async methods**: Both `rerender` and `unmount` return Promises and should be awaited
- **Suspense support**: Compatible with React Suspense boundaries and `React.use()`
```ts
import { renderHook } from '@testing-library/react-native';
import { renderHook, act } from '@testing-library/react-native';
import { useCount } from '../useCount';
it('should increment count', () => {
const { result } = renderHook(() => useCount());
it('should increment count', async () => {
const { result } = await renderHook(() => useCount());
expect(result.current.count).toBe(0);
act(() => {
await act(async () => {
// Note that you should wrap the calls to functions your hook returns with `act` if they trigger an update of your hook's state to ensure pending useEffects are run before your next assertion.
result.current.increment();
});
@@ -55,22 +61,17 @@ The initial values to pass as `props` to the `callback` function of `renderHook`
A React component to wrap the test component in when rendering. This is usually used to add context providers from `React.createContext` for the hook to access with `useContext`.
### `concurrentRoot` {#concurrent-root}
Set to `false` to disable concurrent rendering.
Otherwise, `render` will default to using concurrent rendering used in the React Native New Architecture.
### Result
```ts
interface RenderHookResult<Result, Props> {
result: { current: Result };
rerender: (props: Props) => void;
unmount: () => void;
rerender: (props: Props) => Promise<void>;
unmount: () => Promise<void>;
}
```
The `renderHook` function returns an object that has the following properties:
The `renderHook` function returns a Promise that resolves to an object with the following properties:
#### `result`
@@ -78,11 +79,15 @@ The `current` value of the `result` will reflect the latest of whatever is retur
#### `rerender`
A function to rerender the test component, causing any hooks to be recalculated. If `newProps` are passed, they will replace the `callback` function's `initialProps` for subsequent rerenders. The `Props` type is determined by the type passed to or inferred by the `renderHook` call.
An async function to rerender the test component, causing any hooks to be recalculated. If `newProps` are passed, they will replace the `callback` function's `initialProps` for subsequent rerenders. The `Props` type is determined by the type passed to or inferred by the `renderHook` call.
**Note**: This method returns a Promise and should be awaited.
#### `unmount`
A function to unmount the test component. This is commonly used to trigger cleanup effects for `useEffect` hooks.
An async function to unmount the test component. This is commonly used to trigger cleanup effects for `useEffect` hooks.
**Note**: This method returns a Promise and should be awaited.
### Examples
@@ -102,19 +107,19 @@ const useCount = (initialCount: number) => {
return { count, increment };
};
it('should increment count', () => {
const { result, rerender } = renderHook((initialCount: number) => useCount(initialCount), {
it('should increment count', async () => {
const { result, rerender } = await renderHook((initialCount: number) => useCount(initialCount), {
initialProps: 1,
});
expect(result.current.count).toBe(1);
act(() => {
await act(async () => {
result.current.increment();
});
expect(result.current.count).toBe(2);
rerender(5);
await rerender(5);
expect(result.current.count).toBe(5);
});
```
@@ -122,61 +127,51 @@ it('should increment count', () => {
#### With `wrapper`
```tsx
it('should use context value', () => {
it('should use context value', async () => {
function Wrapper({ children }: { children: ReactNode }) {
return <Context.Provider value="provided">{children}</Context.Provider>;
}
const { result } = renderHook(() => useHook(), { wrapper: Wrapper });
const { result } = await renderHook(() => useHook(), { wrapper: Wrapper });
// ...
});
```
## `renderHookAsync` function
#### With React Suspense
```ts
async function renderHookAsync<Result, Props>(
hookFn: (props?: Props) => Result,
options?: RenderHookOptions<Props>
): Promise<RenderHookAsyncResult<Result, Props>>;
```
```tsx
import { renderHook, act } from '@testing-library/react-native';
import { Text } from 'react-native';
Async versions of `renderHook` designed for working with React 19 and React Suspense. This method uses async `act` function internally to ensure all pending React updates are executed during rendering.
- **Returns a Promise**: Should be awaited
- **Async methods**: Both `rerender` and `unmount` return Promises and should be awaited
- **Suspense support**: Compatible with React Suspense boundaries and `React.use()`
### Result {#result-async}
```ts
interface RenderHookAsyncResult<Result, Props> {
result: { current: Result };
rerenderAsync: (props: Props) => Promise<void>;
unmountAsync: () => Promise<void>;
function useSuspendingHook(promise: Promise<string>) {
return React.use(promise);
}
```
The `RenderHookAsyncResult` differs from `RenderHookResult` in that `rerenderAsync` and `unmountAsync` are async functions.
```ts
import { renderHookAsync, act } from '@testing-library/react-native';
test('should handle async hook behavior', async () => {
const { result, rerenderAsync } = await renderHookAsync(useAsyncHook);
// Test initial state
expect(result.current.loading).toBe(true);
// Wait for async operation to complete
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 100));
it('handles hook with suspense', async () => {
let resolvePromise: (value: string) => void;
const promise = new Promise<string>((resolve) => {
resolvePromise = resolve;
});
// Re-render to get updated state
await rerenderAsync();
expect(result.current.loading).toBe(false);
const { result } = await renderHook(useSuspendingHook, {
initialProps: promise,
wrapper: ({ children }) => (
<React.Suspense fallback={<Text>Loading...</Text>}>{children}</React.Suspense>
),
});
// Initially suspended, result should not be available
expect(result.current).toBeNull();
await act(async () => resolvePromise('resolved'));
expect(result.current).toBe('resolved');
});
```
Use `renderHookAsync` when testing hooks that use React Suspense, `React.use()`, or other concurrent features where timing of re-renders matters.
## Migration from v13
If you're migrating from React Native Testing Library v13, you may have existing tests using the synchronous `renderHook` API. To ease migration, we provide `deprecated_renderHookSync` which maintains the same synchronous behavior.
**⚠️ Deprecated**: `deprecated_renderHookSync` is provided for migration purposes only. Use async `renderHook` instead.
For detailed migration instructions, see the [v14 migration guide](../../migration/v14).
@@ -1,4 +1,5 @@
[
"v14",
"v13",
"jest-matchers",
{ "type": "dir", "name": "previous", "label": "Previous versions", "collapsed": true }
+206
View File
@@ -0,0 +1,206 @@
# Migration to 14.x
This guide describes the migration to React Native Testing Library version 14 from version 13.x.
## Breaking changes
### `renderHook` is now async by default
In v14, `renderHook` is now async by default and returns a Promise. This change makes it compatible with React 19, React Suspense, and `React.use()`.
**Before (v13):**
```ts
import { renderHook } from '@testing-library/react-native';
it('should test hook', () => {
const { result, rerender } = renderHook(() => useMyHook());
rerender(newProps);
unmount();
});
```
**After (v14):**
```ts
import { renderHook } from '@testing-library/react-native';
it('should test hook', async () => {
const { result, rerender } = await renderHook(() => useMyHook());
await rerender(newProps);
await unmount();
});
```
### Migration path
To ease migration, we provide `deprecated_renderHookSync` which maintains the same synchronous behavior as v13. This allows you to migrate gradually.
#### `deprecated_renderHookSync` {#deprecated-render-hook-sync}
```ts
function deprecated_renderHookSync<Result, Props>(
hookFn: (props?: Props) => Result,
options?: RenderHookOptions<Props>
): RenderHookSyncResult<Result, Props>;
```
**⚠️ Deprecated**: This function is provided for migration purposes only. Use async `renderHook` instead.
The synchronous version of `renderHook` that returns immediately without awaiting React updates. This maintains backward compatibility with v13 tests but is not recommended for new code.
```ts
// Old v13 code (still works but deprecated)
import { deprecated_renderHookSync } from '@testing-library/react-native';
it('should increment count', () => {
const { result, rerender } = deprecated_renderHookSync(() => useCount());
expect(result.current.count).toBe(0);
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
rerender();
});
```
#### Result types
**v13 (deprecated):**
```ts
interface RenderHookSyncResult<Result, Props> {
result: { current: Result };
rerender: (props: Props) => void;
unmount: () => void;
}
```
**v14 (default):**
```ts
interface RenderHookResult<Result, Props> {
result: { current: Result };
rerender: (props: Props) => Promise<void>;
unmount: () => Promise<void>;
}
```
**Note**: Both `RenderHookSyncResult` and `RenderHookResult` use `rerender` and `unmount` methods. The difference is that `RenderHookSyncResult` methods are synchronous (return `void`), while `RenderHookResult` methods are async (return `Promise<void>`).
## Step-by-step migration guide
To migrate from `deprecated_renderHookSync` to `renderHook`:
### 1. Add `async` to your test function
```ts
// Before
it('should test hook', () => {
// After
it('should test hook', async () => {
```
### 2. Await `renderHook`
```ts
// Before
const { result } = deprecated_renderHookSync(() => useMyHook());
// After
const { result } = await renderHook(() => useMyHook());
```
### 3. Update `rerender` calls to await
```ts
// Before
rerender(newProps);
// After
await rerender(newProps);
```
### 4. Update `unmount` calls to await
```ts
// Before
unmount();
// After
await unmount();
```
### 5. Update `act` calls to use async `act`
```ts
// Before
act(() => {
result.current.doSomething();
});
// After
await act(async () => {
result.current.doSomething();
});
```
## Complete example
**Before (v13):**
```ts
import { renderHook, act } from '@testing-library/react-native';
it('should increment count', () => {
const { result, rerender } = renderHook((initialCount: number) => useCount(initialCount), {
initialProps: 1,
});
expect(result.current.count).toBe(1);
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(2);
rerender(5);
expect(result.current.count).toBe(5);
});
```
**After (v14):**
```ts
import { renderHook, act } from '@testing-library/react-native';
it('should increment count', async () => {
const { result, rerender } = await renderHook((initialCount: number) => useCount(initialCount), {
initialProps: 1,
});
expect(result.current.count).toBe(1);
await act(async () => {
result.current.increment();
});
expect(result.current.count).toBe(2);
await rerender(5);
expect(result.current.count).toBe(5);
});
```
## Benefits of async `renderHook`
- **React 19 compatibility**: Works seamlessly with React 19's async features
- **Suspense support**: Properly handles React Suspense boundaries and `React.use()`
- **Better timing**: Ensures all pending React updates are executed before assertions
- **Future-proof**: Aligns with React's direction toward async rendering
For more details, see the [`renderHook` API documentation](/docs/api/misc/render-hook).
+1 -1
View File
@@ -19,7 +19,7 @@ export default defineConfig({
},
multiVersion: {
default: '13.x',
versions: ['12.x', '13.x'],
versions: ['12.x', '13.x', '14.x'],
},
route: {
cleanUrls: true,