Files
react/packages/react-reconciler/src/ReactFiberThenable.js
T
Sebastian Markbåge 9e7944f67c Suspend Thenable/Lazy if it's used in React.Children and unwrap (#28284)
This pains me because `React.Children` is really already
pseudo-deprecated.

`React.Children` takes any children that `React.Node` takes. We now
support Lazy and Thenable in this position elsewhere, but it errors in
`React.Children`.

This becomes an issue with async Server Components which can resolve
into a Lazy and in the future Lazy will just become Thenables. Which
causes this to error.

There are a few different semantics we could have:

1) Error like we already do (#28280). `React.Children` is about
introspecting children. It was always sketchy because you can't
introspect inside an abstraction anyway. With Server Components we fold
away the components so you can actually introspect inside of them kind
of but what they do is an implementation detail and you should be able
to turn it into a Client Component at any point. The type of an Element
passing the boundary actually reduces to `React.Node`.
2) Suspend and unwrap the Node (this PR). If we assume that Children is
called inside of render, then throwing a Promise if it's not already
loaded or unwrapping would treat it as if it wasn't there. Just like if
you rendered it in React. This lets you introspect what's inside which
isn't really something you should be able to do. This isn't compatible
with deprecating throwing-a-Promise and enable static compilation like
`use()` does. We'd have to deprecate `React.Children` before doing that
which we might anyway.
3) Wrap in a Fragment. If a Server Component was instead a Client
Component, you couldn't introspect through it anyway. Another
alternative might be to let it pass through but then it wouldn't be
given a flat key. We could also wrap it in a Fragment that is keyed.
That way you're always seeing an element. The issue with this solution
is that it wouldn't see the key of the Server Component since that gets
forwarded to the child that is yet to resolve. The nice thing about that
strategy is it doesn't depend on throw-a-Promise but it might not be
keyed correctly when things move.
2024-02-12 13:39:30 -05:00

