Repository navigation
React: Fix act environment leak causing act(async) without await warnings in multi-file Vitest runs #34999
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: next
Are you sure you want to change the base?
React: Fix act environment leak causing act(async) without await warnings in multi-file Vitest runs #34999
Changes from all commits
4bb4e48
84a404b
8e2ec96
dd462d3
0208d8f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,181 @@ | ||
| import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; | ||
|
|
||
| import { getAct, getReactActEnvironment, setReactActEnvironment } from './act-compat.ts'; | ||
|
|
||
| // Top-level module mocks. `react` keeps its real exports, but the fallback test | ||
| // can hide `act` (via the hoisted flag, so it's available to the hoisted mock | ||
| // factory) to route getAct onto the deprecated react-dom/test-utils branch. | ||
| const mockState = vi.hoisted(() => ({ reactExposesAct: true })); | ||
|
|
||
| vi.mock('react', async (importOriginal) => { | ||
| const actual = await importOriginal<typeof import('react')>(); | ||
| return { | ||
| ...actual, | ||
| get act() { | ||
| return mockState.reactExposesAct ? actual.act : undefined; | ||
| }, | ||
| }; | ||
| }); | ||
|
|
||
| vi.mock('react-dom/test-utils', { spy: true }); | ||
|
|
||
| // Regression coverage for https://github.com/storybookjs/storybook/issues/34708: | ||
| // `IS_REACT_ACT_ENVIRONMENT` must be restored once the act work settles (so it | ||
| // can't leak `true` across story boundaries) and stay set while concurrent acts | ||
| // overlap. A stuck flag makes React log "act(async () => ...) without await". | ||
| describe('act-compat', () => { | ||
| let errors: string[] = []; | ||
|
|
||
| beforeEach(() => { | ||
| errors = []; | ||
| setReactActEnvironment(false); | ||
| vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => { | ||
| errors.push(args.map((arg) => String(arg)).join(' ')); | ||
| }); | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| vi.restoreAllMocks(); | ||
| mockState.reactExposesAct = true; | ||
| }); | ||
|
|
||
| describe('restores the environment once the act settles', () => { | ||
| it('after an un-awaited async act, without warning', async () => { | ||
| const act = await getAct(); | ||
|
|
||
| // Trigger an async act but never await the returned thenable, mimicking a | ||
| // pipeline caller that discards the result. | ||
| void (act(async () => { | ||
| await Promise.resolve(); | ||
| }) as unknown as Promise<unknown>); | ||
|
|
||
| // The flag is set synchronously while act is in progress. | ||
| expect(getReactActEnvironment()).toBe(true); | ||
|
|
||
| // Let the act work and React's deferred await-tracking check settle without | ||
| // the caller ever awaiting the thenable. | ||
| await new Promise((resolve) => setTimeout(resolve, 0)); | ||
|
|
||
| const actWarnings = errors.filter((error) => | ||
| error.includes('You called act(async () => ...) without await') | ||
| ); | ||
|
|
||
| expect(getReactActEnvironment()).toBe(false); | ||
| expect(actWarnings).toEqual([]); | ||
| }); | ||
|
|
||
| it('after an awaited async act resolves', async () => { | ||
| const act = await getAct(); | ||
|
|
||
| await act(async () => { | ||
| await Promise.resolve(); | ||
| }); | ||
|
|
||
| expect(getReactActEnvironment()).toBe(false); | ||
| }); | ||
|
|
||
| it('after an awaited async act rejects', async () => { | ||
| const act = await getAct(); | ||
|
|
||
| await expect( | ||
| act(async () => { | ||
| throw new Error('boom'); | ||
| }) | ||
| ).rejects.toThrow('boom'); | ||
|
|
||
| expect(getReactActEnvironment()).toBe(false); | ||
| }); | ||
|
|
||
| it('after a synchronous act callback', async () => { | ||
| const act = await getAct(); | ||
|
|
||
| act(() => { | ||
| expect(getReactActEnvironment()).toBe(true); | ||
| }); | ||
|
|
||
| expect(getReactActEnvironment()).toBe(false); | ||
| }); | ||
|
|
||
| it('after a synchronous act callback throws', async () => { | ||
| const act = await getAct(); | ||
|
|
||
| expect(() => | ||
| act(() => { | ||
| throw new Error('boom'); | ||
| }) | ||
| ).toThrow('boom'); | ||
|
|
||
| expect(getReactActEnvironment()).toBe(false); | ||
| }); | ||
| }); | ||
|
|
||
| describe('ref-counts across overlapping acts', () => { | ||
| it('stays set until every interleaved act has settled', async () => { | ||
| const act = await getAct(); | ||
|
|
||
| let resolveFirst: () => void = () => {}; | ||
| let resolveSecond: () => void = () => {}; | ||
|
|
||
| const first = act(() => new Promise<void>((resolve) => (resolveFirst = resolve))); | ||
| const second = act(() => new Promise<void>((resolve) => (resolveSecond = resolve))); | ||
|
|
||
| expect(getReactActEnvironment()).toBe(true); | ||
|
|
||
| resolveFirst(); | ||
| await first; | ||
|
|
||
| // The second act is still in flight, so the flag must remain set. | ||
| expect(getReactActEnvironment()).toBe(true); | ||
|
|
||
| resolveSecond(); | ||
| await second; | ||
|
|
||
| expect(getReactActEnvironment()).toBe(false); | ||
| }); | ||
|
Comment on lines
+113
to
+134
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🤖 Automated review bot · Code quality (stage 2) · non-blocking · S2-3 Problem: Effect: Suggested change (optional): resolve the pending acts in a |
||
|
|
||
| it('restores the pre-existing value when a nested act settles', async () => { | ||
| const act = await getAct(); | ||
|
|
||
| // An outer act already enabled the environment; the nested act must leave | ||
| // it enabled rather than hardcode it back to `false`. | ||
| setReactActEnvironment(true); | ||
|
|
||
| await act(async () => { | ||
| expect(getReactActEnvironment()).toBe(true); | ||
| await Promise.resolve(); | ||
| }); | ||
|
|
||
| expect(getReactActEnvironment()).toBe(true); | ||
| }); | ||
|
Comment on lines
+136
to
+149
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🤖 Automated review bot · Code quality (stage 2) · non-blocking · S2-4 Problem: no act is nested here. The test sets the flag to Suggested change (optional): rename it to say what it checks (for example "restores a pre-existing |
||
| }); | ||
|
|
||
| describe('getAct resolution', () => { | ||
| it('returns a no-op that leaves the environment untouched when act is disabled', async () => { | ||
| const act = await getAct({ disableAct: true }); | ||
|
|
||
| let called = false; | ||
| act(() => { | ||
| called = true; | ||
| expect(getReactActEnvironment()).toBe(false); | ||
| }); | ||
|
|
||
| expect(called).toBe(true); | ||
| expect(getReactActEnvironment()).toBe(false); | ||
| }); | ||
|
|
||
| it('falls back to react-dom/test-utils when React.act is unavailable', async () => { | ||
| // Hide `act` on the mocked react so getAct takes the fallback branch, then | ||
| // re-evaluate act-compat (it snapshots `{ ...React }` at module load). | ||
| mockState.reactExposesAct = false; | ||
| vi.resetModules(); | ||
|
|
||
| const { getAct: getActFresh } = await import('./act-compat.ts'); | ||
| const testUtils = await import('react-dom/test-utils'); | ||
| const act = await getActFresh(); | ||
|
|
||
| act(() => {}); | ||
|
|
||
| expect(vi.mocked(testUtils).act).toHaveBeenCalledOnce(); | ||
| }); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,4 +1,4 @@ | ||
| // Copied from | ||
| // Adapted from | ||
| // https://github.com/testing-library/react-testing-library/blob/3dcd8a9649e25054c0e650d95fca2317b7008576/src/act-compat.js | ||
| import * as React from 'react'; | ||
|
|
||
|
|
@@ -19,10 +19,32 @@ export function getReactActEnvironment() { | |
| return globalThis.IS_REACT_ACT_ENVIRONMENT; | ||
| } | ||
|
|
||
| // Storybook interleaves act calls across stories, so a per-call save/restore | ||
| // (as react-testing-library does) is wrong: the first call to settle would | ||
| // clear the flag while others are still in flight. Ref-count instead, and only | ||
| // restore the captured baseline once the last concurrent act settles. | ||
| // See https://github.com/storybookjs/storybook/issues/34708. | ||
| const actEnvironment = { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🤖 Automated review bot · Architecture (stage 1) / Code quality (stage 2) · non-blocking · S1-3, S2-6 Two optional notes on how this state is stored. If you rework this owner for the blocking
|
||
| depth: 0, | ||
| baseline: false, | ||
| enter() { | ||
| if (this.depth === 0) { | ||
| this.baseline = getReactActEnvironment(); | ||
| } | ||
| this.depth = this.depth + 1; | ||
| setReactActEnvironment(true); | ||
| }, | ||
|
Comment on lines
+30
to
+36
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🤖 Automated review bot · Architecture (stage 1) · blocking · S1-1 Problem: The ref-count is meant to be the only thing that decides Effect (reproduced with real React
So "reset only after all acts resolve" holds for act-vs-act overlap, but not for act-vs- Requested change: make |
||
| exit() { | ||
| this.depth = this.depth - 1; | ||
| if (this.depth === 0) { | ||
| setReactActEnvironment(this.baseline); | ||
| } | ||
| }, | ||
|
Comment on lines
+37
to
+42
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🤖 Automated review bot · Architecture (stage 1) · non-blocking · S1-2 Problem: with a global counter, one act that never settles (for example a story whose render or teardown promise never resolves, or an aborted test) keeps Effect: every later story in the same iframe runs with Suggested change (optional): either document this trade-off in the comment above |
||
| }; | ||
|
|
||
| function withGlobalActEnvironment(actImplementation: (callback: () => void) => Promise<any>) { | ||
| return (callback: () => any) => { | ||
| const previousActEnvironment = getReactActEnvironment(); | ||
| setReactActEnvironment(true); | ||
| actEnvironment.enter(); | ||
| try { | ||
| // The return value of `act` is always a thenable. | ||
| let callbackNeedsToBeAwaited = false; | ||
|
|
@@ -35,28 +57,32 @@ function withGlobalActEnvironment(actImplementation: (callback: () => void) => P | |
| }); | ||
| if (callbackNeedsToBeAwaited) { | ||
| const thenable = actResult; | ||
| return { | ||
| then: (resolve: (param: any) => void, reject: (param: any) => void) => { | ||
| thenable.then( | ||
| (returnValue: any) => { | ||
| setReactActEnvironment(previousActEnvironment); | ||
| resolve(returnValue); | ||
| }, | ||
| (error: any) => { | ||
| setReactActEnvironment(previousActEnvironment); | ||
| reject(error); | ||
| } | ||
| ); | ||
| }, | ||
| }; | ||
| // Attach to React's act thenable eagerly (the executor runs | ||
| // synchronously): some pipeline callers (e.g. the testing-library | ||
| // `eventWrapper`) discard the result without awaiting, yet the | ||
| // environment reset must still be tied to the act work settling. We wrap | ||
| // it in a real Promise because React's act thenable is non-conformant — | ||
| // its `.then` returns `undefined` rather than a chainable promise. | ||
|
Comment on lines
+60
to
+65
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🤖 Automated review bot · Code quality (stage 2) · non-blocking · S2-2 Problem: the comment says the wrapper exists because React's Effect: an edit that looks harmless, such as Suggested change (optional): state the single-subscriber invariant and the |
||
| return new Promise((resolve, reject) => { | ||
| thenable.then( | ||
| (returnValue: any) => { | ||
| actEnvironment.exit(); | ||
| resolve(returnValue); | ||
| }, | ||
| (error: any) => { | ||
| actEnvironment.exit(); | ||
| reject(error); | ||
| } | ||
| ); | ||
| }); | ||
|
valentinpalkovic marked this conversation as resolved.
Comment on lines
+66
to
+77
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🤖 Automated review bot · Code quality (stage 2) · blocking · S2-1 Problem: This Promise subscribes to React's thenable straight away, so when the act callback rejects, this Promise rejects too. Effect: a user-event interaction that rejects inside let result;
act(() => { result = Promise.reject(new Error('click failed')); return result; });
await expect(result).rejects.toThrow('click failed');On this head: Requested change: keep the eager single subscription, but mark the wrapper as handled while it still rejects for callers that await it, for example |
||
| } else { | ||
| setReactActEnvironment(previousActEnvironment); | ||
| actEnvironment.exit(); | ||
| return actResult; | ||
| } | ||
| } catch (error) { | ||
| // Can't be a `finally {}` block since we don't know if we have to immediately restore IS_REACT_ACT_ENVIRONMENT | ||
| // or if we have to await the callback first. | ||
| setReactActEnvironment(previousActEnvironment); | ||
| // Not a `finally`: the async branch defers exit() until the act thenable | ||
| // settles, so finally would call exit() twice. This only covers sync throws. | ||
| actEnvironment.exit(); | ||
| throw error; | ||
| } | ||
| }; | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🤖 Automated review bot · Code quality (stage 2) · non-blocking · S2-5
Problem: the
as unknown as Promise<unknown>cast isn't needed:getAct()already returns a function typed=> any. With the cast removed (void act(async () => { … });),yarn nx run react:checkstill passes.Suggested change (optional): drop the cast.