diff --git a/src/__tests__/react-native-gesture-handler.test.tsx b/src/__tests__/react-native-gesture-handler.test.tsx index bab0a354c..3fc17f588 100644 --- a/src/__tests__/react-native-gesture-handler.test.tsx +++ b/src/__tests__/react-native-gesture-handler.test.tsx @@ -57,5 +57,9 @@ test('userEvent can invoke press events for RNGH Pressable', async () => { const pressable = screen.getByTestId('pressable'); await user.press(pressable); - expect(getEventsNames(events)).toEqual(['pressIn', 'pressOut', 'press']); + + const eventSequence = getEventsNames(events).join(', '); + expect( + eventSequence === 'pressIn, pressOut, press' || eventSequence === 'pressIn, press, pressOut', + ).toBe(true); }); diff --git a/src/act.ts b/src/act.ts index 61481f8ea..404e5c643 100644 --- a/src/act.ts +++ b/src/act.ts @@ -66,7 +66,10 @@ function withGlobalActEnvironment(actImplementation: ReactAct) { }; } -const act = withGlobalActEnvironment(reactAct) as ReactAct; +export const unsafe_act = withGlobalActEnvironment(reactAct) as ReactAct; + +export function act(callback: () => T | Promise): Promise { + return unsafe_act(async () => await callback()); +} -export default act; export { getIsReactActEnvironment, setIsReactActEnvironment as setReactActEnvironment }; diff --git a/src/fire-event.ts b/src/fire-event.ts index 1fe38367c..e5d428e94 100644 --- a/src/fire-event.ts +++ b/src/fire-event.ts @@ -7,7 +7,7 @@ import type { } from 'react-native'; import type { Fiber, HostElement } from 'universal-test-renderer'; -import act from './act'; +import { act, unsafe_act } from './act'; import type { EventHandler } from './event-handler'; import { getEventHandlerFromProps } from './event-handler'; import { isElementMounted, isHostElement } from './helpers/component-tree'; @@ -139,8 +139,7 @@ async function fireEvent(element: HostElement, eventName: EventName, ...data: un } let returnValue; - // eslint-disable-next-line require-await - await act(async () => { + await act(() => { returnValue = handler(...data); }); @@ -170,7 +169,7 @@ function unsafe_fireEventSync(element: HostElement, eventName: EventName, ...dat } let returnValue; - void act(() => { + void unsafe_act(() => { returnValue = handler(...data); }); diff --git a/src/pure.ts b/src/pure.ts index 9ea2b5419..8ae817104 100644 --- a/src/pure.ts +++ b/src/pure.ts @@ -1,4 +1,4 @@ -export { default as act } from './act'; +export { act, unsafe_act } from './act'; export { cleanup } from './cleanup'; export { fireEvent, unsafe_fireEventSync } from './fire-event'; export { render } from './render'; diff --git a/src/render-act.ts b/src/render-act.ts deleted file mode 100644 index ecddfc471..000000000 --- a/src/render-act.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { Root, RootOptions } from 'universal-test-renderer'; -import { createRoot } from 'universal-test-renderer'; - -import act from './act'; - -export function renderWithAct(element: React.ReactElement, options?: RootOptions): Root { - const root = createRoot(options); - - // This will be called synchronously. - void act(() => { - root.render(element); - }); - - return root; -} - -export async function renderWithAsyncAct( - element: React.ReactElement, - options?: RootOptions, -): Promise { - const root = createRoot(options); - - // eslint-disable-next-line require-await - await act(async () => { - root.render(element); - }); - - return root; -} diff --git a/src/render.tsx b/src/render.tsx index cedff3e2a..6ff86bf73 100644 --- a/src/render.tsx +++ b/src/render.tsx @@ -1,13 +1,18 @@ import * as React from 'react'; -import type { HostElement, JsonElement, Root, RootOptions } from 'universal-test-renderer'; - -import act from './act'; +import { + createRoot, + type HostElement, + type JsonElement, + type Root, + type RootOptions, +} from 'universal-test-renderer'; + +import { act } from './act'; import { addToCleanupQueue } from './cleanup'; import { getConfig } from './config'; import type { DebugOptions } from './helpers/debug'; import { debug } from './helpers/debug'; import { HOST_TEXT_NAMES } from './helpers/host-component-names'; -import { renderWithAsyncAct } from './render-act'; import { setRenderResult } from './screen'; import { getQueriesForElement } from './within'; @@ -28,7 +33,7 @@ export type RenderResult = Awaited>; * Renders test component deeply using React Test Renderer and exposes helpers * to assert on the output. */ -export async function render(component: React.ReactElement, options: RenderOptions = {}) { +export async function render(element: React.ReactElement, options: RenderOptions = {}) { const { wrapper: Wrapper, createNodeMock } = options || {}; const rendererOptions: RootOptions = { @@ -37,26 +42,22 @@ export async function render(component: React.ReactElement, options: Rende }; const wrap = (element: React.ReactElement) => (Wrapper ? {element} : element); - const renderer = await renderWithAsyncAct(wrap(component), rendererOptions); - return buildRenderResult(renderer, wrap); -} + const renderer = createRoot(rendererOptions); + + await act(() => { + renderer.render(wrap(element)); + }); -function buildRenderResult( - renderer: Root, - wrap: (element: React.ReactElement) => React.JSX.Element, -) { const container = renderer.container; const rerender = async (component: React.ReactElement) => { - // eslint-disable-next-line require-await - await act(async () => { + await act(() => { renderer.render(wrap(component)); }); }; const unmount = async () => { - // eslint-disable-next-line require-await - await act(async () => { + await act(() => { renderer.unmount(); }); }; diff --git a/src/unsafe-render-sync.tsx b/src/unsafe-render-sync.tsx index 4b7959c55..2989492ea 100644 --- a/src/unsafe-render-sync.tsx +++ b/src/unsafe-render-sync.tsx @@ -1,13 +1,18 @@ import * as React from 'react'; -import type { HostElement, JsonElement, Root, RootOptions } from 'universal-test-renderer'; - -import act from './act'; +import { + createRoot, + type HostElement, + type JsonElement, + type Root, + type RootOptions, +} from 'universal-test-renderer'; + +import { act, unsafe_act } from './act'; import { addToCleanupQueue } from './cleanup'; import { getConfig } from './config'; import type { DebugOptions } from './helpers/debug'; import { debug } from './helpers/debug'; import { HOST_TEXT_NAMES } from './helpers/host-component-names'; -import { renderWithAct } from './render-act'; import { setRenderResult } from './screen'; import { getQueriesForElement } from './within'; @@ -45,36 +50,33 @@ export function renderInternal(component: React.ReactElement, options?: Re }; const wrap = (element: React.ReactElement) => (Wrapper ? {element} : element); - const renderer = renderWithAct(wrap(component), rendererOptions); - return buildRenderResult(renderer, wrap); -} -function buildRenderResult( - renderer: Root, - wrap: (element: React.ReactElement) => React.JSX.Element, -) { + const renderer = createRoot(rendererOptions); + + unsafe_act(() => { + renderer.render(wrap(component)); + }); + const rerender = (component: React.ReactElement) => { - void act(() => { + unsafe_act(() => { renderer.render(wrap(component)); }); }; const rerenderAsync = async (component: React.ReactElement) => { - // eslint-disable-next-line require-await - await act(async () => { + await act(() => { renderer.render(wrap(component)); }); }; const unmount = () => { - void act(() => { + unsafe_act(() => { renderer.unmount(); }); }; const unmountAsync = async () => { - // eslint-disable-next-line require-await - await act(async () => { + await act(() => { renderer.unmount(); }); }; diff --git a/src/user-event/press/press.ts b/src/user-event/press/press.ts index 2ec329bf7..6677ac520 100644 --- a/src/user-event/press/press.ts +++ b/src/user-event/press/press.ts @@ -1,6 +1,6 @@ import type { HostElement } from 'universal-test-renderer'; -import act from '../../act'; +import { act } from '../../act'; import { getEventHandlerFromProps } from '../../event-handler'; import { isHostElement } from '../../helpers/component-tree'; import { ErrorWithStack } from '../../helpers/errors'; diff --git a/src/user-event/utils/dispatch-event.ts b/src/user-event/utils/dispatch-event.ts index bb53a6fc4..869c21bc7 100644 --- a/src/user-event/utils/dispatch-event.ts +++ b/src/user-event/utils/dispatch-event.ts @@ -1,6 +1,6 @@ import type { HostElement } from 'universal-test-renderer'; -import act from '../../act'; +import { act } from '../../act'; import { getEventHandlerFromProps } from '../../event-handler'; import { isElementMounted } from '../../helpers/component-tree'; @@ -21,8 +21,7 @@ export async function dispatchEvent(element: HostElement, eventName: string, ... return; } - // eslint-disable-next-line require-await - await act(async () => { + await act(() => { handler(...event); }); } diff --git a/src/wait-for.ts b/src/wait-for.ts index 945d29a99..74174e3f6 100644 --- a/src/wait-for.ts +++ b/src/wait-for.ts @@ -1,5 +1,5 @@ /* globals jest */ -import act from './act'; +import { act } from './act'; import { getConfig } from './config'; import { flushMicroTasks } from './flush-micro-tasks'; import { copyStackTrace, ErrorWithStack } from './helpers/errors'; @@ -70,7 +70,7 @@ function waitForInternal( // third party code that's setting up recursive timers so rapidly that // the user's timer's don't get a chance to resolve. So we'll advance // by an interval instead. (We have a test for this case). - await act(async () => await jest.advanceTimersByTime(interval)); + await act(() => jest.advanceTimersByTime(interval)); // It's really important that checkExpectation is run *before* we flush // in-flight promises. To be honest, I'm not sure why, and I can't quite diff --git a/website/docs/14.x/docs/advanced/understanding-act.mdx b/website/docs/14.x/docs/advanced/understanding-act.mdx index c1b6e2b58..3fd346fe4 100644 --- a/website/docs/14.x/docs/advanced/understanding-act.mdx +++ b/website/docs/14.x/docs/advanced/understanding-act.mdx @@ -61,9 +61,9 @@ test('render without act', () => { When testing without `act` call wrapping rendering call, we see that the assertion runs just after the rendering but before `useEffect`hooks effects are applied. Which is not what we expected in our tests. ```jsx -test('render with act', () => { +test('render with act', async () => { let renderer: ReactTestRenderer; - act(() => { + await act(async () => { renderer = TestRenderer.create(); }); @@ -73,6 +73,8 @@ test('render with act', () => { }); ``` +**Note**: In v14, `act` is now async by default and always returns a Promise. Even if your callback is synchronous, you should use `await act(async () => ...)`. + When wrapping rendering call with `act` we see that the changes caused by `useEffect` hook have been applied as we would expect. ### When to use act @@ -95,7 +97,7 @@ Note that `act` calls can be safely nested and internally form a stack of calls. As of React version of 18.1.0, the `act` implementation is defined in the [ReactAct.js source file](https://github.com/facebook/react/blob/main/packages/react/src/ReactAct.js) inside React repository. This implementation has been fairly stable since React 17.0. -RNTL exports `act` for convenience of the users as defined in the [act.ts source file](https://github.com/callstack/react-native-testing-library/blob/main/src/act.ts). That file refers to [ReactTestRenderer.js source](https://github.com/facebook/react/blob/ce13860281f833de8a3296b7a3dad9caced102e9/packages/react-test-renderer/src/ReactTestRenderer.js#L52) file from React Test Renderer package, which finally leads to React act implementation in ReactAct.js (already mentioned above). +RNTL exports `act` for convenience of the users as defined in the [act.ts source file](https://github.com/callstack/react-native-testing-library/blob/main/src/act.ts). In v14, `act` is now async by default and always returns a Promise, making it compatible with React 19, React Suspense, and `React.use()`. The underlying implementation still uses React's `act` function, but wraps it to ensure consistent async behavior. ## Asynchronous `act` @@ -149,17 +151,19 @@ Note that this is not yet the infamous async act warning. It only asks us to wra First solution is to use Jest's fake timers inside out tests: ```jsx -test('render with fake timers', () => { +test('render with fake timers', async () => { jest.useFakeTimers(); render(); - act(() => { + await act(async () => { jest.runAllTimers(); }); expect(screen.getByText('Count 1')).toBeOnTheScreen(); }); ``` +**Note**: In v14, `act` is now async by default, so you should await it even when using fake timers. + That way we can wrap `jest.runAllTimers()` call which triggers the `setTimeout` updates inside an `act` call, hence resolving the act warning. Note that this whole code is synchronous thanks to usage of Jest fake timers. ### Solution with real timers diff --git a/website/docs/14.x/docs/api/misc/other.mdx b/website/docs/14.x/docs/api/misc/other.mdx index 560f8d791..b6e1a1cb3 100644 --- a/website/docs/14.x/docs/api/misc/other.mdx +++ b/website/docs/14.x/docs/api/misc/other.mdx @@ -29,10 +29,51 @@ Use cases for scoped queries include: ## `act` -Useful function to help testing components that use hooks API. By default any `render`, `update`, `fireEvent`, and `waitFor` calls are wrapped by this function, so there is no need to wrap it manually. This method is provided by [Universal Test Renderer](https://github.com/callstack/universal-test-renderer) and re-exported for convenience. +```ts +function act(callback: () => T | Promise): Promise; +``` + +Useful function to help testing components that use hooks API. By default any `render`, `update`, `fireEvent`, and `waitFor` calls are wrapped by this function, so there is no need to wrap it manually. + +**In v14, `act` is now async by default and always returns a Promise**, making it compatible with React 19, React Suspense, and `React.use()`. This ensures all pending React updates are executed before the Promise resolves. + +```ts +import { act } from '@testing-library/react-native'; + +it('should update state', async () => { + await act(async () => { + setState('new value'); + }); + expect(state).toBe('new value'); +}); +``` + +**Note**: Even if your callback is synchronous, you should still use `await act(async () => ...)` as `act` now always returns a Promise. Consult our [Understanding Act function](docs/advanced/understanding-act.md) document for more understanding of its intricacies. +## `unsafe_act` + +```ts +function unsafe_act(callback: () => T | Promise): T | Thenable; +``` + +**⚠️ Deprecated**: This function is provided for migration purposes only. Use async `act` instead. + +The synchronous version of `act` that maintains the same behavior as v13. It returns immediately for sync callbacks or a thenable for async callbacks. This is not recommended for new code as it doesn't work well with React 19's async features. + +```ts +// Deprecated - use act instead +import { unsafe_act } from '@testing-library/react-native'; + +it('should update state', () => { + unsafe_act(() => { + setState('new value'); + }); + expect(state).toBe('new value'); +}); +``` + ## `cleanup` ```ts diff --git a/website/docs/14.x/docs/migration/v14.mdx b/website/docs/14.x/docs/migration/v14.mdx index 908f22378..503aa7e7d 100644 --- a/website/docs/14.x/docs/migration/v14.mdx +++ b/website/docs/14.x/docs/migration/v14.mdx @@ -523,3 +523,163 @@ await render(); ``` If you were relying on the previous behavior where strings could be rendered outside of `` components, you'll need to fix your components to wrap strings in `` components, as this matches React Native's actual runtime behavior. + +### `act` is now async by default + +In v14, `act` is now async by default and always returns a Promise. The previous synchronous `act` function has been renamed to `unsafe_act`. This change makes it compatible with React 19, React Suspense, and `React.use()`. + +**What changed:** + +- `act` now always returns a `Promise` instead of `T | Thenable` +- `unsafe_act` is available for the old behavior (direct React.act wrapper) +- Both `act` and `unsafe_act` are now named exports (no default export) + +**Before (v13):** + +```ts +import act from '@testing-library/react-native'; + +it('should update state', () => { + act(() => { + // Synchronous callback + setState('new value'); + }); + // act could return synchronously or a thenable + expect(state).toBe('new value'); +}); +``` + +**After (v14):** + +```ts +import { act } from '@testing-library/react-native'; + +it('should update state', async () => { + await act(async () => { + // Callback can be sync or async + setState('new value'); + }); + // act always returns a Promise + expect(state).toBe('new value'); +}); +``` + +#### Migration path + +To ease migration, we provide `unsafe_act` which maintains the same behavior as v13. This allows you to migrate gradually. + +##### `unsafe_act` {#unsafe-act} + +```ts +function unsafe_act(callback: () => T | Promise): T | Thenable; +``` + +**⚠️ Deprecated**: This function is provided for migration purposes only. Use async `act` instead. + +The synchronous version of `act` that returns immediately for sync callbacks or a thenable for async callbacks. This maintains backward compatibility with v13 tests but is not recommended for new code. + +```ts +// Old v13 code (still works but deprecated) +import { unsafe_act } from '@testing-library/react-native'; + +it('should update state', () => { + unsafe_act(() => { + setState('new value'); + }); + expect(state).toBe('new value'); +}); +``` + +#### Step-by-step migration guide + +To migrate from `unsafe_act` to `act`: + +##### 1. Update import statement + +```ts +// Before +import act from '@testing-library/react-native'; + +// After +import { act } from '@testing-library/react-native'; +``` + +##### 2. Add `async` to your test function + +```ts +// Before +it('should update state', () => { + +// After +it('should update state', async () => { +``` + +##### 3. Await `act` calls + +```ts +// Before +act(() => { + setState('new value'); +}); + +// After +await act(async () => { + setState('new value'); +}); +``` + +**Note**: Even if your callback is synchronous, you should still use `await act(async () => ...)` as `act` now always returns a Promise. + +#### Complete example + +**Before (v13):** + +```ts +import act from '@testing-library/react-native'; + +it('should update state synchronously', () => { + act(() => { + setState('new value'); + }); + expect(state).toBe('new value'); +}); + +it('should update state asynchronously', () => { + act(async () => { + await Promise.resolve(); + setState('new value'); + }).then(() => { + expect(state).toBe('new value'); + }); +}); +``` + +**After (v14):** + +```ts +import { act } from '@testing-library/react-native'; + +it('should update state synchronously', async () => { + await act(async () => { + setState('new value'); + }); + expect(state).toBe('new value'); +}); + +it('should update state asynchronously', async () => { + await act(async () => { + await Promise.resolve(); + setState('new value'); + }); + expect(state).toBe('new value'); +}); +``` + +#### Benefits of async `act` + +- **React 19 compatibility**: Works seamlessly with React 19's async features +- **Suspense support**: Properly handles React Suspense boundaries and `React.use()` +- **Consistent API**: Always returns a Promise, making the API more predictable +- **Future-proof**: Aligns with React's direction toward async rendering + +For more details, see the [`act` API documentation](/docs/api/misc/other#act).