| 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 | import type { |
| 11 | Thenable, |
| 12 | PendingThenable, |
| 13 | FulfilledThenable, |
| 14 | RejectedThenable, |
| 15 | ReactIOInfo, |
| 16 | } from 'shared/ReactTypes'; |
| 17 | |
| 18 | import type {LazyComponent as LazyComponentType} from 'react/src/ReactLazy'; |
| 19 | import type {Fiber} from './ReactInternalTypes'; |
| 20 | |
| 21 | import {callLazyInitInDEV} from './ReactFiberCallUserSpace'; |
| 22 | |
| 23 | import {getWorkInProgressRoot} from './ReactFiberWorkLoop'; |
| 24 | |
| 25 | import ReactSharedInternals from 'shared/ReactSharedInternals'; |
| 26 | |
| 27 | import { |
| 28 | enableAsyncDebugInfo, |
| 29 | enableConditionalUseWarning, |
| 30 | } from 'shared/ReactFeatureFlags'; |
| 31 | |
| 32 | import noop from 'shared/noop'; |
| 33 | |
| 34 | import {HostRoot} from './ReactWorkTags'; |
| 35 | |
| 36 | opaque type ThenableStateDev = { |
| 37 | didWarnAboutUncachedPromise: boolean, |
| 38 | thenables: Array<Thenable<any>>, |
| 39 | }; |
| 40 | |
| 41 | opaque type ThenableStateProd = Array<Thenable<any>>; |
| 42 | |
| 43 | export opaque type ThenableState = ThenableStateDev | ThenableStateProd; |
| 44 | |
| 45 | function getThenablesFromState(state: ThenableState): Array<Thenable<any>> { |
| 46 | if (__DEV__) { |
| 47 | const devState: ThenableStateDev = state as any; |
| 48 | return devState.thenables; |
| 49 | } else { |
| 50 | const prodState = state as any; |
| 51 | return prodState; |
| 52 | } |
| 53 | } |
| 54 | |
| 55 | // An error that is thrown (e.g. by `use`) to trigger Suspense. If we |
| 56 | // detect this is caught by userspace, we'll log a warning in development. |
| 57 | export const SuspenseException: mixed = new Error( |
| 58 | "Suspense Exception: This is not a real error! It's an implementation " + |
| 59 | 'detail of `use` to interrupt the current render. You must either ' + |
| 60 | 'rethrow it immediately, or move the `use` call outside of the ' + |
| 61 | '`try/catch` block. Capturing without rethrowing will lead to ' + |
| 62 | 'unexpected behavior.\n\n' + |
| 63 | 'To handle async errors, wrap your component in an error boundary, or ' + |
| 64 | "call the promise's `.catch` method and pass the result to `use`.", |
| 65 | ); |
| 66 | |
| 67 | export const SuspenseyCommitException: mixed = new Error( |
| 68 | 'Suspense Exception: This is not a real error, and should not leak into ' + |
| 69 | "userspace. If you're seeing this, it's likely a bug in React.", |
| 70 | ); |
| 71 | |
| 72 | export const SuspenseActionException: mixed = new Error( |
| 73 | "Suspense Exception: This is not a real error! It's an implementation " + |
| 74 | 'detail of `useActionState` to interrupt the current render. You must either ' + |
| 75 | 'rethrow it immediately, or move the `useActionState` call outside of the ' + |
| 76 | '`try/catch` block. Capturing without rethrowing will lead to ' + |
| 77 | 'unexpected behavior.\n\n' + |
| 78 | 'To handle async errors, wrap your component in an error boundary.', |
| 79 | ); |
| 80 | // This is a noop thenable that we use to trigger a fallback in throwException. |
| 81 | // TODO: It would be better to refactor throwException into multiple functions |
| 82 | // so we can trigger a fallback directly without having to check the type. But |
| 83 | // for now this will do. |
| 84 | export const noopSuspenseyCommitThenable = { |
| 85 | then() { |
| 86 | if (__DEV__) { |
| 87 | console.error( |
| 88 | 'Internal React error: A listener was unexpectedly attached to a ' + |
| 89 | '"noop" thenable. This is a bug in React. Please file an issue.', |
| 90 | ); |
| 91 | } |
| 92 | }, |
| 93 | }; |
| 94 | |
| 95 | export function createThenableState(): ThenableState { |
| 96 | // The ThenableState is created the first time a component suspends. If it |
| 97 | // suspends again, we'll reuse the same state. |
| 98 | if (__DEV__) { |
| 99 | return { |
| 100 | didWarnAboutUncachedPromise: false, |
| 101 | thenables: [], |
| 102 | }; |
| 103 | } else { |
| 104 | return []; |
| 105 | } |
| 106 | } |
| 107 | |
| 108 | export function isThenableResolved(thenable: Thenable<mixed>): boolean { |
| 109 | const status = thenable.status; |
| 110 | return status === 'fulfilled' || status === 'rejected'; |
| 111 | } |
| 112 | |
| 113 | // DEV-only |
| 114 | let lastSuspendedFiber: null | Fiber = null; |
| 115 | let lastSuspendedStack: null | Error = null; |
| 116 | let didIssueUseWarning = false; |
| 117 | |
| 118 | export function hasPotentialUseWarnings(): boolean { |
| 119 | return enableConditionalUseWarning && lastSuspendedFiber !== null; |
| 120 | } |
| 121 | export function clearUseWarnings() { |
| 122 | lastSuspendedFiber = null; |
| 123 | } |
| 124 | |
| 125 | export function trackUsedThenable<T>( |
| 126 | thenableState: ThenableState, |
| 127 | thenable: Thenable<T>, |
| 128 | index: number, |
| 129 | fiber: null | Fiber, // DEV-only |
| 130 | ): T { |
| 131 | if (__DEV__ && ReactSharedInternals.actQueue !== null) { |
| 132 | ReactSharedInternals.didUsePromise = true; |
| 133 | } |
| 134 | const trackedThenables = getThenablesFromState(thenableState); |
| 135 | const previous = trackedThenables[index]; |
| 136 | if (previous === undefined) { |
| 137 | trackedThenables.push(thenable); |
| 138 | } else { |
| 139 | if (previous !== thenable) { |
| 140 | // Reuse the previous thenable, and drop the new one. We can assume |
| 141 | // they represent the same value, because components are idempotent. |
| 142 | |
| 143 | if (__DEV__) { |
| 144 | const thenableStateDev: ThenableStateDev = thenableState as any; |
| 145 | if (!thenableStateDev.didWarnAboutUncachedPromise) { |
| 146 | // We should only warn the first time an uncached thenable is |
| 147 | // discovered per component, because if there are multiple, the |
| 148 | // subsequent ones are likely derived from the first. |
| 149 | // |
| 150 | // We track this on the thenableState instead of deduping using the |
| 151 | // component name like we usually do, because in the case of a |
| 152 | // promise-as-React-node, the owner component is likely different from |
| 153 | // the parent that's currently being reconciled. We'd have to track |
| 154 | // the owner using state, which we're trying to move away from. Though |
| 155 | // since this is dev-only, maybe that'd be OK. |
| 156 | // |
| 157 | // However, another benefit of doing it this way is we might |
| 158 | // eventually have a thenableState per memo/Forget boundary instead |
| 159 | // of per component, so this would allow us to have more |
| 160 | // granular warnings. |
| 161 | thenableStateDev.didWarnAboutUncachedPromise = true; |
| 162 | |
| 163 | // TODO: This warning should link to a corresponding docs page. |
| 164 | console.error( |
| 165 | 'A component was suspended by an uncached promise. Creating ' + |
| 166 | 'promises inside a Client Component or hook is not yet ' + |
| 167 | 'supported, except via a Suspense-compatible library or framework.', |
| 168 | ); |
| 169 | } |
| 170 | } |
| 171 | |
| 172 | // Avoid an unhandled rejection errors for the Promises that we'll |
| 173 | // intentionally ignore. |
| 174 | thenable.then(noop, noop); |
| 175 | thenable = previous; |
| 176 | } |
| 177 | } |
| 178 | |
| 179 | if (__DEV__ && enableAsyncDebugInfo && thenable._debugInfo === undefined) { |
| 180 | // In DEV mode if the thenable that we observed had no debug info, then we add |
| 181 | // an inferred debug info so that we're able to track its potential I/O uniquely. |
| 182 | // We don't know the real start time since the I/O could have started much |
| 183 | // earlier and this could even be a cached Promise. Could be misleading. |
| 184 | const startTime = performance.now(); |
| 185 | const displayName = thenable.displayName; |
| 186 | const ioInfo: ReactIOInfo = { |
| 187 | name: typeof displayName === 'string' ? displayName : 'Promise', |
| 188 | start: startTime, |
| 189 | end: startTime, |
| 190 | value: thenable as any, |
| 191 | // We don't know the requesting owner nor stack. |
| 192 | }; |
| 193 | // We can infer the await owner/stack lazily from where this promise ends up |
| 194 | // used. It can be used in more than one place so we can't assign it here. |
| 195 | thenable._debugInfo = [{awaited: ioInfo}]; |
| 196 | // Track when we resolved the Promise as the approximate end time. |
| 197 | if (thenable.status !== 'fulfilled' && thenable.status !== 'rejected') { |
| 198 | const trackEndTime = () => { |
| 199 | // $FlowFixMe[cannot-write] |
| 200 | ioInfo.end = performance.now(); |
| 201 | }; |
| 202 | thenable.then(trackEndTime, trackEndTime); |
| 203 | } |
| 204 | } |
| 205 | |
| 206 | // We use an expando to track the status and result of a thenable so that we |
| 207 | // can synchronously unwrap the value. Think of this as an extension of the |
| 208 | // Promise API, or a custom interface that is a superset of Thenable. |
| 209 | // |
| 210 | // If the thenable doesn't have a status, set it to "pending" and attach |
| 211 | // a listener that will update its status and result when it resolves. |
| 212 | switch (thenable.status) { |
| 213 | case 'fulfilled': { |
| 214 | // This could be a bad instrumentation that doesn't set .value. |
| 215 | // We're not type-checking since this is a hot path where you can |
| 216 | // track down easily when something becomes `undefined` unexpectedly. |
| 217 | const fulfilledValue: T = thenable.value; |
| 218 | return fulfilledValue; |
| 219 | } |
| 220 | case 'rejected': { |
| 221 | const rejectedError = thenable.reason; |
| 222 | checkIfUseWrappedInAsyncCatch(rejectedError); |
| 223 | |
| 224 | // Rejected Promises are rarer so we're doing an extra type-check in |
| 225 | // case of a bad instrumentation that doesn't set .reason |
| 226 | // If we end up throwing `undefined` it becomes hard to track down |
| 227 | // where that throw originated because no callstack would exist. |
| 228 | // React would still have a Component stack but that could only be used |
| 229 | // as an approximation. |
| 230 | if (rejectedError === undefined && !('reason' in thenable)) { |
| 231 | throw new Error( |
| 232 | 'A rejected Promise was passed to React without a `reason` property. ' + |
| 233 | 'React threw a generic error from where the Promise was used to assist in identifying the problematic Promise. ' + |
| 234 | "Make sure that instrumented Promises correctly set the `reason` property when setting `status` to `'rejected'`.", |
| 235 | ); |
| 236 | } |
| 237 | |
| 238 | throw rejectedError; |
| 239 | } |
| 240 | default: { |
| 241 | if (typeof thenable.status === 'string') { |
| 242 | // Only instrument the thenable if the status if not defined. If |
| 243 | // it's defined, but an unknown value, assume it's been instrumented by |
| 244 | // some custom userspace implementation. We treat it as "pending". |
| 245 | // Attach a dummy listener, to ensure that any lazy initialization can |
| 246 | // happen. Flight lazily parses JSON when the value is actually awaited. |
| 247 | thenable.then(noop, noop); |
| 248 | } else { |
| 249 | // This is an uncached thenable that we haven't seen before. |
| 250 | |
| 251 | // Detect infinite ping loops caused by uncached promises. |
| 252 | const root = getWorkInProgressRoot(); |
| 253 | if (root !== null && root.shellSuspendCounter > 100) { |
| 254 | // This root has suspended repeatedly in the shell without making any |
| 255 | // progress (i.e. committing something). This is highly suggestive of |
| 256 | // an infinite ping loop, often caused by an accidental Async Client |
| 257 | // Component. |
| 258 | // |
| 259 | // During a transition, we can suspend the work loop until the promise |
| 260 | // to resolve, but this is a sync render, so that's not an option. We |
| 261 | // also can't show a fallback, because none was provided. So our last |
| 262 | // resort is to throw an error. |
| 263 | // |
| 264 | // TODO: Remove this error in a future release. Other ways of handling |
| 265 | // this case include forcing a concurrent render, or putting the whole |
| 266 | // root into offscreen mode. |
| 267 | throw new Error( |
| 268 | 'An unknown Component is an async Client Component. ' + |
| 269 | 'Only Server Components can be async at the moment. ' + |
| 270 | 'This error is often caused by accidentally ' + |
| 271 | "adding `'use client'` to a module that was originally written " + |
| 272 | 'for the server.', |
| 273 | ); |
| 274 | } |
| 275 | |
| 276 | const pendingThenable: PendingThenable<T> = thenable as any; |
| 277 | pendingThenable.status = 'pending'; |
| 278 | pendingThenable.then( |
| 279 | fulfilledValue => { |
| 280 | if (thenable.status === 'pending') { |
| 281 | const fulfilledThenable: FulfilledThenable<T> = thenable as any; |
| 282 | fulfilledThenable.status = 'fulfilled'; |
| 283 | fulfilledThenable.value = fulfilledValue; |
| 284 | } |
| 285 | }, |
| 286 | (error: mixed) => { |
| 287 | if (thenable.status === 'pending') { |
| 288 | const rejectedThenable: RejectedThenable<T> = thenable as any; |
| 289 | rejectedThenable.status = 'rejected'; |
| 290 | rejectedThenable.reason = error; |
| 291 | } |
| 292 | }, |
| 293 | ); |
| 294 | } |
| 295 | |
| 296 | // Check one more time in case the thenable resolved synchronously. |
| 297 | switch ((thenable as Thenable<T>).status) { |
| 298 | case 'fulfilled': { |
| 299 | const fulfilledThenable: FulfilledThenable<T> = thenable as any; |
| 300 | return fulfilledThenable.value; |
| 301 | } |
| 302 | case 'rejected': { |
| 303 | const rejectedThenable: RejectedThenable<T> = thenable as any; |
| 304 | const rejectedError = rejectedThenable.reason; |
| 305 | checkIfUseWrappedInAsyncCatch(rejectedError); |
| 306 | throw rejectedError; |
| 307 | } |
| 308 | } |
| 309 | |
| 310 | // Suspend. |
| 311 | // |
| 312 | // Throwing here is an implementation detail that allows us to unwind the |
| 313 | // call stack. But we shouldn't allow it to leak into userspace. Throw an |
| 314 | // opaque placeholder value instead of the actual thenable. If it doesn't |
| 315 | // get captured by the work loop, log a warning, because that means |
| 316 | // something in userspace must have caught it. |
| 317 | suspendedThenable = thenable; |
| 318 | if (__DEV__) { |
| 319 | needsToResetSuspendedThenableDEV = true; |
| 320 | if ( |
| 321 | enableConditionalUseWarning && |
| 322 | !didIssueUseWarning && |
| 323 | fiber !== null && |
| 324 | // Only track initial mount for now to avoid warning too much for updates. |
| 325 | fiber.alternate === null |
| 326 | ) { |
| 327 | lastSuspendedFiber = fiber; |
| 328 | // Stash an error in case we end up triggering the use() warning. |
| 329 | // This ensures that we have a stack trace at the location of the first use() |
| 330 | // call since there won't be a second one we have to do that eagerly. |
| 331 | lastSuspendedStack = new Error( |
| 332 | 'This library called use() to suspend in a previous render but ' + |
| 333 | 'did not call use() when it finished. This indicates an incorrect use of use(). ' + |
| 334 | 'Learn more: https://react.dev/warnings/conditional-use-of-use', |
| 335 | ); |
| 336 | } |
| 337 | } |
| 338 | throw SuspenseException; |
| 339 | } |
| 340 | } |
| 341 | } |
| 342 | |
| 343 | export function suspendCommit(): void { |
| 344 | // This extra indirection only exists so it can handle passing |
| 345 | // noopSuspenseyCommitThenable through to throwException. |
| 346 | // TODO: Factor the thenable check out of throwException |
| 347 | // $FlowFixMe[incompatible-type] |
| 348 | suspendedThenable = noopSuspenseyCommitThenable; |
| 349 | throw SuspenseyCommitException; |
| 350 | } |
| 351 | |
| 352 | export function resolveLazy<T>(lazyType: LazyComponentType<T, any>): T { |
| 353 | try { |
| 354 | if (__DEV__) { |
| 355 | return callLazyInitInDEV(lazyType); |
| 356 | } |
| 357 | const payload = lazyType._payload; |
| 358 | const init = lazyType._init; |
| 359 | return init(payload); |
| 360 | } catch (x) { |
| 361 | if (x !== null && typeof x === 'object' && typeof x.then === 'function') { |
| 362 | // This lazy Suspended. Treat this as if we called use() to unwrap it. |
| 363 | suspendedThenable = x; |
| 364 | if (__DEV__) { |
| 365 | needsToResetSuspendedThenableDEV = true; |
| 366 | } |
| 367 | throw SuspenseException; |
| 368 | } |
| 369 | throw x; |
| 370 | } |
| 371 | } |
| 372 | |
| 373 | // This is used to track the actual thenable that suspended so it can be |
| 374 | // passed to the rest of the Suspense implementation — which, for historical |
| 375 | // reasons, expects to receive a thenable. |
| 376 | let suspendedThenable: Thenable<any> | null = null; |
| 377 | let needsToResetSuspendedThenableDEV = false; |
| 378 | export function getSuspendedThenable(): Thenable<mixed> { |
| 379 | // This is called right after `use` suspends by throwing an exception. `use` |
| 380 | // throws an opaque value instead of the thenable itself so that it can't be |
| 381 | // caught in userspace. Then the work loop accesses the actual thenable using |
| 382 | // this function. |
| 383 | if (suspendedThenable === null) { |
| 384 | throw new Error( |
| 385 | 'Expected a suspended thenable. This is a bug in React. Please file ' + |
| 386 | 'an issue.', |
| 387 | ); |
| 388 | } |
| 389 | const thenable = suspendedThenable; |
| 390 | suspendedThenable = null; |
| 391 | if (__DEV__) { |
| 392 | needsToResetSuspendedThenableDEV = false; |
| 393 | } |
| 394 | return thenable; |
| 395 | } |
| 396 | |
| 397 | export function checkIfUseWrappedInTryCatch(): boolean { |
| 398 | if (__DEV__) { |
| 399 | // This was set right before SuspenseException was thrown, and it should |
| 400 | // have been cleared when the exception was handled. If it wasn't, |
| 401 | // it must have been caught by userspace. |
| 402 | if (needsToResetSuspendedThenableDEV) { |
| 403 | needsToResetSuspendedThenableDEV = false; |
| 404 | return true; |
| 405 | } |
| 406 | } |
| 407 | return false; |
| 408 | } |
| 409 | |
| 410 | export function checkIfUseWrappedInAsyncCatch(rejectedReason: any) { |
| 411 | // This check runs in prod, too, because it prevents a more confusing |
| 412 | // downstream error, where SuspenseException is caught by a promise and |
| 413 | // thrown asynchronously. |
| 414 | // TODO: Another way to prevent SuspenseException from leaking into an async |
| 415 | // execution context is to check the dispatcher every time `use` is called, |
| 416 | // or some equivalent. That might be preferable for other reasons, too, since |
| 417 | // it matches how we prevent similar mistakes for other hooks. |
| 418 | if ( |
| 419 | rejectedReason === SuspenseException || |
| 420 | rejectedReason === SuspenseActionException |
| 421 | ) { |
| 422 | throw new Error( |
| 423 | 'Hooks are not supported inside an async component. This ' + |
| 424 | "error is often caused by accidentally adding `'use client'` " + |
| 425 | 'to a module that was originally written for the server.', |
| 426 | ); |
| 427 | } |
| 428 | } |
| 429 | |
| 430 | function areSameKeyPath(a: Fiber, b: Fiber): boolean { |
| 431 | if (a === b) { |
| 432 | return true; |
| 433 | } |
| 434 | if ( |
| 435 | a.tag !== b.tag || |
| 436 | a.type !== b.type || |
| 437 | a.key !== b.key || |
| 438 | a.index !== b.index |
| 439 | ) { |
| 440 | return false; |
| 441 | } |
| 442 | if (a.tag === HostRoot && a.stateNode !== b.stateNode) { |
| 443 | // These are both roots but they're different roots so they're not in the same tree. |
| 444 | return false; |
| 445 | } |
| 446 | if (a.return === null || b.return === null) { |
| 447 | return false; |
| 448 | } |
| 449 | return areSameKeyPath(a.return, b.return); |
| 450 | } |
| 451 | |
| 452 | export function checkIfUseWasUsedBefore( |
| 453 | unsuspendedFiber: Fiber, |
| 454 | thenableState: null | ThenableState, |
| 455 | ): void { |
| 456 | if (__DEV__ && enableConditionalUseWarning) { |
| 457 | if ( |
| 458 | lastSuspendedFiber !== null && |
| 459 | areSameKeyPath(lastSuspendedFiber, unsuspendedFiber) |
| 460 | ) { |
| 461 | if (thenableState !== null) { |
| 462 | // It's still using use() ever after resolving. We could warn for different number of them but for |
| 463 | // now we treat this as ok and clear the state. |
| 464 | lastSuspendedFiber = null; |
| 465 | lastSuspendedStack = null; |
| 466 | } else { |
| 467 | // The last suspended Fiber using use() is no longer using use() in the same position. |
| 468 | // That's suspicious. Likely it was unblocked by conditionally using use() which is incorrect. |
| 469 | if (lastSuspendedStack !== null && !didIssueUseWarning) { |
| 470 | didIssueUseWarning = true; |
| 471 | // We pass the error object instead of custom message so that the browser displays the error natively. |
| 472 | console['error'](lastSuspendedStack); |
| 473 | } |
| 474 | lastSuspendedFiber = null; |
| 475 | lastSuspendedStack = null; |
| 476 | } |
| 477 | } |
| 478 | } |
| 479 | } |