Skip to content

Commit ffabc0c

Browse files
committed
Internal act: Call scope function after an async gap
This adds an async gap to our internal implementation of `act` (the one used by our repo, not the public API). Rather than call the provided scope function synchronously when `act` is called, we call it in a separate async task. This is an extra precaution to ensure that our tests do not accidentally rely on work being queued synchronously, because that is an implementation detail that we should be allowed to change. We don't do this in the public version of `act`, though we maybe should in the future, for the same rationale. That might be tricky, though, because it could break existing tests.
1 parent 8364377 commit ffabc0c

File tree

3 files changed

+64
-87
lines changed

3 files changed

+64
-87
lines changed

packages/internal-test-utils/internalAct.js

+60-83
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,15 @@ import * as Scheduler from 'scheduler/unstable_mock';
2020

2121
import enqueueTask from './enqueueTask';
2222

23-
let actingUpdatesScopeDepth = 0;
23+
let actingUpdatesScopeDepth: number = 0;
2424

25-
export function act<T>(scope: () => Thenable<T>): Thenable<T> {
25+
async function waitForMicrotasks() {
26+
return new Promise(resolve => {
27+
enqueueTask(() => resolve());
28+
});
29+
}
30+
31+
export async function act<T>(scope: () => Thenable<T>): Thenable<T> {
2632
if (Scheduler.unstable_flushUntilNextPaint === undefined) {
2733
throw Error(
2834
'This version of `act` requires a special mock build of Scheduler.',
@@ -46,93 +52,64 @@ export function act<T>(scope: () => Thenable<T>): Thenable<T> {
4652
global.IS_REACT_ACT_ENVIRONMENT = false;
4753
}
4854

49-
const unwind = () => {
50-
if (actingUpdatesScopeDepth === 1) {
55+
// Create the error object before doing any async work, to get a better
56+
// stack trace.
57+
const error = new Error();
58+
Error.captureStackTrace(error, act);
59+
60+
// Call the provided scope function after an async gap. This is an extra
61+
// precaution to ensure that our tests do not accidentally rely on the act
62+
// scope adding work to the queue synchronously. We don't do this in the
63+
// public version of `act`, though we maybe should in the future.
64+
await waitForMicrotasks();
65+
66+
try {
67+
const result = await scope();
68+
69+
do {
70+
// Wait until end of current task/microtask.
71+
await waitForMicrotasks();
72+
73+
// $FlowFixMe: Flow doesn't know about global Jest object
74+
if (jest.isEnvironmentTornDown()) {
75+
error.message =
76+
'The Jest environment was torn down before `act` completed. This ' +
77+
'probably means you forgot to `await` an `act` call.';
78+
throw error;
79+
}
80+
81+
if (!Scheduler.unstable_hasPendingWork()) {
82+
// $FlowFixMe: Flow doesn't know about global Jest object
83+
jest.runOnlyPendingTimers();
84+
if (Scheduler.unstable_hasPendingWork()) {
85+
// Committing a fallback scheduled additional work. Continue flushing.
86+
} else {
87+
// There's no pending work, even after both the microtask queue
88+
// and the timer queue are empty. Stop flushing.
89+
break;
90+
}
91+
}
92+
// flushUntilNextPaint stops when React yields execution. Allow microtasks
93+
// queue to flush before continuing.
94+
Scheduler.unstable_flushUntilNextPaint();
95+
} while (true);
96+
97+
return result;
98+
} finally {
99+
const depth = actingUpdatesScopeDepth;
100+
if (depth === 1) {
51101
global.IS_REACT_ACT_ENVIRONMENT = previousIsActEnvironment;
52102
}
53-
actingUpdatesScopeDepth--;
103+
actingUpdatesScopeDepth = depth - 1;
54104

55-
if (actingUpdatesScopeDepth > previousActingUpdatesScopeDepth) {
105+
if (actingUpdatesScopeDepth !== previousActingUpdatesScopeDepth) {
56106
// if it's _less than_ previousActingUpdatesScopeDepth, then we can
57107
// assume the 'other' one has warned
58-
throw new Error(
108+
Scheduler.unstable_clearLog();
109+
error.message =
59110
'You seem to have overlapping act() calls, this is not supported. ' +
60-
'Be sure to await previous act() calls before making a new one. ',
61-
);
62-
}
63-
};
64-
65-
// TODO: This would be way simpler if we used async/await.
66-
try {
67-
const result = scope();
68-
if (
69-
typeof result !== 'object' ||
70-
result === null ||
71-
typeof (result: any).then !== 'function'
72-
) {
73-
throw new Error(
74-
'The internal version of `act` used in the React repo must be passed ' +
75-
"an async function, even if doesn't await anything. This is a " +
76-
'temporary limitation that will soon be fixed.',
77-
);
111+
'Be sure to await previous act() calls before making a new one. ';
112+
throw error;
78113
}
79-
const thenableResult: Thenable<T> = (result: any);
80-
81-
return {
82-
then(resolve: T => mixed, reject: mixed => mixed) {
83-
thenableResult.then(
84-
returnValue => {
85-
flushActWork(
86-
() => {
87-
unwind();
88-
resolve(returnValue);
89-
},
90-
error => {
91-
unwind();
92-
reject(error);
93-
},
94-
);
95-
},
96-
error => {
97-
unwind();
98-
reject(error);
99-
},
100-
);
101-
},
102-
};
103-
} catch (error) {
104-
unwind();
105-
throw error;
106114
}
107115
}
108-
109-
function flushActWork(resolve: () => void, reject: (error: any) => void) {
110-
if (Scheduler.unstable_hasPendingWork()) {
111-
try {
112-
Scheduler.unstable_flushUntilNextPaint();
113-
} catch (error) {
114-
reject(error);
115-
return;
116-
}
117-
118-
// If Scheduler yields while there's still work, it's so that we can
119-
// unblock the main thread (e.g. for paint or for microtasks). Yield to
120-
// the main thread and continue in a new task.
121-
enqueueTask(() => flushActWork(resolve, reject));
122-
return;
123-
}
124-
125-
// Once the scheduler queue is empty, run all the timers. The purpose of this
126-
// is to force any pending fallbacks to commit. The public version of act does
127-
// this with dev-only React runtime logic, but since our internal act needs to
128-
// work production builds of React, we have to cheat.
129-
// $FlowFixMe: Flow doesn't know about global Jest object
130-
jest.runOnlyPendingTimers();
131-
if (Scheduler.unstable_hasPendingWork()) {
132-
// Committing a fallback scheduled additional work. Continue flushing.
133-
flushActWork(resolve, reject);
134-
return;
135-
}
136-
137-
resolve();
138-
}

packages/react-devtools-shared/src/__tests__/storeComponentFilters-test.js

+1-3
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ describe('Store component filters', () => {
2020
let internalAct;
2121

2222
const act = async (callback: Function) => {
23-
internalAct(callback);
23+
await internalAct(callback);
2424
jest.runAllTimers(); // Flush Bridge operations
2525
};
2626

@@ -373,7 +373,6 @@ describe('Store component filters', () => {
373373
legacyRender(<Wrapper shouldSuspend={false} />, container),
374374
);
375375
expect(store).toMatchInlineSnapshot(`
376-
✕ 1, ⚠ 0
377376
[root]
378377
▾ <Wrapper>
379378
<Component>
@@ -383,7 +382,6 @@ describe('Store component filters', () => {
383382
legacyRender(<Wrapper shouldSuspend={true} />, container),
384383
);
385384
expect(store).toMatchInlineSnapshot(`
386-
✕ 2, ⚠ 0
387385
[root]
388386
▾ <Wrapper>
389387
▾ <Loading>

packages/react-reconciler/src/__tests__/ReactSuspenseFuzz-test.internal.js

+3-1
Original file line numberDiff line numberDiff line change
@@ -315,7 +315,9 @@ describe('ReactSuspenseFuzz', () => {
315315

316316
const rand = Random.create(SEED);
317317

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

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

0 commit comments

Comments
 (0)