main
js 296 lines 10.3 KB
Raw
1 /**
2 * Copyright (c) Meta Platforms, Inc. and affiliates.
3 *
4 * This source code is licensed under the MIT license found in the
5 * LICENSE file in the root directory of this source tree.
6 *
7 * @flow strict
8 */
9
10 // This version of `act` is only used by our tests. Unlike the public version
11 // of `act`, it's designed to work identically in both production and
12 // development. It may have slightly different behavior from the public
13 // version, too, since our constraints in our test suite are not the same as
14 // those of developers using React — we're testing React itself, as opposed to
15 // building an app with React.
16
17 import type {Thenable} from 'shared/ReactTypes';
18
19 import * as Scheduler from 'scheduler/unstable_mock';
20
21 import enqueueTask from './enqueueTask';
22 import {assertConsoleLogsCleared} from './consoleMock';
23 import {diff} from 'jest-diff';
24
25 export let actingUpdatesScopeDepth: number = 0;
26
27 export const thrownErrors: Array<mixed> = [];
28
29 async function waitForMicrotasks() {
30 return new Promise(resolve => {
31 enqueueTask(() => resolve());
32 });
33 }
34
35 function aggregateErrors(errors: Array<mixed>): mixed {
36 if (errors.length > 1 && typeof AggregateError === 'function') {
37 return new AggregateError(errors);
38 }
39 return errors[0];
40 }
41
42 export async function act<T>(scope: () => Thenable<T>): Thenable<T> {
43 if (Scheduler.unstable_flushUntilNextPaint === undefined) {
44 throw Error(
45 'This version of `act` requires a special mock build of Scheduler.',
46 );
47 }
48
49 const actualYields = Scheduler.unstable_clearLog();
50 if (actualYields.length !== 0) {
51 const error = Error(
52 'Log of yielded values is not empty. Call assertLog first.\n\n' +
53 `Received:\n${diff('', actualYields.join('\n'), {
54 omitAnnotationLines: true,
55 })}`,
56 );
57 Error.captureStackTrace(error, act);
58 throw error;
59 }
60
61 // We require every `act` call to assert console logs
62 // with one of the assertion helpers. Fails if not empty.
63 assertConsoleLogsCleared();
64
65 // $FlowFixMe[cannot-resolve-name]: Flow doesn't know about global Jest object
66 if (!jest.isMockFunction(setTimeout)) {
67 throw Error(
68 "This version of `act` requires Jest's timer mocks " +
69 '(i.e. jest.useFakeTimers).',
70 );
71 }
72
73 const previousIsActEnvironment = global.IS_REACT_ACT_ENVIRONMENT;
74 const previousActingUpdatesScopeDepth = actingUpdatesScopeDepth;
75 actingUpdatesScopeDepth++;
76 if (actingUpdatesScopeDepth === 1) {
77 // Because this is not the "real" `act`, we set this to `false` so React
78 // knows not to fire `act` warnings.
79 global.IS_REACT_ACT_ENVIRONMENT = false;
80 }
81
82 // Create the error object before doing any async work, to get a better
83 // stack trace.
84 const error = new Error();
85 Error.captureStackTrace(error, act);
86
87 // Call the provided scope function after an async gap. This is an extra
88 // precaution to ensure that our tests do not accidentally rely on the act
89 // scope adding work to the queue synchronously. We don't do this in the
90 // public version of `act`, though we maybe should in the future.
91 await waitForMicrotasks();
92
93 const errorHandlerDOM = function (event: ErrorEvent) {
94 // Prevent logs from reprinting this error.
95 event.preventDefault();
96 thrownErrors.push(event.error);
97 };
98 const errorHandlerNode = function (err: mixed) {
99 thrownErrors.push(err);
100 };
101 // We track errors that were logged globally as if they occurred in this scope and then rethrow them.
102 if (actingUpdatesScopeDepth === 1) {
103 if (
104 typeof window === 'object' &&
105 typeof window.addEventListener === 'function'
106 ) {
107 // We're in a JS DOM environment.
108 window.addEventListener('error', errorHandlerDOM);
109 } else if (typeof process === 'object') {
110 // Node environment
111 process.on('uncaughtException', errorHandlerNode);
112 }
113 }
114
115 try {
116 const result = await scope();
117
118 do {
119 // Wait until end of current task/microtask.
120 await waitForMicrotasks();
121
122 // $FlowFixMe[cannot-resolve-name]: Flow doesn't know about global Jest object
123 if (jest.isEnvironmentTornDown()) {
124 error.message =
125 'The Jest environment was torn down before `act` completed. This ' +
126 'probably means you forgot to `await` an `act` call.';
127 throw error;
128 }
129
130 if (!Scheduler.unstable_hasPendingWork()) {
131 // $FlowFixMe[cannot-resolve-name]: Flow doesn't know about global Jest object
132 const j = jest;
133 if (j.getTimerCount() > 0) {
134 // There's a pending timer. Flush it now. We only do this in order to
135 // force Suspense fallbacks to display; the fact that it's a timer
136 // is an implementation detail. If there are other timers scheduled,
137 // those will also fire now, too, which is not ideal. (The public
138 // version of `act` doesn't do this.) For this reason, we should try
139 // to avoid using timers in our internal tests.
140 j.runAllTicks();
141 j.runOnlyPendingTimers();
142 // If a committing a fallback triggers another update, it might not
143 // get scheduled until a microtask. So wait one more time.
144 await waitForMicrotasks();
145 }
146 if (Scheduler.unstable_hasPendingWork()) {
147 // Committing a fallback scheduled additional work. Continue flushing.
148 } else {
149 // There's no pending work, even after both the microtask queue
150 // and the timer queue are empty. Stop flushing.
151 break;
152 }
153 }
154 // flushUntilNextPaint stops when React yields execution. Allow microtasks
155 // queue to flush before continuing.
156 Scheduler.unstable_flushUntilNextPaint();
157 } while (true);
158
159 if (thrownErrors.length > 0) {
160 // Rethrow any errors logged by the global error handling.
161 const thrownError = aggregateErrors(thrownErrors);
162 thrownErrors.length = 0;
163 throw thrownError;
164 }
165
166 // $FlowFixMe[incompatible-type]
167 return result;
168 } finally {
169 const depth = actingUpdatesScopeDepth;
170 if (depth === 1) {
171 if (
172 typeof window === 'object' &&
173 typeof window.addEventListener === 'function'
174 ) {
175 // We're in a JS DOM environment.
176 window.removeEventListener('error', errorHandlerDOM);
177 } else if (typeof process === 'object') {
178 // Node environment
179 process.off('uncaughtException', errorHandlerNode);
180 }
181 global.IS_REACT_ACT_ENVIRONMENT = previousIsActEnvironment;
182 }
183 actingUpdatesScopeDepth = depth - 1;
184
185 if (actingUpdatesScopeDepth !== previousActingUpdatesScopeDepth) {
186 // if it's _less than_ previousActingUpdatesScopeDepth, then we can
187 // assume the 'other' one has warned
188 Scheduler.unstable_clearLog();
189 error.message =
190 'You seem to have overlapping act() calls, this is not supported. ' +
191 'Be sure to await previous act() calls before making a new one. ';
192 throw error;
193 }
194 }
195 }
196
197 async function waitForTasksAndTimers(error: Error) {
198 do {
199 // Wait until end of current task/microtask.
200 await waitForMicrotasks();
201
202 // $FlowFixMe[cannot-resolve-name]: Flow doesn't know about global Jest object
203 if (jest.isEnvironmentTornDown()) {
204 error.message =
205 'The Jest environment was torn down before `act` completed. This ' +
206 'probably means you forgot to `await` an `act` call.';
207 throw error;
208 }
209
210 // $FlowFixMe[cannot-resolve-name]: Flow doesn't know about global Jest object
211 const j = jest;
212 if (j.getTimerCount() > 0) {
213 // There's a pending timer. Flush it now. We only do this in order to
214 // force Suspense fallbacks to display; the fact that it's a timer
215 // is an implementation detail. If there are other timers scheduled,
216 // those will also fire now, too, which is not ideal. (The public
217 // version of `act` doesn't do this.) For this reason, we should try
218 // to avoid using timers in our internal tests.
219 j.runAllTicks();
220 j.runOnlyPendingTimers();
221 // If a committing a fallback triggers another update, it might not
222 // get scheduled until a microtask. So wait one more time.
223 await waitForMicrotasks();
224 } else {
225 break;
226 }
227 } while (true);
228 }
229
230 export async function serverAct<T>(scope: () => Thenable<T>): Thenable<T> {
231 // We require every `act` call to assert console logs
232 // with one of the assertion helpers. Fails if not empty.
233 assertConsoleLogsCleared();
234
235 // $FlowFixMe[cannot-resolve-name]: Flow doesn't know about global Jest object
236 if (!jest.isMockFunction(setTimeout)) {
237 throw Error(
238 "This version of `act` requires Jest's timer mocks " +
239 '(i.e. jest.useFakeTimers).',
240 );
241 }
242
243 // Create the error object before doing any async work, to get a better
244 // stack trace.
245 const error = new Error();
246 Error.captureStackTrace(error, act);
247
248 // Call the provided scope function after an async gap. This is an extra
249 // precaution to ensure that our tests do not accidentally rely on the act
250 // scope adding work to the queue synchronously. We don't do this in the
251 // public version of `act`, though we maybe should in the future.
252 await waitForMicrotasks();
253
254 const errorHandlerNode = function (err: mixed) {
255 thrownErrors.push(err);
256 };
257 // We track errors that were logged globally as if they occurred in this scope and then rethrow them.
258 if (typeof process === 'object') {
259 // Node environment
260 process.on('uncaughtException', errorHandlerNode);
261 } else if (
262 typeof window === 'object' &&
263 typeof window.addEventListener === 'function'
264 ) {
265 throw new Error('serverAct is not supported in JSDOM environments');
266 }
267
268 try {
269 const promise = scope();
270 // $FlowFixMe[prop-missing]
271 if (promise && typeof promise.catch === 'function') {
272 // $FlowFixMe[incompatible-use]
273 promise.catch(() => {}); // Handle below
274 }
275 // See if we need to do some work to unblock the promise first.
276 await waitForTasksAndTimers(error);
277 const result = await promise;
278 // Then wait to flush the result.
279 await waitForTasksAndTimers(error);
280
281 if (thrownErrors.length > 0) {
282 // Rethrow any errors logged by the global error handling.
283 const thrownError = aggregateErrors(thrownErrors);
284 thrownErrors.length = 0;
285 throw thrownError;
286 }
287
288 // $FlowFixMe[incompatible-type]
289 return result;
290 } finally {
291 if (typeof process === 'object') {
292 // Node environment
293 process.off('uncaughtException', errorHandlerNode);
294 }
295 }
296 }