295 lines
11 KiB
JavaScript
295 lines
11 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
|
|
*/
|
|
|
|
// Corresponds to ReactFiberThenable and ReactFlightThenable modules. Generally,
|
|
// changes to one module should be reflected in the others.
|
|
|
|
import type {
|
|
Thenable,
|
|
PendingThenable,
|
|
FulfilledThenable,
|
|
RejectedThenable,
|
|
} from 'shared/ReactTypes';
|
|
import type {ComponentStackNode} from './ReactFizzComponentStack';
|
|
|
|
import noop from 'shared/noop';
|
|
import {currentTaskInDEV} from './ReactFizzCurrentTask';
|
|
|
|
export opaque type ThenableState = Array<Thenable<any>>;
|
|
|
|
// An error that is thrown (e.g. by `use`) to trigger Suspense. If we
|
|
// detect this is caught by userspace, we'll log a warning in development.
|
|
export const SuspenseException: mixed = new Error(
|
|
"Suspense Exception: This is not a real error! It's an implementation " +
|
|
'detail of `use` to interrupt the current render. You must either ' +
|
|
'rethrow it immediately, or move the `use` call outside of the ' +
|
|
'`try/catch` block. Capturing without rethrowing will lead to ' +
|
|
'unexpected behavior.\n\n' +
|
|
'To handle async errors, wrap your component in an error boundary, or ' +
|
|
"call the promise's `.catch` method and pass the result to `use`.",
|
|
);
|
|
|
|
export function createThenableState(): ThenableState {
|
|
// The ThenableState is created the first time a component suspends. If it
|
|
// suspends again, we'll reuse the same state.
|
|
return [];
|
|
}
|
|
|
|
export function trackUsedThenable<T>(
|
|
thenableState: ThenableState,
|
|
thenable: Thenable<T>,
|
|
index: number,
|
|
): T {
|
|
const previous = thenableState[index];
|
|
if (previous === undefined) {
|
|
thenableState.push(thenable);
|
|
} else {
|
|
if (previous !== thenable) {
|
|
// Reuse the previous thenable, and drop the new one. We can assume
|
|
// they represent the same value, because components are idempotent.
|
|
|
|
// Avoid an unhandled rejection errors for the Promises that we'll
|
|
// intentionally ignore.
|
|
thenable.then(noop, noop);
|
|
thenable = previous;
|
|
}
|
|
}
|
|
|
|
// We use an expando to track the status and result of a thenable so that we
|
|
// can synchronously unwrap the value. Think of this as an extension of the
|
|
// Promise API, or a custom interface that is a superset of Thenable.
|
|
//
|
|
// If the thenable doesn't have a status, set it to "pending" and attach
|
|
// a listener that will update its status and result when it resolves.
|
|
switch (thenable.status) {
|
|
case 'fulfilled': {
|
|
const fulfilledValue: T = thenable.value;
|
|
return fulfilledValue;
|
|
}
|
|
case 'rejected': {
|
|
const rejectedError = thenable.reason;
|
|
throw rejectedError;
|
|
}
|
|
default: {
|
|
if (typeof thenable.status === 'string') {
|
|
// Only instrument the thenable if the status if not defined. If
|
|
// it's defined, but an unknown value, assume it's been instrumented by
|
|
// some custom userspace implementation. We treat it as "pending".
|
|
// Attach a dummy listener, to ensure that any lazy initialization can
|
|
// happen. Flight lazily parses JSON when the value is actually awaited.
|
|
thenable.then(noop, noop);
|
|
} else {
|
|
const pendingThenable: PendingThenable<T> = (thenable: any);
|
|
pendingThenable.status = 'pending';
|
|
pendingThenable.then(
|
|
fulfilledValue => {
|
|
if (thenable.status === 'pending') {
|
|
const fulfilledThenable: FulfilledThenable<T> = (thenable: any);
|
|
fulfilledThenable.status = 'fulfilled';
|
|
fulfilledThenable.value = fulfilledValue;
|
|
}
|
|
},
|
|
(error: mixed) => {
|
|
if (thenable.status === 'pending') {
|
|
const rejectedThenable: RejectedThenable<T> = (thenable: any);
|
|
rejectedThenable.status = 'rejected';
|
|
rejectedThenable.reason = error;
|
|
}
|
|
},
|
|
);
|
|
}
|
|
|
|
// Check one more time in case the thenable resolved synchronously
|
|
switch ((thenable: Thenable<T>).status) {
|
|
case 'fulfilled': {
|
|
const fulfilledThenable: FulfilledThenable<T> = (thenable: any);
|
|
return fulfilledThenable.value;
|
|
}
|
|
case 'rejected': {
|
|
const rejectedThenable: RejectedThenable<T> = (thenable: any);
|
|
throw rejectedThenable.reason;
|
|
}
|
|
}
|
|
|
|
// Suspend.
|
|
//
|
|
// Throwing here is an implementation detail that allows us to unwind the
|
|
// call stack. But we shouldn't allow it to leak into userspace. Throw an
|
|
// opaque placeholder value instead of the actual thenable. If it doesn't
|
|
// get captured by the work loop, log a warning, because that means
|
|
// something in userspace must have caught it.
|
|
suspendedThenable = thenable;
|
|
if (__DEV__ && shouldCaptureSuspendedCallSite) {
|
|
captureSuspendedCallSite();
|
|
}
|
|
throw SuspenseException;
|
|
}
|
|
}
|
|
}
|
|
|
|
export function readPreviousThenable<T>(
|
|
thenableState: ThenableState,
|
|
index: number,
|
|
): void | T {
|
|
const previous = thenableState[index];
|
|
if (previous === undefined) {
|
|
return undefined;
|
|
} else {
|
|
// We assume this has been resolved already.
|
|
return (previous: any).value;
|
|
}
|
|
}
|
|
|
|
// This is used to track the actual thenable that suspended so it can be
|
|
// passed to the rest of the Suspense implementation — which, for historical
|
|
// reasons, expects to receive a thenable.
|
|
let suspendedThenable: Thenable<any> | null = null;
|
|
export function getSuspendedThenable(): Thenable<mixed> {
|
|
// This is called right after `use` suspends by throwing an exception. `use`
|
|
// throws an opaque value instead of the thenable itself so that it can't be
|
|
// caught in userspace. Then the work loop accesses the actual thenable using
|
|
// this function.
|
|
if (suspendedThenable === null) {
|
|
throw new Error(
|
|
'Expected a suspended thenable. This is a bug in React. Please file ' +
|
|
'an issue.',
|
|
);
|
|
}
|
|
const thenable = suspendedThenable;
|
|
suspendedThenable = null;
|
|
return thenable;
|
|
}
|
|
|
|
let shouldCaptureSuspendedCallSite: boolean = false;
|
|
export function setCaptureSuspendedCallSiteDEV(capture: boolean): void {
|
|
if (!__DEV__) {
|
|
// eslint-disable-next-line react-internal/prod-error-codes
|
|
throw new Error(
|
|
'setCaptureSuspendedCallSiteDEV was called in a production environment. ' +
|
|
'This is a bug in React.',
|
|
);
|
|
}
|
|
shouldCaptureSuspendedCallSite = capture;
|
|
}
|
|
|
|
// DEV-only
|
|
let suspendedCallSiteStack: ComponentStackNode | null = null;
|
|
let suspendedCallSiteDebugTask: ConsoleTask | null = null;
|
|
function captureSuspendedCallSite(): void {
|
|
// This is currently only used when aborting in Fizz.
|
|
// You can only abort the render in Fizz and Flight.
|
|
// In Fiber we only track suspended use via DevTools.
|
|
// In Flight, we track suspended use via async debug info.
|
|
const currentTask = currentTaskInDEV;
|
|
if (currentTask === null) {
|
|
// eslint-disable-next-line react-internal/prod-error-codes -- not a prod error
|
|
throw new Error(
|
|
'Expected to have a current task when tracking a suspend call site. ' +
|
|
'This is a bug in React.',
|
|
);
|
|
}
|
|
const currentComponentStack = currentTask.componentStack;
|
|
if (currentComponentStack === null) {
|
|
// eslint-disable-next-line react-internal/prod-error-codes -- not a prod error
|
|
throw new Error(
|
|
'Expected to have a component stack on the current task when ' +
|
|
'tracking a suspended call site. This is a bug in React.',
|
|
);
|
|
}
|
|
suspendedCallSiteStack = {
|
|
parent: currentComponentStack.parent,
|
|
type: currentComponentStack.type,
|
|
owner: currentComponentStack.owner,
|
|
stack: Error('react-stack-top-frame'),
|
|
};
|
|
// TODO: If this is used in error handlers, the ConsoleTask stack
|
|
// will just be this debugTask + the stack of the abort() call which usually means
|
|
// it's just this debugTask.
|
|
// Ideally we'd be able to reconstruct the owner ConsoleTask as well.
|
|
// The stack of the debugTask would not point to the suspend location anyway.
|
|
// The focus is really on callsite which should be used in captureOwnerStack().
|
|
suspendedCallSiteDebugTask = currentTask.debugTask;
|
|
}
|
|
export function getSuspendedCallSiteStackDEV(): ComponentStackNode | null {
|
|
if (__DEV__) {
|
|
if (suspendedCallSiteStack === null) {
|
|
return null;
|
|
}
|
|
const callSite = suspendedCallSiteStack;
|
|
suspendedCallSiteStack = null;
|
|
return callSite;
|
|
} else {
|
|
// eslint-disable-next-line react-internal/prod-error-codes
|
|
throw new Error(
|
|
'getSuspendedCallSiteDEV was called in a production environment. ' +
|
|
'This is a bug in React.',
|
|
);
|
|
}
|
|
}
|
|
|
|
export function getSuspendedCallSiteDebugTaskDEV(): ConsoleTask | null {
|
|
if (__DEV__) {
|
|
if (suspendedCallSiteDebugTask === null) {
|
|
return null;
|
|
}
|
|
const debugTask = suspendedCallSiteDebugTask;
|
|
suspendedCallSiteDebugTask = null;
|
|
return debugTask;
|
|
} else {
|
|
// eslint-disable-next-line react-internal/prod-error-codes
|
|
throw new Error(
|
|
'getSuspendedCallSiteDebugTaskDEV was called in a production environment. ' +
|
|
'This is a bug in React.',
|
|
);
|
|
}
|
|
}
|
|
|
|
export function ensureSuspendableThenableStateDEV(
|
|
thenableState: ThenableState,
|
|
): () => void {
|
|
if (__DEV__) {
|
|
const lastThenable = thenableState[thenableState.length - 1];
|
|
// Reset the last thenable back to pending.
|
|
switch (lastThenable.status) {
|
|
case 'fulfilled':
|
|
const previousThenableValue = lastThenable.value;
|
|
// $FlowIgnore[method-unbinding] We rebind .then immediately.
|
|
const previousThenableThen = lastThenable.then.bind(lastThenable);
|
|
delete lastThenable.value;
|
|
delete (lastThenable: any).status;
|
|
// We'll call .then again if we resuspend. Since we potentially corrupted
|
|
// the internal state of unknown classes, we need to diffuse the potential
|
|
// crash by replacing the .then method with a noop.
|
|
// $FlowFixMe[cannot-write] Custom userspace Thenables may not be but native Promises are.
|
|
lastThenable.then = noop;
|
|
return () => {
|
|
// $FlowFixMe[cannot-write] Custom userspace Thenables may not be but native Promises are.
|
|
lastThenable.then = previousThenableThen;
|
|
lastThenable.value = previousThenableValue;
|
|
lastThenable.status = 'fulfilled';
|
|
};
|
|
case 'rejected':
|
|
const previousThenableReason = lastThenable.reason;
|
|
delete lastThenable.reason;
|
|
delete (lastThenable: any).status;
|
|
return () => {
|
|
lastThenable.reason = previousThenableReason;
|
|
lastThenable.status = 'rejected';
|
|
};
|
|
}
|
|
return noop;
|
|
} else {
|
|
// eslint-disable-next-line react-internal/prod-error-codes
|
|
throw new Error(
|
|
'ensureSuspendableThenableStateDEV was called in a production environment. ' +
|
|
'This is a bug in React.',
|
|
);
|
|
}
|
|
}
|