Skip to content

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

@lyhoang-web-dev

Description

@lyhoang-web-dev

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 <Tabs> component built following the React Aria Tabs docs
(i.e. a custom Tab with <SelectionIndicator /> 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 <Tabs> component containing <SelectionIndicator>, 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 <Tabs> with <SelectionIndicator> logs the following console.error:

 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:

// 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):

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

{
  "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

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

import failOnConsole from "vitest-fail-on-console";

failOnConsole();

src/tabs.test.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 (
    <RACTab {...props}>
      {composeRenderProps(props.children, (children) => (
        <>
          {children}
          <SelectionIndicator />
        </>
      ))}
    </RACTab>
  );
}

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(
      <Tabs defaultSelectedKey="overview">
        <TabList aria-label="Sections">
          <Tab id="overview">Overview</Tab>
          <Tab id="details">Details</Tab>
        </TabList>
        <TabPanels>
          <TabPanel id="overview">Overview content</TabPanel>
          <TabPanel id="details">Details content</TabPanel>
        </TabPanels>
      </Tabs>,
    );

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

index.html

<!doctype html>
<html>
  <head><meta charset="UTF-8" /></head>
  <body><div id="root"></div></body>
</html>

tsconfig.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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions