diff --git a/src/__tests__/render-async.test.tsx b/src/__tests__/render-async.test.tsx
index 8fad2cc31..f98af36b8 100644
--- a/src/__tests__/render-async.test.tsx
+++ b/src/__tests__/render-async.test.tsx
@@ -54,8 +54,8 @@ test('renderAsync with wrapper option', async () => {
expect(screen.getByTestId('inner')).toBeTruthy();
});
-test('renderAsync supports concurrent rendering option', async () => {
- await renderAsync(, { concurrentRoot: true });
+test('renderAsync supports legacy rendering option', async () => {
+ await renderAsync(, { concurrentRoot: false });
expect(screen.root).toBeOnTheScreen();
});
diff --git a/src/__tests__/render-hook-async.test.tsx b/src/__tests__/render-hook-async.test.tsx
new file mode 100644
index 000000000..82ee858a9
--- /dev/null
+++ b/src/__tests__/render-hook-async.test.tsx
@@ -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 {children};
+ }
+
+ 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) {
+ return React.use(promise);
+ }
+
+ let resolvePromise: (value: string) => void;
+ const promise = new Promise((resolve) => {
+ resolvePromise = resolve;
+ });
+
+ const { result } = await renderHookAsync(useSuspendingHook, {
+ initialProps: promise,
+ wrapper: ({ children }) => {children},
+ });
+
+ // 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) {
+ return React.use(promise);
+ }
+
+ let rejectPromise: (error: Error) => void;
+ const promise = new Promise((_resolve, reject) => {
+ rejectPromise = reject;
+ });
+
+ const { result } = await renderHookAsync(useSuspendingHook, {
+ initialProps: promise,
+ wrapper: ({ children }) => (
+
+ {children}
+
+ ),
+ });
+
+ // 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);
+});
diff --git a/src/pure.ts b/src/pure.ts
index 62be84c21..6b8483081 100644
--- a/src/pure.ts
+++ b/src/pure.ts
@@ -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';
@@ -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';
diff --git a/src/render-hook.tsx b/src/render-hook.tsx
index 4cc67ac57..63f59c92d 100644
--- a/src/render-hook.tsx
+++ b/src/render-hook.tsx
@@ -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: React.RefObject;
rerender: (props: Props) => void;
- result: React.MutableRefObject;
unmount: () => void;
};
+export type RenderHookAsyncResult = {
+ result: React.RefObject;
+ rerenderAsync: (props: Props) => Promise;
+ unmountAsync: () => Promise;
+};
+
export type RenderHookOptions = {
/**
* The initial props to pass to the hook.
@@ -32,13 +39,40 @@ export function renderHook(
hookToRender: (props: Props) => Result,
options?: RenderHookOptions,
): RenderHookResult {
+ const result: React.RefObject = 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
+ ,
+ renderOptions,
+ );
- const result: React.MutableRefObject = React.createRef();
+ return {
+ // Result should already be set after the first render effects are run.
+ result: result as React.RefObject,
+ rerender: (hookProps: Props) => rerenderComponent(),
+ unmount,
+ };
+}
+
+export async function renderHookAsync(
+ hookToRender: (props: Props) => Result,
+ options?: RenderHookOptions,
+): Promise> {
+ const result: React.RefObject = React.createRef();
function TestComponent({ hookProps }: { hookProps: Props }) {
const renderResult = hookToRender(hookProps);
-
React.useEffect(() => {
result.current = renderResult;
});
@@ -46,20 +80,18 @@ export function renderHook(
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
,
renderOptions,
);
- function rerender(hookProps: Props) {
- return componentRerender();
- }
-
return {
// Result should already be set after the first render effects are run.
- result: result as React.MutableRefObject,
- rerender,
- unmount,
+ result: result as React.RefObject,
+ rerenderAsync: (hookProps: Props) =>
+ rerenderComponentAsync(),
+ unmountAsync,
};
}
diff --git a/website/docs/13.x/docs/api/events/fire-event.mdx b/website/docs/13.x/docs/api/events/fire-event.mdx
index 8ce544706..c2ab68951 100644
--- a/website/docs/13.x/docs/api/events/fire-event.mdx
+++ b/website/docs/13.x/docs/api/events/fire-event.mdx
@@ -157,7 +157,6 @@ Prefer using [`user.scrollTo`](docs/api/events/user-event#scrollto) over `fireEv
:::
-
## `fireEventAsync`
:::info RNTL minimal version
@@ -166,12 +165,15 @@ This API requires RNTL v13.3.0 or later.
:::
-
```ts
-async function fireEventAsync(element: ReactTestInstance, eventName: string, ...data: unknown[]): Promise;
+async function fireEventAsync(
+ element: ReactTestInstance,
+ eventName: string,
+ ...data: unknown[]
+): Promise;
```
-The `fireEventAsync` function is the async version of `fireEvent` designed for working with React 19 and React Suspense. It wraps event handler execution in async `act()`, making it suitable for event handlers that trigger suspense boundaries or other async behavior.
+The `fireEventAsync` function is the async version of `fireEvent` designed for working with React 19 and React Suspense. This function uses async `act` function internally to ensure all pending React updates are executed during event handling.
```jsx
import { renderAsync, screen, fireEventAsync } from '@testing-library/react-native';
@@ -192,7 +194,7 @@ Like `fireEvent`, `fireEventAsync` also provides convenience methods for common
fireEventAsync.press: (element: ReactTestInstance, ...data: Array) => Promise
```
-Async version of `fireEvent.press` designed for React 19 and React Suspense. Use when press event handlers trigger suspense boundaries or other async behavior.
+Async version of `fireEvent.press` designed for React 19 and React Suspense. Use when press event handlers trigger suspense boundaries.
### `fireEventAsync.changeText` {#async-change-text}
@@ -200,7 +202,7 @@ Async version of `fireEvent.press` designed for React 19 and React Suspense. Use
fireEventAsync.changeText: (element: ReactTestInstance, ...data: Array) => Promise
```
-Async version of `fireEvent.changeText` designed for React 19 and React Suspense. Use when changeText event handlers trigger suspense boundaries or other async behavior.
+Async version of `fireEvent.changeText` designed for React 19 and React Suspense. Use when changeText event handlers trigger suspense boundaries.
### `fireEventAsync.scroll` {#async-scroll}
@@ -208,4 +210,4 @@ Async version of `fireEvent.changeText` designed for React 19 and React Suspense
fireEventAsync.scroll: (element: ReactTestInstance, ...data: Array) => Promise
```
-Async version of `fireEvent.scroll` designed for React 19 and React Suspense. Use when scroll event handlers trigger suspense boundaries or other async behavior.
+Async version of `fireEvent.scroll` designed for React 19 and React Suspense. Use when scroll event handlers trigger suspense boundaries.
diff --git a/website/docs/13.x/docs/api/misc/render-hook.mdx b/website/docs/13.x/docs/api/misc/render-hook.mdx
index 5767aaaf2..eab8e8562 100644
--- a/website/docs/13.x/docs/api/misc/render-hook.mdx
+++ b/website/docs/13.x/docs/api/misc/render-hook.mdx
@@ -2,7 +2,7 @@
```ts
function renderHook(
- callback: (props?: Props) => Result,
+ hookFn: (props?: Props) => Result,
options?: RenderHookOptions
): RenderHookResult;
```
@@ -129,3 +129,52 @@ it('should use context value', () => {
// ...
});
```
+
+## `renderHookAsync` function
+
+```ts
+async function renderHookAsync(
+ hookFn: (props?: Props) => Result,
+ options?: RenderHookOptions
+): Promise>;
+```
+
+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: { current: Result };
+ rerender: (props: Props) => Promise;
+ unmount: () => Promise;
+}
+```
+
+The `RenderHookAsyncResult` differs from `RenderHookResult` in that `rerender` and `unmount` are async functions.
+
+```ts
+import { renderHookAsync, act } from '@testing-library/react-native';
+
+test('should handle async hook behavior', async () => {
+ const { result, rerender } = 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));
+ });
+
+ // Re-render to get updated state
+ await rerender();
+ expect(result.current.loading).toBe(false);
+});
+```
+
+Use `renderHookAsync` when testing hooks that use React Suspense, `React.use()`, or other concurrent features where timing of re-renders matters.
diff --git a/website/docs/13.x/docs/api/render.mdx b/website/docs/13.x/docs/api/render.mdx
index 2c89591c1..b7560e94f 100644
--- a/website/docs/13.x/docs/api/render.mdx
+++ b/website/docs/13.x/docs/api/render.mdx
@@ -77,10 +77,10 @@ This API requires RNTL v13.3.0 or later.
async function renderAsync(
component: React.Element,
options?: RenderAsyncOptions
-): Promise
+): Promise;
```
-The `renderAsync` function is the async version of `render` designed for working with React 19 and React Suspense. It allows components to be properly rendered when they contain suspense boundaries or async behavior that needs to complete before the render result is returned.
+The `renderAsync` function is the async version of `render` designed for working with React 19 and React Suspense. This function uses async `act` function internally to ensure all pending React updates are executed during rendering.
```jsx
import { renderAsync, screen } from '@testing-library/react-native';
diff --git a/website/docs/13.x/docs/api/screen.mdx b/website/docs/13.x/docs/api/screen.mdx
index 06b448dff..d4016d4f3 100644
--- a/website/docs/13.x/docs/api/screen.mdx
+++ b/website/docs/13.x/docs/api/screen.mdx
@@ -55,17 +55,17 @@ This API requires RNTL v13.3.0 or later.
function rerenderAsync(element: React.Element): Promise;
```
-Async versions of `rerender` designed for working with React 19 and React Suspense. These methods wait for async operations to complete during re-rendering, making them suitable for components that use suspense boundaries or other async behavior.
+Async versions of `rerender` 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 updating.
```jsx
import { renderAsync, screen } from '@testing-library/react-native';
test('async rerender test', async () => {
await renderAsync();
-
+
// Use async rerender when component has suspense or async behavior
await screen.rerenderAsync();
-
+
expect(screen.getByText('updated')).toBeOnTheScreen();
});
```
@@ -96,7 +96,7 @@ This API requires RNTL v13.3.0 or later.
function unmountAsync(): Promise;
```
-Async version of `unmount` designed for working with React 19 and React Suspense. This method waits for async cleanup operations to complete during unmounting, making it suitable for components that have async cleanup behavior.
+Async version of `unmount` 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 unmounting.
:::note