Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
181 changes: 181 additions & 0 deletions code/renderers/react/src/act-compat.test.ts
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>);
Comment on lines +48 to +50

Copy link
Copy Markdown

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:check still passes.

Suggested change (optional): drop the cast.


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

Copy link
Copy Markdown

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-3

Problem: beforeEach resets the global flag but not the module's depth/baseline. If an expect in this test fails before resolveSecond(), depth stays above 0 for the rest of the file.

Effect: enter() never captures the baseline again and exit() never reaches 0, so unrelated later tests fail too, which hides the real failure.

Suggested change (optional): resolve the pending acts in a try/finally (or onTestFinished) so every test leaves depth at 0.


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

Copy link
Copy Markdown

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-4

Problem: no act is nested here. The test sets the flag to true by hand and checks that this baseline is restored. Real nesting (an act called inside another act callback), where the depth counter and React's own act scope interact, is not covered.

Suggested change (optional): rename it to say what it checks (for example "restores a pre-existing true baseline"), and consider adding a test with a sync and an async act nested inside an outer async act.

});

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();
});
});
});
68 changes: 47 additions & 21 deletions code/renderers/react/src/act-compat.ts
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';

Expand All @@ -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 = {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 asyncWrapper comment, these fit into that change.

  • S1-3: the counter is module-local, but the flag it guards is on globalThis. With one @storybook/react install this is fine (the browser entries share one chunk). With two module instances (two @storybook/react copies, or source and dist mixed), each would keep its own depth/baseline and fight over one global, and the ref-count would silently stop working. Consider keeping the counter on globalThis under a Symbol.for(...) key, or note in the comment that one module instance is assumed.
  • S2-6: enter/exit depend on this. Passing actEnvironment.exit as a callback would lose this and fail at runtime. Two module-level functions over let depth = 0; let baseline = false; avoid that and are shorter.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 IS_REACT_ACT_ENVIRONMENT, but asyncWrapper in entry-preview.tsx:62-84 (used by waitFor/findBy*) still does its own raw getReactActEnvironment() → setReactActEnvironment(false) → finally restore. enter()/exit() don't see those writes, and asyncWrapper doesn't see depth/baseline.

Effect (reproduced with real React act and an asyncWrapper copy):

So "reset only after all acts resolve" holds for act-vs-act overlap, but not for act-vs-waitFor.

Requested change: make act-compat.ts the single owner of the flag and route asyncWrapper through it instead of the raw setters. For example, add a suspend scope to the same model (actDepth, suspendDepth, captured baseline) and derive the flag in one place: suspendDepth > 0 ? false : actDepth > 0 ? true : baseline. Export something like withoutActEnvironment(cb) for entry-preview.tsx. Please add tests for both interleavings above.

exit() {
this.depth = this.depth - 1;
if (this.depth === 0) {
setReactActEnvironment(this.baseline);
}
},
Comment on lines +37 to +42

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 depth > 0 for the rest of the session. Before, a hung act only affected its own restore.

Effect: every later story in the same iframe runs with IS_REACT_ACT_ENVIRONMENT = true, and the resulting act warnings show up far from their cause.

Suggested change (optional): either document this trade-off in the comment above actEnvironment, or reset/assert the depth at an existing story boundary (for example where renderToCanvas teardown runs) so a leaked depth can't cross stories.

};

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;
Expand All @@ -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

Copy link
Copy Markdown

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-2

Problem: the comment says the wrapper exists because React's .then "returns undefined". The reasons that actually matter (React 18.3 act implementation) are different: each call to thenable.then pops React's act scope, so the thenable must be subscribed exactly once; and that first then sets wasAwaited = true, which is what suppresses the "act(async) without await" warning for discarded acts.

Effect: an edit that looks harmless, such as thenable.then(exit, exit) and then returning thenable, would double-pop React's act scope, and nothing here warns against it.

Suggested change (optional): state the single-subscriber invariant and the wasAwaited side effect in this comment.

return new Promise((resolve, reject) => {
thenable.then(
(returnValue: any) => {
actEnvironment.exit();
resolve(returnValue);
},
(error: any) => {
actEnvironment.exit();
reject(error);
}
);
});
Comment thread
valentinpalkovic marked this conversation as resolved.
Comment on lines +66 to +77

Copy link
Copy Markdown

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) · blocking · S2-1

Problem: This Promise subscribes to React's thenable straight away, so when the act callback rejects, this Promise rejects too. eventWrapper (entry-preview.tsx:86-92) discards the value act(...) returns and gives the caller only cb()'s own promise, so nothing ever handles this one. Before this change the returned object was a lazy thenable, so no rejection existed unless someone awaited it.

Effect: a user-event interaction that rejects inside eventWrapper (for example the normal await expect(userEvent.click(el)).rejects… pattern) now also raises a global unhandled rejection, even when the test handles its own error. Vitest fails the run. Reproduced:

let result;
act(() => { result = Promise.reject(new Error('click failed')); return result; });
await expect(result).rejects.toThrow('click failed');

On this head: Tests 1 passed, Errors 1 error, Unhandled Rejection, exit 1. On next: exit 0.

Requested change: keep the eager single subscription, but mark the wrapper as handled while it still rejects for callers that await it, for example const settled = new Promise(...); settled.catch(() => {}); return settled;. Please add a test that discards a rejecting async act and asserts there is no unhandled rejection and that the flag is restored.

} 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;
}
};
Expand Down
Loading