-
Notifications
You must be signed in to change notification settings - Fork 47.3k
/
ReactFiberThenable.js
317 lines (291 loc) · 12.2 KB
/
ReactFiberThenable.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow
*/
import type {
Thenable,
PendingThenable,
FulfilledThenable,
RejectedThenable,
} from 'shared/ReactTypes';
import {getWorkInProgressRoot} from './ReactFiberWorkLoop';
import ReactSharedInternals from 'shared/ReactSharedInternals';
opaque type ThenableStateDev = {
didWarnAboutUncachedPromise: boolean,
thenables: Array<Thenable<any>>,
};
opaque type ThenableStateProd = Array<Thenable<any>>;
export opaque type ThenableState = ThenableStateDev | ThenableStateProd;
function getThenablesFromState(state: ThenableState): Array<Thenable<any>> {
if (__DEV__) {
const devState: ThenableStateDev = (state: any);
return devState.thenables;
} else {
const prodState = (state: any);
return prodState;
}
}
// An error that is thrown (e.g. by `use`) to trigger Suspense. If we
// detect this is caught by userspace, we'll log a warning in development.
export const SuspenseException: mixed = new Error(
"Suspense Exception: This is not a real error! It's an implementation " +
'detail of `use` to interrupt the current render. You must either ' +
'rethrow it immediately, or move the `use` call outside of the ' +
'`try/catch` block. Capturing without rethrowing will lead to ' +
'unexpected behavior.\n\n' +
'To handle async errors, wrap your component in an error boundary, or ' +
"call the promise's `.catch` method and pass the result to `use`.",
);
export const SuspenseyCommitException: mixed = new Error(
'Suspense Exception: This is not a real error, and should not leak into ' +
"userspace. If you're seeing this, it's likely a bug in React.",
);
export const SuspenseActionException: mixed = new Error(
"Suspense Exception: This is not a real error! It's an implementation " +
'detail of `useActionState` to interrupt the current render. You must either ' +
'rethrow it immediately, or move the `useActionState` call outside of the ' +
'`try/catch` block. Capturing without rethrowing will lead to ' +
'unexpected behavior.\n\n' +
'To handle async errors, wrap your component in an error boundary.',
);
// This is a noop thenable that we use to trigger a fallback in throwException.
// TODO: It would be better to refactor throwException into multiple functions
// so we can trigger a fallback directly without having to check the type. But
// for now this will do.
export const noopSuspenseyCommitThenable = {
then() {
if (__DEV__) {
console.error(
'Internal React error: A listener was unexpectedly attached to a ' +
'"noop" thenable. This is a bug in React. Please file an issue.',
);
}
},
};
export function createThenableState(): ThenableState {
// The ThenableState is created the first time a component suspends. If it
// suspends again, we'll reuse the same state.
if (__DEV__) {
return {
didWarnAboutUncachedPromise: false,
thenables: [],
};
} else {
return [];
}
}
export function isThenableResolved(thenable: Thenable<mixed>): boolean {
const status = thenable.status;
return status === 'fulfilled' || status === 'rejected';
}
function noop(): void {}
export function trackUsedThenable<T>(
thenableState: ThenableState,
thenable: Thenable<T>,
index: number,
): T {
if (__DEV__ && ReactSharedInternals.actQueue !== null) {
ReactSharedInternals.didUsePromise = true;
}
const trackedThenables = getThenablesFromState(thenableState);
const previous = trackedThenables[index];
if (previous === undefined) {
trackedThenables.push(thenable);
} else {
if (previous !== thenable) {
// Reuse the previous thenable, and drop the new one. We can assume
// they represent the same value, because components are idempotent.
if (__DEV__) {
const thenableStateDev: ThenableStateDev = (thenableState: any);
if (!thenableStateDev.didWarnAboutUncachedPromise) {
// We should only warn the first time an uncached thenable is
// discovered per component, because if there are multiple, the
// subsequent ones are likely derived from the first.
//
// We track this on the thenableState instead of deduping using the
// component name like we usually do, because in the case of a
// promise-as-React-node, the owner component is likely different from
// the parent that's currently being reconciled. We'd have to track
// the owner using state, which we're trying to move away from. Though
// since this is dev-only, maybe that'd be OK.
//
// However, another benefit of doing it this way is we might
// eventually have a thenableState per memo/Forget boundary instead
// of per component, so this would allow us to have more
// granular warnings.
thenableStateDev.didWarnAboutUncachedPromise = true;
// TODO: This warning should link to a corresponding docs page.
console.error(
'A component was suspended by an uncached promise. Creating ' +
'promises inside a Client Component or hook is not yet ' +
'supported, except via a Suspense-compatible library or framework.',
);
}
}
// Avoid an unhandled rejection errors for the Promises that we'll
// intentionally ignore.
thenable.then(noop, noop);
thenable = previous;
}
}
// We use an expando to track the status and result of a thenable so that we
// can synchronously unwrap the value. Think of this as an extension of the
// Promise API, or a custom interface that is a superset of Thenable.
//
// If the thenable doesn't have a status, set it to "pending" and attach
// a listener that will update its status and result when it resolves.
switch (thenable.status) {
case 'fulfilled': {
const fulfilledValue: T = thenable.value;
return fulfilledValue;
}
case 'rejected': {
const rejectedError = thenable.reason;
checkIfUseWrappedInAsyncCatch(rejectedError);
throw rejectedError;
}
default: {
if (typeof thenable.status === 'string') {
// Only instrument the thenable if the status if not defined. If
// it's defined, but an unknown value, assume it's been instrumented by
// some custom userspace implementation. We treat it as "pending".
// Attach a dummy listener, to ensure that any lazy initialization can
// happen. Flight lazily parses JSON when the value is actually awaited.
thenable.then(noop, noop);
} else {
// This is an uncached thenable that we haven't seen before.
// Detect infinite ping loops caused by uncached promises.
const root = getWorkInProgressRoot();
if (root !== null && root.shellSuspendCounter > 100) {
// This root has suspended repeatedly in the shell without making any
// progress (i.e. committing something). This is highly suggestive of
// an infinite ping loop, often caused by an accidental Async Client
// Component.
//
// During a transition, we can suspend the work loop until the promise
// to resolve, but this is a sync render, so that's not an option. We
// also can't show a fallback, because none was provided. So our last
// resort is to throw an error.
//
// TODO: Remove this error in a future release. Other ways of handling
// this case include forcing a concurrent render, or putting the whole
// root into offscreen mode.
throw new Error(
'async/await is not yet supported in Client Components, only ' +
'Server Components. This error is often caused by accidentally ' +
"adding `'use client'` to a module that was originally written " +
'for the server.',
);
}
const pendingThenable: PendingThenable<T> = (thenable: any);
pendingThenable.status = 'pending';
pendingThenable.then(
fulfilledValue => {
if (thenable.status === 'pending') {
const fulfilledThenable: FulfilledThenable<T> = (thenable: any);
fulfilledThenable.status = 'fulfilled';
fulfilledThenable.value = fulfilledValue;
}
},
(error: mixed) => {
if (thenable.status === 'pending') {
const rejectedThenable: RejectedThenable<T> = (thenable: any);
rejectedThenable.status = 'rejected';
rejectedThenable.reason = error;
}
},
);
}
// Check one more time in case the thenable resolved synchronously.
switch ((thenable: Thenable<T>).status) {
case 'fulfilled': {
const fulfilledThenable: FulfilledThenable<T> = (thenable: any);
return fulfilledThenable.value;
}
case 'rejected': {
const rejectedThenable: RejectedThenable<T> = (thenable: any);
const rejectedError = rejectedThenable.reason;
checkIfUseWrappedInAsyncCatch(rejectedError);
throw rejectedError;
}
}
// Suspend.
//
// Throwing here is an implementation detail that allows us to unwind the
// call stack. But we shouldn't allow it to leak into userspace. Throw an
// opaque placeholder value instead of the actual thenable. If it doesn't
// get captured by the work loop, log a warning, because that means
// something in userspace must have caught it.
suspendedThenable = thenable;
if (__DEV__) {
needsToResetSuspendedThenableDEV = true;
}
throw SuspenseException;
}
}
}
export function suspendCommit(): void {
// This extra indirection only exists so it can handle passing
// noopSuspenseyCommitThenable through to throwException.
// TODO: Factor the thenable check out of throwException
suspendedThenable = noopSuspenseyCommitThenable;
throw SuspenseyCommitException;
}
// This is used to track the actual thenable that suspended so it can be
// passed to the rest of the Suspense implementation — which, for historical
// reasons, expects to receive a thenable.
let suspendedThenable: Thenable<any> | null = null;
let needsToResetSuspendedThenableDEV = false;
export function getSuspendedThenable(): Thenable<mixed> {
// This is called right after `use` suspends by throwing an exception. `use`
// throws an opaque value instead of the thenable itself so that it can't be
// caught in userspace. Then the work loop accesses the actual thenable using
// this function.
if (suspendedThenable === null) {
throw new Error(
'Expected a suspended thenable. This is a bug in React. Please file ' +
'an issue.',
);
}
const thenable = suspendedThenable;
suspendedThenable = null;
if (__DEV__) {
needsToResetSuspendedThenableDEV = false;
}
return thenable;
}
export function checkIfUseWrappedInTryCatch(): boolean {
if (__DEV__) {
// This was set right before SuspenseException was thrown, and it should
// have been cleared when the exception was handled. If it wasn't,
// it must have been caught by userspace.
if (needsToResetSuspendedThenableDEV) {
needsToResetSuspendedThenableDEV = false;
return true;
}
}
return false;
}
export function checkIfUseWrappedInAsyncCatch(rejectedReason: any) {
// This check runs in prod, too, because it prevents a more confusing
// downstream error, where SuspenseException is caught by a promise and
// thrown asynchronously.
// TODO: Another way to prevent SuspenseException from leaking into an async
// execution context is to check the dispatcher every time `use` is called,
// or some equivalent. That might be preferable for other reasons, too, since
// it matches how we prevent similar mistakes for other hooks.
if (
rejectedReason === SuspenseException ||
rejectedReason === SuspenseActionException
) {
throw new Error(
'Hooks are not supported inside an async component. This ' +
"error is often caused by accidentally adding `'use client'` " +
'to a module that was originally written for the server.',
);
}
}