| 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 ReactFiberThenable and ReactFlightThenable modules. Generally, |
| 11 | // changes to one module should be reflected in the others. |
| 12 | |
| 13 | import type { |
| 14 | Thenable, |
| 15 | PendingThenable, |
| 16 | FulfilledThenable, |
| 17 | RejectedThenable, |
| 18 | } from 'shared/ReactTypes'; |
| 19 | import type {ComponentStackNode} from './ReactFizzComponentStack'; |
| 20 | |
| 21 | import noop from 'shared/noop'; |
| 22 | import {currentTaskInDEV} from './ReactFizzCurrentTask'; |
| 23 | |
| 24 | export opaque type ThenableState = Array<Thenable<any>>; |
| 25 | |
| 26 | // An error that is thrown (e.g. by `use`) to trigger Suspense. If we |
| 27 | // detect this is caught by userspace, we'll log a warning in development. |
| 28 | export const SuspenseException: mixed = new Error( |
| 29 | "Suspense Exception: This is not a real error! It's an implementation " + |
| 30 | 'detail of `use` to interrupt the current render. You must either ' + |
| 31 | 'rethrow it immediately, or move the `use` call outside of the ' + |
| 32 | '`try/catch` block. Capturing without rethrowing will lead to ' + |
| 33 | 'unexpected behavior.\n\n' + |
| 34 | 'To handle async errors, wrap your component in an error boundary, or ' + |
| 35 | "call the promise's `.catch` method and pass the result to `use`.", |
| 36 | ); |
| 37 | |
| 38 | export function createThenableState(): ThenableState { |
| 39 | // The ThenableState is created the first time a component suspends. If it |
| 40 | // suspends again, we'll reuse the same state. |
| 41 | return []; |
| 42 | } |
| 43 | |
| 44 | export function trackUsedThenable<T>( |
| 45 | thenableState: ThenableState, |
| 46 | thenable: Thenable<T>, |
| 47 | index: number, |
| 48 | ): T { |
| 49 | const previous = thenableState[index]; |
| 50 | if (previous === undefined) { |
| 51 | thenableState.push(thenable); |
| 52 | } else { |
| 53 | if (previous !== thenable) { |
| 54 | // Reuse the previous thenable, and drop the new one. We can assume |
| 55 | // they represent the same value, because components are idempotent. |
| 56 | |
| 57 | // Avoid an unhandled rejection errors for the Promises that we'll |
| 58 | // intentionally ignore. |
| 59 | thenable.then(noop, noop); |
| 60 | thenable = previous; |
| 61 | } |
| 62 | } |
| 63 | |
| 64 | // We use an expando to track the status and result of a thenable so that we |
| 65 | // can synchronously unwrap the value. Think of this as an extension of the |
| 66 | // Promise API, or a custom interface that is a superset of Thenable. |
| 67 | // |
| 68 | // If the thenable doesn't have a status, set it to "pending" and attach |
| 69 | // a listener that will update its status and result when it resolves. |
| 70 | switch (thenable.status) { |
| 71 | case 'fulfilled': { |
| 72 | // This could be a bad instrumentation that doesn't set .value. |
| 73 | // We're not type-checking since this is a hot path where you can |
| 74 | // track down easily when something becomes `undefined` unexpectedly. |
| 75 | const fulfilledValue: T = thenable.value; |
| 76 | return fulfilledValue; |
| 77 | } |
| 78 | case 'rejected': { |
| 79 | const rejectedError = thenable.reason; |
| 80 | |
| 81 | // Rejected Promises are rarer so we're doing an extra type-check in |
| 82 | // case of a bad instrumentation that doesn't set .reason |
| 83 | // If we end up throwing `undefined` it becomes hard to track down |
| 84 | // where that throw originated because no callstack would exist. |
| 85 | // React would still have a Component stack but that could only be used |
| 86 | // as an approximation. |
| 87 | if (rejectedError === undefined && !('reason' in thenable)) { |
| 88 | throw new Error( |
| 89 | 'A rejected Promise was passed to React without a `reason` property. ' + |
| 90 | 'React threw a generic error from where the Promise was used to assist in identifying the problematic Promise. ' + |
| 91 | "Make sure that instrumented Promises correctly set the `reason` property when setting `status` to `'rejected'`.", |
| 92 | ); |
| 93 | } |
| 94 | |
| 95 | throw rejectedError; |
| 96 | } |
| 97 | default: { |
| 98 | if (typeof thenable.status === 'string') { |
| 99 | // Only instrument the thenable if the status if not defined. If |
| 100 | // it's defined, but an unknown value, assume it's been instrumented by |
| 101 | // some custom userspace implementation. We treat it as "pending". |
| 102 | // Attach a dummy listener, to ensure that any lazy initialization can |
| 103 | // happen. Flight lazily parses JSON when the value is actually awaited. |
| 104 | thenable.then(noop, noop); |
| 105 | } else { |
| 106 | const pendingThenable: PendingThenable<T> = thenable as any; |
| 107 | pendingThenable.status = 'pending'; |
| 108 | pendingThenable.then( |
| 109 | fulfilledValue => { |
| 110 | if (thenable.status === 'pending') { |
| 111 | const fulfilledThenable: FulfilledThenable<T> = thenable as any; |
| 112 | fulfilledThenable.status = 'fulfilled'; |
| 113 | fulfilledThenable.value = fulfilledValue; |
| 114 | } |
| 115 | }, |
| 116 | (error: mixed) => { |
| 117 | if (thenable.status === 'pending') { |
| 118 | const rejectedThenable: RejectedThenable<T> = thenable as any; |
| 119 | rejectedThenable.status = 'rejected'; |
| 120 | rejectedThenable.reason = error; |
| 121 | } |
| 122 | }, |
| 123 | ); |
| 124 | } |
| 125 | |
| 126 | // Check one more time in case the thenable resolved synchronously |
| 127 | switch ((thenable as Thenable<T>).status) { |
| 128 | case 'fulfilled': { |
| 129 | const fulfilledThenable: FulfilledThenable<T> = thenable as any; |
| 130 | return fulfilledThenable.value; |
| 131 | } |
| 132 | case 'rejected': { |
| 133 | const rejectedThenable: RejectedThenable<T> = thenable as any; |
| 134 | throw rejectedThenable.reason; |
| 135 | } |
| 136 | } |
| 137 | |
| 138 | // Suspend. |
| 139 | // |
| 140 | // Throwing here is an implementation detail that allows us to unwind the |
| 141 | // call stack. But we shouldn't allow it to leak into userspace. Throw an |
| 142 | // opaque placeholder value instead of the actual thenable. If it doesn't |
| 143 | // get captured by the work loop, log a warning, because that means |
| 144 | // something in userspace must have caught it. |
| 145 | suspendedThenable = thenable; |
| 146 | if (__DEV__ && shouldCaptureSuspendedCallSite) { |
| 147 | captureSuspendedCallSite(); |
| 148 | } |
| 149 | throw SuspenseException; |
| 150 | } |
| 151 | } |
| 152 | } |
| 153 | |
| 154 | export function readPreviousThenable<T>( |
| 155 | thenableState: ThenableState, |
| 156 | index: number, |
| 157 | ): void | T { |
| 158 | const previous = thenableState[index]; |
| 159 | if (previous === undefined) { |
| 160 | return undefined; |
| 161 | } else { |
| 162 | // We assume this has been resolved already. |
| 163 | return (previous as any).value; |
| 164 | } |
| 165 | } |
| 166 | |
| 167 | // This is used to track the actual thenable that suspended so it can be |
| 168 | // passed to the rest of the Suspense implementation — which, for historical |
| 169 | // reasons, expects to receive a thenable. |
| 170 | let suspendedThenable: Thenable<any> | null = null; |
| 171 | export function getSuspendedThenable(): Thenable<mixed> { |
| 172 | // This is called right after `use` suspends by throwing an exception. `use` |
| 173 | // throws an opaque value instead of the thenable itself so that it can't be |
| 174 | // caught in userspace. Then the work loop accesses the actual thenable using |
| 175 | // this function. |
| 176 | if (suspendedThenable === null) { |
| 177 | throw new Error( |
| 178 | 'Expected a suspended thenable. This is a bug in React. Please file ' + |
| 179 | 'an issue.', |
| 180 | ); |
| 181 | } |
| 182 | const thenable = suspendedThenable; |
| 183 | suspendedThenable = null; |
| 184 | return thenable; |
| 185 | } |
| 186 | |
| 187 | let shouldCaptureSuspendedCallSite: boolean = false; |
| 188 | export function setCaptureSuspendedCallSiteDEV(capture: boolean): void { |
| 189 | if (!__DEV__) { |
| 190 | // eslint-disable-next-line react-internal/prod-error-codes |
| 191 | throw new Error( |
| 192 | 'setCaptureSuspendedCallSiteDEV was called in a production environment. ' + |
| 193 | 'This is a bug in React.', |
| 194 | ); |
| 195 | } |
| 196 | shouldCaptureSuspendedCallSite = capture; |
| 197 | } |
| 198 | |
| 199 | // DEV-only |
| 200 | let suspendedCallSiteStack: ComponentStackNode | null = null; |
| 201 | let suspendedCallSiteDebugTask: ConsoleTask | null = null; |
| 202 | function captureSuspendedCallSite(): void { |
| 203 | // This is currently only used when aborting in Fizz. |
| 204 | // You can only abort the render in Fizz and Flight. |
| 205 | // In Fiber we only track suspended use via DevTools. |
| 206 | // In Flight, we track suspended use via async debug info. |
| 207 | const currentTask = currentTaskInDEV; |
| 208 | if (currentTask === null) { |
| 209 | // eslint-disable-next-line react-internal/prod-error-codes -- not a prod error |
| 210 | throw new Error( |
| 211 | 'Expected to have a current task when tracking a suspend call site. ' + |
| 212 | 'This is a bug in React.', |
| 213 | ); |
| 214 | } |
| 215 | const currentComponentStack = currentTask.componentStack; |
| 216 | if (currentComponentStack === null) { |
| 217 | // eslint-disable-next-line react-internal/prod-error-codes -- not a prod error |
| 218 | throw new Error( |
| 219 | 'Expected to have a component stack on the current task when ' + |
| 220 | 'tracking a suspended call site. This is a bug in React.', |
| 221 | ); |
| 222 | } |
| 223 | suspendedCallSiteStack = { |
| 224 | parent: currentComponentStack.parent, |
| 225 | type: currentComponentStack.type, |
| 226 | owner: currentComponentStack.owner, |
| 227 | stack: Error('react-stack-top-frame'), |
| 228 | }; |
| 229 | // TODO: If this is used in error handlers, the ConsoleTask stack |
| 230 | // will just be this debugTask + the stack of the abort() call which usually means |
| 231 | // it's just this debugTask. |
| 232 | // Ideally we'd be able to reconstruct the owner ConsoleTask as well. |
| 233 | // The stack of the debugTask would not point to the suspend location anyway. |
| 234 | // The focus is really on callsite which should be used in captureOwnerStack(). |
| 235 | suspendedCallSiteDebugTask = currentTask.debugTask; |
| 236 | } |
| 237 | export function getSuspendedCallSiteStackDEV(): ComponentStackNode | null { |
| 238 | if (__DEV__) { |
| 239 | if (suspendedCallSiteStack === null) { |
| 240 | return null; |
| 241 | } |
| 242 | const callSite = suspendedCallSiteStack; |
| 243 | suspendedCallSiteStack = null; |
| 244 | return callSite; |
| 245 | } else { |
| 246 | // eslint-disable-next-line react-internal/prod-error-codes |
| 247 | throw new Error( |
| 248 | 'getSuspendedCallSiteDEV was called in a production environment. ' + |
| 249 | 'This is a bug in React.', |
| 250 | ); |
| 251 | } |
| 252 | } |
| 253 | |
| 254 | export function getSuspendedCallSiteDebugTaskDEV(): ConsoleTask | null { |
| 255 | if (__DEV__) { |
| 256 | if (suspendedCallSiteDebugTask === null) { |
| 257 | return null; |
| 258 | } |
| 259 | const debugTask = suspendedCallSiteDebugTask; |
| 260 | suspendedCallSiteDebugTask = null; |
| 261 | return debugTask; |
| 262 | } else { |
| 263 | // eslint-disable-next-line react-internal/prod-error-codes |
| 264 | throw new Error( |
| 265 | 'getSuspendedCallSiteDebugTaskDEV was called in a production environment. ' + |
| 266 | 'This is a bug in React.', |
| 267 | ); |
| 268 | } |
| 269 | } |
| 270 | |
| 271 | export function ensureSuspendableThenableStateDEV( |
| 272 | thenableState: ThenableState, |
| 273 | ): () => void { |
| 274 | if (__DEV__) { |
| 275 | const lastThenable = thenableState[thenableState.length - 1]; |
| 276 | // Reset the last thenable back to pending. |
| 277 | switch (lastThenable.status) { |
| 278 | case 'fulfilled': { |
| 279 | const previousThenableValue = lastThenable.value; |
| 280 | // $FlowFixMe[method-unbinding] We rebind .then immediately. |
| 281 | const previousThenableThen = lastThenable.then.bind(lastThenable); |
| 282 | delete lastThenable.value; |
| 283 | delete (lastThenable as any).status; |
| 284 | // We'll call .then again if we resuspend. Since we potentially corrupted |
| 285 | // the internal state of unknown classes, we need to diffuse the potential |
| 286 | // crash by replacing the .then method with a noop. |
| 287 | // $FlowFixMe[cannot-write] Custom userspace Thenables may not be but native Promises are. |
| 288 | lastThenable.then = noop; |
| 289 | return () => { |
| 290 | // $FlowFixMe[cannot-write] Custom userspace Thenables may not be but native Promises are. |
| 291 | lastThenable.then = previousThenableThen; |
| 292 | lastThenable.value = previousThenableValue; |
| 293 | lastThenable.status = 'fulfilled'; |
| 294 | }; |
| 295 | } |
| 296 | case 'rejected': { |
| 297 | const previousThenableReason = lastThenable.reason; |
| 298 | // $FlowFixMe[method-unbinding] We rebind .then immediately. |
| 299 | const previousThenableThen = lastThenable.then.bind(lastThenable); |
| 300 | delete lastThenable.reason; |
| 301 | delete (lastThenable as any).status; |
| 302 | // We'll call .then again if we resuspend. Since we potentially corrupted |
| 303 | // the internal state of unknown classes, we need to diffuse the potential |
| 304 | // crash by replacing the .then method with a noop. |
| 305 | // $FlowFixMe[cannot-write] Custom userspace Thenables may not be but native Promises are. |
| 306 | lastThenable.then = noop; |
| 307 | return () => { |
| 308 | // $FlowFixMe[cannot-write] Custom userspace Thenables may not be but native Promises are. |
| 309 | lastThenable.then = previousThenableThen; |
| 310 | lastThenable.reason = previousThenableReason; |
| 311 | lastThenable.status = 'rejected'; |
| 312 | }; |
| 313 | } |
| 314 | } |
| 315 | return noop; |
| 316 | } else { |
| 317 | // eslint-disable-next-line react-internal/prod-error-codes |
| 318 | throw new Error( |
| 319 | 'ensureSuspendableThenableStateDEV was called in a production environment. ' + |
| 320 | 'This is a bug in React.', |
| 321 | ); |
| 322 | } |
| 323 | } |