Skip to content

testing library act warnings

Paulo Gomes da Cruz Junior edited this page Sep 11, 2026 · 6 revisions

title: "Fix Guide — React Testing Library act(...) Warnings" type: fix-guide domain: frontend framework: react testing: "vitest + @testing-library/react" severity: warning tags: [testing-library, act, react, vitest, tanstack-router, carbon] created: 2026-07-14 updated: 2026-07-29

Fix Guide — React Testing Library act(...) Warnings

Problem

act(...) warnings print to stderr during vitest runs:

An update to <Component> inside a test was not wrapped in act(...).

You see this warning when a state update lands after the test's synchronous code finishes, outside the act() helper. The trigger is an async task: a router transition, a validation callback, or a session refresh. The task resolves after render() returns, so the change slips past act().

Root Cause

You get these warnings from three sources. Each source runs an async state update that settles outside act().

Source 1 — Transitioner (internal to @tanstack/react-router)

Transitioner is an INTERNAL component of @tanstack/react-router, mounted by RouterProvider. When a component under test renders inside the test router helper (createTestRouter from @/config/tests/routerTestHelper), the router's mount-time async state update resolves outside act(). This is the largest source: 61 warnings in the ReportingUnitCreate suite before the fix.

Source 2 — Carbon ComboBox / Field / FilterableMultiSelect

Real Carbon components run async state updates (for example onBlurAsync validation) after the synchronous test body ends. Those updates land outside act() and trigger warnings.

Source 3 — AuthProvider

The app AuthProvider runs a periodic session-refresh that updates state outside act(). Components rendered under the real provider inherit the warning.

Fix

Fix 1 — Wrap render() in act() (Transitioner, validated 61 → 0)

Wrap the render() call inside the test's shared renderComponent() helper in await act(async () => { render(<.../>); });. Import act from @testing-library/react (v16).

import { act, render, screen } from "@testing-library/react";

async function renderComponent() {
  const queryClient = new QueryClient({
    defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
  });
  const router = createTestRouter(() => <ReportingUnitCreate />);
  await router.load();
  await act(async () => {
    render(
      <QueryClientProvider client={queryClient}>
        <RouterProvider router={router} />
      </QueryClientProvider>,
    );
  });
}

This fix removed all 61 Transitioner warnings from the ReportingUnitCreate suite.

