Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion src/__tests__/react-native-gesture-handler.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
7 changes: 5 additions & 2 deletions src/act.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>(callback: () => T | Promise<T>): Promise<T> {
return unsafe_act(async () => await callback());
}

export default act;
export { getIsReactActEnvironment, setIsReactActEnvironment as setReactActEnvironment };
7 changes: 3 additions & 4 deletions src/fire-event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
});

Expand Down Expand Up @@ -170,7 +169,7 @@ function unsafe_fireEventSync(element: HostElement, eventName: EventName, ...dat
}

let returnValue;
void act(() => {
void unsafe_act(() => {
returnValue = handler(...data);
});

Expand Down
2 changes: 1 addition & 1 deletion src/pure.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
29 changes: 0 additions & 29 deletions src/render-act.ts

This file was deleted.

33 changes: 17 additions & 16 deletions src/render.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -28,7 +33,7 @@ export type RenderResult = Awaited<ReturnType<typeof render>>;
* Renders test component deeply using React Test Renderer and exposes helpers
* to assert on the output.
*/
export async function render<T>(component: React.ReactElement<T>, options: RenderOptions = {}) {
export async function render<T>(element: React.ReactElement<T>, options: RenderOptions = {}) {
const { wrapper: Wrapper, createNodeMock } = options || {};

const rendererOptions: RootOptions = {
Expand All @@ -37,26 +42,22 @@ export async function render<T>(component: React.ReactElement<T>, options: Rende
};

const wrap = (element: React.ReactElement) => (Wrapper ? <Wrapper>{element}</Wrapper> : 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();
});
};
Expand Down
36 changes: 19 additions & 17 deletions src/unsafe-render-sync.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -45,36 +50,33 @@ export function renderInternal<T>(component: React.ReactElement<T>, options?: Re
};

const wrap = (element: React.ReactElement) => (Wrapper ? <Wrapper>{element}</Wrapper> : 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();
});
};
Expand Down
2 changes: 1 addition & 1 deletion src/user-event/press/press.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
5 changes: 2 additions & 3 deletions src/user-event/utils/dispatch-event.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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);
});
}
4 changes: 2 additions & 2 deletions src/wait-for.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -70,7 +70,7 @@ function waitForInternal<T>(
// 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
Expand Down
14 changes: 9 additions & 5 deletions website/docs/14.x/docs/advanced/understanding-act.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<TestComponent />);
});

Expand All @@ -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
Expand All @@ -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`

Expand Down Expand Up @@ -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(<TestAsyncComponent />);

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
Expand Down
43 changes: 42 additions & 1 deletion website/docs/14.x/docs/api/misc/other.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>(callback: () => T | Promise<T>): Promise<T>;
```

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<T>(callback: () => T | Promise<T>): T | Thenable<T>;
```

**⚠️ 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
Expand Down
Loading