From a238c93d7988a53a5488b6d9aaa655749ca7af56 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Tue, 12 Sep 2023 21:55:55 -0400 Subject: [PATCH 1/7] Enable tests --- .../src/__tests__/ReactDOMFizzServer-test.js | 2 +- .../ReactDOMFizzStaticBrowser-test.js | 59 ++++++++++++++++--- 2 files changed, 53 insertions(+), 8 deletions(-) diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js index e0a1b64c810c4..076c442f52831 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js @@ -6365,6 +6365,6 @@ describe('ReactDOMFizzServer', () => { resumed.pipe(writable); }); - // TODO: expect(getVisibleChildren(container)).toEqual(
Hello
); + expect(getVisibleChildren(container)).toEqual(
Hello
); }); }); diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzStaticBrowser-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzStaticBrowser-test.js index 1a221dbb88c20..38628e77c8561 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzStaticBrowser-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzStaticBrowser-test.js @@ -490,7 +490,51 @@ describe('ReactDOMFizzStaticBrowser', () => { await readIntoContainer(resumed); - // TODO: expect(getVisibleChildren(container)).toEqual(
Hello
); + expect(getVisibleChildren(container)).toEqual( +
{['Hello', 'World']}
, + ); + }); + + // @gate enablePostpone + it('supports postponing in prerender and resuming with a prefix', async () => { + let prerendering = true; + function Postpone() { + if (prerendering) { + React.unstable_postpone(); + } + return 'World'; + } + + function App() { + return ( +
+ + Hello + + +
+ ); + } + + const prerendered = await ReactDOMFizzStatic.prerender(); + expect(prerendered.postponed).not.toBe(null); + + prerendering = false; + + const resumed = await ReactDOMFizzServer.resume( + , + prerendered.postponed, + ); + + await readIntoContainer(prerendered.prelude); + + expect(getVisibleChildren(container)).toEqual(
Loading...
); + + await readIntoContainer(resumed); + + expect(getVisibleChildren(container)).toEqual( +
{['Hello', 'World']}
, + ); }); // @gate enablePostpone @@ -500,16 +544,12 @@ describe('ReactDOMFizzStaticBrowser', () => { React.unstable_postpone(); }); - function Postpone() { - return 'Hello'; - } - function App() { return (
Hi - {prerendering ? Hole : } + {prerendering ? Hole : 'Hello'}
); @@ -531,7 +571,12 @@ describe('ReactDOMFizzStaticBrowser', () => { await readIntoContainer(resumed); - // TODO: expect(getVisibleChildren(container)).toEqual(
Hello
); + expect(getVisibleChildren(container)).toEqual( +
+ {'Hi'} + {'Hello'} +
, + ); }); // @gate enablePostpone From 31823a13e6a5f68c8ecb2a53dc9fa226306a395a Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Wed, 6 Sep 2023 22:38:48 -0400 Subject: [PATCH 2/7] Split Task into RenderTask and ReplayTask A ReplayTask is a task that isn't writing to the output but follows a resumable path. Most of the code paths will be reused for this type of work. --- packages/react-server/src/ReactFizzServer.js | 93 +++++++++++++++----- 1 file changed, 72 insertions(+), 21 deletions(-) diff --git a/packages/react-server/src/ReactFizzServer.js b/packages/react-server/src/ReactFizzServer.js index 64ecfd1d84e93..a74013188e6fa 100644 --- a/packages/react-server/src/ReactFizzServer.js +++ b/packages/react-server/src/ReactFizzServer.js @@ -230,7 +230,8 @@ type SuspenseBoundary = { keyPath: Root | KeyNode, }; -export type Task = { +type RenderTask = { + replay: null, node: ReactNodeList, childIndex: number, ping: () => void, @@ -246,6 +247,25 @@ export type Task = { thenableState: null | ThenableState, }; +type ReplayTask = { + replay: ResumableNode, + node: ReactNodeList, + childIndex: number, + ping: () => void, + blockedBoundary: Root | SuspenseBoundary, + blockedSegment: null, // we don't write to anything when we replay + abortSet: Set, // the abortable set that this task belongs to + keyPath: Root | KeyNode, // the path of all parent keys currently rendering + formatContext: FormatContext, // the format's specific context (e.g. HTML/SVG/MathML) + legacyContext: LegacyContext, // the current legacy context that this task is executing in + 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, +}; + +export type Task = RenderTask | ReplayTask; + const PENDING = 0; const COMPLETED = 1; const FLUSHED = 2; @@ -400,7 +420,7 @@ export function createRequest( ); // There is no parent so conceptually, we're unblocked to flush this segment. rootSegment.parentFlushed = true; - const rootTask = createTask( + const rootTask = createRenderTask( request, null, children, @@ -490,25 +510,13 @@ export function resumeRequest( onFatalError: onFatalError === undefined ? noop : onFatalError, formState: null, }; - // This segment represents the root fallback. - const rootSegment = createPendingSegment( - request, - 0, - null, - postponedState.rootFormatContext, - // Root segments are never embedded in Text on either edge - false, - false, - ); - // There is no parent so conceptually, we're unblocked to flush this segment. - rootSegment.parentFlushed = true; - const rootTask = createTask( + const rootTask = createReplayTask( request, null, + postponedState.resumablePath[0], children, -1, null, - rootSegment, abortSet, null, postponedState.rootFormatContext, @@ -560,7 +568,7 @@ function createSuspenseBoundary( }; } -function createTask( +function createRenderTask( request: Request, thenableState: ThenableState | null, node: ReactNodeList, @@ -573,14 +581,15 @@ function createTask( legacyContext: LegacyContext, context: ContextSnapshot, treeContext: TreeContext, -): Task { +): RenderTask { request.allPendingTasks++; if (blockedBoundary === null) { request.pendingRootTasks++; } else { blockedBoundary.pendingTasks++; } - const task: Task = ({ + const task: RenderTask = ({ + replay: null, node, childIndex, ping: () => pingTask(request, task), @@ -601,6 +610,48 @@ function createTask( return task; } +function createReplayTask( + request: Request, + thenableState: ThenableState | null, + replay: ResumableNode, + node: ReactNodeList, + childIndex: number, + blockedBoundary: Root | SuspenseBoundary, + abortSet: Set, + keyPath: Root | KeyNode, + formatContext: FormatContext, + legacyContext: LegacyContext, + context: ContextSnapshot, + treeContext: TreeContext, +): ReplayTask { + request.allPendingTasks++; + if (blockedBoundary === null) { + request.pendingRootTasks++; + } else { + blockedBoundary.pendingTasks++; + } + const task: ReplayTask = ({ + replay, + node, + childIndex, + ping: () => pingTask(request, task), + blockedBoundary, + blockedSegment: null, + abortSet, + keyPath, + formatContext, + legacyContext, + context, + treeContext, + thenableState, + }: any); + if (__DEV__) { + task.componentStack = null; + } + abortSet.add(task); + return task; +} + function createPendingSegment( request: Request, index: number, @@ -866,7 +917,7 @@ function renderSuspenseBoundary( // We create suspended task for the fallback because we don't want to actually work // on it yet in case we finish the main content, so we queue for later. - const suspendedFallbackTask = createTask( + const suspendedFallbackTask = createRenderTask( request, null, fallback, @@ -2049,7 +2100,7 @@ function spawnNewSuspendedTask( segment.children.push(newSegment); // Reset lastPushedText for current Segment since the new Segment "consumed" it segment.lastPushedText = false; - const newTask = createTask( + const newTask = createRenderTask( request, thenableState, task.node, From 7d2ed2741ac0017e422857380a41c9dbf93beddc Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Thu, 7 Sep 2023 00:06:48 -0400 Subject: [PATCH 3/7] Replay elements down the parent path that matches --- packages/react-server/src/ReactFizzServer.js | 808 +++++++++++++++---- 1 file changed, 644 insertions(+), 164 deletions(-) diff --git a/packages/react-server/src/ReactFizzServer.js b/packages/react-server/src/ReactFizzServer.js index a74013188e6fa..d99f2a9f0436f 100644 --- a/packages/react-server/src/ReactFizzServer.js +++ b/packages/react-server/src/ReactFizzServer.js @@ -247,8 +247,15 @@ type RenderTask = { thenableState: null | ThenableState, }; +type ReplaySet = { + nodes: Array, // the possible paths to follow down the replaying + pendingTasks: number, // tracks the number of tasks currently tracking this set of nodes + // if pending tasks reach zero but there are still nodes left, it means we couldn't find + // them all in the tree, so we need to abort and client render the boundary. +}; + type ReplayTask = { - replay: ResumableNode, + replay: ReplaySet, node: ReactNodeList, childIndex: number, ping: () => void, @@ -513,7 +520,7 @@ export function resumeRequest( const rootTask = createReplayTask( request, null, - postponedState.resumablePath[0], + {nodes: postponedState.resumablePath, pendingTasks: 0}, children, -1, null, @@ -613,7 +620,7 @@ function createRenderTask( function createReplayTask( request: Request, thenableState: ThenableState | null, - replay: ResumableNode, + replay: ReplaySet, node: ReactNodeList, childIndex: number, blockedBoundary: Root | SuspenseBoundary, @@ -630,6 +637,7 @@ function createReplayTask( } else { blockedBoundary.pendingTasks++; } + replay.pendingTasks++; const task: ReplayTask = ({ replay, node, @@ -790,10 +798,19 @@ function fatalError(request: Request, error: mixed): void { function renderSuspenseBoundary( request: Request, - task: Task, + someTask: Task, keyPath: Root | KeyNode, props: Object, ): void { + if (someTask.replay !== null) { + throw new Error( + 'Did not expect to see a Suspense boundary in this slot. ' + + "The tree doesn't match so React will fallback to client rendering.", + ); + } + // $FlowFixMe: Refined. + const task: RenderTask = someTask; + pushBuiltInComponentStackInDEV(task, 'Suspense'); const prevKeyPath = task.keyPath; @@ -942,6 +959,100 @@ function renderSuspenseBoundary( popComponentStackInDEV(task); } +function replaySuspenseBoundary( + request: Request, + task: ReplayTask, + props: Object, + replayNode: ReplaySuspenseBoundary, +): void { + pushBuiltInComponentStackInDEV(task, 'Suspense'); + + const previousReplaySet: ReplaySet = task.replay; + + const parentBoundary = task.blockedBoundary; + + const content: ReactNodeList = props.children; + + const fallbackAbortSet: Set = new Set(); + const resumedBoundary = createSuspenseBoundary( + request, + fallbackAbortSet, + task.keyPath, + ); + resumedBoundary.parentFlushed = true; + // We restore the same id of this boundary as was used during prerender. + resumedBoundary.id = replayNode[4]; + resumedBoundary.rootSegmentID = replayNode[5]; + + // We can reuse the current context and task to render the content immediately without + // context switching. We just need to temporarily switch which boundary and replay node + // we're writing to. If something suspends, it'll spawn new suspended task with that context. + task.blockedBoundary = resumedBoundary; + task.replay = {nodes: replayNode[3], pendingTasks: 1}; + if (enableFloat) { + // Does this even matter for replaying? + setCurrentlyRenderingBoundaryResourcesTarget( + request.renderState, + resumedBoundary.resources, + ); + } + try { + // We use the safe form because we don't handle suspending here. Only error handling. + renderNode(request, task, content, -1); + if ( + resumedBoundary.pendingTasks === 0 && + resumedBoundary.status === PENDING + ) { + resumedBoundary.status = COMPLETED; + request.completedBoundaries.push(resumedBoundary); + } + if (task.replay.pendingTasks === 1 && task.replay.nodes.length > 0) { + throw new Error( + "Couldn't find all resumable slots by key/index during replaying. " + + "The tree doesn't match so React will fallback to client rendering.", + ); + } + task.replay.pendingTasks--; + } catch (error) { + resumedBoundary.status = CLIENT_RENDERED; + let errorDigest; + if ( + enablePostpone && + typeof error === 'object' && + error !== null && + error.$$typeof === REACT_POSTPONE_TYPE + ) { + const postponeInstance: Postpone = (error: any); + logPostpone(request, postponeInstance.message); + // TODO: Figure out a better signal than a magic digest value. + errorDigest = 'POSTPONE'; + } else { + errorDigest = logRecoverableError(request, error); + } + resumedBoundary.errorDigest = errorDigest; + if (__DEV__) { + captureBoundaryErrorDetailsDev(resumedBoundary, error); + } + + task.replay.pendingTasks--; + + // We don't need to decrement any task numbers because we didn't spawn any new task. + // We don't need to schedule any task because we know the parent has written yet. + // We do need to fallthrough to create the fallback though. + } finally { + if (enableFloat) { + setCurrentlyRenderingBoundaryResourcesTarget( + request.renderState, + parentBoundary ? parentBoundary.resources : null, + ); + } + task.blockedBoundary = parentBoundary; + task.replay = previousReplaySet; + } + // TODO: Should this be in the finally? + popComponentStackInDEV(task); +} + function renderBackupSuspenseBoundary( request: Request, task: Task, @@ -952,13 +1063,18 @@ function renderBackupSuspenseBoundary( const content = props.children; const segment = task.blockedSegment; - - pushStartCompletedSuspenseBoundary(segment.chunks); const prevKeyPath = task.keyPath; task.keyPath = keyPath; - renderNode(request, task, content, -1); + if (segment === null) { + // Replay + renderNode(request, task, content, -1); + } else { + // Render + pushStartCompletedSuspenseBoundary(segment.chunks); + renderNode(request, task, content, -1); + pushEndCompletedSuspenseBoundary(segment.chunks); + } task.keyPath = prevKeyPath; - pushEndCompletedSuspenseBoundary(segment.chunks); popComponentStackInDEV(task); } @@ -972,38 +1088,56 @@ function renderHostElement( ): void { pushBuiltInComponentStackInDEV(task, type); const segment = task.blockedSegment; + if (segment === null) { + // Replay + const children = props.children; // TODO: Make this a Config for replaying. + const prevContext = task.formatContext; + const prevKeyPath = task.keyPath; + task.formatContext = getChildFormatContext(prevContext, type, props); + task.keyPath = keyPath; - const children = pushStartInstance( - segment.chunks, - type, - props, - request.resumableState, - request.renderState, - task.formatContext, - segment.lastPushedText, - ); - segment.lastPushedText = false; - const prevContext = task.formatContext; - const prevKeyPath = task.keyPath; - task.formatContext = getChildFormatContext(prevContext, type, props); - task.keyPath = keyPath; + // We use the non-destructive form because if something suspends, we still + // need to pop back up and finish this subtree of HTML. + renderNode(request, task, children, -1); - // We use the non-destructive form because if something suspends, we still - // need to pop back up and finish this subtree of HTML. - renderNode(request, task, children, -1); + // We expect that errors will fatal the whole task and that we don't need + // the correct context. Therefore this is not in a finally. + task.formatContext = prevContext; + task.keyPath = prevKeyPath; + } else { + // Render + const children = pushStartInstance( + segment.chunks, + type, + props, + request.resumableState, + request.renderState, + task.formatContext, + segment.lastPushedText, + ); + segment.lastPushedText = false; + const prevContext = task.formatContext; + const prevKeyPath = task.keyPath; + task.formatContext = getChildFormatContext(prevContext, type, props); + task.keyPath = keyPath; - // We expect that errors will fatal the whole task and that we don't need - // the correct context. Therefore this is not in a finally. - task.formatContext = prevContext; - task.keyPath = prevKeyPath; - pushEndInstance( - segment.chunks, - type, - props, - request.resumableState, - prevContext, - ); - segment.lastPushedText = false; + // We use the non-destructive form because if something suspends, we still + // need to pop back up and finish this subtree of HTML. + renderNode(request, task, children, -1); + + // We expect that errors will fatal the whole task and that we don't need + // the correct context. Therefore this is not in a finally. + task.formatContext = prevContext; + task.keyPath = prevKeyPath; + pushEndInstance( + segment.chunks, + type, + props, + request.resumableState, + prevContext, + ); + segment.lastPushedText = false; + } popComponentStackInDEV(task); } @@ -1697,6 +1831,116 @@ function renderElement( ); } +function replayElement( + request: Request, + task: ReplayTask, + keyPath: Root | KeyNode, + prevThenableState: ThenableState | null, + name: null | string, + keyOrIndex: number | string, + childIndex: number, + type: any, + props: Object, + ref: any, + replay: ReplaySet, +): void { + // We're replaying. Find the path to follow. + const replayNodes = replay.nodes; + for (let i = 0; i < replayNodes.length; i++) { + // Flow doesn't support refinement on tuples so we do it manually here. + const candidate: any = replayNodes[i]; + switch (candidate[0]) { + case REPLAY_NODE: { + const node: ReplayNode = candidate; + if (keyOrIndex === node[2]) { + // Let's double check that the component name matches as a precaution. + if (name !== null && name !== node[1]) { + throw new Error( + 'Expected to see a component of type "' + + name + + '" in this slot. ' + + "The tree doesn't match so React will fallback to client rendering.", + ); + } + // Matched a replayable path. + task.replay = {nodes: node[3], pendingTasks: 1}; + try { + renderElement(request, task, prevThenableState, type, props, ref); + // We finished rendering this node, so now we can consume this + // slot. This must happen after in case we rerender this task. + replayNodes.splice(i, 1); + } finally { + task.replay.pendingTasks--; + task.replay = replay; + } + } + continue; + } + case REPLAY_SUSPENSE_BOUNDARY: { + const node: ReplaySuspenseBoundary = candidate; + if (keyOrIndex === node[2]) { + // Let's double check that the component type matches. + if (type !== REACT_SUSPENSE_TYPE) { + throw new Error( + 'Expected to see a Suspense boundary in this slot. ' + + "The tree doesn't match so React will fallback to client rendering.", + ); + } + // Matched a replayable path. + replaySuspenseBoundary(request, task, props, node); + // We finished rendering this node, so now we can consume this + // slot. This must happen after in case we rerender this task. + replayNodes.splice(i, 1); + } + continue; + } + case RESUME_ELEMENT: { + const node: ResumeElement = candidate; + if (keyOrIndex === node[2]) { + // Let's double check that the component name matches as a precaution. + if (name !== node[1]) { + throw new Error( + 'Expected to see a component of type "' + + (name || 'unknown') + + '" in this slot. ' + + "The tree doesn't match so React will fallback to client rendering.", + ); + } + // Matched a resumable element. + + const segmentId = node[3]; + + // TODO: Resume. + + // We finished rendering this node, so now we can consume this + // slot. This must happen after in case we rerender this task. + replayNodes.splice(i, 1); + } + continue; + } + case RESUME_SLOT: { + const node: ResumeSlot = candidate; + if (childIndex === node[1]) { + // Matched a resumable slot. We don't know what should be in here + // so whatever name/key it has we assume it's the right one. + + const segmentId = node[2]; + + // TODO: Resume. + + // We finished rendering this node, so now we can consume this + // slot. This must happen after in case we rerender this task. + replayNodes.splice(i, 1); + return; + } + continue; + } + } + } + // We didn't find any matching nodes. We assume that this element was already + // rendered in the prelude and skip it. +} + // $FlowFixMe[missing-local-annot] function validateIterable(iterable, iteratorFn: Function): void { if (__DEV__) { @@ -1800,20 +2044,38 @@ function renderNodeDestructiveImpl( const props = element.props; const ref = element.ref; const name = getComponentNameFromType(type); - const keyPath = [ - task.keyPath, - name, - key == null ? (childIndex === -1 ? 0 : childIndex) : key, - ]; - renderElement( - request, - task, - keyPath, - prevThenableState, - type, - props, - ref, - ); + const prevKeyPath = task.keyPath; + const keyOrIndex = + key == null ? (childIndex === -1 ? 0 : childIndex) : key; + const keyPath = [task.keyPath, name, keyOrIndex]; + if (task.replay !== null) { + replayElement( + request, + task, + keyPath, + prevThenableState, + name, + keyOrIndex, + childIndex, + type, + props, + ref, + task.replay, + ); + // No matches found for this node. We assume it's already emitted in the + // prelude and skip it during the replay. + } else { + // We're doing a plain render. + renderElement( + request, + task, + keyPath, + prevThenableState, + type, + props, + ref, + ); + } return; } case REACT_PORTAL_TYPE: @@ -1934,23 +2196,33 @@ function renderNodeDestructiveImpl( if (typeof node === 'string') { const segment = task.blockedSegment; - segment.lastPushedText = pushTextInstance( - task.blockedSegment.chunks, - node, - request.renderState, - segment.lastPushedText, - ); + if (segment === null) { + // We assume a text node doesn't have a representation in the replay set, + // since it can't postpone. If it does, it'll be left unmatched and error. + } else { + segment.lastPushedText = pushTextInstance( + segment.chunks, + node, + request.renderState, + segment.lastPushedText, + ); + } return; } if (typeof node === 'number') { const segment = task.blockedSegment; - segment.lastPushedText = pushTextInstance( - task.blockedSegment.chunks, - '' + node, - request.renderState, - segment.lastPushedText, - ); + if (segment === null) { + // We assume a text node doesn't have a representation in the replay set, + // since it can't postpone. If it does, it'll be left unmatched and error. + } else { + segment.lastPushedText = pushTextInstance( + segment.chunks, + '' + node, + request.renderState, + segment.lastPushedText, + ); + } return; } @@ -1973,6 +2245,7 @@ function renderChildrenArray( ) { const prevKeyPath = task.keyPath; if (childIndex !== -1) { + // TODO: Consume Replay nodes. task.keyPath = [task.keyPath, '', childIndex]; } const prevTreeContext = task.treeContext; @@ -2055,7 +2328,7 @@ function trackPostpone( function injectPostponedHole( request: Request, - task: Task, + task: RenderTask, reason: string, ): Segment { logPostpone(request, reason); @@ -2078,9 +2351,41 @@ function injectPostponedHole( return newSegment; } -function spawnNewSuspendedTask( +function spawnNewSuspendedReplayTask( request: Request, - task: Task, + task: ReplayTask, + thenableState: ThenableState | null, + x: Wakeable, +): void { + const newTask = createReplayTask( + request, + thenableState, + task.replay, + task.node, + task.childIndex, + task.blockedBoundary, + task.abortSet, + task.keyPath, + task.formatContext, + task.legacyContext, + task.context, + task.treeContext, + ); + + if (__DEV__) { + if (task.componentStack !== null) { + // We pop one task off the stack because the node that suspended will be tried again, + // which will add it back onto the stack. + newTask.componentStack = task.componentStack.parent; + } + } + const ping = newTask.ping; + x.then(ping, ping); +} + +function spawnNewSuspendedRenderTask( + request: Request, + task: RenderTask, thenableState: ThenableState | null, x: Wakeable, ): void { @@ -2134,12 +2439,6 @@ function renderNode( node: ReactNodeList, childIndex: number, ): void { - // Store how much we've pushed at this point so we can reset it in case something - // suspended partially through writing something. - const segment = task.blockedSegment; - const childrenLength = segment.children.length; - const chunkLength = segment.chunks.length; - // Snapshot the current context in case something throws to interrupt the // process. const previousFormatContext = task.formatContext; @@ -2151,102 +2450,164 @@ function renderNode( if (__DEV__) { previousComponentStack = task.componentStack; } - try { - return renderNodeDestructive(request, task, null, node, childIndex); - } catch (thrownValue) { - resetHooksState(); + let x; + // Store how much we've pushed at this point so we can reset it in case something + // suspended partially through writing something. + const segment = task.blockedSegment; + if (segment === null) { + // Replay + try { + return renderNodeDestructive(request, task, null, node, childIndex); + } catch (thrownValue) { + resetHooksState(); + + x = + thrownValue === SuspenseException + ? // This is a special type of exception used for Suspense. For historical + // reasons, the rest of the Suspense implementation expects the thrown + // value to be a thenable, because before `use` existed that was the + // (unstable) API for suspending. This implementation detail can change + // later, once we deprecate the old API in favor of `use`. + getSuspendedThenable() + : thrownValue; + + if (typeof x === 'object' && x !== null) { + // $FlowFixMe[method-unbinding] + if (typeof x.then === 'function') { + const wakeable: Wakeable = (x: any); + const thenableState = getThenableStateAfterSuspending(); + spawnNewSuspendedReplayTask( + request, + // $FlowFixMe: Refined. + task, + thenableState, + wakeable, + ); - // Reset the write pointers to where we started. - segment.children.length = childrenLength; - segment.chunks.length = chunkLength; + // 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.formatContext = previousFormatContext; + task.legacyContext = previousLegacyContext; + task.context = previousContext; + task.keyPath = previousKeyPath; + task.treeContext = previousTreeContext; + // Restore all active ReactContexts to what they were before. + switchContext(previousContext); + if (__DEV__) { + task.componentStack = previousComponentStack; + } + return; + } + } - const x = - thrownValue === SuspenseException - ? // This is a special type of exception used for Suspense. For historical - // reasons, the rest of the Suspense implementation expects the thrown - // value to be a thenable, because before `use` existed that was the - // (unstable) API for suspending. This implementation detail can change - // later, once we deprecate the old API in favor of `use`. - getSuspendedThenable() - : thrownValue; + // TODO: Abort any undiscovered Suspense boundaries in the ResumableNode. + } + } else { + // Render + const childrenLength = segment.children.length; + const chunkLength = segment.chunks.length; + try { + return renderNodeDestructive(request, task, null, node, childIndex); + } catch (thrownValue) { + resetHooksState(); + + // Reset the write pointers to where we started. + segment.children.length = childrenLength; + segment.chunks.length = chunkLength; + + x = + thrownValue === SuspenseException + ? // This is a special type of exception used for Suspense. For historical + // reasons, the rest of the Suspense implementation expects the thrown + // value to be a thenable, because before `use` existed that was the + // (unstable) API for suspending. This implementation detail can change + // later, once we deprecate the old API in favor of `use`. + getSuspendedThenable() + : thrownValue; + + if (typeof x === 'object' && x !== null) { + // $FlowFixMe[method-unbinding] + if (typeof x.then === 'function') { + const wakeable: Wakeable = (x: any); + const thenableState = getThenableStateAfterSuspending(); + spawnNewSuspendedRenderTask( + request, + // $FlowFixMe: Refined. + task, + thenableState, + wakeable, + ); - if (typeof x === 'object' && x !== null) { - // $FlowFixMe[method-unbinding] - if (typeof x.then === 'function') { - const wakeable: Wakeable = (x: any); - const thenableState = getThenableStateAfterSuspending(); - spawnNewSuspendedTask(request, task, thenableState, wakeable); - - // 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.formatContext = previousFormatContext; - task.legacyContext = previousLegacyContext; - task.context = previousContext; - task.keyPath = previousKeyPath; - task.treeContext = previousTreeContext; - // Restore all active ReactContexts to what they were before. - switchContext(previousContext); - if (__DEV__) { - task.componentStack = previousComponentStack; + // 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.formatContext = previousFormatContext; + task.legacyContext = previousLegacyContext; + task.context = previousContext; + task.keyPath = previousKeyPath; + task.treeContext = previousTreeContext; + // Restore all active ReactContexts to what they were before. + switchContext(previousContext); + if (__DEV__) { + task.componentStack = previousComponentStack; + } + return; } - return; - } - if ( - enablePostpone && - request.trackedPostpones !== null && - x.$$typeof === REACT_POSTPONE_TYPE && - task.blockedBoundary !== null // TODO: Support holes in the shell - ) { - // If we're tracking postpones, we inject a hole here and continue rendering - // sibling. Similar to suspending. If we're not tracking, we treat it more like - // an error. Notably this doesn't spawn a new task since nothing will fill it - // in during this prerender. - const postponeInstance: Postpone = (x: any); - const trackedPostpones = request.trackedPostpones; - const postponedSegment = injectPostponedHole( - request, - task, - postponeInstance.message, - ); - trackPostpone(request, trackedPostpones, task, postponedSegment); - - // 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.formatContext = previousFormatContext; - task.legacyContext = previousLegacyContext; - task.context = previousContext; - task.keyPath = previousKeyPath; - task.treeContext = previousTreeContext; - // Restore all active ReactContexts to what they were before. - switchContext(previousContext); - if (__DEV__) { - task.componentStack = previousComponentStack; + if ( + enablePostpone && + request.trackedPostpones !== null && + x.$$typeof === REACT_POSTPONE_TYPE && + task.blockedBoundary !== null // TODO: Support holes in the shell + ) { + // If we're tracking postpones, we inject a hole here and continue rendering + // sibling. Similar to suspending. If we're not tracking, we treat it more like + // an error. Notably this doesn't spawn a new task since nothing will fill it + // in during this prerender. + const postponeInstance: Postpone = (x: any); + const trackedPostpones = request.trackedPostpones; + const postponedSegment = injectPostponedHole( + request, + ((task: any): RenderTask), // We don't use ReplayTasks in prerenders. + postponeInstance.message, + ); + trackPostpone(request, trackedPostpones, task, postponedSegment); + + // 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.formatContext = previousFormatContext; + task.legacyContext = previousLegacyContext; + task.context = previousContext; + task.keyPath = previousKeyPath; + task.treeContext = previousTreeContext; + // Restore all active ReactContexts to what they were before. + switchContext(previousContext); + if (__DEV__) { + task.componentStack = previousComponentStack; + } + return; } - return; } } - // 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.formatContext = previousFormatContext; - task.legacyContext = previousLegacyContext; - task.context = previousContext; - task.keyPath = previousKeyPath; - task.treeContext = previousTreeContext; - // Restore all active ReactContexts to what they were before. - switchContext(previousContext); - if (__DEV__) { - task.componentStack = previousComponentStack; - } - // We assume that we don't need the correct context. - // Let's terminate the rest of the tree and don't render any siblings. - throw 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.formatContext = previousFormatContext; + task.legacyContext = previousLegacyContext; + task.context = previousContext; + task.keyPath = previousKeyPath; + task.treeContext = previousTreeContext; + // Restore all active ReactContexts to what they were before. + switchContext(previousContext); + if (__DEV__) { + task.componentStack = previousComponentStack; + } + // We assume that we don't need the correct context. + // Let's terminate the rest of the tree and don't render any siblings. + throw x; } function erroredTask( request: Request, boundary: Root | SuspenseBoundary, - segment: Segment, error: mixed, ) { // Report the error to a global handler. @@ -2265,6 +2626,9 @@ function erroredTask( errorDigest = logRecoverableError(request, error); } if (boundary === null) { + // TODO: If the shell errors during a replay, that's not a fatal error. Instead + // we should be able to recover by client rendering all the root boundaries in + // the ReplaySet and any already matched. fatalError(request, error); } else { boundary.pendingTasks--; @@ -2301,8 +2665,17 @@ function abortTaskSoft(this: Request, task: Task): void { const request: Request = this; const boundary = task.blockedBoundary; const segment = task.blockedSegment; - segment.status = ABORTED; - finishedTask(request, boundary, segment); + if (segment !== null) { + segment.status = ABORTED; + finishedTask(request, boundary, segment); + } +} + +function abortRemainingResumableNodes( + nodes: Array, + error: mixed, +): void { + // TODO: Abort any undiscovered Suspense boundaries in the ReplaySet. } function abortTask(task: Task, request: Request, error: mixed): void { @@ -2310,7 +2683,16 @@ function abortTask(task: Task, request: Request, error: mixed): void { // client rendered mode. const boundary = task.blockedBoundary; const segment = task.blockedSegment; - segment.status = ABORTED; + if (segment === null) { + // $FlowFixMe: Refined. + const replay: ReplaySet = task.replay; + replay.pendingTasks--; + if (replay.pendingTasks === 0) { + abortRemainingResumableNodes(replay.nodes, error); + } + } else { + segment.status = ABORTED; + } if (boundary === null) { request.allPendingTasks--; @@ -2391,10 +2773,10 @@ function queueCompletedSegment( function finishedTask( request: Request, boundary: Root | SuspenseBoundary, - segment: Segment, + segment: null | Segment, ) { if (boundary === null) { - if (segment.parentFlushed) { + if (segment !== null && segment.parentFlushed) { if (request.completedRootSegment !== null) { throw new Error( 'There can only be one root segment. This is a bug in React.', @@ -2419,7 +2801,7 @@ function finishedTask( boundary.status = COMPLETED; } // This must have been the last segment we were waiting on. This boundary is now complete. - if (segment.parentFlushed) { + if (segment !== null && segment.parentFlushed) { // Our parent segment already flushed, so we need to schedule this segment to be emitted. // If it is a segment that was aborted, we'll write other content instead so we don't need // to emit it. @@ -2439,7 +2821,7 @@ function finishedTask( boundary.fallbackAbortableTasks.forEach(abortTaskSoft, request); boundary.fallbackAbortableTasks.clear(); } else { - if (segment.parentFlushed) { + if (segment !== null && segment.parentFlushed) { // Our parent already flushed, so we need to schedule this segment to be emitted. // If it is a segment that was aborted, we'll write other content instead so we don't need // to emit it. @@ -2477,6 +2859,27 @@ function retryTask(request: Request, task: Task): void { ); } const segment = task.blockedSegment; + if (segment === null) { + retryReplayTask( + request, + // $FlowFixMe: Refined. + task, + ); + } else { + retryRenderTask( + request, + // $FlowFixMe: Refined. + task, + segment, + ); + } +} + +function retryRenderTask( + request: Request, + task: RenderTask, + segment: Segment, +): void { if (segment.status !== PENDING) { // We completed this by other means before we had a chance to retry it. return; @@ -2565,7 +2968,82 @@ function retryTask(request: Request, task: Task): void { } task.abortSet.delete(task); segment.status = ERRORED; - erroredTask(request, task.blockedBoundary, segment, x); + erroredTask(request, task.blockedBoundary, x); + return; + } finally { + if (enableFloat) { + setCurrentlyRenderingBoundaryResourcesTarget(request.renderState, null); + } + if (__DEV__) { + currentTaskInDEV = prevTaskInDEV; + } + } +} + +function retryReplayTask(request: Request, task: ReplayTask): void { + if (task.replay.pendingTasks === 0) { + // There are no pending tasks working on this set, so we must have aborted. + return; + } + + // We restore the context to what it was when we suspended. + // We don't restore it after we leave because it's likely that we'll end up + // needing a very similar context soon again. + switchContext(task.context); + let prevTaskInDEV = null; + if (__DEV__) { + prevTaskInDEV = currentTaskInDEV; + currentTaskInDEV = task; + } + + 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. + + // 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, -1); + + if (task.replay.pendingTasks === 1 && task.replay.nodes.length > 0) { + throw new Error( + "Couldn't find all resumable slots by key/index during replaying. " + + "The tree doesn't match so React will fallback to client rendering.", + ); + } + task.replay.pendingTasks--; + + task.abortSet.delete(task); + finishedTask(request, task.blockedBoundary, null); + } catch (thrownValue) { + resetHooksState(); + + const x = + thrownValue === SuspenseException + ? // This is a special type of exception used for Suspense. For historical + // reasons, the rest of the Suspense implementation expects the thrown + // value to be a thenable, because before `use` existed that was the + // (unstable) API for suspending. This implementation detail can change + // later, once we deprecate the old API in favor of `use`. + getSuspendedThenable() + : thrownValue; + + if (typeof x === 'object' && x !== null) { + // $FlowFixMe[method-unbinding] + if (typeof x.then === 'function') { + // Something suspended again, let's pick it back up later. + const ping = task.ping; + x.then(ping, ping); + task.thenableState = getThenableStateAfterSuspending(); + return; + } + } + task.replay.pendingTasks--; + task.abortSet.delete(task); + erroredTask(request, task.blockedBoundary, x); return; } finally { if (enableFloat) { @@ -3063,7 +3541,9 @@ function flushCompletedQueues( if ( !enablePostpone || request.trackedPostpones === null || - request.trackedPostpones.root.length === 0 + // We check the working map instead of the root because the root could've + // been mutated at this point if it was passed straight through to resume(). + request.trackedPostpones.workingMap.size === 0 ) { writePostamble(destination, request.resumableState); } From 38132a5aa963fe2a7bc7a1d595085b3dad72f9f6 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Wed, 13 Sep 2023 22:04:19 -0400 Subject: [PATCH 4/7] Resume slots from the children array If we have ResumeSlots we don't have an element key to match them with. This only happens inside an array. If we have any, we can simply pick those children out of the array and resume them by rendering that node normally into the relevant segment. --- packages/react-server/src/ReactFizzServer.js | 168 ++++++++++++++++--- 1 file changed, 148 insertions(+), 20 deletions(-) diff --git a/packages/react-server/src/ReactFizzServer.js b/packages/react-server/src/ReactFizzServer.js index d99f2a9f0436f..f30a46fce174c 100644 --- a/packages/react-server/src/ReactFizzServer.js +++ b/packages/react-server/src/ReactFizzServer.js @@ -1831,6 +1831,91 @@ function renderElement( ); } +function resumeNode( + request: Request, + task: ReplayTask, + segmentId: number, + node: ReactNodeList, + childIndex: number, +): void { + const prevReplay = task.replay; + const blockedBoundary = task.blockedBoundary; + const resumedSegment = createPendingSegment( + request, + 0, + null, + task.formatContext, + false, + false, + ); + resumedSegment.id = segmentId; + resumedSegment.parentFlushed = true; + try { + // Convert the current ReplayTask to a RenderTask. + const renderTask: RenderTask = (task: any); + renderTask.replay = null; + renderTask.blockedSegment = resumedSegment; + renderNode(request, task, node, childIndex); + resumedSegment.status = COMPLETED; + if (blockedBoundary === null) { + request.completedRootSegment = resumedSegment; + } else { + queueCompletedSegment(blockedBoundary, resumedSegment); + if (blockedBoundary.parentFlushed) { + request.partialBoundaries.push(blockedBoundary); + } + } + } finally { + // Restore to a ReplayTask. + task.replay = prevReplay; + task.blockedSegment = null; + } +} + +function resumeElement( + request: Request, + task: ReplayTask, + keyPath: Root | KeyNode, + segmentId: number, + prevThenableState: ThenableState | null, + type: any, + props: Object, + ref: any, +): void { + const prevReplay = task.replay; + const blockedBoundary = task.blockedBoundary; + const resumedSegment = createPendingSegment( + request, + 0, + null, + task.formatContext, + false, + false, + ); + resumedSegment.id = segmentId; + resumedSegment.parentFlushed = true; + try { + // Convert the current ReplayTask to a RenderTask. + const renderTask: RenderTask = (task: any); + renderTask.replay = null; + renderTask.blockedSegment = resumedSegment; + renderElement(request, task, keyPath, prevThenableState, type, props, ref); + resumedSegment.status = COMPLETED; + if (blockedBoundary === null) { + request.completedRootSegment = resumedSegment; + } else { + queueCompletedSegment(blockedBoundary, resumedSegment); + if (blockedBoundary.parentFlushed) { + request.partialBoundaries.push(blockedBoundary); + } + } + } finally { + // Restore to a ReplayTask. + task.replay = prevReplay; + task.blockedSegment = null; + } +} + function replayElement( request: Request, task: ReplayTask, @@ -1865,12 +1950,29 @@ function replayElement( // Matched a replayable path. task.replay = {nodes: node[3], pendingTasks: 1}; try { - renderElement(request, task, prevThenableState, type, props, ref); + renderElement( + request, + task, + keyPath, + prevThenableState, + type, + props, + ref, + ); // We finished rendering this node, so now we can consume this // slot. This must happen after in case we rerender this task. replayNodes.splice(i, 1); } finally { task.replay.pendingTasks--; + if ( + task.replay.pendingTasks === 0 && + task.replay.nodes.length > 0 + ) { + throw new Error( + "Couldn't find all resumable slots by key/index during replaying. " + + "The tree doesn't match so React will fallback to client rendering.", + ); + } task.replay = replay; } } @@ -1910,31 +2012,25 @@ function replayElement( const segmentId = node[3]; - // TODO: Resume. - - // We finished rendering this node, so now we can consume this - // slot. This must happen after in case we rerender this task. - replayNodes.splice(i, 1); - } - continue; - } - case RESUME_SLOT: { - const node: ResumeSlot = candidate; - if (childIndex === node[1]) { - // Matched a resumable slot. We don't know what should be in here - // so whatever name/key it has we assume it's the right one. - - const segmentId = node[2]; - - // TODO: Resume. + resumeElement( + request, + task, + keyPath, + segmentId, + prevThenableState, + type, + props, + ref, + ); // We finished rendering this node, so now we can consume this // slot. This must happen after in case we rerender this task. replayNodes.splice(i, 1); - return; } continue; } + // For RESUME_SLOT we ignore them here and assume we've handled them + // separately already. } } // We didn't find any matching nodes. We assume that this element was already @@ -2044,7 +2140,6 @@ function renderNodeDestructiveImpl( const props = element.props; const ref = element.ref; const name = getComponentNameFromType(type); - const prevKeyPath = task.keyPath; const keyOrIndex = key == null ? (childIndex === -1 ? 0 : childIndex) : key; const keyPath = [task.keyPath, name, keyOrIndex]; @@ -2250,6 +2345,38 @@ function renderChildrenArray( } const prevTreeContext = task.treeContext; const totalChildren = children.length; + + if (task.replay !== null) { + // Replay + // First we need to check if we have any resume slots at this level. + // TODO: This could be simpler if we just stored RESUME_SLOT in a separate set. + let hadOtherReplayNodes = false; + const replayNodes = task.replay.nodes; + for (let j = 0; j < replayNodes.length; ) { + const replayNode = replayNodes[j]; + if (replayNode[0] !== RESUME_SLOT) { + hadOtherReplayNodes = true; + j++; // skip + continue; + } + const resumeSlot: ResumeSlot = (replayNode: any); + const i = resumeSlot[1]; // The index of the child to resume. + const segmentId = resumeSlot[2]; + task.treeContext = pushTreeContext(prevTreeContext, totalChildren, i); + resumeNode(request, task, segmentId, children[i], i); + // We finished rendering this node, so now we can consume this + // slot. This must happen after in case we rerender this task. + replayNodes.splice(j, 1); + } + // If had non-resume slot nodes, we need to also try to match them below. + if (!hadOtherReplayNodes) { + // If we didn't, we can bail early. + task.treeContext = prevTreeContext; + task.keyPath = prevKeyPath; + return; + } + } + for (let i = 0; i < totalChildren; i++) { const node = children[i]; task.treeContext = pushTreeContext(prevTreeContext, totalChildren, i); @@ -2257,6 +2384,7 @@ function renderChildrenArray( // up and render the sibling if something suspends. renderNode(request, task, node, i); } + // Because this context is always set right before rendering every child, we // only need to reset it to the previous value at the very end. task.treeContext = prevTreeContext; From 2808e87c31d7e926207237ee4de94be615de3774 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Wed, 13 Sep 2023 22:56:47 -0400 Subject: [PATCH 5/7] Replay implicit fragments in nested array --- .../ReactDOMFizzStaticBrowser-test.js | 47 ++++++++++++++++++- packages/react-server/src/ReactFizzServer.js | 45 ++++++++++++++++-- 2 files changed, 88 insertions(+), 4 deletions(-) diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzStaticBrowser-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzStaticBrowser-test.js index 38628e77c8561..f31b3748bfdf0 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzStaticBrowser-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzStaticBrowser-test.js @@ -105,7 +105,7 @@ describe('ReactDOMFizzStaticBrowser', () => { } const temp = document.createElement('div'); temp.innerHTML = result; - insertNodesAndExecuteScripts(temp, container, null); + await insertNodesAndExecuteScripts(temp, container, null); } // @gate experimental @@ -579,6 +579,51 @@ describe('ReactDOMFizzStaticBrowser', () => { ); }); + // @gate enablePostpone + it('supports postponing in a nested array', async () => { + let prerendering = true; + const Hole = React.lazy(async () => { + React.unstable_postpone(); + }); + function Postpone() { + if (prerendering) { + React.unstable_postpone(); + } + return 'Hello'; + } + + function App() { + return ( +
+ + Hi + {[, prerendering ? Hole : 'World']} + +
+ ); + } + + const prerendered = await ReactDOMFizzStatic.prerender(); + expect(prerendered.postponed).not.toBe(null); + + prerendering = false; + + const resumed = await ReactDOMFizzServer.resume( + , + prerendered.postponed, + ); + + await readIntoContainer(prerendered.prelude); + + expect(getVisibleChildren(container)).toEqual(
Loading...
); + + await readIntoContainer(resumed); + + expect(getVisibleChildren(container)).toEqual( +
{['Hi', 'Hello', 'World']}
, + ); + }); + // @gate enablePostpone it('only emits end tags once when resuming', async () => { let prerendering = true; diff --git a/packages/react-server/src/ReactFizzServer.js b/packages/react-server/src/ReactFizzServer.js index f30a46fce174c..8f963242055c8 100644 --- a/packages/react-server/src/ReactFizzServer.js +++ b/packages/react-server/src/ReactFizzServer.js @@ -2337,11 +2337,50 @@ function renderChildrenArray( task: Task, children: Array, childIndex: number, -) { +): void { const prevKeyPath = task.keyPath; if (childIndex !== -1) { - // TODO: Consume Replay nodes. - task.keyPath = [task.keyPath, '', childIndex]; + task.keyPath = [task.keyPath, 'Fragment', childIndex]; + if (task.replay !== null) { + // If we're supposed follow this array, we'd expect to see a ReplayNode matching + // this fragment. + const replayTask: ReplayTask = task; + const replay = task.replay; + const replayNodes = replay.nodes; + for (let j = 0; j < replayNodes.length; j++) { + const replayNode = replayNodes[j]; + if (replayNode[0] !== REPLAY_NODE) { + continue; + } + const node: ReplayNode = (replayNode: any); + if (node[2] !== childIndex) { + continue; + } + // Matched a replayable path. + replayTask.replay = {nodes: node[3], pendingTasks: 1}; + try { + renderChildrenArray(request, task, children, -1); + } finally { + replayTask.replay.pendingTasks--; + if ( + replayTask.replay.pendingTasks === 0 && + replayTask.replay.nodes.length > 0 + ) { + throw new Error( + "Couldn't find all resumable slots by key/index during replaying. " + + "The tree doesn't match so React will fallback to client rendering.", + ); + } + replayTask.replay = replay; + } + // We finished rendering this node, so now we can consume this + // slot. This must happen after in case we rerender this task. + replayNodes.splice(j, 1); + break; + } + task.keyPath = prevKeyPath; + return; + } } const prevTreeContext = task.treeContext; const totalChildren = children.length; From cf125d21456d57b9a153a83671a0476cc5827411 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Thu, 14 Sep 2023 00:18:10 -0400 Subject: [PATCH 6/7] Error codes --- scripts/error-codes/codes.json | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/scripts/error-codes/codes.json b/scripts/error-codes/codes.json index 4eaed23f7cc00..3f7bc8d47a875 100644 --- a/scripts/error-codes/codes.json +++ b/scripts/error-codes/codes.json @@ -471,5 +471,9 @@ "483": "Hooks are not supported inside an async component. This error is often caused by accidentally adding `'use client'` to a module that was originally written for the server.", "484": "A Server Component was postponed. The reason is omitted in production builds to avoid leaking sensitive details.", "485": "Cannot update form state while rendering.", - "486": "It should not be possible to postpone at the root. This is a bug in React." + "486": "It should not be possible to postpone at the root. This is a bug in React.", + "487": "Did not expect to see a Suspense boundary in this slot. The tree doesn't match so React will fallback to client rendering.", + "488": "Couldn't find all resumable slots by key/index during replaying. The tree doesn't match so React will fallback to client rendering.", + "489": "Expected to see a component of type \"%s\" in this slot. The tree doesn't match so React will fallback to client rendering.", + "490": "Expected to see a Suspense boundary in this slot. The tree doesn't match so React will fallback to client rendering." } \ No newline at end of file From 5b276c6e914db7f37d68e63ed739276eee8b4432 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Tue, 12 Sep 2023 23:34:03 -0400 Subject: [PATCH 7/7] Posponing directly inside a Suspense boundary needs a special case It's like a combination of ReplaySuspenseBoundary and ResumeElement. --- .../ReactDOMFizzStaticBrowser-test.js | 39 ++++ packages/react-server/src/ReactFizzServer.js | 213 +++++++++++++++--- 2 files changed, 226 insertions(+), 26 deletions(-) diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzStaticBrowser-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzStaticBrowser-test.js index f31b3748bfdf0..2c8944ebf9215 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzStaticBrowser-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzStaticBrowser-test.js @@ -624,6 +624,45 @@ describe('ReactDOMFizzStaticBrowser', () => { ); }); + // @gate enablePostpone + it('supports postponing in lazy as a direct child', async () => { + let prerendering = true; + const Hole = React.lazy(async () => { + React.unstable_postpone(); + }); + function Postpone() { + return prerendering ? Hole : 'Hello'; + } + + function App() { + return ( +
+ + + +
+ ); + } + + const prerendered = await ReactDOMFizzStatic.prerender(); + expect(prerendered.postponed).not.toBe(null); + + prerendering = false; + + const resumed = await ReactDOMFizzServer.resume( + , + prerendered.postponed, + ); + + await readIntoContainer(prerendered.prelude); + + expect(getVisibleChildren(container)).toEqual(
Loading...
); + + await readIntoContainer(resumed); + + expect(getVisibleChildren(container)).toEqual(
Hello
); + }); + // @gate enablePostpone it('only emits end tags once when resuming', async () => { let prerendering = true; diff --git a/packages/react-server/src/ReactFizzServer.js b/packages/react-server/src/ReactFizzServer.js index 8f963242055c8..8c78241096889 100644 --- a/packages/react-server/src/ReactFizzServer.js +++ b/packages/react-server/src/ReactFizzServer.js @@ -168,7 +168,8 @@ export type KeyNode = [ const REPLAY_NODE = 0; const REPLAY_SUSPENSE_BOUNDARY = 1; const RESUME_ELEMENT = 2; -const RESUME_SLOT = 3; +const RESUME_SUSPENSE_BOUNDARY = 3; +const RESUME_SLOT = 4; type ReplaySuspenseBoundary = [ 1, // REPLAY_SUSPENSE_BOUNDARY @@ -188,6 +189,14 @@ type ReplayNode = ] | ReplaySuspenseBoundary; +type ResumeSuspenseBoundary = [ + 3, // RESUME_SUSPENSE_BOUNDARY + string | null /* name */, + string | number /* key */, + SuspenseBoundaryID /* id */, + number /* rootSegmentID */, +]; + type ResumeElement = [ 2, // RESUME_ELEMENT string | null /* name */, @@ -196,12 +205,16 @@ type ResumeElement = [ ]; type ResumeSlot = [ - 3, // RESUME_SLOT + 4, // RESUME_SLOT number /* index */, number /* segment id */, ]; -type ResumableNode = ReplayNode | ResumeElement | ResumeSlot; +type ResumableNode = + | ReplayNode + | ResumeElement + | ResumeSuspenseBoundary + | ResumeSlot; type PostponedHoles = { workingMap: Map, @@ -1053,6 +1066,109 @@ function replaySuspenseBoundary( popComponentStackInDEV(task); } +function resumeSuspenseBoundary( + request: Request, + task: ReplayTask, + props: Object, + replayNode: ResumeSuspenseBoundary, +): void { + pushBuiltInComponentStackInDEV(task, 'Suspense'); + + const previousReplaySet: ReplaySet = task.replay; + + const parentBoundary = task.blockedBoundary; + + const content: ReactNodeList = props.children; + + const fallbackAbortSet: Set = new Set(); + const resumedBoundary = createSuspenseBoundary( + request, + fallbackAbortSet, + task.keyPath, + ); + resumedBoundary.parentFlushed = true; + // We restore the same id of this boundary as was used during prerender. + resumedBoundary.id = replayNode[3]; + resumedBoundary.rootSegmentID = replayNode[4]; + + const resumedSegment = createPendingSegment( + request, + 0, + null, + task.formatContext, + false, + false, + ); + resumedSegment.parentFlushed = true; + resumedSegment.id = replayNode[4]; + + // We can reuse the current context and task to render the content immediately without + // context switching. We just need to temporarily switch which boundary and replay node + // we're writing to. If something suspends, it'll spawn new suspended task with that context. + task.blockedBoundary = resumedBoundary; + if (enableFloat) { + // Does this even matter for replaying? + setCurrentlyRenderingBoundaryResourcesTarget( + request.renderState, + resumedBoundary.resources, + ); + } + try { + // Convert the current ReplayTask to a RenderTask. + const renderTask: RenderTask = (task: any); + renderTask.replay = null; + renderTask.blockedSegment = resumedSegment; + // We use the safe form because we don't handle suspending here. Only error handling. + renderNode(request, task, content, -1); + resumedSegment.status = COMPLETED; + queueCompletedSegment(resumedBoundary, resumedSegment); + if ( + resumedBoundary.pendingTasks === 0 && + resumedBoundary.status === PENDING + ) { + resumedBoundary.status = COMPLETED; + request.completedBoundaries.push(resumedBoundary); + } + } catch (error) { + resumedBoundary.status = CLIENT_RENDERED; + let errorDigest; + if ( + enablePostpone && + typeof error === 'object' && + error !== null && + error.$$typeof === REACT_POSTPONE_TYPE + ) { + const postponeInstance: Postpone = (error: any); + logPostpone(request, postponeInstance.message); + // TODO: Figure out a better signal than a magic digest value. + errorDigest = 'POSTPONE'; + } else { + errorDigest = logRecoverableError(request, error); + } + resumedBoundary.errorDigest = errorDigest; + if (__DEV__) { + captureBoundaryErrorDetailsDev(resumedBoundary, error); + } + + // We don't need to decrement any task numbers because we didn't spawn any new task. + // We don't need to schedule any task because we know the parent has written yet. + // We do need to fallthrough to create the fallback though. + } finally { + if (enableFloat) { + setCurrentlyRenderingBoundaryResourcesTarget( + request.renderState, + parentBoundary ? parentBoundary.resources : null, + ); + } + task.blockedBoundary = parentBoundary; + // Restore to a ReplayTask + task.blockedSegment = null; + task.replay = previousReplaySet; + } + // TODO: Should this be in the finally? + popComponentStackInDEV(task); +} + function renderBackupSuspenseBoundary( request: Request, task: Task, @@ -2029,6 +2145,25 @@ function replayElement( } continue; } + case RESUME_SUSPENSE_BOUNDARY: { + const node: ResumeSuspenseBoundary = candidate; + if (keyOrIndex === node[2]) { + // Let's double check that the component name matches as a precaution. + if (type !== REACT_SUSPENSE_TYPE) { + throw new Error( + 'Expected to see a Suspense boundary in this slot. ' + + "The tree doesn't match so React will fallback to client rendering.", + ); + } + // Matched a resumable suspense boundary. + resumeSuspenseBoundary(request, task, props, node); + + // We finished rendering this node, so now we can consume this + // slot. This must happen after in case we rerender this task. + replayNodes.splice(i, 1); + } + continue; + } // For RESUME_SLOT we ignore them here and assume we've handled them // separately already. } @@ -2437,8 +2572,13 @@ function trackPostpone( segment: Segment, ): void { segment.status = POSTPONED; - // We know that this will leave a hole so we might as well assign an ID now. - segment.id = request.nextSegmentId++; + + const keyPath = task.keyPath; + if (keyPath === null) { + throw new Error( + 'It should not be possible to postpone at the root. This is a bug in React.', + ); + } const boundary = task.blockedBoundary; if (boundary !== null && boundary.status === PENDING) { @@ -2457,29 +2597,52 @@ function trackPostpone( 'It should not be possible to postpone at the root. This is a bug in React.', ); } - const children: Array = []; - const boundaryNode: ReplaySuspenseBoundary = [ - REPLAY_SUSPENSE_BOUNDARY, - boundaryKeyPath[1], - boundaryKeyPath[2], - children, - boundary.id, - boundary.rootSegmentID, - ]; - trackedPostpones.workingMap.set(boundaryKeyPath, boundaryNode); - addToReplayParent(boundaryNode, boundaryKeyPath[0], trackedPostpones); + + if (boundaryKeyPath === keyPath && task.childIndex === -1) { + // Since we postponed directly in the Suspense boundary we can't have written anything + // to its segment. Therefore this will end up becoming the root segment. + segment.id = boundary.rootSegmentID; + // We postponed directly inside the Suspense boundary so we mark this for resuming. + const boundaryNode: ResumeSuspenseBoundary = [ + RESUME_SUSPENSE_BOUNDARY, + boundaryKeyPath[1], + boundaryKeyPath[2], + boundary.id, + boundary.rootSegmentID, + ]; + addToReplayParent(boundaryNode, boundaryKeyPath[0], trackedPostpones); + return; + } else { + const children: Array = []; + const boundaryNode: ReplaySuspenseBoundary = [ + REPLAY_SUSPENSE_BOUNDARY, + boundaryKeyPath[1], + boundaryKeyPath[2], + children, + boundary.id, + boundary.rootSegmentID, + ]; + trackedPostpones.workingMap.set(boundaryKeyPath, boundaryNode); + addToReplayParent(boundaryNode, boundaryKeyPath[0], trackedPostpones); + // Fall through to add the child node. + } } - const keyPath = task.keyPath; - if (keyPath === null) { - throw new Error( - 'It should not be possible to postpone at the root. This is a bug in React.', - ); + // We know that this will leave a hole so we might as well assign an ID now. + // We might have one already if we had a parent that gave us its ID. + if (segment.id === -1) { + if (segment.parentFlushed && boundary !== null) { + // If this segment's parent was already flushed, it means we really just + // skipped the parent and this segment is now the root. + segment.id = boundary.rootSegmentID; + } else { + segment.id = request.nextSegmentId++; + } } if (task.childIndex === -1) { - // Resume at the position before the first array - const resumableElement = [ + // Resume starting from directly inside the previous parent element. + const resumableElement: ResumeElement = [ RESUME_ELEMENT, keyPath[1], keyPath[2], @@ -2919,9 +3082,7 @@ function queueCompletedSegment( if ( segment.chunks.length === 0 && segment.children.length === 1 && - segment.children[0].boundary === null && - // Typically the id would not be assigned yet but if it's a postponed segment it might be. - segment.children[0].id === -1 + segment.children[0].boundary === null ) { // This is an empty segment. There's nothing to write, so we can instead transfer the ID // to the child. That way any existing references point to the child.