From a0abef3efda12510fe529baf506b644aec4b29ba Mon Sep 17 00:00:00 2001 From: Andrew Clark Date: Thu, 8 Sep 2022 21:47:33 -0400 Subject: [PATCH] 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. --- .../src/__tests__/ReactDOMFizzServer-test.js | 212 ++++++++++++++++++ packages/react-server/src/ReactFizzHooks.js | 105 ++++++++- packages/react-server/src/ReactFizzServer.js | 127 ++++++++--- .../react-server/src/ReactFizzWakeable.js | 107 +++++++++ packages/react-server/src/ReactFlightHooks.js | 4 +- .../react-server/src/ReactFlightWakeable.js | 4 +- 6 files changed, 525 insertions(+), 34 deletions(-) create mode 100644 packages/react-server/src/ReactFizzWakeable.js diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js index 3812baffc7486..243c7dba8607c 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js @@ -19,6 +19,7 @@ let Suspense; let SuspenseList; let useSyncExternalStore; let useSyncExternalStoreWithSelector; +let use; let PropTypes; let textCache; let window; @@ -42,6 +43,7 @@ describe('ReactDOMFizzServer', () => { Suspense = React.Suspense; if (gate(flags => flags.enableSuspenseList)) { SuspenseList = React.SuspenseList; + use = React.experimental_use; } PropTypes = require('prop-types'); @@ -5243,5 +5245,215 @@ describe('ReactDOMFizzServer', () => { console.error = originalConsoleError; } }); + + // @gate enableUseHook + it('basic use(promise)', async () => { + const promiseA = Promise.resolve('A'); + const promiseB = Promise.resolve('B'); + const promiseC = Promise.resolve('C'); + + function Async() { + return use(promiseA) + use(promiseB) + use(promiseC); + } + + function App() { + return ( + + + + ); + } + + await act(async () => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(); + pipe(writable); + }); + + // TODO: The `act` implementation in this file doesn't unwrap microtasks + // automatically. We can't use the same `act` we use for Fiber tests + // because that relies on the mock Scheduler. Doesn't affect any public + // API but we might want to fix this for our own internal tests. + // + // For now, wait for each promise in sequence. + await act(async () => { + await promiseA; + }); + await act(async () => { + await promiseB; + }); + await act(async () => { + await promiseC; + }); + + expect(getVisibleChildren(container)).toEqual('ABC'); + + ReactDOMClient.hydrateRoot(container, ); + expect(Scheduler).toFlushAndYield([]); + expect(getVisibleChildren(container)).toEqual('ABC'); + }); + + // @gate enableUseHook + it('use(promise) in multiple components', async () => { + const promiseA = Promise.resolve('A'); + const promiseB = Promise.resolve('B'); + const promiseC = Promise.resolve('C'); + const promiseD = Promise.resolve('D'); + + function Child({prefix}) { + return prefix + use(promiseC) + use(promiseD); + } + + function Parent() { + return ; + } + + function App() { + return ( + + + + ); + } + + await act(async () => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(); + pipe(writable); + }); + + // TODO: The `act` implementation in this file doesn't unwrap microtasks + // automatically. We can't use the same `act` we use for Fiber tests + // because that relies on the mock Scheduler. Doesn't affect any public + // API but we might want to fix this for our own internal tests. + // + // For now, wait for each promise in sequence. + await act(async () => { + await promiseA; + }); + await act(async () => { + await promiseB; + }); + await act(async () => { + await promiseC; + }); + await act(async () => { + await promiseD; + }); + + expect(getVisibleChildren(container)).toEqual('ABCD'); + + ReactDOMClient.hydrateRoot(container, ); + expect(Scheduler).toFlushAndYield([]); + expect(getVisibleChildren(container)).toEqual('ABCD'); + }); + + // @gate enableUseHook + it('using a rejected promise will throw', async () => { + const promiseA = Promise.resolve('A'); + const promiseB = Promise.reject(new Error('Oops!')); + const promiseC = Promise.resolve('C'); + + // Jest/Node will raise an unhandled rejected error unless we await this. It + // works fine in the browser, though. + await expect(promiseB).rejects.toThrow('Oops!'); + + function Async() { + return use(promiseA) + use(promiseB) + use(promiseC); + } + + class ErrorBoundary extends React.Component { + state = {error: null}; + static getDerivedStateFromError(error) { + return {error}; + } + render() { + if (this.state.error) { + return this.state.error.message; + } + return this.props.children; + } + } + + function App() { + return ( + + + + + + ); + } + + const reportedServerErrors = []; + await act(async () => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(, { + onError(error) { + reportedServerErrors.push(error); + }, + }); + pipe(writable); + }); + + // TODO: The `act` implementation in this file doesn't unwrap microtasks + // automatically. We can't use the same `act` we use for Fiber tests + // because that relies on the mock Scheduler. Doesn't affect any public + // API but we might want to fix this for our own internal tests. + // + // For now, wait for each promise in sequence. + await act(async () => { + await promiseA; + }); + await act(async () => { + await expect(promiseB).rejects.toThrow('Oops!'); + }); + await act(async () => { + await promiseC; + }); + + expect(getVisibleChildren(container)).toEqual('Loading...'); + expect(reportedServerErrors.length).toBe(1); + expect(reportedServerErrors[0].message).toBe('Oops!'); + + const reportedClientErrors = []; + ReactDOMClient.hydrateRoot(container, , { + onRecoverableError(error) { + reportedClientErrors.push(error); + }, + }); + expect(Scheduler).toFlushAndYield([]); + expect(getVisibleChildren(container)).toEqual('Oops!'); + expect(reportedClientErrors.length).toBe(1); + if (__DEV__) { + expect(reportedClientErrors[0].message).toBe('Oops!'); + } else { + expect(reportedClientErrors[0].message).toBe( + 'The server could not finish this Suspense boundary, likely due to ' + + 'an error during server rendering. Switched to client rendering.', + ); + } + }); + + // @gate enableUseHook + it("use a promise that's already been instrumented and resolved", async () => { + const thenable = { + status: 'fulfilled', + value: 'Hi', + then() {}, + }; + + // This will never suspend because the thenable already resolved + function App() { + return use(thenable); + } + + await act(async () => { + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(); + pipe(writable); + }); + expect(getVisibleChildren(container)).toEqual('Hi'); + + ReactDOMClient.hydrateRoot(container, ); + expect(Scheduler).toFlushAndYield([]); + expect(getVisibleChildren(container)).toEqual('Hi'); + }); }); }); diff --git a/packages/react-server/src/ReactFizzHooks.js b/packages/react-server/src/ReactFizzHooks.js index 960800e903c2c..02072fabd3f37 100644 --- a/packages/react-server/src/ReactFizzHooks.js +++ b/packages/react-server/src/ReactFizzHooks.js @@ -15,17 +15,29 @@ import type { MutableSourceSubscribeFn, ReactContext, StartTransitionOptions, + Thenable, + Usable, } from 'shared/ReactTypes'; import type {ResponseState} from './ReactServerFormatConfig'; import type {Task} from './ReactFizzServer'; +import type {ThenableState} from './ReactFizzWakeable'; import {readContext as readContextImpl} from './ReactFizzNewContext'; import {getTreeId} from './ReactFizzTreeContext'; +import { + getPreviouslyUsedThenableAtIndex, + createThenableState, + trackUsedThenable, +} from './ReactFizzWakeable'; import {makeId} from './ReactServerFormatConfig'; -import {enableCache, enableUseMemoCacheHook} from 'shared/ReactFeatureFlags'; +import { + enableCache, + enableUseHook, + enableUseMemoCacheHook, +} from 'shared/ReactFeatureFlags'; import is from 'shared/objectIs'; type BasicStateAction = (S => S) | S; @@ -57,6 +69,9 @@ let isReRender: boolean = false; let didScheduleRenderPhaseUpdate: boolean = false; // Counts the number of useId hooks in this component let localIdCounter: number = 0; +// Counts the number of use(thenable) calls in this component +let thenableIndexCounter: number = 0; +let thenableState: ThenableState | null = null; // Lazily created map of render-phase updates let renderPhaseUpdates: Map, Update> | null = null; // Counter to prevent infinite loops. @@ -169,7 +184,11 @@ function createWorkInProgressHook(): Hook { return workInProgressHook; } -export function prepareToUseHooks(task: Task, componentIdentity: Object): void { +export function prepareToUseHooks( + task: Task, + componentIdentity: Object, + prevThenableState: ThenableState | null, +): void { currentlyRenderingComponent = componentIdentity; currentlyRenderingTask = task; if (__DEV__) { @@ -178,13 +197,14 @@ export function prepareToUseHooks(task: Task, componentIdentity: Object): void { // The following should have already been reset // didScheduleRenderPhaseUpdate = false; - // localIdCounter = 0; // firstWorkInProgressHook = null; // numberOfReRenders = 0; // renderPhaseUpdates = null; // workInProgressHook = null; localIdCounter = 0; + thenableIndexCounter = 0; + thenableState = prevThenableState; } export function finishHooks( @@ -203,6 +223,7 @@ export function finishHooks( // restarting until no more updates are scheduled. didScheduleRenderPhaseUpdate = false; localIdCounter = 0; + thenableIndexCounter = 0; numberOfReRenders += 1; // Start over from the beginning of the list @@ -214,6 +235,12 @@ export function finishHooks( return children; } +export function getThenableStateAfterSuspending() { + const state = thenableState; + thenableState = null; + return state; +} + export function checkDidRenderIdHook() { // This should be called immediately after every finishHooks call. // Conceptually, it's part of the return value of finishHooks; it's only a @@ -529,6 +556,75 @@ function useId(): string { return makeId(responseState, treeId, localId); } +function use(usable: Usable): T { + if (usable !== null && typeof usable === 'object') { + if (typeof usable.then === 'function') { + // This is a thenable. + const thenable: Thenable = (usable: any); + + // Track the position of the thenable within this fiber. + const index = thenableIndexCounter; + thenableIndexCounter += 1; + + switch (thenable.status) { + case 'fulfilled': { + const fulfilledValue: T = thenable.value; + return fulfilledValue; + } + case 'rejected': { + const rejectedError = thenable.reason; + throw rejectedError; + } + default: { + const prevThenableAtIndex: Thenable | null = getPreviouslyUsedThenableAtIndex( + thenableState, + index, + ); + if (prevThenableAtIndex !== null) { + switch (prevThenableAtIndex.status) { + case 'fulfilled': { + const fulfilledValue: T = prevThenableAtIndex.value; + return fulfilledValue; + } + case 'rejected': { + const rejectedError: mixed = prevThenableAtIndex.reason; + throw rejectedError; + } + default: { + // The thenable still hasn't resolved. Suspend with the same + // thenable as last time to avoid redundant listeners. + throw prevThenableAtIndex; + } + } + } else { + // This is the first time something has been used at this index. + // Stash the thenable at the current index so we can reuse it during + // the next attempt. + if (thenableState === null) { + thenableState = createThenableState(); + } + trackUsedThenable(thenableState, thenable, index); + + // Suspend. + // TODO: 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. + throw thenable; + } + } + } + } else { + // TODO: Add support for Context + } + } + + // eslint-disable-next-line react-internal/safe-string-coercion + throw new Error('An unsupported type was passed to use(): ' + String(usable)); +} + function unsupportedRefresh() { throw new Error('Cache cannot be refreshed during server rendering.'); } @@ -574,6 +670,9 @@ if (enableCache) { if (enableUseMemoCacheHook) { Dispatcher.useMemoCache = useMemoCache; } +if (enableUseHook) { + Dispatcher.use = use; +} export let currentResponseState: null | ResponseState = (null: any); export function setCurrentResponseState( diff --git a/packages/react-server/src/ReactFizzServer.js b/packages/react-server/src/ReactFizzServer.js index 7828c808f16c5..5f44458e7889c 100644 --- a/packages/react-server/src/ReactFizzServer.js +++ b/packages/react-server/src/ReactFizzServer.js @@ -17,6 +17,7 @@ import type { ReactContext, ReactProviderType, OffscreenMode, + Wakeable, } from 'shared/ReactTypes'; import type {LazyComponent as LazyComponentType} from 'react/src/ReactLazy'; import type { @@ -27,6 +28,7 @@ import type { import type {ContextSnapshot} from './ReactFizzNewContext'; import type {ComponentStackNode} from './ReactFizzComponentStack'; import type {TreeContext} from './ReactFizzTreeContext'; +import type {ThenableState} from './ReactFizzWakeable'; import { scheduleWork, @@ -87,6 +89,7 @@ import { Dispatcher, currentResponseState, setCurrentResponseState, + getThenableStateAfterSuspending, } from './ReactFizzHooks'; import {getStackByComponentStackNode} from './ReactFizzComponentStack'; import {emptyTreeContext, pushTreeContext} from './ReactFizzTreeContext'; @@ -123,6 +126,7 @@ import { import assign from 'shared/assign'; import getComponentNameFromType from 'shared/getComponentNameFromType'; import isArray from 'shared/isArray'; +import {trackSuspendedWakeable} from './ReactFizzWakeable'; const ReactCurrentDispatcher = ReactSharedInternals.ReactCurrentDispatcher; const ReactDebugCurrentFrame = ReactSharedInternals.ReactDebugCurrentFrame; @@ -155,6 +159,7 @@ export type Task = { context: ContextSnapshot, // the current new context that this task is executing in treeContext: TreeContext, // the current tree context that this task is executing in componentStack: null | ComponentStackNode, // DEV-only component stack + thenableState: null | ThenableState, }; const PENDING = 0; @@ -297,6 +302,7 @@ export function createRequest( rootSegment.parentFlushed = true; const rootTask = createTask( request, + null, children, null, rootSegment, @@ -336,6 +342,7 @@ function createSuspenseBoundary( function createTask( request: Request, + thenableState: ThenableState | null, node: ReactNodeList, blockedBoundary: Root | SuspenseBoundary, blockedSegment: Segment, @@ -359,6 +366,7 @@ function createTask( legacyContext, context, treeContext, + thenableState, }: any); if (__DEV__) { task.componentStack = null; @@ -592,6 +600,7 @@ function renderSuspenseBoundary( // on it yet in case we finish the main content, so we queue for later. const suspendedFallbackTask = createTask( request, + null, fallback, parentBoundary, boundarySegment, @@ -665,12 +674,13 @@ function shouldConstruct(Component) { function renderWithHooks( request: Request, task: Task, + prevThenableState: ThenableState | null, Component: (p: Props, arg: SecondArg) => any, props: Props, secondArg: SecondArg, ): any { const componentIdentity = {}; - prepareToUseHooks(task, componentIdentity); + prepareToUseHooks(task, componentIdentity, prevThenableState); const result = Component(props, secondArg); return finishHooks(Component, props, result, secondArg); } @@ -708,13 +718,13 @@ function finishClassComponent( childContextTypes, ); task.legacyContext = mergedContext; - renderNodeDestructive(request, task, nextChildren); + renderNodeDestructive(request, task, null, nextChildren); task.legacyContext = previousContext; return; } } - renderNodeDestructive(request, task, nextChildren); + renderNodeDestructive(request, task, null, nextChildren); } function renderClassComponent( @@ -748,6 +758,7 @@ let hasWarnedAboutUsingContextAsConsumer = false; function renderIndeterminateComponent( request: Request, task: Task, + prevThenableState: ThenableState | null, Component: any, props: any, ): void { @@ -776,7 +787,14 @@ function renderIndeterminateComponent( } } - const value = renderWithHooks(request, task, Component, props, legacyContext); + const value = renderWithHooks( + request, + task, + prevThenableState, + Component, + props, + legacyContext, + ); const hasId = checkDidRenderIdHook(); if (__DEV__) { @@ -857,12 +875,12 @@ function renderIndeterminateComponent( const index = 0; task.treeContext = pushTreeContext(prevTreeContext, totalChildren, index); try { - renderNodeDestructive(request, task, value); + renderNodeDestructive(request, task, null, value); } finally { task.treeContext = prevTreeContext; } } else { - renderNodeDestructive(request, task, value); + renderNodeDestructive(request, task, null, value); } } popComponentStackInDEV(task); @@ -942,12 +960,20 @@ function resolveDefaultProps(Component: any, baseProps: Object): Object { function renderForwardRef( request: Request, task: Task, + prevThenableState, type: any, props: Object, ref: any, ): void { pushFunctionComponentStackInDEV(task, type.render); - const children = renderWithHooks(request, task, type.render, props, ref); + const children = renderWithHooks( + request, + task, + prevThenableState, + type.render, + props, + ref, + ); const hasId = checkDidRenderIdHook(); if (hasId) { // This component materialized an id. We treat this as its own level, with @@ -957,12 +983,12 @@ function renderForwardRef( const index = 0; task.treeContext = pushTreeContext(prevTreeContext, totalChildren, index); try { - renderNodeDestructive(request, task, children); + renderNodeDestructive(request, task, null, children); } finally { task.treeContext = prevTreeContext; } } else { - renderNodeDestructive(request, task, children); + renderNodeDestructive(request, task, null, children); } popComponentStackInDEV(task); } @@ -970,13 +996,21 @@ function renderForwardRef( function renderMemo( request: Request, task: Task, + prevThenableState: ThenableState | null, type: any, props: Object, ref: any, ): void { const innerType = type.type; const resolvedProps = resolveDefaultProps(innerType, props); - renderElement(request, task, innerType, resolvedProps, ref); + renderElement( + request, + task, + prevThenableState, + innerType, + resolvedProps, + ref, + ); } function renderContextConsumer( @@ -1026,7 +1060,7 @@ function renderContextConsumer( const newValue = readContext(context); const newChildren = render(newValue); - renderNodeDestructive(request, task, newChildren); + renderNodeDestructive(request, task, null, newChildren); } function renderContextProvider( @@ -1043,7 +1077,7 @@ function renderContextProvider( prevSnapshot = task.context; } task.context = pushProvider(context, value); - renderNodeDestructive(request, task, children); + renderNodeDestructive(request, task, null, children); task.context = popProvider(context); if (__DEV__) { if (prevSnapshot !== task.context) { @@ -1057,6 +1091,7 @@ function renderContextProvider( function renderLazyComponent( request: Request, task: Task, + prevThenableState: ThenableState | null, lazyComponent: LazyComponentType, props: Object, ref: any, @@ -1066,7 +1101,14 @@ function renderLazyComponent( const init = lazyComponent._init; const Component = init(payload); const resolvedProps = resolveDefaultProps(Component, props); - renderElement(request, task, Component, resolvedProps, ref); + renderElement( + request, + task, + prevThenableState, + Component, + resolvedProps, + ref, + ); popComponentStackInDEV(task); } @@ -1078,13 +1120,14 @@ function renderOffscreen(request: Request, task: Task, props: Object): void { } else { // A visible Offscreen boundary is treated exactly like a fragment: a // pure indirection. - renderNodeDestructive(request, task, props.children); + renderNodeDestructive(request, task, null, props.children); } } function renderElement( request: Request, task: Task, + prevThenableState: ThenableState | null, type: any, props: Object, ref: any, @@ -1094,7 +1137,13 @@ function renderElement( renderClassComponent(request, task, type, props); return; } else { - renderIndeterminateComponent(request, task, type, props); + renderIndeterminateComponent( + request, + task, + prevThenableState, + type, + props, + ); return; } } @@ -1118,7 +1167,7 @@ function renderElement( case REACT_STRICT_MODE_TYPE: case REACT_PROFILER_TYPE: case REACT_FRAGMENT_TYPE: { - renderNodeDestructive(request, task, props.children); + renderNodeDestructive(request, task, null, props.children); return; } case REACT_OFFSCREEN_TYPE: { @@ -1128,13 +1177,13 @@ function renderElement( case REACT_SUSPENSE_LIST_TYPE: { pushBuiltInComponentStackInDEV(task, 'SuspenseList'); // TODO: SuspenseList should control the boundaries. - renderNodeDestructive(request, task, props.children); + renderNodeDestructive(request, task, null, props.children); popComponentStackInDEV(task); return; } case REACT_SCOPE_TYPE: { if (enableScopeAPI) { - renderNodeDestructive(request, task, props.children); + renderNodeDestructive(request, task, null, props.children); return; } throw new Error('ReactDOMServer does not yet support scope components.'); @@ -1156,11 +1205,11 @@ function renderElement( if (typeof type === 'object' && type !== null) { switch (type.$$typeof) { case REACT_FORWARD_REF_TYPE: { - renderForwardRef(request, task, type, props, ref); + renderForwardRef(request, task, prevThenableState, type, props, ref); return; } case REACT_MEMO_TYPE: { - renderMemo(request, task, type, props, ref); + renderMemo(request, task, prevThenableState, type, props, ref); return; } case REACT_PROVIDER_TYPE: { @@ -1172,7 +1221,7 @@ function renderElement( return; } case REACT_LAZY_TYPE: { - renderLazyComponent(request, task, type, props); + renderLazyComponent(request, task, prevThenableState, type, props); return; } } @@ -1237,6 +1286,9 @@ function validateIterable(iterable, iteratorFn: Function): void { function renderNodeDestructive( request: Request, task: Task, + // The thenable state reused from the previous attempt, if any. This is almost + // always null, except when called by retryTask. + prevThenableState: ThenableState | null, node: ReactNodeList, ): void { if (__DEV__) { @@ -1244,7 +1296,7 @@ function renderNodeDestructive( // a component stack at the right place in the tree. We don't do this in renderNode // becuase it is not called at every layer of the tree and we may lose frames try { - return renderNodeDestructiveImpl(request, task, node); + return renderNodeDestructiveImpl(request, task, prevThenableState, node); } catch (x) { if (typeof x === 'object' && x !== null && typeof x.then === 'function') { // This is a Wakable, noop @@ -1259,7 +1311,7 @@ function renderNodeDestructive( throw x; } } else { - return renderNodeDestructiveImpl(request, task, node); + return renderNodeDestructiveImpl(request, task, prevThenableState, node); } } @@ -1268,6 +1320,7 @@ function renderNodeDestructive( function renderNodeDestructiveImpl( request: Request, task: Task, + prevThenableState: ThenableState | null, node: ReactNodeList, ): void { // Stash the node we're working on. We'll pick up from this task in case @@ -1282,7 +1335,7 @@ function renderNodeDestructiveImpl( const type = element.type; const props = element.props; const ref = element.ref; - renderElement(request, task, type, props, ref); + renderElement(request, task, prevThenableState, type, props, ref); return; } case REACT_PORTAL_TYPE: @@ -1316,7 +1369,7 @@ function renderNodeDestructiveImpl( } else { resolvedNode = init(payload); } - renderNodeDestructive(request, task, resolvedNode); + renderNodeDestructive(request, task, null, resolvedNode); return; } } @@ -1417,6 +1470,7 @@ function renderChildrenArray(request, task, children) { function spawnNewSuspendedTask( request: Request, task: Task, + thenableState: ThenableState | null, x: Promise, ): void { // Something suspended, we'll need to create a new segment and resolve it later. @@ -1437,6 +1491,7 @@ function spawnNewSuspendedTask( segment.lastPushedText = false; const newTask = createTask( request, + thenableState, task.node, task.blockedBoundary, newSegment, @@ -1445,6 +1500,9 @@ function spawnNewSuspendedTask( task.context, task.treeContext, ); + + trackSuspendedWakeable(x); + if (__DEV__) { if (task.componentStack !== null) { // 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 { previousComponentStack = task.componentStack; } try { - return renderNodeDestructive(request, task, node); + return renderNodeDestructive(request, task, null, node); } catch (x) { resetHooksState(); if (typeof x === 'object' && x !== null && typeof x.then === 'function') { - spawnNewSuspendedTask(request, task, x); + const thenableState = getThenableStateAfterSuspending(); + spawnNewSuspendedTask(request, task, thenableState, x); + // Restore the context. We assume that this will be restored by the inner // functions in case nothing throws so we don't use "finally" here. task.blockedSegment.formatContext = previousFormatContext; @@ -1731,7 +1791,14 @@ function retryTask(request: Request, task: Task): void { try { // We call the destructive form that mutates this task. That way if something // suspends again, we can reuse the same task instead of spawning a new one. - renderNodeDestructive(request, task, task.node); + + // Reset the task's thenable state before continuing, so that if a later + // component suspends we can reuse the same task object. If the same + // component suspends again, the thenable state will be restored. + const prevThenableState = task.thenableState; + task.thenableState = null; + + renderNodeDestructive(request, task, prevThenableState, task.node); pushSegmentFinale( segment.chunks, request.responseState, @@ -1748,6 +1815,10 @@ function retryTask(request: Request, task: Task): void { // Something suspended again, let's pick it back up later. const ping = task.ping; x.then(ping, ping); + + const wakeable: Wakeable = x; + trackSuspendedWakeable(wakeable); + task.thenableState = getThenableStateAfterSuspending(); } else { task.abortSet.delete(task); segment.status = ERRORED; diff --git a/packages/react-server/src/ReactFizzWakeable.js b/packages/react-server/src/ReactFizzWakeable.js new file mode 100644 index 0000000000000..5a42ed396c4d9 --- /dev/null +++ b/packages/react-server/src/ReactFizzWakeable.js @@ -0,0 +1,107 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +// Corresponds to ReactFiberWakeable and ReactFlightWakeable modules. Generally, +// changes to one module should be reflected in the others. + +// TODO: Rename this module and the corresponding Fiber one to "Thenable" +// instead of "Wakeable". Or some other more appropriate name. + +import type { + Wakeable, + Thenable, + PendingThenable, + FulfilledThenable, + RejectedThenable, +} from 'shared/ReactTypes'; + +// TODO: Sparse arrays are bad for performance. +export opaque type ThenableState = Array | void>; + +export function createThenableState(): ThenableState { + // The ThenableState is created the first time a component suspends. If it + // suspends again, we'll reuse the same state. + return []; +} + +export function trackSuspendedWakeable(wakeable: Wakeable) { + // If this wakeable isn't already a thenable, turn it into one now. Then, + // when we resume the work loop, we can check if its status is + // still pending. + // TODO: Get rid of the Wakeable type? It's superseded by UntrackedThenable. + const thenable: Thenable = (wakeable: any); + + // 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 'pending': + // Since the status is already "pending", we can assume it will be updated + // when it resolves, either by React or something in userspace. + break; + case 'fulfilled': + case 'rejected': + // A thenable that already resolved shouldn't have been thrown, so this is + // unexpected. Suggests a mistake in a userspace data library. Don't track + // this thenable, because if we keep trying it will likely infinite loop + // without ever resolving. + // TODO: Log a warning? + break; + default: { + // TODO: 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. + const pendingThenable: PendingThenable = (thenable: any); + pendingThenable.status = 'pending'; + pendingThenable.then( + fulfilledValue => { + if (thenable.status === 'pending') { + const fulfilledThenable: FulfilledThenable = (thenable: any); + fulfilledThenable.status = 'fulfilled'; + fulfilledThenable.value = fulfilledValue; + } + }, + (error: mixed) => { + if (thenable.status === 'pending') { + const rejectedThenable: RejectedThenable = (thenable: any); + rejectedThenable.status = 'rejected'; + rejectedThenable.reason = error; + } + }, + ); + break; + } + } +} + +export function trackUsedThenable( + thenableState: ThenableState, + thenable: Thenable, + index: number, +) { + // This is only a separate function from trackSuspendedWakeable for symmetry + // with Fiber. + thenableState[index] = thenable; +} + +export function getPreviouslyUsedThenableAtIndex( + thenableState: ThenableState | null, + index: number, +): Thenable | null { + if (thenableState !== null) { + const thenable = thenableState[index]; + if (thenable !== undefined) { + return thenable; + } + } + return null; +} diff --git a/packages/react-server/src/ReactFlightHooks.js b/packages/react-server/src/ReactFlightHooks.js index 2644898f1cf5b..f4784c82e1cf1 100644 --- a/packages/react-server/src/ReactFlightHooks.js +++ b/packages/react-server/src/ReactFlightHooks.js @@ -40,7 +40,9 @@ export function prepareToUseHooksForComponent( } export function getThenableStateAfterSuspending() { - return thenableState; + const state = thenableState; + thenableState = null; + return state; } function readContext(context: ReactServerContext): T { diff --git a/packages/react-server/src/ReactFlightWakeable.js b/packages/react-server/src/ReactFlightWakeable.js index c4d2e67d863bf..c3eda3c16aee6 100644 --- a/packages/react-server/src/ReactFlightWakeable.js +++ b/packages/react-server/src/ReactFlightWakeable.js @@ -7,8 +7,8 @@ * @flow */ -// Corresponds to ReactFiberWakeable module. Generally, changes to one module -// should be reflected in the other. +// Corresponds to ReactFiberWakeable and ReactFizzWakeable modules. Generally, +// changes to one module should be reflected in the others. // TODO: Rename this module and the corresponding Fiber one to "Thenable" // instead of "Wakeable". Or some other more appropriate name.