main
js 366 lines 13.8 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
8 */
9
10 import type {Thenable} from 'shared/ReactTypes';
11 import type {RendererTask} from './ReactSharedInternalsClient';
12 import ReactSharedInternals from './ReactSharedInternalsClient';
13 import queueMacrotask from 'shared/enqueueTask';
14
15 import {disableLegacyMode} from 'shared/ReactFeatureFlags';
16
17 // `act` calls can be nested, so we track the depth. This represents the
18 // number of `act` scopes on the stack.
19 let actScopeDepth = 0;
20
21 // We only warn the first time you neglect to await an async `act` scope.
22 let didWarnNoAwaitAct = false;
23
24 function aggregateErrors(errors: Array<mixed>): mixed {
25 if (errors.length > 1 && typeof AggregateError === 'function') {
26 return new AggregateError(errors);
27 }
28 return errors[0];
29 }
30
31 export function act<T>(callback: () => T | Thenable<T>): Thenable<T> {
32 if (__DEV__) {
33 // When ReactSharedInternals.actQueue is not null, it signals to React that
34 // we're currently inside an `act` scope. React will push all its tasks to
35 // this queue instead of scheduling them with platform APIs.
36 //
37 // We set this to an empty array when we first enter an `act` scope, and
38 // only unset it once we've left the outermost `act` scope — remember that
39 // `act` calls can be nested.
40 //
41 // If we're already inside an `act` scope, reuse the existing queue.
42 const prevIsBatchingLegacy = !disableLegacyMode
43 ? ReactSharedInternals.isBatchingLegacy
44 : false;
45 const prevActQueue = ReactSharedInternals.actQueue;
46 const prevActScopeDepth = actScopeDepth;
47 actScopeDepth++;
48 const queue = (ReactSharedInternals.actQueue =
49 prevActQueue !== null ? prevActQueue : []);
50 // Used to reproduce behavior of `batchedUpdates` in legacy mode. Only
51 // set to `true` while the given callback is executed, not for updates
52 // triggered during an async event, because this is how the legacy
53 // implementation of `act` behaved.
54 if (!disableLegacyMode) {
55 ReactSharedInternals.isBatchingLegacy = true;
56 }
57
58 let result;
59 // This tracks whether the `act` call is awaited. In certain cases, not
60 // awaiting it is a mistake, so we will detect that and warn.
61 let didAwaitActCall = false;
62 try {
63 // Reset this to `false` right before entering the React work loop. The
64 // only place we ever read this fields is just below, right after running
65 // the callback. So we don't need to reset after the callback runs.
66 if (!disableLegacyMode) {
67 ReactSharedInternals.didScheduleLegacyUpdate = false;
68 }
69 result = callback();
70 const didScheduleLegacyUpdate = !disableLegacyMode
71 ? ReactSharedInternals.didScheduleLegacyUpdate
72 : false;
73
74 // Replicate behavior of original `act` implementation in legacy mode,
75 // which flushed updates immediately after the scope function exits, even
76 // if it's an async function.
77 if (!prevIsBatchingLegacy && didScheduleLegacyUpdate) {
78 flushActQueue(queue);
79 }
80 // `isBatchingLegacy` gets reset using the regular stack, not the async
81 // one used to track `act` scopes. Why, you may be wondering? Because
82 // that's how it worked before version 18. Yes, it's confusing! We should
83 // delete legacy mode!!
84 if (!disableLegacyMode) {
85 ReactSharedInternals.isBatchingLegacy = prevIsBatchingLegacy;
86 }
87 } catch (error) {
88 // `isBatchingLegacy` gets reset using the regular stack, not the async
89 // one used to track `act` scopes. Why, you may be wondering? Because
90 // that's how it worked before version 18. Yes, it's confusing! We should
91 // delete legacy mode!!
92 ReactSharedInternals.thrownErrors.push(error);
93 }
94 if (ReactSharedInternals.thrownErrors.length > 0) {
95 if (!disableLegacyMode) {
96 ReactSharedInternals.isBatchingLegacy = prevIsBatchingLegacy;
97 }
98 popActScope(prevActQueue, prevActScopeDepth);
99 const thrownError = aggregateErrors(ReactSharedInternals.thrownErrors);
100 ReactSharedInternals.thrownErrors.length = 0;
101 throw thrownError;
102 }
103
104 if (
105 // $FlowFixMe[invalid-compare]
106 result !== null &&
107 typeof result === 'object' &&
108 // $FlowFixMe[method-unbinding]
109 typeof result.then === 'function'
110 ) {
111 // A promise/thenable was returned from the callback. Wait for it to
112 // resolve before flushing the queue.
113 //
114 // If `act` were implemented as an async function, this whole block could
115 // be a single `await` call. That's really the only difference between
116 // this branch and the next one.
117 const thenable = result as any as Thenable<T>;
118
119 // Warn if the an `act` call with an async scope is not awaited. In a
120 // future release, consider making this an error.
121 queueSeveralMicrotasks(() => {
122 if (!didAwaitActCall && !didWarnNoAwaitAct) {
123 didWarnNoAwaitAct = true;
124 console.error(
125 'You called act(async () => ...) without await. ' +
126 'This could lead to unexpected testing behaviour, ' +
127 'interleaving multiple act calls and mixing their ' +
128 'scopes. ' +
129 'You should - await act(async () => ...);',
130 );
131 }
132 });
133
134 return {
135 then(resolve: T => mixed, reject: mixed => mixed) {
136 didAwaitActCall = true;
137 thenable.then(
138 returnValue => {
139 popActScope(prevActQueue, prevActScopeDepth);
140 if (prevActScopeDepth === 0) {
141 // We're exiting the outermost `act` scope. Flush the queue.
142 try {
143 flushActQueue(queue);
144 queueMacrotask(() =>
145 // Recursively flush tasks scheduled by a microtask.
146 recursivelyFlushAsyncActWork(returnValue, resolve, reject),
147 );
148 } catch (error) {
149 // `thenable` might not be a real promise, and `flushActQueue`
150 // might throw, so we need to wrap `flushActQueue` in a
151 // try/catch.
152 ReactSharedInternals.thrownErrors.push(error);
153 }
154 if (ReactSharedInternals.thrownErrors.length > 0) {
155 const thrownError = aggregateErrors(
156 ReactSharedInternals.thrownErrors,
157 );
158 ReactSharedInternals.thrownErrors.length = 0;
159 reject(thrownError);
160 }
161 } else {
162 resolve(returnValue);
163 }
164 },
165 error => {
166 popActScope(prevActQueue, prevActScopeDepth);
167 if (ReactSharedInternals.thrownErrors.length > 0) {
168 const thrownError = aggregateErrors(
169 ReactSharedInternals.thrownErrors,
170 );
171 ReactSharedInternals.thrownErrors.length = 0;
172 reject(thrownError);
173 } else {
174 reject(error);
175 }
176 },
177 );
178 },
179 };
180 } else {
181 const returnValue: T = result as any;
182 // The callback is not an async function. Exit the current
183 // scope immediately.
184 popActScope(prevActQueue, prevActScopeDepth);
185 if (prevActScopeDepth === 0) {
186 // We're exiting the outermost `act` scope. Flush the queue.
187 flushActQueue(queue);
188
189 // If the queue is not empty, it implies that we intentionally yielded
190 // to the main thread, because something suspended. We will continue
191 // in an asynchronous task.
192 //
193 // Warn if something suspends but the `act` call is not awaited.
194 // In a future release, consider making this an error.
195 if (queue.length !== 0) {
196 queueSeveralMicrotasks(() => {
197 if (!didAwaitActCall && !didWarnNoAwaitAct) {
198 didWarnNoAwaitAct = true;
199 console.error(
200 'A component suspended inside an `act` scope, but the ' +
201 '`act` call was not awaited. When testing React ' +
202 'components that depend on asynchronous data, you must ' +
203 'await the result:\n\n' +
204 'await act(() => ...)',
205 );
206 }
207 });
208 }
209
210 // Like many things in this module, this is next part is confusing.
211 //
212 // We do not currently require every `act` call that is passed a
213 // callback to be awaited, through arguably we should. Since this
214 // callback was synchronous, we need to exit the current scope before
215 // returning.
216 //
217 // However, if thenable we're about to return *is* awaited, we'll
218 // immediately restore the current scope. So it shouldn't observable.
219 //
220 // This doesn't affect the case where the scope callback is async,
221 // because we always require those calls to be awaited.
222 //
223 // TODO: In a future version, consider always requiring all `act` calls
224 // to be awaited, regardless of whether the callback is sync or async.
225 ReactSharedInternals.actQueue = null;
226 }
227
228 if (ReactSharedInternals.thrownErrors.length > 0) {
229 const thrownError = aggregateErrors(ReactSharedInternals.thrownErrors);
230 ReactSharedInternals.thrownErrors.length = 0;
231 throw thrownError;
232 }
233
234 return {
235 then(resolve: T => mixed, reject: mixed => mixed) {
236 didAwaitActCall = true;
237 if (prevActScopeDepth === 0) {
238 // If the `act` call is awaited, restore the queue we were
239 // using before (see long comment above) so we can flush it.
240 ReactSharedInternals.actQueue = queue;
241 queueMacrotask(() =>
242 // Recursively flush tasks scheduled by a microtask.
243 recursivelyFlushAsyncActWork(returnValue, resolve, reject),
244 );
245 } else {
246 resolve(returnValue);
247 }
248 },
249 };
250 }
251 } else {
252 throw new Error('act(...) is not supported in production builds of React.');
253 }
254 }
255
256 function popActScope(
257 prevActQueue: null | Array<RendererTask>,
258 prevActScopeDepth: number,
259 ) {
260 if (__DEV__) {
261 if (prevActScopeDepth !== actScopeDepth - 1) {
262 console.error(
263 'You seem to have overlapping act() calls, this is not supported. ' +
264 'Be sure to await previous act() calls before making a new one. ',
265 );
266 }
267 actScopeDepth = prevActScopeDepth;
268 }
269 }
270
271 function recursivelyFlushAsyncActWork<T>(
272 returnValue: T,
273 resolve: T => mixed,
274 reject: mixed => mixed,
275 ) {
276 if (__DEV__) {
277 // Check if any tasks were scheduled asynchronously.
278 const queue = ReactSharedInternals.actQueue;
279 if (queue !== null) {
280 if (queue.length !== 0) {
281 // Async tasks were scheduled, mostly likely in a microtask.
282 // Keep flushing until there are no more.
283 try {
284 flushActQueue(queue);
285 // The work we just performed may have schedule additional async
286 // tasks. Wait a macrotask and check again.
287 queueMacrotask(() =>
288 recursivelyFlushAsyncActWork(returnValue, resolve, reject),
289 );
290 return;
291 } catch (error) {
292 // Leave remaining tasks on the queue if something throws.
293 ReactSharedInternals.thrownErrors.push(error);
294 }
295 } else {
296 // The queue is empty. We can finish.
297 ReactSharedInternals.actQueue = null;
298 }
299 }
300 if (ReactSharedInternals.thrownErrors.length > 0) {
301 const thrownError = aggregateErrors(ReactSharedInternals.thrownErrors);
302 ReactSharedInternals.thrownErrors.length = 0;
303 reject(thrownError);
304 } else {
305 resolve(returnValue);
306 }
307 }
308 }
309
310 let isFlushing = false;
311 function flushActQueue(queue: Array<RendererTask>) {
312 if (__DEV__) {
313 if (!isFlushing) {
314 // Prevent re-entrance.
315 isFlushing = true;
316 let i = 0;
317 try {
318 for (; i < queue.length; i++) {
319 let callback: RendererTask = queue[i];
320 do {
321 ReactSharedInternals.didUsePromise = false;
322 const continuation = callback(false);
323 if (continuation !== null) {
324 if (ReactSharedInternals.didUsePromise) {
325 // The component just suspended. Yield to the main thread in
326 // case the promise is already resolved. If so, it will ping in
327 // a microtask and we can resume without unwinding the stack.
328 queue[i] = callback;
329 queue.splice(0, i);
330 return;
331 }
332 callback = continuation;
333 } else {
334 break;
335 }
336 } while (true);
337 }
338 // We flushed the entire queue.
339 queue.length = 0;
340 } catch (error) {
341 // If something throws, leave the remaining callbacks on the queue.
342 queue.splice(0, i + 1);
343 ReactSharedInternals.thrownErrors.push(error);
344 } finally {
345 isFlushing = false;
346 }
347 }
348 }
349 }
350
351 // Some of our warnings attempt to detect if the `act` call is awaited by
352 // checking in an asynchronous task. Wait a few microtasks before checking. The
353 // only reason one isn't sufficient is we want to accommodate the case where an
354 // `act` call is returned from an async function without first being awaited,
355 // since that's a somewhat common pattern. If you do this too many times in a
356 // nested sequence, you might get a warning, but you can always fix by awaiting
357 // the call.
358 //
359 // A macrotask would also work (and is the fallback) but depending on the test
360 // environment it may cause the warning to fire too late.
361 const queueSeveralMicrotasks =
362 typeof queueMicrotask === 'function'
363 ? (callback: () => void) => {
364 queueMicrotask(() => queueMicrotask(callback));
365 }
366 : queueMacrotask;