Skip to content
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

Internal act: Call scope function after an async gap #26347

Merged
merged 1 commit into from
Mar 8, 2023
Merged
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
143 changes: 60 additions & 83 deletions packages/internal-test-utils/internalAct.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,15 @@ import * as Scheduler from 'scheduler/unstable_mock';

import enqueueTask from './enqueueTask';

let actingUpdatesScopeDepth = 0;
let actingUpdatesScopeDepth: number = 0;

export function act<T>(scope: () => Thenable<T>): Thenable<T> {
async function waitForMicrotasks() {
return new Promise(resolve => {
enqueueTask(() => resolve());
});
}

export async function act<T>(scope: () => Thenable<T>): Thenable<T> {
if (Scheduler.unstable_flushUntilNextPaint === undefined) {
throw Error(
'This version of `act` requires a special mock build of Scheduler.',
Expand All @@ -46,93 +52,64 @@ export function act<T>(scope: () => Thenable<T>): Thenable<T> {
global.IS_REACT_ACT_ENVIRONMENT = false;
}

const unwind = () => {
if (actingUpdatesScopeDepth === 1) {
// Create the error object before doing any async work, to get a better
// stack trace.
const error = new Error();
Error.captureStackTrace(error, act);

// Call the provided scope function after an async gap. This is an extra
// precaution to ensure that our tests do not accidentally rely on the act
// scope adding work to the queue synchronously. We don't do this in the
// public version of `act`, though we maybe should in the future.
await waitForMicrotasks();

try {
const result = await scope();

do {
// Wait until end of current task/microtask.
await waitForMicrotasks();

// $FlowFixMe: Flow doesn't know about global Jest object
if (jest.isEnvironmentTornDown()) {
error.message =
'The Jest environment was torn down before `act` completed. This ' +
'probably means you forgot to `await` an `act` call.';
throw error;
}

if (!Scheduler.unstable_hasPendingWork()) {
// $FlowFixMe: Flow doesn't know about global Jest object
jest.runOnlyPendingTimers();
if (Scheduler.unstable_hasPendingWork()) {
// Committing a fallback scheduled additional work. Continue flushing.
} else {
// There's no pending work, even after both the microtask queue
// and the timer queue are empty. Stop flushing.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might be overly pedantic but technically the timer queue isn't empty at this point. jest.runOnlyPendingTimers() does not run timers that were scheduled from pending timers. Don't know if that's relevant but I thought it was worth pointing out.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah the comment could be more accurate. The only reason we call it is to commit any pending Suspense fallbacks that were triggered by whatever React work was just performed.

break;
}
}
// flushUntilNextPaint stops when React yields execution. Allow microtasks
// queue to flush before continuing.
Scheduler.unstable_flushUntilNextPaint();
} while (true);

return result;
} finally {
const depth = actingUpdatesScopeDepth;
if (depth === 1) {
global.IS_REACT_ACT_ENVIRONMENT = previousIsActEnvironment;
}
actingUpdatesScopeDepth--;
actingUpdatesScopeDepth = depth - 1;

if (actingUpdatesScopeDepth > previousActingUpdatesScopeDepth) {
if (actingUpdatesScopeDepth !== previousActingUpdatesScopeDepth) {
// if it's _less than_ previousActingUpdatesScopeDepth, then we can
// assume the 'other' one has warned
throw new Error(
Scheduler.unstable_clearLog();
error.message =
'You seem to have overlapping act() calls, this is not supported. ' +
'Be sure to await previous act() calls before making a new one. ',
);
}
};

// TODO: This would be way simpler if we used async/await.
try {
const result = scope();
if (
typeof result !== 'object' ||
result === null ||
typeof (result: any).then !== 'function'
) {
throw new Error(
'The internal version of `act` used in the React repo must be passed ' +
"an async function, even if doesn't await anything. This is a " +
'temporary limitation that will soon be fixed.',
);
'Be sure to await previous act() calls before making a new one. ';
throw error;
}
const thenableResult: Thenable<T> = (result: any);

return {
then(resolve: T => mixed, reject: mixed => mixed) {
thenableResult.then(
returnValue => {
flushActWork(
() => {
unwind();
resolve(returnValue);
},
error => {
unwind();
reject(error);
},
);
},
error => {
unwind();
reject(error);
},
);
},
};
} catch (error) {
unwind();
throw error;
}
}

function flushActWork(resolve: () => void, reject: (error: any) => void) {
if (Scheduler.unstable_hasPendingWork()) {
try {
Scheduler.unstable_flushUntilNextPaint();
} catch (error) {
reject(error);
return;
}

// If Scheduler yields while there's still work, it's so that we can
// unblock the main thread (e.g. for paint or for microtasks). Yield to
// the main thread and continue in a new task.
enqueueTask(() => flushActWork(resolve, reject));
return;
}

// Once the scheduler queue is empty, run all the timers. The purpose of this
// is to force any pending fallbacks to commit. The public version of act does
// this with dev-only React runtime logic, but since our internal act needs to
// work production builds of React, we have to cheat.
// $FlowFixMe: Flow doesn't know about global Jest object
jest.runOnlyPendingTimers();
if (Scheduler.unstable_hasPendingWork()) {
// Committing a fallback scheduled additional work. Continue flushing.
flushActWork(resolve, reject);
return;
}

resolve();
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ describe('Store component filters', () => {
let internalAct;

const act = async (callback: Function) => {
internalAct(callback);
await internalAct(callback);
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missed this in 702fc98. The warning was firing but it wasn't causing the test to fail. Changing it to a hard error surfaced it.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In my defense, that particular diff in #26338 was collapsed in GitHub so how was I supposed to see the snapshot changes? 😛 The snapshot changes made it easy to spot after the fact.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I must have accidentally updated the snapshots while it was in watch mode without realizing it, and then didn't see it in the collapsed view

jest.runAllTimers(); // Flush Bridge operations
};

Expand Down Expand Up @@ -373,7 +373,6 @@ describe('Store component filters', () => {
legacyRender(<Wrapper shouldSuspend={false} />, container),
);
expect(store).toMatchInlineSnapshot(`
✕ 1, ⚠ 0
[root]
▾ <Wrapper>
<Component>
Expand All @@ -383,7 +382,6 @@ describe('Store component filters', () => {
legacyRender(<Wrapper shouldSuspend={true} />, container),
);
expect(store).toMatchInlineSnapshot(`
✕ 2, ⚠ 0
[root]
▾ <Wrapper>
▾ <Loading>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -315,7 +315,9 @@ describe('ReactSuspenseFuzz', () => {

const rand = Random.create(SEED);

const NUMBER_OF_TEST_CASES = 500;
// If this is too large the test will time out. We use a scheduled CI
// workflow to run these tests with a random seed.
const NUMBER_OF_TEST_CASES = 250;
const ELEMENTS_PER_CASE = 12;

for (let i = 0; i < NUMBER_OF_TEST_CASES; i++) {
Expand Down