SelectionIndicator: flushSync inside queueMicrotask causes "not wrapped in act" warning in Vitest browser mode
- 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.
### 🤔 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
Assessment
This issue has not been assessed yet.