main
js 479 lines 18.3 KB
Raw
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 }