adobe / adobe/react-spectrum

SelectionIndicator: flushSync inside queueMicrotask causes "not wrapped in act" warning in Vitest browser mode

Open
#9,925 12 comments 0 reactions 0 assignees View on GitHub
Dominant language
TypeScript
Stars
15.9k
Forks
1.6k
Avg merge
3d 9m
Merged PRs (30d)
59

Description

### Provide a general summary of the issue here

`SelectionIndicator` (via `SharedElement`) schedules React state updates using `flushSync` inside `queueMicrotask` and `requestAnimationFrame` callbacks.
These fire **after** React's `act()` boundary closes during test renders, triggering:

> An update to ForwardRef(SharedElement) inside a test was not wrapped in act(...)

When using `vitest-fail-on-console` — a common CI-safety package that converts `console.error` into hard test failures
Every test that renders a `` component built following the [React Aria Tabs docs](https://react-aria.adobe.com/Tabs)
(i.e. a custom `Tab` with `` inside) will **fail**, even though the component renders and behaves correctly.

Image

### 🤔 Expected Behavior?

- When running Vitest tests (browser mode, Chromium via Playwright) that render a `` component containing ``, no `console.error` should be emitted.
- State updates triggered by rendering the component should be compatible with React's testing `act()` boundary.

### 😯 Current Behavior

Every test that renders `` with `` logs the following `console.error`:

```tsx
An update to ForwardRef(SharedElement) inside a test was not wrapped in act(...).
When testing, code that causes React state updates should be wrapped into act(...):
act(() => {
/* fire events that update state /
});
/ assert on the output */
```

With `vitest-fail-on-console` (a common CI-safety package) this turns into a
**hard test failure** even though the component renders and behaves correctly.

**Root cause — `SharedElementTransition.tsx`**

The `SharedElement` component schedules state updates via `flushSync` inside asynchronous callbacks that fire after React's `act()` window has closed:

```ts
// line 122 — new element entering
queueMicrotask(() => flushSync(() => setState('entering')));
requestAnimationFrame(() => setState('visible')); // line 125

// line 130–134 — element exiting
queueMicrotask(() => {
if (scope[name]) {
flushSync(() => setState('exiting')); // line 133
Promise.all(element.getAnimations().map(a => a.finished))
.then(() => setState('hidden')); // line 136
}
});

// queueMicrotask fires after act() finishes.
// The flushSync call inside it synchronously flushes a React state update, which React detects as an out-of-act() update and logs the warning.
```

### 💁 Possible Solution

_No response_

### 🔦 Context

_No response_

### 🖥️ Steps to Reproduce

Clone and run the minimal reproduction below (or copy the files manually):

```bash
git clone https://github.com/lyhoang-web-dev/react-aria-shared-element-repro
cd react-aria-shared-element-repro
pnpm install
pnpm exec playwright install chromium
pnpm test
```

**Expected:** tests pass with no console errors
**Actual:** test fails — `vitest-fail-on-console` converts the `console.error` into a hard failure

---

### Minimal reproduction files

**`package.json`**
```json
{
"name": "react-aria-shared-element-repro",
"private": true,
"scripts": {
"test": "vitest"
},
"dependencies": {
"react": "19.2.4",
"react-dom": "19.2.4",
"react-aria-components": "1.16.0"
},
"devDependencies": {
"@types/react": "^19.2.14",
"@vitejs/plugin-react": "4.5.0",
"@vitest/browser": "4.0.18",
"@vitest/browser-playwright": "4.0.18",
"playwright": "1.58.0",
"vitest": "4.0.18",
"vitest-browser-react": "2.0.4",
"vitest-fail-on-console": "0.10.1"
}
}
```

**`vite.config.ts`**
```ts
import react from "@vitejs/plugin-react";
import { playwright } from "@vitest/browser-playwright";
import { defineConfig } from "vitest/config";

export default defineConfig({
plugins: [react()],
test: {
setupFiles: ["./src/setup-tests.ts"],
browser: {
enabled: true,
provider: playwright(),
instances: [{ browser: "chromium" }],
},
},
});
```

**`src/setup-tests.ts`**
```ts
import failOnConsole from "vitest-fail-on-console";

failOnConsole();
```

**`src/tabs.test.tsx`**
```tsx
import { render } from "vitest-browser-react";
import { describe, expect, test } from "vitest";
import {
SelectionIndicator,
Tab as RACTab,
type TabProps as RACTabProps,
TabList,
TabPanel,
TabPanels,
Tabs,
composeRenderProps,
} from "react-aria-components";

/**
* Custom Tab that includes SelectionIndicator, matching the React Aria docs:
* https://react-aria.adobe.com/Tabs
*
* This is what triggers SharedElement → flushSync inside queueMicrotask,
* causing the "not wrapped in act" warning.
*/
function Tab(props: RACTabProps) {
return (

{composeRenderProps(props.children, (children) => (
<>
{children}


))}

);
}

describe("Tabs", () => {
test("renders the selected tab panel", async () => {
// Tab renders SelectionIndicator → SharedElement, which schedules state
// updates via flushSync inside queueMicrotask/requestAnimationFrame.
// These fire outside React's act() boundary, producing:
//
// "An update to ForwardRef(SharedElement) inside a test was not wrapped in act(...)"
//
// vitest-fail-on-console converts this console.error into a hard failure.
const screen = await render(


Overview
Details


Overview content
Details content

,
);

await expect
.element(screen.getByRole("tab", { name: "Overview" }))
.toBeInTheDocument();
});
});
```

**`index.html`**
```html





```

**`tsconfig.json`**
```json
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"lib": ["ESNext", "DOM", "DOM.Iterable"]
},
"include": ["src"]
}
```

### Version

1.16.0

### What browsers are you seeing the problem on?

Chrome

### If other, please specify.

_No response_

### What operating system are you using?

Windows 11 Pro (10.0.26200)

### 🧢 Your Company/Team

_No response_

### 🕷 Tracking Issue

_No response_

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.