mirror of
https://github.com/zebrajr/react.git
synced 2025-12-06 12:20:20 +01:00
This uses the richer `serverAct` helper that we already use in other tests. This avoids using the `Scheduler`. We don't use that package on the server so it doesn't make sense to simulate going through it. Additionally, we really should be getting rid of it on the client too to favor `postTask` polyfills.
298 lines
10 KiB
JavaScript
298 lines
10 KiB
JavaScript
/**
|
|
* 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<mixed> = [];
|
|
|
|
async function waitForMicrotasks() {
|
|
return new Promise(resolve => {
|
|
enqueueTask(() => resolve());
|
|
});
|
|
}
|
|
|
|
function aggregateErrors(errors: Array<mixed>): 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<T>(scope: () => Thenable<T>): Thenable<T> {
|
|
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<T>(scope: () => Thenable<T>): Thenable<T> {
|
|
// 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);
|
|
}
|
|
}
|
|
}
|