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.test.tsx b/src/__tests__/render-hook.test.tsx index 138deaf00..01ca3ea6c 100644 --- a/src/__tests__/render-hook.test.tsx +++ b/src/__tests__/render-hook.test.tsx @@ -1,10 +1,19 @@ 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; +}); + +test('renders hook and waits for effects to complete', async () => { + const { result } = await renderHook(() => { const [state, setState] = React.useState(1); React.useEffect(() => { @@ -17,8 +26,42 @@ test('gives committed result', () => { expect(result.current).toEqual([2, expect.any(Function)]); }); -test('allows rerendering', () => { - const { result, rerender } = renderHook( +test('handles multiple state updates in single effect', 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('renders hook with initialProps', 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('rerenders hook with new props', async () => { + const { result, rerender } = await renderHook( (props: { branch: 'left' | 'right' }) => { const [left, setLeft] = React.useState('left'); const [right, setRight] = React.useState('right'); @@ -37,17 +80,37 @@ test('allows rerendering', () => { expect(result.current).toEqual(['left', expect.any(Function)]); - rerender({ branch: 'right' }); + await rerender({ branch: 'right' }); expect(result.current).toEqual(['right', expect.any(Function)]); }); -test('allows wrapper components', () => { +test('rerender updates hook state based on 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('supports wrapper option for context providers', async () => { const Context = React.createContext('default'); function Wrapper({ children }: { children: ReactNode }) { return {children}; } - const { result } = renderHook( + const { result } = await renderHook( () => { return React.useContext(Context); }, @@ -59,27 +122,199 @@ test('allows wrapper components', () => { expect(result.current).toEqual('provided'); }); +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 cleanup effects on rerender and unmount', 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); +}); + function useMyHook(param: T) { return { param }; } -test('props type is inferred correctly when initial props is defined', () => { - const { result, rerender } = renderHook((num: number) => useMyHook(num), { +test('infers props type from initialProps', async () => { + const { result, rerender } = await renderHook((num: number) => useMyHook(num), { initialProps: 5, }); expect(result.current.param).toBe(5); - rerender(6); + await 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), { +test('infers props type when initialProps is undefined', async () => { + const { result, rerender } = await renderHook((num: number | undefined) => useMyHook(num), { initialProps: undefined, }); expect(result.current.param).toBeUndefined(); - rerender(6); + await rerender(6); expect(result.current.param).toBe(6); }); + +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('supports React 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('handles Suspense errors 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} + + ), + }); + + // 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 hooks with callbacks and complex state', 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); +}); diff --git a/src/pure.ts b/src/pure.ts index c538b3401..1ce750cb5 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 } 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 } 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..9185467a1 100644 --- a/src/render-hook.tsx +++ b/src/render-hook.tsx @@ -1,19 +1,12 @@ import * as React from 'react'; -import render from './render'; import renderAsync from './render-async'; import type { RefObject } from './types'; export type RenderHookResult = { result: RefObject; - rerender: (props: Props) => void; - unmount: () => void; -}; - -export type RenderHookAsyncResult = { - result: RefObject; - rerenderAsync: (props: Props) => Promise; - unmountAsync: () => Promise; + rerender: (props: Props) => Promise; + unmount: () => Promise; }; export type RenderHookOptions = { @@ -30,39 +23,10 @@ export type RenderHookOptions = { wrapper?: React.ComponentType; }; -export function renderHook( - hookToRender: (props: Props) => Result, - options?: RenderHookOptions>, -): RenderHookResult { - const result = React.createRef() as RefObject; - - 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, - ); - - return { - result: result, - rerender: (hookProps: Props) => rerenderComponent(), - unmount, - }; -} - -export async function renderHookAsync( +export async function renderHook( hookToRender: (props: Props) => Result, options?: RenderHookOptions>, -): Promise> { +): Promise> { const result = React.createRef() as RefObject; function TestComponent({ hookProps }: { hookProps: Props }) { @@ -83,8 +47,7 @@ export async function renderHookAsync( return { result: result, - rerenderAsync: (hookProps: Props) => - rerenderComponentAsync(), - unmountAsync, + rerender: (hookProps: Props) => rerenderComponentAsync(), + unmount: unmountAsync, }; } 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..edae19aa0 100644 --- a/website/docs/14.x/docs/api/misc/render-hook.mdx +++ b/website/docs/14.x/docs/api/misc/render-hook.mdx @@ -3,20 +3,22 @@ ## `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 method uses async `act` function internally to ensure all pending React updates are executed during rendering. It's designed for working with React 19 and React Suspense, and is compatible with React Suspense boundaries and `React.use()`. ```ts import { renderHook } 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(() => { @@ -41,7 +43,7 @@ The `renderHook` function accepts the following arguments: Callback is a function that is called each `render` of the test component. This function should call one or more hooks for testing. -The `props` passed into the callback will be the `initialProps` provided in the `options` to `renderHook`, unless new props are provided by a subsequent `rerender` call. +The `props` passed into the callback will be the `initialProps` provided in the `options` to `renderHook`, unless new props are provided by a subsequent `rerender` call. Note that `renderHook` is async and must be awaited, and `rerender` and `unmount` are also async and must be awaited. ### `options` @@ -65,12 +67,12 @@ Otherwise, `render` will default to using concurrent rendering used in the React ```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 that has the following properties: #### `result` @@ -78,11 +80,11 @@ 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. This function returns a Promise and must 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. This function returns a Promise and must be awaited. ### Examples @@ -102,8 +104,8 @@ 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, }); @@ -114,7 +116,7 @@ it('should increment count', () => { }); expect(result.current.count).toBe(2); - rerender(5); + await rerender(5); expect(result.current.count).toBe(5); }); ``` @@ -122,61 +124,12 @@ 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 - -```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 }; - rerenderAsync: (props: Props) => Promise; - unmountAsync: () => 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)); - }); - - // Re-render to get updated state - await rerenderAsync(); - 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.