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..2c8944ebf9215 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 @@ -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,7 +544,51 @@ describe('ReactDOMFizzStaticBrowser', () => { React.unstable_postpone(); }); + function App() { + return ( +
+ + Hi + {prerendering ? Hole : '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( +
+ {'Hi'} + {'Hello'} +
, + ); + }); + + // @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'; } @@ -509,7 +597,48 @@ describe('ReactDOMFizzStaticBrowser', () => {
Hi - {prerendering ? Hole : } + {[, 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('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 ( +
+ +
); @@ -531,7 +660,7 @@ describe('ReactDOMFizzStaticBrowser', () => { await readIntoContainer(resumed); - // TODO: expect(getVisibleChildren(container)).toEqual(
Hello
); + expect(getVisibleChildren(container)).toEqual(
Hello
); }); // @gate enablePostpone diff --git a/packages/react-server/src/ReactFizzServer.js b/packages/react-server/src/ReactFizzServer.js index 64ecfd1d84e93..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, @@ -230,7 +243,8 @@ type SuspenseBoundary = { keyPath: Root | KeyNode, }; -export type Task = { +type RenderTask = { + replay: null, node: ReactNodeList, childIndex: number, ping: () => void, @@ -246,6 +260,32 @@ export type Task = { 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: ReplaySet, + 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 +440,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 +530,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, + {nodes: postponedState.resumablePath, pendingTasks: 0}, children, -1, null, - rootSegment, abortSet, null, postponedState.rootFormatContext, @@ -560,7 +588,7 @@ function createSuspenseBoundary( }; } -function createTask( +function createRenderTask( request: Request, thenableState: ThenableState | null, node: ReactNodeList, @@ -573,14 +601,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 +630,49 @@ function createTask( return task; } +function createReplayTask( + request: Request, + thenableState: ThenableState | null, + replay: ReplaySet, + 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++; + } + replay.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, @@ -739,10 +811,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; @@ -866,7 +947,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, @@ -891,6 +972,203 @@ 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 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, @@ -901,13 +1179,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); } @@ -921,38 +1204,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 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 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; - pushEndInstance( - segment.chunks, - type, - props, - request.resumableState, - prevContext, - ); - segment.lastPushedText = false; + // 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); } @@ -1646,9 +1947,234 @@ function renderElement( ); } -// $FlowFixMe[missing-local-annot] -function validateIterable(iterable, iteratorFn: Function): void { - if (__DEV__) { +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, + 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, + 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; + } + } + 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]; + + 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); + } + 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. + } + } + // 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__) { // We don't support rendering Generators because it's a mutation. // See https://github.com/facebook/react/issues/12995 if ( @@ -1749,20 +2275,37 @@ 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 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: @@ -1883,23 +2426,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; } @@ -1919,13 +2472,85 @@ function renderChildrenArray( task: Task, children: Array, childIndex: number, -) { +): void { const prevKeyPath = task.keyPath; if (childIndex !== -1) { - 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; + + 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); @@ -1933,6 +2558,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; @@ -1946,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) { @@ -1966,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], @@ -2004,7 +2658,7 @@ function trackPostpone( function injectPostponedHole( request: Request, - task: Task, + task: RenderTask, reason: string, ): Segment { logPostpone(request, reason); @@ -2027,9 +2681,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 { @@ -2049,7 +2735,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, @@ -2083,12 +2769,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; @@ -2100,102 +2780,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. @@ -2214,6 +2956,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--; @@ -2250,8 +2995,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 { @@ -2259,7 +3013,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--; @@ -2319,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. @@ -2340,10 +3101,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.', @@ -2368,7 +3129,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. @@ -2388,7 +3149,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. @@ -2426,6 +3187,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; @@ -2514,7 +3296,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) { @@ -3012,7 +3869,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); } 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