diff --git a/src/__tests__/render-hook-async.test.tsx b/src/__tests__/render-hook-async.test.tsx deleted file mode 100644 index c85d736d2..000000000 --- a/src/__tests__/render-hook-async.test.tsx +++ /dev/null @@ -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) { - // 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 {children}; - } - - 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((resolve) => { - resolvePromise = resolve; - }); - - const { result } = await renderHookAsync(useSuspendingHook, { - initialProps: promise, - wrapper: ({ children }) => ( - Loading...}>{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; - } -} - -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((_resolve, reject) => { - rejectPromise = reject; - }); - - const { result } = await renderHookAsync(useSuspendingHook, { - initialProps: promise, - wrapper: ({ children }) => ( - - Loading...}>{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 } = 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); -}); diff --git a/src/__tests__/render-hook-sync.test.tsx b/src/__tests__/render-hook-sync.test.tsx new file mode 100644 index 000000000..6b269b74a --- /dev/null +++ b/src/__tests__/render-hook-sync.test.tsx @@ -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 {children}; + } + 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(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); +}); diff --git a/src/__tests__/render-hook.test.tsx b/src/__tests__/render-hook.test.tsx index 138deaf00..782fec0d1 100644 --- a/src/__tests__/render-hook.test.tsx +++ b/src/__tests__/render-hook.test.tsx @@ -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) { + 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'); - - 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('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 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 {children}; } - const { result } = renderHook( - () => { - return React.useContext(Context); - }, - { - wrapper: Wrapper, - }, - ); + const { result } = await renderHook(useTestHook, { wrapper: Wrapper }); expect(result.current).toEqual('provided'); }); -function useMyHook(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((resolve) => { + resolvePromise = resolve; + }); + + const { result } = await renderHook(useSuspendingHook, { + initialProps: promise, + wrapper: ({ children }) => ( + Loading...}>{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; + } } -test('props type is inferred correctly when initial props is defined', () => { - const { result, rerender } = renderHook((num: number) => useMyHook(num), { - initialProps: 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); + + let rejectPromise: (error: Error) => void; + const promise = new Promise((_resolve, reject) => { + rejectPromise = reject; + }); + + const { result } = await renderHook(useSuspendingHook, { + initialProps: promise, + wrapper: ({ children }) => ( + + Loading...}>{children} + + ), }); - expect(result.current.param).toBe(5); - 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('props type is inferred correctly when initial props is explicitly undefined', () => { - const { result, rerender } = renderHook((num: number | undefined) => useMyHook(num), { - initialProps: undefined, +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); - expect(result.current.param).toBeUndefined(); + // eslint-disable-next-line require-await + await act(async () => { + result.current.reset(); + }); + expect(result.current.count).toBe(5); - rerender(6); - expect(result.current.param).toBe(6); + // eslint-disable-next-line require-await + await act(async () => { + result.current.decrement(); + }); + expect(result.current.count).toBe(4); }); diff --git a/src/pure.ts b/src/pure.ts index c538b3401..5503cf968 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, 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'; diff --git a/src/render-hook.tsx b/src/render-hook.tsx index dadeb171f..2aaa18dfd 100644 --- a/src/render-hook.tsx +++ b/src/render-hook.tsx @@ -6,14 +6,15 @@ import type { RefObject } from './types'; export type RenderHookResult = { result: RefObject; - rerender: (props: Props) => void; - unmount: () => void; + rerender: (props: Props) => Promise; + unmount: () => Promise; }; -export type RenderHookAsyncResult = { +/** @deprecated - Use `renderHook` and `RenderHookResult` instead. */ +export type RenderHookSyncResult = { result: RefObject; - rerenderAsync: (props: Props) => Promise; - unmountAsync: () => Promise; + rerender: (props: Props) => void; + unmount: () => void; }; export type RenderHookOptions = { @@ -30,10 +31,10 @@ export type RenderHookOptions = { wrapper?: React.ComponentType; }; -export function renderHook( +export async function renderHook( hookToRender: (props: Props) => Result, options?: RenderHookOptions>, -): RenderHookResult { +): Promise> { const result = React.createRef() as RefObject; function HookContainer({ hookProps }: { hookProps: Props }) { @@ -46,7 +47,7 @@ export function renderHook( } const { initialProps, ...renderOptions } = options ?? {}; - const { rerender: rerenderComponent, unmount } = render( + const { rerenderAsync: rerenderComponentAsync, unmountAsync } = await renderAsync( // @ts-expect-error since option can be undefined, initialProps can be undefined when it should'nt , renderOptions, @@ -54,18 +55,19 @@ export function renderHook( return { result: result, - rerender: (hookProps: Props) => rerenderComponent(), - unmount, + rerender: (hookProps: Props) => rerenderComponentAsync(), + unmount: unmountAsync, }; } -export async function renderHookAsync( +/** @deprecated - Use async `renderHook` instead. */ +export function deprecated_renderHookSync( hookToRender: (props: Props) => Result, options?: RenderHookOptions>, -): Promise> { +): RenderHookSyncResult { const result = React.createRef() as RefObject; - function TestComponent({ hookProps }: { hookProps: Props }) { + function HookContainer({ hookProps }: { hookProps: Props }) { const renderResult = hookToRender(hookProps); React.useEffect(() => { result.current = renderResult; @@ -75,16 +77,15 @@ export async function renderHookAsync( } const { initialProps, ...renderOptions } = options ?? {}; - const { rerenderAsync: rerenderComponentAsync, unmountAsync } = await renderAsync( + const { rerender: rerenderComponent, unmount } = render( // @ts-expect-error since option can be undefined, initialProps can be undefined when it should'nt - , + , renderOptions, ); return { result: result, - rerenderAsync: (hookProps: Props) => - rerenderComponentAsync(), - unmountAsync, + rerender: (hookProps: Props) => rerenderComponent(), + unmount, }; } diff --git a/website/docs/14.x/docs/api/misc/render-hook.mdx b/website/docs/14.x/docs/api/misc/render-hook.mdx index a581ad128..c9e24716e 100644 --- a/website/docs/14.x/docs/api/misc/render-hook.mdx +++ b/website/docs/14.x/docs/api/misc/render-hook.mdx @@ -3,23 +3,29 @@ ## `renderHook` ```ts -function renderHook( +async function renderHook( hookFn: (props?: Props) => Result, options?: RenderHookOptions -): RenderHookResult; +): Promise>; ``` -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: { current: Result }; - rerender: (props: Props) => void; - unmount: () => void; + rerender: (props: Props) => Promise; + unmount: () => Promise; } ``` -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 {children}; } - const { result } = renderHook(() => useHook(), { wrapper: Wrapper }); + const { result } = await renderHook(() => useHook(), { wrapper: Wrapper }); // ... }); ``` -## `renderHookAsync` function +#### With React Suspense -```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} +```tsx +import { renderHook, act } from '@testing-library/react-native'; +import { Text } from 'react-native'; -```ts -interface RenderHookAsyncResult { - result: { current: Result }; - rerenderAsync: (props: Props) => Promise; - unmountAsync: () => Promise; +function useSuspendingHook(promise: Promise) { + 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); +it('handles hook with suspense', async () => { + let resolvePromise: (value: string) => void; + const promise = new Promise((resolve) => { + resolvePromise = resolve; + }); - // Wait for async operation to complete - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 100)); + const { result } = await renderHook(useSuspendingHook, { + initialProps: promise, + wrapper: ({ children }) => ( + Loading...}>{children} + ), }); - // Re-render to get updated state - await rerenderAsync(); - expect(result.current.loading).toBe(false); + // 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). diff --git a/website/docs/14.x/docs/migration/_meta.json b/website/docs/14.x/docs/migration/_meta.json index 43f91b8a8..64bd10c03 100644 --- a/website/docs/14.x/docs/migration/_meta.json +++ b/website/docs/14.x/docs/migration/_meta.json @@ -1,4 +1,5 @@ [ + "v14", "v13", "jest-matchers", { "type": "dir", "name": "previous", "label": "Previous versions", "collapsed": true } diff --git a/website/docs/14.x/docs/migration/v14.mdx b/website/docs/14.x/docs/migration/v14.mdx new file mode 100644 index 000000000..f4476c1bd --- /dev/null +++ b/website/docs/14.x/docs/migration/v14.mdx @@ -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( + hookFn: (props?: Props) => Result, + options?: RenderHookOptions +): RenderHookSyncResult; +``` + +**⚠️ 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: { current: Result }; + rerender: (props: Props) => void; + unmount: () => void; +} +``` + +**v14 (default):** + +```ts +interface RenderHookResult { + result: { current: Result }; + rerender: (props: Props) => Promise; + unmount: () => Promise; +} +``` + +**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`). + +## 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). diff --git a/website/rspress.config.ts b/website/rspress.config.ts index 035c07741..705fb2055 100644 --- a/website/rspress.config.ts +++ b/website/rspress.config.ts @@ -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,