@samitouri / QOS-React-2 / commits / c28f313e6d

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.