308 lines
12 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
*/
import type {
Thenable,
PendingThenable,
FulfilledThenable,
RejectedThenable,
} from 'shared/ReactTypes';
import {getWorkInProgressRoot} from './ReactFiberWorkLoop';
import ReactSharedInternals from 'shared/ReactSharedInternals';
const {ReactCurrentActQueue} = ReactSharedInternals;
opaque type ThenableStateDev = {
didWarnAboutUncachedPromise: boolean,
thenables: Array<Thenable<any>>,
};
opaque type ThenableStateProd = Array<Thenable<any>>;
export opaque type ThenableState = ThenableStateDev | ThenableStateProd;
function getThenablesFromState(state: ThenableState): Array<Thenable<any>> {
if (__DEV__) {
const devState: ThenableStateDev = (state: any);
return devState.thenables;
} else {
const prodState = (state: any);
return prodState;
}
}
// 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 const SuspenseyCommitException: mixed = new Error(
'Suspense Exception: This is not a real error, and should not leak into ' +
"userspace. If you're seeing this, it's likely a bug in React.",
);
// This is a noop thenable that we use to trigger a fallback in throwException.
// TODO: It would be better to refactor throwException into multiple functions
// so we can trigger a fallback directly without having to check the type. But
// for now this will do.
export const noopSuspenseyCommitThenable = {
then() {
if (__DEV__) {
console.error(
'Internal React error: A listener was unexpectedly attached to a ' +
'"noop" thenable. This is a bug in React. Please file an issue.',
);
}
},
};
export function createThenableState(): ThenableState {
// The ThenableState is created the first time a component suspends. If it
// suspends again, we'll reuse the same state.
if (__DEV__) {
return {
didWarnAboutUncachedPromise: false,
thenables: [],
};
} else {
return [];
}
}
export function isThenableResolved(thenable: Thenable<mixed>): boolean {
const status = thenable.status;
return status === 'fulfilled' || status === 'rejected';
}
function noop(): void {}
export function trackUsedThenable<T>(
thenableState: ThenableState,
thenable: Thenable<T>,
index: number,
): T {
if (__DEV__ && ReactCurrentActQueue.current !== null) {
ReactCurrentActQueue.didUsePromise = true;
}
const trackedThenables = getThenablesFromState(thenableState);
const previous = trackedThenables[index];
if (previous === undefined) {
trackedThenables.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.
if (__DEV__) {
const thenableStateDev: ThenableStateDev = (thenableState: any);
if (!thenableStateDev.didWarnAboutUncachedPromise) {
// We should only warn the first time an uncached thenable is
// discovered per component, because if there are multiple, the
// subsequent ones are likely derived from the first.
//
// We track this on the thenableState instead of deduping using the
// component name like we usually do, because in the case of a
// promise-as-React-node, the owner component is likely different from
// the parent that's currently being reconciled. We'd have to track
// the owner using state, which we're trying to move away from. Though
// since this is dev-only, maybe that'd be OK.
//
// However, another benefit of doing it this way is we might
// eventually have a thenableState per memo/Forget boundary instead
// of per component, so this would allow us to have more
// granular warnings.
thenableStateDev.didWarnAboutUncachedPromise = true;
// TODO: This warning should link to a corresponding docs page.
console.error(
'A component was suspended by an uncached promise. Creating ' +
'promises inside a Client Component or hook is not yet ' +
'supported, except via a Suspense-compatible library or framework.',
);
}
}
// 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;
checkIfUseWrappedInAsyncCatch(rejectedError);
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 {
// This is an uncached thenable that we haven't seen before.
// Detect infinite ping loops caused by uncached promises.
const root = getWorkInProgressRoot();
if (root !== null && root.shellSuspendCounter > 100) {
// This root has suspended repeatedly in the shell without making any
// progress (i.e. committing something). This is highly suggestive of
// an infinite ping loop, often caused by an accidental Async Client
// Component.
//
// During a transition, we can suspend the work loop until the promise
// to resolve, but this is a sync render, so that's not an option. We
// also can't show a fallback, because none was provided. So our last
// resort is to throw an error.
//
// TODO: Remove this error in a future release. Other ways of handling
// this case include forcing a concurrent render, or putting the whole
// root into offscreen mode.
throw new Error(
'async/await is not yet supported in Client Components, only ' +
'Server Components. This error is often caused by accidentally ' +
"adding `'use client'` to a module that was originally written " +
'for the server.',
);
}
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.status) {
case 'fulfilled': {
const fulfilledThenable: FulfilledThenable<T> = (thenable: any);
return fulfilledThenable.value;
}
case 'rejected': {
const rejectedThenable: RejectedThenable<T> = (thenable: any);
const rejectedError = rejectedThenable.reason;
checkIfUseWrappedInAsyncCatch(rejectedError);
throw rejectedError;
}
}
// 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__) {
needsToResetSuspendedThenableDEV = true;
}
throw SuspenseException;
}
}
}
export function suspendCommit(): void {
// This extra indirection only exists so it can handle passing
// noopSuspenseyCommitThenable through to throwException.
// TODO: Factor the thenable check out of throwException
suspendedThenable = noopSuspenseyCommitThenable;
throw SuspenseyCommitException;
}
// 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;
let needsToResetSuspendedThenableDEV = false;
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;
if (__DEV__) {
needsToResetSuspendedThenableDEV = false;
}
return thenable;
}
export function checkIfUseWrappedInTryCatch(): boolean {
if (__DEV__) {
// This was set right before SuspenseException was thrown, and it should
// have been cleared when the exception was handled. If it wasn't,
// it must have been caught by userspace.
if (needsToResetSuspendedThenableDEV) {
needsToResetSuspendedThenableDEV = false;
return true;
}
}
return false;
}
export function checkIfUseWrappedInAsyncCatch(rejectedReason: any) {
// This check runs in prod, too, because it prevents a more confusing
// downstream error, where SuspenseException is caught by a promise and
// thrown asynchronously.
// TODO: Another way to prevent SuspenseException from leaking into an async
// execution context is to check the dispatcher every time `use` is called,
// or some equivalent. That might be preferable for other reasons, too, since
// it matches how we prevent similar mistakes for other hooks.
if (rejectedReason === SuspenseException) {
throw new Error(
'Hooks are not supported inside an async component. This ' +
"error is often caused by accidentally adding `'use client'` " +
'to a module that was originally written for the server.',
);
}
}