experimental_use(promise) for SSR (#25214)
Follow up to #25084 and #25207. Implements experimental_use(promise) API in the SSR runtime (Fizz). This is largely a copy-paste of the Flight implementation. I have intentionally tried to keep both as close as possible.
Andrew Clark committed
Sep 8, 2022 at 21:47 UTC
c28f313e6d90a13b2eca85c1b3543d1ea6f81603
6 files changed
+525
-34
packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
+212
@@ -19,6 +19,7 @@ let Suspense;
19
let SuspenseList;
20
let useSyncExternalStore;
21
let useSyncExternalStoreWithSelector;
22
+let use;
23
let PropTypes;
24
let textCache;
25
let window;
@@ -42,6 +43,7 @@ describe('ReactDOMFizzServer', () => {
43
Suspense = React.Suspense;
44
if (gate(flags => flags.enableSuspenseList)) {
45
SuspenseList = React.SuspenseList;
46
+ use = React.experimental_use;
47
}
48
49
PropTypes = require('prop-types');
@@ -5243,5 +5245,215 @@ describe('ReactDOMFizzServer', () => {
5245
console.error = originalConsoleError;
5246
}
5247
});
5248
+
5249
+ // @gate enableUseHook
5250
+ it('basic use(promise)', async () => {
5251
+ const promiseA = Promise.resolve('A');
5252
+ const promiseB = Promise.resolve('B');
5253
+ const promiseC = Promise.resolve('C');
5254
+
5255
+ function Async() {
5256
+ return use(promiseA) + use(promiseB) + use(promiseC);
5257
+ }
5258
+
5259
+ function App() {
5260
+ return (
5261
+ <Suspense fallback="Loading...">
5262
+ <Async />
5263
+ </Suspense>
5264
+ );
5265
+ }
5266
+
5267
+ await act(async () => {
5268
+ const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
5269
+ pipe(writable);
5270
+ });
5271
+
5272
+ // TODO: The `act` implementation in this file doesn't unwrap microtasks
5273
+ // automatically. We can't use the same `act` we use for Fiber tests
5274
+ // because that relies on the mock Scheduler. Doesn't affect any public
5275
+ // API but we might want to fix this for our own internal tests.
5276
+ //
5277
+ // For now, wait for each promise in sequence.
5278
+ await act(async () => {
5279
+ await promiseA;
5280
+ });
5281
+ await act(async () => {
5282
+ await promiseB;
5283
+ });
5284
+ await act(async () => {
5285
+ await promiseC;
5286
+ });
5287
+
5288
+ expect(getVisibleChildren(container)).toEqual('ABC');
5289
+
5290
+ ReactDOMClient.hydrateRoot(container, <App />);
5291
+ expect(Scheduler).toFlushAndYield([]);
5292
+ expect(getVisibleChildren(container)).toEqual('ABC');
5293
+ });
5294
+
5295
+ // @gate enableUseHook
5296
+ it('use(promise) in multiple components', async () => {
5297
+ const promiseA = Promise.resolve('A');
5298
+ const promiseB = Promise.resolve('B');
5299
+ const promiseC = Promise.resolve('C');
5300
+ const promiseD = Promise.resolve('D');
5301
+
5302
+ function Child({prefix}) {
5303
+ return prefix + use(promiseC) + use(promiseD);
5304
+ }
5305
+
5306
+ function Parent() {
5307
+ return <Child prefix={use(promiseA) + use(promiseB)} />;
5308
+ }
5309
+
5310
+ function App() {
5311
+ return (
5312
+ <Suspense fallback="Loading...">
5313
+ <Parent />
5314
+ </Suspense>
5315
+ );
5316
+ }
5317
+
5318
+ await act(async () => {
5319
+ const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
5320
+ pipe(writable);
5321
+ });
5322
+
5323
+ // TODO: The `act` implementation in this file doesn't unwrap microtasks
5324
+ // automatically. We can't use the same `act` we use for Fiber tests
5325
+ // because that relies on the mock Scheduler. Doesn't affect any public
5326
+ // API but we might want to fix this for our own internal tests.
5327
+ //
5328
+ // For now, wait for each promise in sequence.
5329
+ await act(async () => {
5330
+ await promiseA;
5331
+ });
5332
+ await act(async () => {
5333
+ await promiseB;
5334
+ });
5335
+ await act(async () => {
5336
+ await promiseC;
5337
+ });
5338
+ await act(async () => {
5339
+ await promiseD;
5340
+ });
5341
+
5342
+ expect(getVisibleChildren(container)).toEqual('ABCD');
5343
+
5344
+ ReactDOMClient.hydrateRoot(container, <App />);
5345
+ expect(Scheduler).toFlushAndYield([]);
5346
+ expect(getVisibleChildren(container)).toEqual('ABCD');
5347
+ });
5348
+
5349
+ // @gate enableUseHook
5350
+ it('using a rejected promise will throw', async () => {
5351
+ const promiseA = Promise.resolve('A');
5352
+ const promiseB = Promise.reject(new Error('Oops!'));
5353
+ const promiseC = Promise.resolve('C');
5354
+
5355
+ // Jest/Node will raise an unhandled rejected error unless we await this. It
5356
+ // works fine in the browser, though.
5357
+ await expect(promiseB).rejects.toThrow('Oops!');
5358
+
5359
+ function Async() {
5360
+ return use(promiseA) + use(promiseB) + use(promiseC);
5361
+ }
5362
+
5363
+ class ErrorBoundary extends React.Component {
5364
+ state = {error: null};
5365
+ static getDerivedStateFromError(error) {
5366
+ return {error};
5367
+ }
5368
+ render() {
5369
+ if (this.state.error) {
5370
+ return this.state.error.message;
5371
+ }
5372
+ return this.props.children;
5373
+ }
5374
+ }
5375
+
5376
+ function App() {
5377
+ return (
5378
+ <Suspense fallback="Loading...">
5379
+ <ErrorBoundary>
5380
+ <Async />
5381
+ </ErrorBoundary>
5382
+ </Suspense>
5383
+ );
5384
+ }
5385
+
5386
+ const reportedServerErrors = [];
5387
+ await act(async () => {
5388
+ const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />, {
5389
+ onError(error) {
5390
+ reportedServerErrors.push(error);
5391
+ },
5392
+ });
5393
+ pipe(writable);
5394
+ });
5395
+
5396
+ // TODO: The `act` implementation in this file doesn't unwrap microtasks
5397
+ // automatically. We can't use the same `act` we use for Fiber tests
5398
+ // because that relies on the mock Scheduler. Doesn't affect any public
5399
+ // API but we might want to fix this for our own internal tests.
5400
+ //
5401
+ // For now, wait for each promise in sequence.
5402
+ await act(async () => {
5403
+ await promiseA;
5404
+ });
5405
+ await act(async () => {
5406
+ await expect(promiseB).rejects.toThrow('Oops!');
5407
+ });
5408
+ await act(async () => {
5409
+ await promiseC;
5410
+ });
5411
+
5412
+ expect(getVisibleChildren(container)).toEqual('Loading...');
5413
+ expect(reportedServerErrors.length).toBe(1);
5414
+ expect(reportedServerErrors[0].message).toBe('Oops!');
5415
+
5416
+ const reportedClientErrors = [];
5417
+ ReactDOMClient.hydrateRoot(container, <App />, {
5418
+ onRecoverableError(error) {
5419
+ reportedClientErrors.push(error);
5420
+ },
5421
+ });
5422
+ expect(Scheduler).toFlushAndYield([]);
5423
+ expect(getVisibleChildren(container)).toEqual('Oops!');
5424
+ expect(reportedClientErrors.length).toBe(1);
5425
+ if (__DEV__) {
5426
+ expect(reportedClientErrors[0].message).toBe('Oops!');
5427
+ } else {
5428
+ expect(reportedClientErrors[0].message).toBe(
5429
+ 'The server could not finish this Suspense boundary, likely due to ' +
5430
+ 'an error during server rendering. Switched to client rendering.',
5431
+ );
5432
+ }
5433
+ });
5434
+
5435
+ // @gate enableUseHook
5436
+ it("use a promise that's already been instrumented and resolved", async () => {
5437
+ const thenable = {
5438
+ status: 'fulfilled',
5439
+ value: 'Hi',
5440
+ then() {},
5441
+ };
5442
+
5443
+ // This will never suspend because the thenable already resolved
5444
+ function App() {
5445
+ return use(thenable);
5446
+ }
5447
+
5448
+ await act(async () => {
5449
+ const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />);
5450
+ pipe(writable);
5451
+ });
5452
+ expect(getVisibleChildren(container)).toEqual('Hi');
5453
+
5454
+ ReactDOMClient.hydrateRoot(container, <App />);
5455
+ expect(Scheduler).toFlushAndYield([]);
5456
+ expect(getVisibleChildren(container)).toEqual('Hi');
5457
+ });
5458
});
5459
});
packages/react-server/src/ReactFizzHooks.js
+102
-3
@@ -15,17 +15,29 @@ import type {
15
MutableSourceSubscribeFn,
16
ReactContext,
17
StartTransitionOptions,
18
+ Thenable,
19
+ Usable,
20
} from 'shared/ReactTypes';
21
22
import type {ResponseState} from './ReactServerFormatConfig';
23
import type {Task} from './ReactFizzServer';
24
+import type {ThenableState} from './ReactFizzWakeable';
25
26
import {readContext as readContextImpl} from './ReactFizzNewContext';
27
import {getTreeId} from './ReactFizzTreeContext';
28
+import {
29
+ getPreviouslyUsedThenableAtIndex,
30
+ createThenableState,
31
+ trackUsedThenable,
32
+} from './ReactFizzWakeable';
33
34
import {makeId} from './ReactServerFormatConfig';
35
28
-import {enableCache, enableUseMemoCacheHook} from 'shared/ReactFeatureFlags';
36
+import {
37
+ enableCache,
38
+ enableUseHook,
39
+ enableUseMemoCacheHook,
40
+} from 'shared/ReactFeatureFlags';
41
import is from 'shared/objectIs';
42
43
type BasicStateAction<S> = (S => S) | S;
@@ -57,6 +69,9 @@ let isReRender: boolean = false;
69
let didScheduleRenderPhaseUpdate: boolean = false;
70
// Counts the number of useId hooks in this component
71
let localIdCounter: number = 0;
72
+// Counts the number of use(thenable) calls in this component
73
+let thenableIndexCounter: number = 0;
74
+let thenableState: ThenableState | null = null;
75
// Lazily created map of render-phase updates
76
let renderPhaseUpdates: Map<UpdateQueue<any>, Update<any>> | null = null;
77
// Counter to prevent infinite loops.
@@ -169,7 +184,11 @@ function createWorkInProgressHook(): Hook {
184
return workInProgressHook;
185
}
186
172
-export function prepareToUseHooks(task: Task, componentIdentity: Object): void {
187
+export function prepareToUseHooks(
188
+ task: Task,
189
+ componentIdentity: Object,
190
+ prevThenableState: ThenableState | null,
191
+): void {
192
currentlyRenderingComponent = componentIdentity;
193
currentlyRenderingTask = task;
194
if (__DEV__) {
@@ -178,13 +197,14 @@ export function prepareToUseHooks(task: Task, componentIdentity: Object): void {
197
198
// The following should have already been reset
199
// didScheduleRenderPhaseUpdate = false;
181
- // localIdCounter = 0;
200
// firstWorkInProgressHook = null;
201
// numberOfReRenders = 0;
202
// renderPhaseUpdates = null;
203
// workInProgressHook = null;
204
205
localIdCounter = 0;
206
+ thenableIndexCounter = 0;
207
+ thenableState = prevThenableState;
208
}
209
210
export function finishHooks(
@@ -203,6 +223,7 @@ export function finishHooks(
223
// restarting until no more updates are scheduled.
224
didScheduleRenderPhaseUpdate = false;
225
localIdCounter = 0;
226
+ thenableIndexCounter = 0;
227
numberOfReRenders += 1;
228
229
// Start over from the beginning of the list
@@ -214,6 +235,12 @@ export function finishHooks(
235
return children;
236
}
237
238
+export function getThenableStateAfterSuspending() {
239
+ const state = thenableState;
240
+ thenableState = null;
241
+ return state;
242
+}
243
+
244
export function checkDidRenderIdHook() {
245
// This should be called immediately after every finishHooks call.
246
// Conceptually, it's part of the return value of finishHooks; it's only a
@@ -529,6 +556,75 @@ function useId(): string {
556
return makeId(responseState, treeId, localId);
557
}
558
559
+function use<T>(usable: Usable<T>): T {
560
+ if (usable !== null && typeof usable === 'object') {
561
+ if (typeof usable.then === 'function') {
562
+ // This is a thenable.
563
+ const thenable: Thenable<T> = (usable: any);
564
+
565
+ // Track the position of the thenable within this fiber.
566
+ const index = thenableIndexCounter;
567
+ thenableIndexCounter += 1;
568
+
569
+ switch (thenable.status) {
570
+ case 'fulfilled': {
571
+ const fulfilledValue: T = thenable.value;
572
+ return fulfilledValue;
573
+ }
574
+ case 'rejected': {
575
+ const rejectedError = thenable.reason;
576
+ throw rejectedError;
577
+ }
578
+ default: {
579
+ const prevThenableAtIndex: Thenable<T> | null = getPreviouslyUsedThenableAtIndex(
580
+ thenableState,
581
+ index,
582
+ );
583
+ if (prevThenableAtIndex !== null) {
584
+ switch (prevThenableAtIndex.status) {
585
+ case 'fulfilled': {
586
+ const fulfilledValue: T = prevThenableAtIndex.value;
587
+ return fulfilledValue;
588
+ }
589
+ case 'rejected': {
590
+ const rejectedError: mixed = prevThenableAtIndex.reason;
591
+ throw rejectedError;
592
+ }
593
+ default: {
594
+ // The thenable still hasn't resolved. Suspend with the same
595
+ // thenable as last time to avoid redundant listeners.
596
+ throw prevThenableAtIndex;
597
+ }
598
+ }
599
+ } else {
600
+ // This is the first time something has been used at this index.
601
+ // Stash the thenable at the current index so we can reuse it during
602
+ // the next attempt.
603
+ if (thenableState === null) {
604
+ thenableState = createThenableState();
605
+ }
606
+ trackUsedThenable(thenableState, thenable, index);
607
+
608
+ // Suspend.
609
+ // TODO: Throwing here is an implementation detail that allows us to
610
+ // unwind the call stack. But we shouldn't allow it to leak into
611
+ // userspace. Throw an opaque placeholder value instead of the
612
+ // actual thenable. If it doesn't get captured by the work loop, log
613
+ // a warning, because that means something in userspace must have
614
+ // caught it.
615
+ throw thenable;
616
+ }
617
+ }
618
+ }
619
+ } else {
620
+ // TODO: Add support for Context
621
+ }
622
+ }
623
+
624
+ // eslint-disable-next-line react-internal/safe-string-coercion
625
+ throw new Error('An unsupported type was passed to use(): ' + String(usable));
626
+}
627
+
628
function unsupportedRefresh() {
629
throw new Error('Cache cannot be refreshed during server rendering.');
630
}
@@ -574,6 +670,9 @@ if (enableCache) {
670
if (enableUseMemoCacheHook) {
671
Dispatcher.useMemoCache = useMemoCache;
672
}
673
+if (enableUseHook) {
674
+ Dispatcher.use = use;
675
+}
676
677
export let currentResponseState: null | ResponseState = (null: any);
678
export function setCurrentResponseState(
packages/react-server/src/ReactFizzServer.js
+99
-28
@@ -17,6 +17,7 @@ import type {
17
ReactContext,
18
ReactProviderType,
19
OffscreenMode,
20
+ Wakeable,
21
} from 'shared/ReactTypes';
22
import type {LazyComponent as LazyComponentType} from 'react/src/ReactLazy';
23
import type {
@@ -27,6 +28,7 @@ import type {
28
import type {ContextSnapshot} from './ReactFizzNewContext';
29
import type {ComponentStackNode} from './ReactFizzComponentStack';
30
import type {TreeContext} from './ReactFizzTreeContext';
31
+import type {ThenableState} from './ReactFizzWakeable';
32
33
import {
34
scheduleWork,
@@ -87,6 +89,7 @@ import {
89
Dispatcher,
90
currentResponseState,
91
setCurrentResponseState,
92
+ getThenableStateAfterSuspending,
93
} from './ReactFizzHooks';
94
import {getStackByComponentStackNode} from './ReactFizzComponentStack';
95
import {emptyTreeContext, pushTreeContext} from './ReactFizzTreeContext';
@@ -123,6 +126,7 @@ import {
126
import assign from 'shared/assign';
127
import getComponentNameFromType from 'shared/getComponentNameFromType';
128
import isArray from 'shared/isArray';
129
+import {trackSuspendedWakeable} from './ReactFizzWakeable';
130
131
const ReactCurrentDispatcher = ReactSharedInternals.ReactCurrentDispatcher;
132
const ReactDebugCurrentFrame = ReactSharedInternals.ReactDebugCurrentFrame;
@@ -155,6 +159,7 @@ export type Task = {
159
context: ContextSnapshot, // the current new context that this task is executing in
160
treeContext: TreeContext, // the current tree context that this task is executing in
161
componentStack: null | ComponentStackNode, // DEV-only component stack
162
+ thenableState: null | ThenableState,
163
};
164
165
const PENDING = 0;
@@ -297,6 +302,7 @@ export function createRequest(
302
rootSegment.parentFlushed = true;
303
const rootTask = createTask(
304
request,
305
+ null,
306
children,
307
null,
308
rootSegment,
@@ -336,6 +342,7 @@ function createSuspenseBoundary(
342
343
function createTask(
344
request: Request,
345
+ thenableState: ThenableState | null,
346
node: ReactNodeList,
347
blockedBoundary: Root | SuspenseBoundary,
348
blockedSegment: Segment,
@@ -359,6 +366,7 @@ function createTask(
366
legacyContext,
367
context,
368
treeContext,
369
+ thenableState,
370
}: any);
371
if (__DEV__) {
372
task.componentStack = null;
@@ -592,6 +600,7 @@ function renderSuspenseBoundary(
600
// on it yet in case we finish the main content, so we queue for later.
601
const suspendedFallbackTask = createTask(
602
request,
603
+ null,
604
fallback,
605
parentBoundary,
606
boundarySegment,
@@ -665,12 +674,13 @@ function shouldConstruct(Component) {
674
function renderWithHooks<Props, SecondArg>(
675
request: Request,
676
task: Task,
677
+ prevThenableState: ThenableState | null,
678
Component: (p: Props, arg: SecondArg) => any,
679
props: Props,
680
secondArg: SecondArg,
681
): any {
682
const componentIdentity = {};
673
- prepareToUseHooks(task, componentIdentity);
683
+ prepareToUseHooks(task, componentIdentity, prevThenableState);
684
const result = Component(props, secondArg);
685
return finishHooks(Component, props, result, secondArg);
686
}
@@ -708,13 +718,13 @@ function finishClassComponent(
718
childContextTypes,
719
);
720
task.legacyContext = mergedContext;
711
- renderNodeDestructive(request, task, nextChildren);
721
+ renderNodeDestructive(request, task, null, nextChildren);
722
task.legacyContext = previousContext;
723
return;
724
}
725
}
726
717
- renderNodeDestructive(request, task, nextChildren);
727
+ renderNodeDestructive(request, task, null, nextChildren);
728
}
729
730
function renderClassComponent(
@@ -748,6 +758,7 @@ let hasWarnedAboutUsingContextAsConsumer = false;
758
function renderIndeterminateComponent(
759
request: Request,
760
task: Task,
761
+ prevThenableState: ThenableState | null,
762
Component: any,
763
props: any,
764
): void {
@@ -776,7 +787,14 @@ function renderIndeterminateComponent(
787
}
788
}
789
779
- const value = renderWithHooks(request, task, Component, props, legacyContext);
790
+ const value = renderWithHooks(
791
+ request,
792
+ task,
793
+ prevThenableState,
794
+ Component,
795
+ props,
796
+ legacyContext,
797
+ );
798
const hasId = checkDidRenderIdHook();
799
800
if (__DEV__) {
@@ -857,12 +875,12 @@ function renderIndeterminateComponent(
875
const index = 0;
876
task.treeContext = pushTreeContext(prevTreeContext, totalChildren, index);
877
try {
860
- renderNodeDestructive(request, task, value);
878
+ renderNodeDestructive(request, task, null, value);
879
} finally {
880
task.treeContext = prevTreeContext;
881
}
882
} else {
865
- renderNodeDestructive(request, task, value);
883
+ renderNodeDestructive(request, task, null, value);
884
}
885
}
886
popComponentStackInDEV(task);
@@ -942,12 +960,20 @@ function resolveDefaultProps(Component: any, baseProps: Object): Object {
960
function renderForwardRef(
961
request: Request,
962
task: Task,
963
+ prevThenableState,
964
type: any,
965
props: Object,
966
ref: any,
967
): void {
968
pushFunctionComponentStackInDEV(task, type.render);
950
- const children = renderWithHooks(request, task, type.render, props, ref);
969
+ const children = renderWithHooks(
970
+ request,
971
+ task,
972
+ prevThenableState,
973
+ type.render,
974
+ props,
975
+ ref,
976
+ );
977
const hasId = checkDidRenderIdHook();
978
if (hasId) {
979
// This component materialized an id. We treat this as its own level, with
@@ -957,12 +983,12 @@ function renderForwardRef(
983
const index = 0;
984
task.treeContext = pushTreeContext(prevTreeContext, totalChildren, index);
985
try {
960
- renderNodeDestructive(request, task, children);
986
+ renderNodeDestructive(request, task, null, children);
987
} finally {
988
task.treeContext = prevTreeContext;
989
}
990
} else {
965
- renderNodeDestructive(request, task, children);
991
+ renderNodeDestructive(request, task, null, children);
992
}
993
popComponentStackInDEV(task);
994
}
@@ -970,13 +996,21 @@ function renderForwardRef(
996
function renderMemo(
997
request: Request,
998
task: Task,
999
+ prevThenableState: ThenableState | null,
1000
type: any,
1001
props: Object,
1002
ref: any,
1003
): void {
1004
const innerType = type.type;
1005
const resolvedProps = resolveDefaultProps(innerType, props);
979
- renderElement(request, task, innerType, resolvedProps, ref);
1006
+ renderElement(
1007
+ request,
1008
+ task,
1009
+ prevThenableState,
1010
+ innerType,
1011
+ resolvedProps,
1012
+ ref,
1013
+ );
1014
}
1015
1016
function renderContextConsumer(
@@ -1026,7 +1060,7 @@ function renderContextConsumer(
1060
const newValue = readContext(context);
1061
const newChildren = render(newValue);
1062
1029
- renderNodeDestructive(request, task, newChildren);
1063
+ renderNodeDestructive(request, task, null, newChildren);
1064
}
1065
1066
function renderContextProvider(
@@ -1043,7 +1077,7 @@ function renderContextProvider(
1077
prevSnapshot = task.context;
1078
}
1079
task.context = pushProvider(context, value);
1046
- renderNodeDestructive(request, task, children);
1080
+ renderNodeDestructive(request, task, null, children);
1081
task.context = popProvider(context);
1082
if (__DEV__) {
1083
if (prevSnapshot !== task.context) {
@@ -1057,6 +1091,7 @@ function renderContextProvider(
1091
function renderLazyComponent(
1092
request: Request,
1093
task: Task,
1094
+ prevThenableState: ThenableState | null,
1095
lazyComponent: LazyComponentType<any, any>,
1096
props: Object,
1097
ref: any,
@@ -1066,7 +1101,14 @@ function renderLazyComponent(
1101
const init = lazyComponent._init;
1102
const Component = init(payload);
1103
const resolvedProps = resolveDefaultProps(Component, props);
1069
- renderElement(request, task, Component, resolvedProps, ref);
1104
+ renderElement(
1105
+ request,
1106
+ task,
1107
+ prevThenableState,
1108
+ Component,
1109
+ resolvedProps,
1110
+ ref,
1111
+ );
1112
popComponentStackInDEV(task);
1113
}
1114
@@ -1078,13 +1120,14 @@ function renderOffscreen(request: Request, task: Task, props: Object): void {
1120
} else {
1121
// A visible Offscreen boundary is treated exactly like a fragment: a
1122
// pure indirection.
1081
- renderNodeDestructive(request, task, props.children);
1123
+ renderNodeDestructive(request, task, null, props.children);
1124
}
1125
}
1126
1127
function renderElement(
1128
request: Request,
1129
task: Task,
1130
+ prevThenableState: ThenableState | null,
1131
type: any,
1132
props: Object,
1133
ref: any,
@@ -1094,7 +1137,13 @@ function renderElement(
1137
renderClassComponent(request, task, type, props);
1138
return;
1139
} else {
1097
- renderIndeterminateComponent(request, task, type, props);
1140
+ renderIndeterminateComponent(
1141
+ request,
1142
+ task,
1143
+ prevThenableState,
1144
+ type,
1145
+ props,
1146
+ );
1147
return;
1148
}
1149
}
@@ -1118,7 +1167,7 @@ function renderElement(
1167
case REACT_STRICT_MODE_TYPE:
1168
case REACT_PROFILER_TYPE:
1169
case REACT_FRAGMENT_TYPE: {
1121
- renderNodeDestructive(request, task, props.children);
1170
+ renderNodeDestructive(request, task, null, props.children);
1171
return;
1172
}
1173
case REACT_OFFSCREEN_TYPE: {
@@ -1128,13 +1177,13 @@ function renderElement(
1177
case REACT_SUSPENSE_LIST_TYPE: {
1178
pushBuiltInComponentStackInDEV(task, 'SuspenseList');
1179
// TODO: SuspenseList should control the boundaries.
1131
- renderNodeDestructive(request, task, props.children);
1180
+ renderNodeDestructive(request, task, null, props.children);
1181
popComponentStackInDEV(task);
1182
return;
1183
}
1184
case REACT_SCOPE_TYPE: {
1185
if (enableScopeAPI) {
1137
- renderNodeDestructive(request, task, props.children);
1186
+ renderNodeDestructive(request, task, null, props.children);
1187
return;
1188
}
1189
throw new Error('ReactDOMServer does not yet support scope components.');
@@ -1156,11 +1205,11 @@ function renderElement(
1205
if (typeof type === 'object' && type !== null) {
1206
switch (type.$$typeof) {
1207
case REACT_FORWARD_REF_TYPE: {
1159
- renderForwardRef(request, task, type, props, ref);
1208
+ renderForwardRef(request, task, prevThenableState, type, props, ref);
1209
return;
1210
}
1211
case REACT_MEMO_TYPE: {
1163
- renderMemo(request, task, type, props, ref);
1212
+ renderMemo(request, task, prevThenableState, type, props, ref);
1213
return;
1214
}
1215
case REACT_PROVIDER_TYPE: {
@@ -1172,7 +1221,7 @@ function renderElement(
1221
return;
1222
}
1223
case REACT_LAZY_TYPE: {
1175
- renderLazyComponent(request, task, type, props);
1224
+ renderLazyComponent(request, task, prevThenableState, type, props);
1225
return;
1226
}
1227
}
@@ -1237,6 +1286,9 @@ function validateIterable(iterable, iteratorFn: Function): void {
1286
function renderNodeDestructive(
1287
request: Request,
1288
task: Task,
1289
+ // The thenable state reused from the previous attempt, if any. This is almost
1290
+ // always null, except when called by retryTask.
1291
+ prevThenableState: ThenableState | null,
1292
node: ReactNodeList,
1293
): void {
1294
if (__DEV__) {
@@ -1244,7 +1296,7 @@ function renderNodeDestructive(
1296
// a component stack at the right place in the tree. We don't do this in renderNode
1297
// becuase it is not called at every layer of the tree and we may lose frames
1298
try {
1247
- return renderNodeDestructiveImpl(request, task, node);
1299
+ return renderNodeDestructiveImpl(request, task, prevThenableState, node);
1300
} catch (x) {
1301
if (typeof x === 'object' && x !== null && typeof x.then === 'function') {
1302
// This is a Wakable, noop
@@ -1259,7 +1311,7 @@ function renderNodeDestructive(
1311
throw x;
1312
}
1313
} else {
1262
- return renderNodeDestructiveImpl(request, task, node);
1314
+ return renderNodeDestructiveImpl(request, task, prevThenableState, node);
1315
}
1316
}
1317
@@ -1268,6 +1320,7 @@ function renderNodeDestructive(
1320
function renderNodeDestructiveImpl(
1321
request: Request,
1322
task: Task,
1323
+ prevThenableState: ThenableState | null,
1324
node: ReactNodeList,
1325
): void {
1326
// Stash the node we're working on. We'll pick up from this task in case
@@ -1282,7 +1335,7 @@ function renderNodeDestructiveImpl(
1335
const type = element.type;
1336
const props = element.props;
1337
const ref = element.ref;
1285
- renderElement(request, task, type, props, ref);
1338
+ renderElement(request, task, prevThenableState, type, props, ref);
1339
return;
1340
}
1341
case REACT_PORTAL_TYPE:
@@ -1316,7 +1369,7 @@ function renderNodeDestructiveImpl(
1369
} else {
1370
resolvedNode = init(payload);
1371
}
1319
- renderNodeDestructive(request, task, resolvedNode);
1372
+ renderNodeDestructive(request, task, null, resolvedNode);
1373
return;
1374
}
1375
}
@@ -1417,6 +1470,7 @@ function renderChildrenArray(request, task, children) {
1470
function spawnNewSuspendedTask(
1471
request: Request,
1472
task: Task,
1473
+ thenableState: ThenableState | null,
1474
x: Promise<any>,
1475
): void {
1476
// Something suspended, we'll need to create a new segment and resolve it later.
@@ -1437,6 +1491,7 @@ function spawnNewSuspendedTask(
1491
segment.lastPushedText = false;
1492
const newTask = createTask(
1493
request,
1494
+ thenableState,
1495
task.node,
1496
task.blockedBoundary,
1497
newSegment,
@@ -1445,6 +1500,9 @@ function spawnNewSuspendedTask(
1500
task.context,
1501
task.treeContext,
1502
);
1503
+
1504
+ trackSuspendedWakeable(x);
1505
+
1506
if (__DEV__) {
1507
if (task.componentStack !== null) {
1508
// We pop one task off the stack because the node that suspended will be tried again,
@@ -1472,11 +1530,13 @@ function renderNode(request: Request, task: Task, node: ReactNodeList): void {
1530
previousComponentStack = task.componentStack;
1531
}
1532
try {
1475
- return renderNodeDestructive(request, task, node);
1533
+ return renderNodeDestructive(request, task, null, node);
1534
} catch (x) {
1535
resetHooksState();
1536
if (typeof x === 'object' && x !== null && typeof x.then === 'function') {
1479
- spawnNewSuspendedTask(request, task, x);
1537
+ const thenableState = getThenableStateAfterSuspending();
1538
+ spawnNewSuspendedTask(request, task, thenableState, x);
1539
+
1540
// Restore the context. We assume that this will be restored by the inner
1541
// functions in case nothing throws so we don't use "finally" here.
1542
task.blockedSegment.formatContext = previousFormatContext;
@@ -1731,7 +1791,14 @@ function retryTask(request: Request, task: Task): void {
1791
try {
1792
// We call the destructive form that mutates this task. That way if something
1793
// suspends again, we can reuse the same task instead of spawning a new one.
1734
- renderNodeDestructive(request, task, task.node);
1794
+
1795
+ // Reset the task's thenable state before continuing, so that if a later
1796
+ // component suspends we can reuse the same task object. If the same
1797
+ // component suspends again, the thenable state will be restored.
1798
+ const prevThenableState = task.thenableState;
1799
+ task.thenableState = null;
1800
+
1801
+ renderNodeDestructive(request, task, prevThenableState, task.node);
1802
pushSegmentFinale(
1803
segment.chunks,
1804
request.responseState,
@@ -1748,6 +1815,10 @@ function retryTask(request: Request, task: Task): void {
1815
// Something suspended again, let's pick it back up later.
1816
const ping = task.ping;
1817
x.then(ping, ping);
1818
+
1819
+ const wakeable: Wakeable = x;
1820
+ trackSuspendedWakeable(wakeable);
1821
+ task.thenableState = getThenableStateAfterSuspending();
1822
} else {
1823
task.abortSet.delete(task);
1824
segment.status = ERRORED;
packages/react-server/src/ReactFizzWakeable.js
new
+107
@@ -0,0 +1,107 @@
1
+/**
2
+ * Copyright (c) Facebook, Inc. and its 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 ReactFiberWakeable and ReactFlightWakeable modules. Generally,
11
+// changes to one module should be reflected in the others.
12
+
13
+// TODO: Rename this module and the corresponding Fiber one to "Thenable"
14
+// instead of "Wakeable". Or some other more appropriate name.
15
+
16
+import type {
17
+ Wakeable,
18
+ Thenable,
19
+ PendingThenable,
20
+ FulfilledThenable,
21
+ RejectedThenable,
22
+} from 'shared/ReactTypes';
23
+
24
+// TODO: Sparse arrays are bad for performance.
25
+export opaque type ThenableState = Array<Thenable<any> | void>;
26
+
27
+export function createThenableState(): ThenableState {
28
+ // The ThenableState is created the first time a component suspends. If it
29
+ // suspends again, we'll reuse the same state.
30
+ return [];
31
+}
32
+
33
+export function trackSuspendedWakeable(wakeable: Wakeable) {
34
+ // If this wakeable isn't already a thenable, turn it into one now. Then,
35
+ // when we resume the work loop, we can check if its status is
36
+ // still pending.
37
+ // TODO: Get rid of the Wakeable type? It's superseded by UntrackedThenable.
38
+ const thenable: Thenable<mixed> = (wakeable: any);
39
+
40
+ // We use an expando to track the status and result of a thenable so that we
41
+ // can synchronously unwrap the value. Think of this as an extension of the
42
+ // Promise API, or a custom interface that is a superset of Thenable.
43
+ //
44
+ // If the thenable doesn't have a status, set it to "pending" and attach
45
+ // a listener that will update its status and result when it resolves.
46
+ switch (thenable.status) {
47
+ case 'pending':
48
+ // Since the status is already "pending", we can assume it will be updated
49
+ // when it resolves, either by React or something in userspace.
50
+ break;
51
+ case 'fulfilled':
52
+ case 'rejected':
53
+ // A thenable that already resolved shouldn't have been thrown, so this is
54
+ // unexpected. Suggests a mistake in a userspace data library. Don't track
55
+ // this thenable, because if we keep trying it will likely infinite loop
56
+ // without ever resolving.
57
+ // TODO: Log a warning?
58
+ break;
59
+ default: {
60
+ // TODO: Only instrument the thenable if the status if not defined. If
61
+ // it's defined, but an unknown value, assume it's been instrumented by
62
+ // some custom userspace implementation.
63
+ const pendingThenable: PendingThenable<mixed> = (thenable: any);
64
+ pendingThenable.status = 'pending';
65
+ pendingThenable.then(
66
+ fulfilledValue => {
67
+ if (thenable.status === 'pending') {
68
+ const fulfilledThenable: FulfilledThenable<mixed> = (thenable: any);
69
+ fulfilledThenable.status = 'fulfilled';
70
+ fulfilledThenable.value = fulfilledValue;
71
+ }
72
+ },
73
+ (error: mixed) => {
74
+ if (thenable.status === 'pending') {
75
+ const rejectedThenable: RejectedThenable<mixed> = (thenable: any);
76
+ rejectedThenable.status = 'rejected';
77
+ rejectedThenable.reason = error;
78
+ }
79
+ },
80
+ );
81
+ break;
82
+ }
83
+ }
84
+}
85
+
86
+export function trackUsedThenable<T>(
87
+ thenableState: ThenableState,
88
+ thenable: Thenable<T>,
89
+ index: number,
90
+) {
91
+ // This is only a separate function from trackSuspendedWakeable for symmetry
92
+ // with Fiber.
93
+ thenableState[index] = thenable;
94
+}
95
+
96
+export function getPreviouslyUsedThenableAtIndex<T>(
97
+ thenableState: ThenableState | null,
98
+ index: number,
99
+): Thenable<T> | null {
100
+ if (thenableState !== null) {
101
+ const thenable = thenableState[index];
102
+ if (thenable !== undefined) {
103
+ return thenable;
104
+ }
105
+ }
106
+ return null;
107
+}
packages/react-server/src/ReactFlightHooks.js
+3
-1
@@ -40,7 +40,9 @@ export function prepareToUseHooksForComponent(
40
}
41
42
export function getThenableStateAfterSuspending() {
43
- return thenableState;
43
+ const state = thenableState;
44
+ thenableState = null;
45
+ return state;
46
}
47
48
function readContext<T>(context: ReactServerContext<T>): T {
packages/react-server/src/ReactFlightWakeable.js
+2
-2
@@ -7,8 +7,8 @@
7
* @flow
8
*/
9
10
-// Corresponds to ReactFiberWakeable module. Generally, changes to one module
11
-// should be reflected in the other.
10
+// Corresponds to ReactFiberWakeable and ReactFizzWakeable modules. Generally,
11
+// changes to one module should be reflected in the others.
12
13
// TODO: Rename this module and the corresponding Fiber one to "Thenable"
14
// instead of "Wakeable". Or some other more appropriate name.