main
js 323 lines 12.5 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 // 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 }