| 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 | // Corresponds to ReactFiberWakeable and ReactFizzWakeable modules. Generally, |
| 11 | // changes to one module should be reflected in the others. |
| 12 | |
| 13 | // TODO: Rename this module and the corresponding Fiber one to "Thenable" |
| 14 | // instead of "Wakeable". Or some other more appropriate name. |
| 15 | |
| 16 | import type { |
| 17 | Thenable, |
| 18 | PendingThenable, |
| 19 | FulfilledThenable, |
| 20 | RejectedThenable, |
| 21 | } from 'shared/ReactTypes'; |
| 22 | |
| 23 | import {enableAsyncDebugInfo} from 'shared/ReactFeatureFlags'; |
| 24 | |
| 25 | import noop from 'shared/noop'; |
| 26 | |
| 27 | export type ThenableState = Array<Thenable<any>>; |
| 28 | |
| 29 | // An error that is thrown (e.g. by `use`) to trigger Suspense. If we |
| 30 | // detect this is caught by userspace, we'll log a warning in development. |
| 31 | export const SuspenseException: mixed = new Error( |
| 32 | "Suspense Exception: This is not a real error! It's an implementation " + |
| 33 | 'detail of `use` to interrupt the current render. You must either ' + |
| 34 | 'rethrow it immediately, or move the `use` call outside of the ' + |
| 35 | '`try/catch` block. Capturing without rethrowing will lead to ' + |
| 36 | 'unexpected behavior.\n\n' + |
| 37 | 'To handle async errors, wrap your component in an error boundary, or ' + |
| 38 | "call the promise's `.catch` method and pass the result to `use`.", |
| 39 | ); |
| 40 | |
| 41 | export function createThenableState(): ThenableState { |
| 42 | // The ThenableState is created the first time a component suspends. If it |
| 43 | // suspends again, we'll reuse the same state. |
| 44 | return []; |
| 45 | } |
| 46 | |
| 47 | export function trackUsedThenable<T>( |
| 48 | thenableState: ThenableState, |
| 49 | thenable: Thenable<T>, |
| 50 | index: number, |
| 51 | ): T { |
| 52 | const previous = thenableState[index]; |
| 53 | if (previous === undefined) { |
| 54 | thenableState.push(thenable); |
| 55 | if (__DEV__ && enableAsyncDebugInfo) { |
| 56 | const stacks: Array<Error> = |
| 57 | (thenableState as any)._stacks || ((thenableState as any)._stacks = []); |
| 58 | stacks.push(new Error()); |
| 59 | } |
| 60 | } else { |
| 61 | if (previous !== thenable) { |
| 62 | // Reuse the previous thenable, and drop the new one. We can assume |
| 63 | // they represent the same value, because components are idempotent. |
| 64 | |
| 65 | // Avoid an unhandled rejection errors for the Promises that we'll |
| 66 | // intentionally ignore. |
| 67 | thenable.then(noop, noop); |
| 68 | thenable = previous; |
| 69 | } |
| 70 | } |
| 71 | |
| 72 | // We use an expando to track the status and result of a thenable so that we |
| 73 | // can synchronously unwrap the value. Think of this as an extension of the |
| 74 | // Promise API, or a custom interface that is a superset of Thenable. |
| 75 | // |
| 76 | // If the thenable doesn't have a status, set it to "pending" and attach |
| 77 | // a listener that will update its status and result when it resolves. |
| 78 | switch (thenable.status) { |
| 79 | case 'fulfilled': { |
| 80 | // This could be a bad instrumentation that doesn't set .value. |
| 81 | // We're not type-checking since this is a hot path where you can |
| 82 | // track down easily when something becomes `undefined` unexpectedly. |
| 83 | const fulfilledValue: T = thenable.value; |
| 84 | return fulfilledValue; |
| 85 | } |
| 86 | case 'rejected': { |
| 87 | const rejectedError = thenable.reason; |
| 88 | |
| 89 | // Rejected Promises are rarer so we're doing an extra type-check in |
| 90 | // case of a bad instrumentation that doesn't set .reason |
| 91 | // If we end up throwing `undefined` it becomes hard to track down |
| 92 | // where that throw originated because no callstack would exist. |
| 93 | // React would still have a Component stack but that could only be used |
| 94 | // as an approximation. |
| 95 | if (rejectedError === undefined && !('reason' in thenable)) { |
| 96 | throw new Error( |
| 97 | 'A rejected Promise was passed to React without a `reason` property. ' + |
| 98 | 'React threw a generic error from where the Promise was used to assist in identifying the problematic Promise. ' + |
| 99 | "Make sure that instrumented Promises correctly set the `reason` property when setting `status` to `'rejected'`.", |
| 100 | ); |
| 101 | } |
| 102 | |
| 103 | throw rejectedError; |
| 104 | } |
| 105 | default: { |
| 106 | if (typeof thenable.status === 'string') { |
| 107 | // Only instrument the thenable if the status if not defined. If |
| 108 | // it's defined, but an unknown value, assume it's been instrumented by |
| 109 | // some custom userspace implementation. We treat it as "pending". |
| 110 | // Attach a dummy listener, to ensure that any lazy initialization can |
| 111 | // happen. Flight lazily parses JSON when the value is actually awaited. |
| 112 | thenable.then(noop, noop); |
| 113 | } else { |
| 114 | const pendingThenable: PendingThenable<T> = thenable as any; |
| 115 | pendingThenable.status = 'pending'; |
| 116 | pendingThenable.then( |
| 117 | fulfilledValue => { |
| 118 | if (thenable.status === 'pending') { |
| 119 | const fulfilledThenable: FulfilledThenable<T> = thenable as any; |
| 120 | fulfilledThenable.status = 'fulfilled'; |
| 121 | fulfilledThenable.value = fulfilledValue; |
| 122 | } |
| 123 | }, |
| 124 | (error: mixed) => { |
| 125 | if (thenable.status === 'pending') { |
| 126 | const rejectedThenable: RejectedThenable<T> = thenable as any; |
| 127 | rejectedThenable.status = 'rejected'; |
| 128 | rejectedThenable.reason = error; |
| 129 | } |
| 130 | }, |
| 131 | ); |
| 132 | } |
| 133 | |
| 134 | // Check one more time in case the thenable resolved synchronously |
| 135 | switch ((thenable as Thenable<T>).status) { |
| 136 | case 'fulfilled': { |
| 137 | const fulfilledThenable: FulfilledThenable<T> = thenable as any; |
| 138 | return fulfilledThenable.value; |
| 139 | } |
| 140 | case 'rejected': { |
| 141 | const rejectedThenable: RejectedThenable<T> = thenable as any; |
| 142 | throw rejectedThenable.reason; |
| 143 | } |
| 144 | } |
| 145 | |
| 146 | // Suspend. |
| 147 | // |
| 148 | // Throwing here is an implementation detail that allows us to unwind the |
| 149 | // call stack. But we shouldn't allow it to leak into userspace. Throw an |
| 150 | // opaque placeholder value instead of the actual thenable. If it doesn't |
| 151 | // get captured by the work loop, log a warning, because that means |
| 152 | // something in userspace must have caught it. |
| 153 | suspendedThenable = thenable; |
| 154 | throw SuspenseException; |
| 155 | } |
| 156 | } |
| 157 | } |
| 158 | |
| 159 | // This is used to track the actual thenable that suspended so it can be |
| 160 | // passed to the rest of the Suspense implementation — which, for historical |
| 161 | // reasons, expects to receive a thenable. |
| 162 | let suspendedThenable: Thenable<any> | null = null; |
| 163 | export function getSuspendedThenable(): Thenable<mixed> { |
| 164 | // This is called right after `use` suspends by throwing an exception. `use` |
| 165 | // throws an opaque value instead of the thenable itself so that it can't be |
| 166 | // caught in userspace. Then the work loop accesses the actual thenable using |
| 167 | // this function. |
| 168 | if (suspendedThenable === null) { |
| 169 | throw new Error( |
| 170 | 'Expected a suspended thenable. This is a bug in React. Please file ' + |
| 171 | 'an issue.', |
| 172 | ); |
| 173 | } |
| 174 | const thenable = suspendedThenable; |
| 175 | suspendedThenable = null; |
| 176 | return thenable; |
| 177 | } |