⚠ V8 coverage caveat (2026-07-29): The await act(async () => { ... }) pattern hangs indefinitely when V8 code coverage is enabled (issue #1130). If your project uses provider: v8 in Vitest coverage config, use Fix 6 (sync act()) instead. Without coverage, Fix 1 as written above works fine.

Fix 2 — Mock Carbon (ComboBox / Field / FilterableMultiSelect) — DOES NOT WORK (counterproductive)

Empirically disproven (2026-07-14). A full @carbon/react mock (override every used component) was piloted on coverage.unit.test.tsx. It removed the ComboBox act warnings (16 → 0) but increased the total noise: form.Field ("Field") warnings went 11 → 30 and react-query LocalSubscribe warnings appeared 18 → 67 — a net rise from ~45 to ~97 stderr lines. Reverting to the real-Carbon setup restored the lower baseline. Do not apply Carbon mocks to these files.

Why it backfires: the dominant act warnings are not Carbon-internal. They come from:

  • @tanstack/react-form's form.Field component (literally named Field) — its store-driven state updates surface as An update to Field inside a test was not wrapped in act(...).
  • @tanstack/react-query's store subscription — An update to LocalSubscribe inside a test ....

A Carbon mock changes the async timing of the ComboBox (mock uses useState/local state instead of Carbon's synchronous internal updates), which breaks the act() boundary for the form.Field/LocalSubscribe updates that were previously landing inside act() with real Carbon. Mocking Carbon therefore trades a small, removable warning class for a larger, uncontrollable one. The branch-coverage.unit.test.tsx file stays clean only because its tests use user.selectOptions(...) and never exercise the form.Field onChangeAsync/onBlurAsync paths that emit the Field/LocalSubscribe warnings — not because its Carbon mock is special.

Conclusion: the ComboBox/FilterableMultiSelect act warnings are a minority third-party signal. The remaining noise is fundamentally from react-form + react-query and cannot be removed by mocking Carbon. Treat all of these as non-fatal test-output noise (every test passes).

Fix 3 — AuthProvider (AuthProvider)

Root cause confirmed in src/context/auth/AuthProvider.tsx: its useEffect calls void initializeAuth() (an unawaited async refreshUserState()) and a setInterval(refreshUserState, 3 * 60 * 1000). Both update user/isLoading state outside act() in tests that mount the real provider (via createTestRouter). The clean fix is test plumbing: wrap render() in await act(async () => { ... }) and await waitFor(...) for the auth state to settle, or mock AuthProvider/useAuth so the provider effect does not run. The AuthContext.unit.test.tsx already wraps updates in act for its own tests; the 11 remaining warnings come from component tests that mount the real provider without awaiting its async init.

Fix 4 — Raw DOM events on Carbon/TanStack-Form fields: act() + setTimeout(0) (validated 2026-07-22, partial: ComboBox/Field/LocalSubscribe/FilterableMultiSelect)

Fix 2 called the ComboBox/Field/LocalSubscribe warnings unfixable without large-scale plumbing. That framing was too broad. A targeted pattern removes them file by file, without touching Carbon or mocking anything.

Root cause (generalized): a raw synchronous DOM event — .click(), .focus(), .blur() — called directly on a Carbon- or TanStack-Form-controlled element, without going through @testing-library/user-event, kicks off an async validation/state-update cascade (Carbon ComboBox's onFocus/onBlur handlers, TanStack Form's Field/LocalSubscribe internal store listeners). That cascade resolves after the synchronous act() scope around the raw event closes, so React logs the warning.

The fix: wrap the raw event in act() and drain a macrotask tick inside it:

import { act } from "@testing-library/react";

await act(async () => {
  someElement.click(); // or .focus() / .blur()
  await new Promise((r) => setTimeout(r, 0));
});

Why setTimeout(r, 0) and not waitFor or Promise.resolve(): both of the earlier flush attempts — await waitFor(() => {...}) right after the raw event, and await Promise.resolve() inside the act() callback — only drain the microtask queue. The cascade these warnings come from apparently resolves on a macrotask (or a promise chain that only settles after a task-queue tick), so nothing short of setTimeout(r, 0) inside the act() callback fully drains it before the test's synchronous scope ends.

.focus() needs its own wrap too: wrapping only .blur() was insufficient in one case. Carbon ComboBox's onFocus handler triggers its own async state update (opening the listbox) independent of onBlur. Each raw event — .focus() and .blur() separately — needs its own act() + setTimeout(0) wrap.

Files fixed and verified (2026-07-22):

  • src/components/waste/ReportingUnits/ReportingUnitCreate/additional-coverage.unit.test.tsx — two tests (should validate district field with runValidators on blur, should validate sampling field with runValidators on blur) called districtComboBox.focus(); districtComboBox.blur(); and samplingComboBox.focus(); samplingComboBox.blur(); with no act() wrapping at all. Wrapped each focus() and each blur() individually in the pattern above. Result: this file's ComboBox/Field/LocalSubscribe warnings dropped 23 → 0; 23/23 tests still pass. (A prior pass in the same session had already wrapped 10 raw districtText.click() / dcrText.click() calls used to select ComboBox dropdown options the same way, bringing an earlier 33-warning baseline in this file down to 23 before this fix took it to 0.)

  • src/components/Form/ActiveMultiSelect/index.unit.test.tsx — the shared openDropdown(user?) test helper had an else branch (used by tests calling openDropdown() without a user object) doing a raw button.click(). Added act to the @testing-library/react import and wrapped that branch:

    await act(async () => {
      button.click();
      await new Promise((r) => setTimeout(r, 0));
    });

    Result: FilterableMultiSelect warnings dropped 7 → 0; 17/17 tests still pass.

Validation for both files: tsc --noEmit clean, eslint --max-warnings=0 clean, vitest run green, full suite 165/165 files and 1994/1994 tests passing, coverage unchanged at 94.09% / 87.9% / 93.11% / 95.04%.

Do not confuse this with Fix 2. This pattern touches only the test file's event-dispatch code. It does not mock @carbon/react or any other module. Fix 2's finding — that mocking Carbon makes Field/LocalSubscribe warnings worse — still holds and is unrelated to this fix.

Fix 5 — Rollout to the last two files: coverage.unit.test.tsx and index.unit.test.tsx (validated 2026-07-22, closes the remaining 52 warnings)

The two files Fix 4 left open both carried the same ReportingUnitCreate raw-event anti-pattern, but the fix diverged between them.

ReportingUnitCreate/coverage.unit.test.tsx — same pattern as Fix 4, applied at scale. Multiple raw .click()/.focus()/.blur() sites needed the wrap: districtText.click(), dcrText.click(), clientBlurInput.blur(), districtInput.focus()/.blur(), coastalRadio.focus()/.blur(), samplingInput.focus()/.blur(). Same act() + setTimeout(0) wrapper as above, applied site by site.

Caveat — don't double-nest act(). An earlier fix pass in this file had already wrapped some of these calls in act(). Applying Fix 4's pattern blindly on top produces act() nested inside act() around the same raw event. Nesting doesn't error, but it's redundant and makes the intent unclear at a glance. Check for an existing wrapper before adding a new one — collapse to a single act() call around the event and its setTimeout(0) flush, don't stack two.

ReportingUnitCreate/index.unit.test.tsx — a different root cause. One test (findSelectedItem should be used when grade value exists) left the ComboBox/Field/LocalSubscribe cascade unflushed even though it already used userEvent.selectOptions() — the Testing-Library-recommended API, not a raw DOM call. This matters because the assumption up to this point (baked into the testing-library/no-unnecessary-act lint rule) was that userEvent calls are self-sufficient and never need an act() wrapper. This test disproves that assumption.

A single trailing setTimeout(0) flush placed after the userEvent.selectOptions() call was insufficient — it made the warning count worse (3 → 7) instead of better. The working fix wraps the userEvent.selectOptions() call itself inside act(), together with two chained await new Promise((r) => setTimeout(r, 0)) flushes, not one:

// eslint-disable-next-line testing-library/no-unnecessary-act
await act(async () => {
  await userEvent.selectOptions(gradeSelect, "coastal");
  await new Promise((r) => setTimeout(r, 0));
  await new Promise((r) => setTimeout(r, 0));
});

The eslint-disable-next-line is scoped to this one call and is justified here: the lint rule's premise (RTL utils never need act()) is measurably false for this cascade. Don't disable the rule file-wide or apply this double-flush pattern speculatively elsewhere — reach for it only when a single flush measurably makes things worse, the same way it was proven here.

Treat this as a refinement to Fix 4, not a contradiction: the general rule — raw DOM events need act() + setTimeout(0) — still holds. The refinement is that some userEvent calls, in some Carbon/TanStack-Form cascades, need the same treatment plus a second flush. Measure before reaching for the double-flush + eslint-disable combination; it's an exception, not a default.

Result: both files' ComboBox/Field/LocalSubscribe warnings dropped to 0. Combined with Fix 4, this closes out all 52 previously-remaining warnings. Full suite: 165/165 files, 1994/1994 tests passing. Coverage unchanged at 94.09% / 87.9% / 93.11% / 95.04%. All act() warning buckets are now at 0.

Fix 6 — Sync act() for V8 coverage compatibility (validated 2026-07-29)

Async act(async () => { ... }) (Fix 1) hangs indefinitely when V8 code coverage (provider: v8) is enabled. This is a known interaction: V8's coverage instrumentation interferes with the microtask/macrotask scheduling inside act()'s internal flush mechanism.

The fix: use sync act() instead of async await act(async () => ...).

import { act, screen } from "@testing-library/react";

function renderComponent() {
  const queryClient = new QueryClient({
    defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
  });
  const router = createTestRouter(() => <WasteSearch />);
  router.load(); // pre-load router synchronously before act
  act(() => {
    render(
      <QueryClientProvider client={queryClient}>
        <RouterProvider router={router} />
      </QueryClientProvider>,
    );
  });
}

Sync act() flushes mount-time state updates synchronously, which avoids the microtask queue that V8 coverage instrumentation interferes with. The router.load() call (which returns a promise) must be called before act(), not inside it — the router's load settles before act() wraps the render, so the Transitioner's mount-time state update is captured by the sync act() flush.

Sub-cascade pattern: sendEvent() calls also need wrapping. Components using a notification system that dispatches CustomEvent on window (which triggers setState in a NotificationProvider) need each sendEvent() call wrapped in sync act():

act(() => {
  sendEvent({ type: "some-event" });
});

Flush point pattern for deferred updates. Tests ending with synchronous assertions (e.g., expect(queryByText('...')).toBeNull()) may still see state updates that fire after the test body ends, because a sync assertion doesn't flush deferred React work. Add a findByText() flush point at the end of such tests:

// Flush deferred updates from Transitioner, PageTitleProvider, FilterableMultiSelect, etc.
await screen.findByText("Page title or stable element");

findByText() internally calls act() via waitFor, which drains deferred work without the V8 hang (it uses microtask-based polling, not async act's scheduling).

IMPORTANT: This fix is ONLY for V8 coverage environments. If you don't need coverage or use a different coverage provider (istanbul), async act() (Fix 1) is cleaner and preferred. Sync act() is a targeted workaround for the V8 coverage hang.

Files fixed and verified (2026-07-29):

  • src/pages/WasteSearch/index.unit.test.tsx — 6 tests: replaced await act(async () => renderWithApp(...)) with act(() => renderWithApp(...)), wrapped all sendEvent() calls in act(), and added await screen.findByText('Waste search') flush point to the last test. Result: 0 stderr warnings, ~230ms test time, 1.60s total with V8 coverage, no hang. TypeScript clean.
  • src/pages/MyClientList/index.unit.test.tsx — 5 tests: replaced renderWithAppAsync (async act) with sync act(() => renderWithApp(...)) + await screen.findByText('My clients') polling for router resolution. The warning notification test was timing out at 94s on CI; with sync act it completes in ~17ms under coverage. TypeScript clean.
  • src/components/Form/TableResource/index.unit.test.tsx — 20 tests: see Fix 6a for the fireEvent.click workaround used here. Full suite dropped from 22s to 713ms under coverage. TypeScript clean.

Current state (2026-07-29): All Transitioner warning buckets remain at 0, now verified under V8 coverage. Three additional test files fixed under V8 coverage: WasteSearch, MyClientList, and TableResource.

Fix 6a — Carbon fireEvent.click workaround for V8 coverage (validated 2026-07-29)

Problem: When tests interact with Carbon components (Pagination, OverflowMenu, expandable table rows) via userEvent.click(), V8 coverage on slower CI runners (Ubuntu) causes these interactions to time out (15s+). Carbon's internal async state updates — popover positioning, tooltip timers, focus management — are instrumented by V8 coverage and deadlock the microtask queue.

Root cause: userEvent performs interaction checks (hover, focus, pointer events) that trigger Carbon's async behavior before dispatching the click. Under V8 coverage, these async cascades never resolve.

Fix: Replace userEvent.click() with fireEvent.click() for direct Carbon component interactions:

import { fireEvent, screen } from "@testing-library/react";

// Instead of: await userEvent.click(button);
fireEvent.click(screen.getByRole("button", { name: "Next page" }));

This bypasses userEvent's interaction checks and dispatches the raw DOM click event synchronously, avoiding the V8 coverage deadlock entirely.

When to use: Only for Carbon components where userEvent.click() consistently hangs under V8 coverage. Keep userEvent for all other interactions (text input, select options, etc.) where it doesn't hang — fireEvent is less realistic.

Files affected: src/components/Form/TableResource/index.unit.test.tsx

  • Sortable header clicks (line ~363, pre-existing): fireEvent.click(nameHeader) — comment says "Use fireEvent to bypass userEvent's interaction checks that hang due to Carbon Tooltip's pointer-interception CSS"
  • Pagination buttons (Next/Previous page): was hanging at 4742ms under coverage, now instant.
  • Carbon OverflowMenu trigger ("Options") + menu item ("Activate"): was hanging at 13838ms under coverage, now instant (276ms).
  • Table row expand/collapse buttons: was hanging at 3212ms under coverage, now instant.

Validation: src/components/Form/TableResource/index.unit.test.tsx — 20 tests, 713ms under coverage (was 22s before fix). tsc clean. Zero stderr warnings.

Fix 6b — Page-level sync act() + findByText polling (validated 2026-07-29)

Problem: Page-level tests (e.g., MyClientList) use renderWithAppAsync which wraps render in await act(async () => { ... }). Under V8 coverage, the async act hang from Fix 1 applies equally to page-level tests as it does to component-level tests. The MyClientList "warning notification" test timed out at 94s on CI.

Fix: Replace renderWithAppAsync with sync act(() => renderWithApp(...)) plus an immediate await screen.findByText() to wait for router/page content resolution:

import { act, screen } from "@testing-library/react";

await act(() => {
  renderWithApp(<MyClientListPage />);
});
// Must wait for router resolution before interacting/asserting
await screen.findByText("My clients");

This matches the WasteSearch pattern (Fix 6). The difference from the WasteSearch pattern: MyClientList dispatches events via notificationEventEmitter directly (not sendEvent() on window), so no additional act() wrapping is needed for event dispatch. The mandatory findByText poll step replaces router.load() as the synchronization mechanism — findByText internally uses waitFor which drains deferred React work without triggering the V8 coverage hang.

Validation: src/pages/MyClientList/index.unit.test.tsx — 5 tests, 139ms under coverage. The warning notification test was timing out at 94s on CI; with sync act it completes in ~17ms. tsc clean. Zero stderr warnings.

Clean Reference

branch-coverage.unit.test.tsx lives in the same folder as index, coverage, additional-coverage, and form-submission. It uses the identical router setup but stays clean. It mocks @carbon/react and every test ends with await waitFor(...). Copy it as the template for new tests in this folder.

Still-Open KB Gaps

All act() warning buckets are resolved to 0. No open gaps remain for AuthProvider, ComboBox, Field, LocalSubscribe, or FilterableMultiSelect.

  1. defaultSelected DOM prop warning — FIXED (2026-07-14). Removed the redundant defaultSelected="" on the controlled RadioButtonGroup in src/components/waste/ReportingUnits/ReportingUnitCreate/index.tsx (line ~218). Warning count dropped 1 → 0; full suite still green (154 files / 1861 tests).
  2. Carbon ComboBox act warnings — FIXED (2026-07-22). Fix 4 and Fix 5 together removed every instance by wrapping the raw .focus()/.click()/.blur() calls (and, in one case, a userEvent.selectOptions() call) that triggered them in act() + setTimeout(0), no Carbon mocking required. Bucket is at 0 across the full suite. Mocking Carbon itself is still not recommended (Fix 2 still holds).
  3. form.Field + react-query LocalSubscribe — FIXED (2026-07-22). Fix 4's pattern, extended by Fix 5 across coverage.unit.test.tsx and index.unit.test.tsx, removed every remaining warning in this bucket. Both buckets are at 0.
  4. FilterableMultiSelect act warnings — FIXED (2026-07-22). Fix 4's pattern applied to src/components/Form/ActiveMultiSelect/index.unit.test.tsx's openDropdown helper took this bucket from 7 → 0.
  5. AuthProvider act warnings (11) — FIXED (prior session). Resolved by removing the real AuthProvider wrapper wherever useAuth is mocked in tests. Listed here for continuity with the resolution table below.
  6. vi.spyOn(console, ...) assertion or suppression pattern — no page documents how to assert on or suppress console output during act warnings. Track this as a missing page. Lower priority now that no test file produces this output to begin with, but still an open documentation gap if a future regression reintroduces the noise.

Warning Progression

Bucket Original Mid-session Final
AuthProvider 11 0 0
ComboBox ~38 21 0
Field ~23 23 0
LocalSubscribe ~31 31 0
FilterableMultiSelect 7 0 0
Total ~110 52 0

V8 coverage note (2026-07-29): All buckets verified at 0 under provider: v8. Fix 6 (sync act()) avoids the V8 coverage hang that affects async act(). Fix 6a (fireEvent.click for Carbon components) and Fix 6b (page-level sync act + findByText) extend the pattern to Carbon interactions and page-level tests respectively. No regression under coverage.

Resolution (updated 2026-07-29)

Item Action Result
A — Transitioner (61) Wrap render() in await act(async () => { … }) in each file's renderComponent() helper Fixed (61 → 0). Validated green.
D — defaultSelected (1) Remove redundant defaultSelected="" on the controlled RadioButtonGroup in src/components/waste/ReportingUnits/ReportingUnitCreate/index.tsx Fixed (1 → 0). Validated green.
B — Carbon ComboBox/FilterableMultiSelect mock pilot Piloted full @carbon/react mock on coverage DISPROVEN / counterproductive — removed the minority signal but multiplied form.Field + LocalSubscribe warnings (45 → 97 lines). Reverted. Do not mock Carbon to silence these.
C — AuthProvider (11) Remove the real AuthProvider wrapper wherever useAuth is mocked in tests Fixed (11 → 0). Validated green.
E — Raw DOM events on Carbon/TanStack-Form fields (see Fix 4) Wrap raw .click()/.focus()/.blur() calls in act(async () => { …; await new Promise((r) => setTimeout(r, 0)); }) Fixed in 2 files — additional-coverage.unit.test.tsx (ComboBox/Field/LocalSubscribe: 23 → 0) and ActiveMultiSelect/index.unit.test.tsx (FilterableMultiSelect: 7 → 0).
F — Rollout to remaining files (see Fix 5) coverage.unit.test.tsx: same wrap applied at scale, collapsing any pre-existing act() wraps instead of nesting. index.unit.test.tsx: wrapped userEvent.selectOptions() itself in act() + double setTimeout(0) flush, with a scoped testing-library/no-unnecessary-act disable. Fixed in both remaining files — all 52 previously-open warnings (16 ComboBox + 15 Field + 21 LocalSubscribe) → 0.
G — V8 coverage compatibility (WasteSearch) Replace await act(async () => ...) with sync act(() => ...) for render + sendEvent wrappers; add findByText flush point for tests with deferred updates Fixed (2026-07-29). WasteSearch: 0 warnings, 229ms, no hang under V8 coverage. Pattern documented in Fix 6.
H — V8 coverage: Carbon fireEvent workaround (TableResource) Replace userEvent.click() with fireEvent.click() for Carbon Pagination, OverflowMenu, and expand/collapse interactions that hang under V8 coverage Fixed (2026-07-29). TableResource: 20 tests, 713ms under coverage (was 22s). Pattern documented in Fix 6a.
I — V8 coverage: Page-level sync act (MyClientList) Replace renderWithAppAsync (async act) with sync act(() => renderWithApp(...)) + await screen.findByText() polling Fixed (2026-07-29). MyClientList: warning notification test passes in ~17ms (was 94s timeout). Pattern documented in Fix 6b.

Current state (2026-07-29): all 165 test files / 1994 tests pass. Coverage unchanged at 94.09% / 87.9% / 93.11% / 95.04%. Total stderr act(...) warnings: 0, down from a baseline of ~110. Validated under V8 coverage (2026-07-29): WasteSearch, MyClientList, and TableResource all pass with 0 warnings and no hangs (Fix 6 / 6a / 6b). Do not attempt Carbon mocks to silence act warnings if this regresses in the future — that still makes the suite worse (Fix 2 holds).

Related Pages

Waste Plus Wiki

Home

Architecture

Codes

Decisions

Knowledge Base

Development

Changes

Clone this wiki locally