/** * Copyright (c) Meta Platforms, Inc. and affiliates. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * * @flow strict */ // This version of `act` is only used by our tests. Unlike the public version // of `act`, it's designed to work identically in both production and // development. It may have slightly different behavior from the public // version, too, since our constraints in our test suite are not the same as // those of developers using React — we're testing React itself, as opposed to // building an app with React. import type {Thenable} from 'shared/ReactTypes'; import * as Scheduler from 'scheduler/unstable_mock'; import enqueueTask from './enqueueTask'; import {assertConsoleLogsCleared} from './consoleMock'; import {diff} from 'jest-diff'; export let actingUpdatesScopeDepth: number = 0; export const thrownErrors: Array = []; async function waitForMicrotasks() { return new Promise(resolve => { enqueueTask(() => resolve()); }); } function aggregateErrors(errors: Array): mixed { if (errors.length > 1 && typeof AggregateError === 'function') { // eslint-disable-next-line no-undef return new AggregateError(errors); } return errors[0]; } export async function act(scope: () => Thenable): Thenable { if (Scheduler.unstable_flushUntilNextPaint === undefined) { throw Error( 'This version of `act` requires a special mock build of Scheduler.', ); } const actualYields = Scheduler.unstable_clearLog(); if (actualYields.length !== 0) { const error = Error( 'Log of yielded values is not empty. Call assertLog first.\n\n' + `Received:\n${diff('', actualYields.join('\n'), { omitAnnotationLines: true, })}`, ); Error.captureStackTrace(error, act); throw error; } // We require every `act` call to assert console logs // with one of the assertion helpers. Fails if not empty. assertConsoleLogsCleared(); // $FlowFixMe[cannot-resolve-name]: Flow doesn't know about global Jest object if (!jest.isMockFunction(setTimeout)) { throw Error( "This version of `act` requires Jest's timer mocks " + '(i.e. jest.useFakeTimers).', ); } const previousIsActEnvironment = global.IS_REACT_ACT_ENVIRONMENT; const previousActingUpdatesScopeDepth = actingUpdatesScopeDepth; actingUpdatesScopeDepth++; if (actingUpdatesScopeDepth === 1) { // Because this is not the "real" `act`, we set this to `false` so React // knows not to fire `act` warnings. global.IS_REACT_ACT_ENVIRONMENT = false; } // 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(); const errorHandlerDOM = function (event: ErrorEvent) { // Prevent logs from reprinting this error. event.preventDefault(); thrownErrors.push(event.error); }; const errorHandlerNode = function (err: mixed) { thrownErrors.push(err); }; // We track errors that were logged globally as if they occurred in this scope and then rethrow them. if (actingUpdatesScopeDepth === 1) { if ( typeof window === 'object' && typeof window.addEventListener === 'function' ) { // We're in a JS DOM environment. window.addEventListener('error', errorHandlerDOM); } else if (typeof process === 'object') { // Node environment process.on('uncaughtException', errorHandlerNode); } } try { const result = await scope(); do { // Wait until end of current task/microtask. await waitForMicrotasks(); // $FlowFixMe[cannot-resolve-name]: 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[cannot-resolve-name]: Flow doesn't know about global Jest object const j = jest; if (j.getTimerCount() > 0) { // There's a pending timer. Flush it now. We only do this in order to // force Suspense fallbacks to display; the fact that it's a timer // is an implementation detail. If there are other timers scheduled, // those will also fire now, too, which is not ideal. (The public // version of `act` doesn't do this.) For this reason, we should try // to avoid using timers in our internal tests. j.runAllTicks(); j.runOnlyPendingTimers(); // If a committing a fallback triggers another update, it might not // get scheduled until a microtask. So wait one more time. await waitForMicrotasks(); } 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. break; } } // flushUntilNextPaint stops when React yields execution. Allow microtasks // queue to flush before continuing. Scheduler.unstable_flushUntilNextPaint(); } while (true); if (thrownErrors.length > 0) { // Rethrow any errors logged by the global error handling. const thrownError = aggregateErrors(thrownErrors); thrownErrors.length = 0; throw thrownError; } // $FlowFixMe[incompatible-return] return result; } finally { const depth = actingUpdatesScopeDepth; if (depth === 1) { if ( typeof window === 'object' && typeof window.addEventListener === 'function' ) { // We're in a JS DOM environment. window.removeEventListener('error', errorHandlerDOM); } else if (typeof process === 'object') { // Node environment process.off('uncaughtException', errorHandlerNode); } global.IS_REACT_ACT_ENVIRONMENT = previousIsActEnvironment; } actingUpdatesScopeDepth = depth - 1; if (actingUpdatesScopeDepth !== previousActingUpdatesScopeDepth) { // if it's _less than_ previousActingUpdatesScopeDepth, then we can // assume the 'other' one has warned 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. '; throw error; } } } async function waitForTasksAndTimers(error: Error) { do { // Wait until end of current task/microtask. await waitForMicrotasks(); // $FlowFixMe[cannot-resolve-name]: 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; } // $FlowFixMe[cannot-resolve-name]: Flow doesn't know about global Jest object const j = jest; if (j.getTimerCount() > 0) { // There's a pending timer. Flush it now. We only do this in order to // force Suspense fallbacks to display; the fact that it's a timer // is an implementation detail. If there are other timers scheduled, // those will also fire now, too, which is not ideal. (The public // version of `act` doesn't do this.) For this reason, we should try // to avoid using timers in our internal tests. j.runAllTicks(); j.runOnlyPendingTimers(); // If a committing a fallback triggers another update, it might not // get scheduled until a microtask. So wait one more time. await waitForMicrotasks(); } else { break; } } while (true); } export async function serverAct(scope: () => Thenable): Thenable { // We require every `act` call to assert console logs // with one of the assertion helpers. Fails if not empty. assertConsoleLogsCleared(); // $FlowFixMe[cannot-resolve-name]: Flow doesn't know about global Jest object if (!jest.isMockFunction(setTimeout)) { throw Error( "This version of `act` requires Jest's timer mocks " + '(i.e. jest.useFakeTimers).', ); } // 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(); const errorHandlerNode = function (err: mixed) { thrownErrors.push(err); }; // We track errors that were logged globally as if they occurred in this scope and then rethrow them. if (typeof process === 'object') { // Node environment process.on('uncaughtException', errorHandlerNode); } else if ( typeof window === 'object' && typeof window.addEventListener === 'function' ) { throw new Error('serverAct is not supported in JSDOM environments'); } try { const promise = scope(); // $FlowFixMe[prop-missing] if (promise && typeof promise.catch === 'function') { // $FlowFixMe[incompatible-use] promise.catch(() => {}); // Handle below } // See if we need to do some work to unblock the promise first. await waitForTasksAndTimers(error); const result = await promise; // Then wait to flush the result. await waitForTasksAndTimers(error); if (thrownErrors.length > 0) { // Rethrow any errors logged by the global error handling. const thrownError = aggregateErrors(thrownErrors); thrownErrors.length = 0; throw thrownError; } // $FlowFixMe[incompatible-return] return result; } finally { if (typeof process === 'object') { // Node environment process.off('uncaughtException', errorHandlerNode); } } }