Repository: facebook/react
Pull request: [Fizz] Replay Postponed Paths

This forks Task into ReplayTask and RenderTask.

A RenderTask is the normal mode and it has a segment to write into.

A ReplayTask doesn't have a segment to write into because that has already been written but instead it has a ReplayState which keeps track of the next set of paths to follow. Once we hit a "Resume" node we convert it into a RenderTask and continue rendering from there.

We can resume at either an Element position or a Slot position. An Element pointing to a component doesn't mean we resume that component, it means we resume in the child position directly below that component. Slots are slots inside arrays.

Instead of statically forking most paths, I kept using the same path and checked for the existence of a segment or replay state dynamically at runtime.

However, there's still quite a bit of forking here like retryRenderTask and retryReplayTask. Even in the new forks there's a lot of duplication like resumeSuspenseBoundary, replaySuspenseBoundary and renderSuspenseBoundary. There's opportunity to simplify this a bit.

Changed files:
filename: packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
patch: @@ -6365,6 +6365,6 @@ describe('ReactDOMFizzServer', () => {
       resumed.pipe(writable);
     });
 
-    // TODO: expect(getVisibleChildren(container)).toEqual(<div>Hello</div>);
+    expect(getVisibleChildren(container)).toEqual(<div>Hello</div>);
   });
 });

filename: packages/react-dom/src/__tests__/ReactDOMFizzStaticBrowser-test.js
patch: @@ -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(<div>Hello</div>);
+    expect(getVisibleChildren(container)).toEqual(
+      <div>{['Hello', 'World']}</div>,
+    );
+  });
+
+  // @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 (
+        <div>
+          <Suspense fallback="Loading...">
+            Hello
+            <Postpone />
+          </Suspense>
+        </div>
+      );
+    }
+
+    const prerendered = await ReactDOMFizzStatic.prerender(<App />);
+    expect(prerendered.postponed).not.toBe(null);
+
+    prerendering = false;
+
+    const resumed = await ReactDOMFizzServer.resume(
+      <App />,
+      prerendered.postponed,
+    );
+
+    await readIntoContainer(prerendered.prelude);
+
+    expect(getVisibleChildren(container)).toEqual(<div>Loading...</div>);
+
+    await readIntoContainer(resumed);
+
+    expect(getVisibleChildren(container)).toEqual(
+      <div>{['Hello', 'World']}</div>,
+    );
   });
 
   // @gate enablePostpone
@@ -500,7 +544,51 @@ describe('ReactDOMFizzStaticBrowser', () => {
       React.unstable_postpone();
     });
 
+    function App() {
+      return (
+        <div>
+          <Suspense fallback="Loading...">
+            Hi
+            {prerendering ? Hole : 'Hello'}
+          </Suspense>
+        </div>
+      );
+    }
+
+    const prerendered = await ReactDOMFizzStatic.prerender(<App />);
+    expect(prerendered.postponed).not.toBe(null);
+
+    prerendering = false;
+
+    const resumed = await ReactDOMFizzServer.resume(
+      <App />,
+      prerendered.postponed,
+    );
+
+    await readIntoContainer(prerendered.prelude);
+
+    expect(getVisibleChildren(container)).toEqual(<div>Loading...</div>);
+
+    await readIntoContainer(resumed);
+
+    expect(getVisibleChildren(container)).toEqual(
+      <div>
+        {'Hi'}
+        {'Hello'}
+      </div>,
+    );
+  });
+
+  // @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', () => {
         <div>
           <Suspense fallback="Loading...">
             Hi
-            {prerendering ? Hole : <Postpone />}
+            {[<Postpone key="key" />, prerendering ? Hole : 'World']}
+          </Suspense>
+        </div>
+      );
+    }
+
+    const prerendered = await ReactDOMFizzStatic.prerender(<App />);
+    expect(prerendered.postponed).not.toBe(null);
+
+    prerendering = false;
+
+    const resumed = await ReactDOMFizzServer.resume(
+      <App />,
+      prerendered.postponed,
+    );
+
+    await readIntoContainer(prerendered.prelude);
+
+    expect(getVisibleChildren(container)).toEqual(<div>Loading...</div>);
+
+    await readIntoContainer(resumed);
+
+    expect(getVisibleChildren(container)).toEqual(
+      <div>{['Hi', 'Hello', 'World']}</div>,
+    );
+  });
+
+  // @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 (
+        <div>
+          <Suspense fallback="Loading...">
+            <Postpone key="key" />
           </Suspense>
         </div>
       );
@@ -531,7 +660,7 @@ describe('ReactDOMFizzStaticBrowser', () => {
 
     await readIntoContainer(resumed);
 
-    // TODO: expect(getVisibleChildren(container)).toEqual(<div>Hello</div>);
+    expect(getVisibleChildren(container)).toEqual(<div>Hello</div>);
   });
 
   // @gate enablePostpone

filename: packages/react-server/src/ReactFizzServer.js
patch: @@ -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<KeyNode, ReplayNode>,
@@ -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<ResumableNode>, // 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<Task>, // 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<Task>,
+  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<Task> = 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<Task> = 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);
 }
 
@@ -1645,9 +1946,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 (
@@ -1748,20 +2274,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:
@@ -1882,23 +2425,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;
   }
 
@@ -1918,20 +2471,93 @@ function renderChildrenArray(
   task: Task,
   children: Array<any>,
   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);
     // We need to use the non-destructive form so that we can safely pop back
     // 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;
@@ -1945,8 +2571,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) {
@@ -1965,29 +2596,52 @@ function trackPostpone(
         'It should not be possible to postpone at the root. This is a bug in React.',
       );
     }
-    const children: Array<ResumableNode> = [];
-    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<ResumableNode> = [];
+      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],
@@ -2003,7 +2657,7 @@ function trackPostpone(
 
 function injectPostponedHole(
   request: Request,
-  task: Task,
+  task: RenderTask,
   reason: string,
 ): Segment {
   logPostpone(request, reason);
@@ -2026,9 +2680,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 {
@@ -2048,7 +2734,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,
@@ -2082,12 +2768,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;
@@ -2099,102 +2779,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.
@@ -2213,6 +2955,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--;
@@ -2249,16 +2994,34 @@ 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<ResumableNode>,
+  error: mixed,
+): void {
+  // TODO: Abort any undiscovered Suspense boundaries in the ReplaySet.
 }
 
 function abortTask(task: Task, request: Request, error: mixed): void {
   // This aborts the task and aborts the parent that it blocks, putting it into
   // 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--;
@@ -2318,9 +3081,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.
@@ -2339,10 +3100,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.',
@@ -2367,7 +3128,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.
@@ -2387,7 +3148,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.
@@ -2425,6 +3186,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;
@@ -2513,7 +3295,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) {
@@ -3011,7 +3868,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);
         }

filename: scripts/error-codes/codes.json
patch: @@ -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