Repository: facebook/react
Pull request: [Fizz] Client render the nearest child or parent suspense boundary if replay errors or is aborted

Based on #27385.

When we error or abort during replay, that doesn't actually error the component that errored because that has already rendered. The error only affects any child that is not yet completed. Therefore the error kind of gets thrown at the resumable point.

The resumable point might be a hole in the replay path, in which case throwing there errors the parent boundary just the same as if the replay component errored. If the hole is inside a deeper Suspense boundary though, then it's that Suspense boundary that gets client rendered. I.e. the child boundary. We can still finish any siblings.

In the shell all resumable points are inside a boundary since we must have finished the shell. Therefore if you error in the root, we just simply just turn all incomplete boundaries into client renders.


Changed files:
filename: packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
patch: @@ -6355,16 +6355,479 @@ describe('ReactDOMFizzServer', () => {
 
     expect(getVisibleChildren(container)).toEqual(<div>Loading...</div>);
 
-    const b = new Stream.PassThrough();
-    b.setEncoding('utf8');
-    b.on('data', chunk => {
+    await act(() => {
+      resumed.pipe(writable);
+    });
+
+    expect(getVisibleChildren(container)).toEqual(<div>Hello</div>);
+  });
+
+  // @gate enablePostpone
+  it('client renders a component if it errors during resuming', async () => {
+    let prerendering = true;
+    let ssr = true;
+    function PostponeAndError() {
+      if (prerendering) {
+        React.unstable_postpone();
+      }
+      if (ssr) {
+        throw new Error('server error');
+      }
+      return 'Hello';
+    }
+
+    function Postpone() {
+      if (prerendering) {
+        React.unstable_postpone();
+      }
+      return 'Hello';
+    }
+
+    const lazyPostponeAndError = React.lazy(async () => {
+      return {default: <PostponeAndError />};
+    });
+
+    function ReplayError() {
+      if (prerendering) {
+        return <Postpone />;
+      }
+      if (ssr) {
+        throw new Error('replay error');
+      }
+      return 'Hello';
+    }
+
+    function App() {
+      return (
+        <div>
+          <Suspense fallback="Loading1">
+            <PostponeAndError />
+          </Suspense>
+          <Suspense fallback="Loading2">
+            <Postpone />
+            <Suspense fallback="Loading3">{lazyPostponeAndError}</Suspense>
+          </Suspense>
+          <Suspense fallback="Loading4">
+            <ReplayError />
+          </Suspense>
+        </div>
+      );
+    }
+
+    const prerenderErrors = [];
+    const prerendered = await ReactDOMFizzStatic.prerenderToNodeStream(
+      <App />,
+      {
+        onError(x) {
+          prerenderErrors.push(x.message);
+        },
+      },
+    );
+    expect(prerendered.postponed).not.toBe(null);
+
+    prerendering = false;
+
+    const ssrErrors = [];
+
+    const resumed = ReactDOMFizzServer.resumeToPipeableStream(
+      <App />,
+      prerendered.postponed,
+      {
+        onError(x) {
+          ssrErrors.push(x.message);
+        },
+      },
+    );
+
+    // Create a separate stream so it doesn't close the writable. I.e. simple concat.
+    const preludeWritable = new Stream.PassThrough();
+    preludeWritable.setEncoding('utf8');
+    preludeWritable.on('data', chunk => {
       writable.write(chunk);
     });
 
+    await act(() => {
+      prerendered.prelude.pipe(preludeWritable);
+    });
+
+    expect(getVisibleChildren(container)).toEqual(
+      <div>
+        {'Loading1'}
+        {'Loading2'}
+        {'Loading4'}
+      </div>,
+    );
+
     await act(() => {
       resumed.pipe(writable);
     });
 
-    expect(getVisibleChildren(container)).toEqual(<div>Hello</div>);
+    expect(prerenderErrors).toEqual([]);
+
+    expect(ssrErrors).toEqual(['server error', 'server error', 'replay error']);
+
+    // Still loading...
+    expect(getVisibleChildren(container)).toEqual(
+      <div>
+        {'Loading1'}
+        {'Hello'}
+        {'Loading3'}
+        {'Loading4'}
+      </div>,
+    );
+
+    const recoverableErrors = [];
+
+    ssr = false;
+
+    await clientAct(() => {
+      ReactDOMClient.hydrateRoot(container, <App />, {
+        onRecoverableError(x) {
+          recoverableErrors.push(x.message);
+        },
+      });
+    });
+
+    expect(recoverableErrors).toEqual(
+      __DEV__
+        ? ['server error', 'replay error', 'server error']
+        : [
+            'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.',
+            'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.',
+            'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.',
+          ],
+    );
+    expect(getVisibleChildren(container)).toEqual(
+      <div>
+        {'Hello'}
+        {'Hello'}
+        {'Hello'}
+        {'Hello'}
+      </div>,
+    );
+  });
+
+  // @gate enablePostpone
+  it('client renders a component if we abort before resuming', async () => {
+    let prerendering = true;
+    let ssr = true;
+    const promise = new Promise(() => {});
+    function PostponeAndSuspend() {
+      if (prerendering) {
+        React.unstable_postpone();
+      }
+      if (ssr) {
+        React.use(promise);
+      }
+      return 'Hello';
+    }
+
+    function Postpone() {
+      if (prerendering) {
+        React.unstable_postpone();
+      }
+      return 'Hello';
+    }
+
+    function DelayedBoundary() {
+      if (!prerendering && ssr) {
+        // We delay discovery of the boundary so we can abort before finding it.
+        React.use(promise);
+      }
+      return (
+        <Suspense fallback="Loading3">
+          <Postpone />
+        </Suspense>
+      );
+    }
+
+    function App() {
+      return (
+        <div>
+          <Suspense fallback="Loading1">
+            <PostponeAndSuspend />
+          </Suspense>
+          <Suspense fallback="Loading2">
+            <Postpone />
+          </Suspense>
+          <Suspense fallback="Not used">
+            <DelayedBoundary />
+          </Suspense>
+        </div>
+      );
+    }
+
+    const prerenderErrors = [];
+    const prerendered = await ReactDOMFizzStatic.prerenderToNodeStream(
+      <App />,
+      {
+        onError(x) {
+          prerenderErrors.push(x.message);
+        },
+      },
+    );
+    expect(prerendered.postponed).not.toBe(null);
+
+    prerendering = false;
+
+    const ssrErrors = [];
+
+    const resumed = ReactDOMFizzServer.resumeToPipeableStream(
+      <App />,
+      prerendered.postponed,
+      {
+        onError(x) {
+          ssrErrors.push(x.message);
+        },
+      },
+    );
+
+    // Create a separate stream so it doesn't close the writable. I.e. simple concat.
+    const preludeWritable = new Stream.PassThrough();
+    preludeWritable.setEncoding('utf8');
+    preludeWritable.on('data', chunk => {
+      writable.write(chunk);
+    });
+
+    await act(() => {
+      prerendered.prelude.pipe(preludeWritable);
+    });
+
+    expect(getVisibleChildren(container)).toEqual(
+      <div>
+        {'Loading1'}
+        {'Loading2'}
+        {'Loading3'}
+      </div>,
+    );
+
+    await act(() => {
+      resumed.pipe(writable);
+    });
+
+    const recoverableErrors = [];
+
+    ssr = false;
+
+    await clientAct(() => {
+      ReactDOMClient.hydrateRoot(container, <App />, {
+        onRecoverableError(x) {
+          recoverableErrors.push(x.message);
+        },
+      });
+    });
+
+    expect(recoverableErrors).toEqual([]);
+    expect(prerenderErrors).toEqual([]);
+    expect(ssrErrors).toEqual([]);
+
+    // Still loading...
+    expect(getVisibleChildren(container)).toEqual(
+      <div>
+        {'Loading1'}
+        {'Hello'}
+        {'Loading3'}
+      </div>,
+    );
+
+    await clientAct(async () => {
+      await act(() => {
+        resumed.abort(new Error('aborted'));
+      });
+    });
+
+    expect(getVisibleChildren(container)).toEqual(
+      <div>
+        {'Hello'}
+        {'Hello'}
+        {'Hello'}
+      </div>,
+    );
+
+    expect(prerenderErrors).toEqual([]);
+    expect(ssrErrors).toEqual(['aborted', 'aborted']);
+    expect(recoverableErrors).toEqual(
+      __DEV__
+        ? [
+            'The server did not finish this Suspense boundary: aborted',
+            'The server did not finish this Suspense boundary: aborted',
+          ]
+        : [
+            'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.',
+            'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.',
+          ],
+    );
+  });
+
+  // @gate enablePostpone
+  it('client renders remaining boundaries below the error in shell', async () => {
+    let prerendering = true;
+    let ssr = true;
+    function Postpone() {
+      if (prerendering) {
+        React.unstable_postpone();
+      }
+      return 'Hello';
+    }
+
+    function ReplayError({children}) {
+      if (!prerendering && ssr) {
+        throw new Error('replay error');
+      }
+      return children;
+    }
+
+    function App() {
+      return (
+        <div>
+          <div>
+            <Suspense fallback="Loading1">
+              <Postpone />
+            </Suspense>
+            <ReplayError>
+              <Suspense fallback="Loading2">
+                <Postpone />
+              </Suspense>
+            </ReplayError>
+            <Suspense fallback="Loading3">
+              <Postpone />
+            </Suspense>
+          </div>
+          <Suspense fallback="Not used">
+            <div>
+              <Suspense fallback="Loading4">
+                <Postpone />
+              </Suspense>
+            </div>
+          </Suspense>
+          <Suspense fallback="Loading5">
+            <Postpone />
+            <ReplayError>
+              <Suspense fallback="Loading6">
+                <Postpone />
+              </Suspense>
+            </ReplayError>
+          </Suspense>
+        </div>
+      );
+    }
+
+    const prerenderErrors = [];
+    const prerendered = await ReactDOMFizzStatic.prerenderToNodeStream(
+      <App />,
+      {
+        onError(x) {
+          prerenderErrors.push(x.message);
+        },
+      },
+    );
+    expect(prerendered.postponed).not.toBe(null);
+
+    prerendering = false;
+
+    const ssrErrors = [];
+
+    const resumed = ReactDOMFizzServer.resumeToPipeableStream(
+      <App />,
+      prerendered.postponed,
+      {
+        onError(x) {
+          ssrErrors.push(x.message);
+        },
+      },
+    );
+
+    // Create a separate stream so it doesn't close the writable. I.e. simple concat.
+    const preludeWritable = new Stream.PassThrough();
+    preludeWritable.setEncoding('utf8');
+    preludeWritable.on('data', chunk => {
+      writable.write(chunk);
+    });
+
+    await act(() => {
+      prerendered.prelude.pipe(preludeWritable);
+    });
+
+    expect(getVisibleChildren(container)).toEqual(
+      <div>
+        <div>
+          {'Loading1'}
+          {'Loading2'}
+          {'Loading3'}
+        </div>
+        <div>{'Loading4'}</div>
+        {'Loading5'}
+      </div>,
+    );
+
+    await act(() => {
+      resumed.pipe(writable);
+    });
+
+    expect(getVisibleChildren(container)).toEqual(
+      <div>
+        <div>
+          {'Hello' /* This was matched and completed before the error */}
+          {
+            'Loading2' /* This will be client rendered because its parent errored during replay */
+          }
+          {
+            'Hello' /* This should be renderable since we matched which previous sibling errored */
+          }
+        </div>
+        <div>
+          {
+            'Hello' /* This should be able to resume because it's in a different parent. */
+          }
+        </div>
+        {'Hello'}
+        {'Loading6' /* The parent could resolve even if the child didn't */}
+      </div>,
+    );
+
+    const recoverableErrors = [];
+
+    ssr = false;
+
+    await clientAct(() => {
+      ReactDOMClient.hydrateRoot(container, <App />, {
+        onRecoverableError(x) {
+          recoverableErrors.push(x.message);
+        },
+      });
+    });
+
+    expect(getVisibleChildren(container)).toEqual(
+      <div>
+        <div>
+          {'Hello'}
+          {'Hello'}
+          {'Hello'}
+        </div>
+        <div>{'Hello'}</div>
+        {'Hello'}
+        {'Hello'}
+      </div>,
+    );
+
+    // We should've logged once for each boundary that this affected.
+    expect(prerenderErrors).toEqual([]);
+    expect(ssrErrors).toEqual([
+      // This error triggered in two replay components.
+      'replay error',
+      'replay error',
+    ]);
+    expect(recoverableErrors).toEqual(
+      // It surfaced in two different suspense boundaries.
+      __DEV__
+        ? [
+            'The server did not finish this Suspense boundary: replay error',
+            'The server did not finish this Suspense boundary: replay error',
+          ]
+        : [
+            'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.',
+            'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.',
+          ],
+    );
   });
 });

filename: packages/react-server/src/ReactFizzServer.js
patch: @@ -816,10 +816,18 @@ function renderSuspenseBoundary(
   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.",
-    );
+    // If we're replaying through this pass, it means we're replaying through
+    // an already completed Suspense boundary. It's too late to do anything about it
+    // so we can just render through it.
+    const prevKeyPath = someTask.keyPath;
+    someTask.keyPath = keyPath;
+    const content: ReactNodeList = props.children;
+    try {
+      renderNode(request, someTask, content, -1);
+    } finally {
+      someTask.keyPath = prevKeyPath;
+    }
+    return;
   }
   // $FlowFixMe: Refined.
   const task: RenderTask = someTask;
@@ -975,11 +983,13 @@ function renderSuspenseBoundary(
 function replaySuspenseBoundary(
   request: Request,
   task: ReplayTask,
+  keyPath: Root | KeyNode,
   props: Object,
   replayNode: ReplaySuspenseBoundary,
 ): void {
   pushBuiltInComponentStackInDEV(task, 'Suspense');
 
+  const prevKeyPath = task.keyPath;
   const previousReplaySet: ReplaySet = task.replay;
 
   const parentBoundary = task.blockedBoundary;
@@ -1003,7 +1013,6 @@ function replaySuspenseBoundary(
   task.blockedBoundary = resumedBoundary;
   task.replay = {nodes: replayNode[3], pendingTasks: 1};
   if (enableFloat) {
-    // Does this even matter for replaying?
     setCurrentlyRenderingBoundaryResourcesTarget(
       request.renderState,
       resumedBoundary.resources,
@@ -1049,6 +1058,9 @@ function replaySuspenseBoundary(
 
     task.replay.pendingTasks--;
 
+    // The parent already flushed in the prerender so we need to schedule this to be emitted.
+    request.clientRenderedBoundaries.push(resumedBoundary);
+
     // 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.
@@ -1061,6 +1073,7 @@ function replaySuspenseBoundary(
     }
     task.blockedBoundary = parentBoundary;
     task.replay = previousReplaySet;
+    task.keyPath = prevKeyPath;
   }
   // TODO: Should this be in the finally?
   popComponentStackInDEV(task);
@@ -1069,11 +1082,13 @@ function replaySuspenseBoundary(
 function resumeSuspenseBoundary(
   request: Request,
   task: ReplayTask,
+  keyPath: Root | KeyNode,
   props: Object,
   replayNode: ResumeSuspenseBoundary,
 ): void {
   pushBuiltInComponentStackInDEV(task, 'Suspense');
 
+  const prevKeyPath = task.keyPath;
   const previousReplaySet: ReplaySet = task.replay;
 
   const parentBoundary = task.blockedBoundary;
@@ -1113,6 +1128,7 @@ function resumeSuspenseBoundary(
       resumedBoundary.resources,
     );
   }
+  task.keyPath = keyPath;
   try {
     // Convert the current ReplayTask to a RenderTask.
     const renderTask: RenderTask = (task: any);
@@ -1150,6 +1166,9 @@ function resumeSuspenseBoundary(
       captureBoundaryErrorDetailsDev(resumedBoundary, error);
     }
 
+    // The parent already flushed in the prerender so we need to schedule this to be emitted.
+    request.clientRenderedBoundaries.push(resumedBoundary);
+
     // 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.
@@ -1164,6 +1183,7 @@ function resumeSuspenseBoundary(
     // Restore to a ReplayTask
     task.blockedSegment = null;
     task.replay = previousReplaySet;
+    task.keyPath = prevKeyPath;
   }
   // TODO: Should this be in the finally?
   popComponentStackInDEV(task);
@@ -2063,7 +2083,8 @@ function replayElement(
             );
           }
           // Matched a replayable path.
-          task.replay = {nodes: node[3], pendingTasks: 1};
+          const childNodes = node[3];
+          task.replay = {nodes: childNodes, pendingTasks: 1};
           try {
             renderElement(
               request,
@@ -2074,22 +2095,37 @@ function replayElement(
               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.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.",
               );
             }
+          } catch (x) {
+            if (
+              typeof x === 'object' &&
+              x !== null &&
+              (x === SuspenseException || typeof x.then === 'function')
+            ) {
+              // Suspend
+              throw x;
+            }
+            // Unlike regular render, we don't terminate the siblings if we error
+            // during a replay. That's because this component didn't actually error
+            // in the original prerender. What's unable to complete is the child
+            // replay nodes which might be Suspense boundaries which are able to
+            // absorb the error and we can still continue with siblings.
+            erroredReplay(request, task.blockedBoundary, x, childNodes);
+          } finally {
+            task.replay.pendingTasks--;
             task.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(i, 1);
         }
         continue;
       }
@@ -2104,7 +2140,7 @@ function replayElement(
             );
           }
           // Matched a replayable path.
-          replaySuspenseBoundary(request, task, props, node);
+          replaySuspenseBoundary(request, task, keyPath, 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);
@@ -2155,7 +2191,7 @@ function replayElement(
             );
           }
           // Matched a resumable suspense boundary.
-          resumeSuspenseBoundary(request, task, props, node);
+          resumeSuspenseBoundary(request, task, keyPath, props, node);
 
           // We finished rendering this node, so now we can consume this
           // slot. This must happen after in case we rerender this task.
@@ -2466,6 +2502,62 @@ function renderNodeDestructiveImpl(
   }
 }
 
+function replayFragment(
+  request: Request,
+  task: ReplayTask,
+  children: Array<any>,
+  childIndex: number,
+): void {
+  // If we're supposed follow this array, we'd expect to see a ReplayNode matching
+  // this fragment.
+  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.
+    const childNodes = node[3];
+    task.replay = {nodes: childNodes, pendingTasks: 1};
+    try {
+      renderChildrenArray(request, task, children, -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.",
+        );
+      }
+    } catch (x) {
+      if (
+        typeof x === 'object' &&
+        x !== null &&
+        (x === SuspenseException || typeof x.then === 'function')
+      ) {
+        // Suspend
+        throw x;
+      }
+      // Unlike regular render, we don't terminate the siblings if we error
+      // during a replay. That's because this component didn't actually error
+      // in the original prerender. What's unable to complete is the child
+      // replay nodes which might be Suspense boundaries which are able to
+      // absorb the error and we can still continue with siblings.
+      erroredReplay(request, task.blockedBoundary, x, childNodes);
+    } finally {
+      task.replay.pendingTasks--;
+      task.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;
+  }
+}
+
 function renderChildrenArray(
   request: Request,
   task: Task,
@@ -2476,42 +2568,13 @@ function renderChildrenArray(
   if (childIndex !== -1) {
     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;
-      }
+      replayFragment(
+        request,
+        // $FlowFixMe: Refined.
+        task,
+        children,
+        childIndex,
+      );
       task.keyPath = prevKeyPath;
       return;
     }
@@ -2934,6 +2997,42 @@ function renderNode(
   throw x;
 }
 
+function erroredReplay(
+  request: Request,
+  boundary: Root | SuspenseBoundary,
+  error: mixed,
+  replayNodes: ResumableNode[],
+): void {
+  // Erroring during a replay doesn't actually cause an error by itself because
+  // that component has already rendered. What causes the error is the resumable
+  // points that we did not yet finish which will be below the point of the reset.
+  // For example, if we're replaying a path to a Suspense boundary that is not done
+  // that doesn't error the parent Suspense boundary.
+  // This might be a bit strange that the error in a parent gets thrown at a child.
+  // We log it only once and reuse the digest.
+  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);
+  }
+  abortRemainingResumableNodes(
+    request,
+    boundary,
+    replayNodes,
+    error,
+    errorDigest,
+  );
+}
+
 function erroredTask(
   request: Request,
   boundary: Root | SuspenseBoundary,
@@ -2955,9 +3054,6 @@ 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--;
@@ -3000,42 +3096,160 @@ function abortTaskSoft(this: Request, task: Task): void {
   }
 }
 
+function abortRemainingSuspenseBoundary(
+  request: Request,
+  id: SuspenseBoundaryID,
+  rootSegmentID: number,
+  error: mixed,
+  errorDigest: ?string,
+): void {
+  const resumedBoundary = createSuspenseBoundary(
+    request,
+    new Set(),
+    null, // The keyPath doesn't matter at this point so we don't bother rebuilding it.
+  );
+  resumedBoundary.parentFlushed = true;
+  // We restore the same id of this boundary as was used during prerender.
+  resumedBoundary.id = id;
+  resumedBoundary.rootSegmentID = rootSegmentID;
+
+  resumedBoundary.status = CLIENT_RENDERED;
+  resumedBoundary.errorDigest = errorDigest;
+  if (__DEV__) {
+    const errorPrefix = 'The server did not finish this Suspense boundary: ';
+    let errorMessage;
+    if (error && typeof error.message === 'string') {
+      errorMessage = errorPrefix + error.message;
+    } else {
+      // eslint-disable-next-line react-internal/safe-string-coercion
+      errorMessage = errorPrefix + String(error);
+    }
+    const previousTaskInDev = currentTaskInDEV;
+    currentTaskInDEV = null;
+    try {
+      captureBoundaryErrorDetailsDev(resumedBoundary, errorMessage);
+    } finally {
+      currentTaskInDEV = previousTaskInDev;
+    }
+  }
+  if (resumedBoundary.parentFlushed) {
+    request.clientRenderedBoundaries.push(resumedBoundary);
+  }
+}
+
 function abortRemainingResumableNodes(
+  request: Request,
+  boundary: Root | SuspenseBoundary,
   nodes: Array<ResumableNode>,
   error: mixed,
+  errorDigest: ?string,
 ): void {
-  // TODO: Abort any undiscovered Suspense boundaries in the ReplaySet.
+  for (let i = 0; i < nodes.length; i++) {
+    const node: any = nodes[i];
+    switch (node[0]) {
+      case REPLAY_NODE: {
+        abortRemainingResumableNodes(
+          request,
+          boundary,
+          node[3],
+          error,
+          errorDigest,
+        );
+        continue;
+      }
+      case REPLAY_SUSPENSE_BOUNDARY: {
+        const boundaryNode: ReplaySuspenseBoundary = node;
+        const id = boundaryNode[4];
+        const rootSegmentID = boundaryNode[5];
+        abortRemainingSuspenseBoundary(
+          request,
+          id,
+          rootSegmentID,
+          error,
+          errorDigest,
+        );
+        continue;
+      }
+      case RESUME_SUSPENSE_BOUNDARY: {
+        const boundaryNode: ResumeSuspenseBoundary = node;
+        const id = boundaryNode[3];
+        const rootSegmentID = boundaryNode[4];
+        abortRemainingSuspenseBoundary(
+          request,
+          id,
+          rootSegmentID,
+          error,
+          errorDigest,
+        );
+        continue;
+      }
+      case RESUME_ELEMENT:
+      case RESUME_SLOT: {
+        // We had something still to resume in the parent boundary. We must trigger
+        // the error on the parent boundary since it's not able to complete.
+        if (boundary === null) {
+          throw new Error(
+            'We should not have any resumable nodes in the shell. ' +
+              'This is a bug in React.',
+          );
+        } else if (boundary.status !== CLIENT_RENDERED) {
+          boundary.status = CLIENT_RENDERED;
+          boundary.errorDigest = errorDigest;
+          if (__DEV__) {
+            captureBoundaryErrorDetailsDev(boundary, error);
+          }
+          if (boundary.parentFlushed) {
+            request.clientRenderedBoundaries.push(boundary);
+          }
+        }
+        continue;
+      }
+    }
+  }
+  // Empty the set, since we've cleared it now.
+  nodes.length = 0;
 }
 
 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;
-  if (segment === null) {
-    // $FlowFixMe: Refined.
-    const replay: ReplaySet = task.replay;
-    replay.pendingTasks--;
-    if (replay.pendingTasks === 0) {
-      abortRemainingResumableNodes(replay.nodes, error);
-    }
-  } else {
+  if (segment !== null) {
     segment.status = ABORTED;
   }
 
   if (boundary === null) {
     request.allPendingTasks--;
-    // We didn't complete the root so we have nothing to show. We can close
-    // the request;
     if (request.status !== CLOSING && request.status !== CLOSED) {
-      logRecoverableError(request, error);
-      fatalError(request, error);
+      const replay: null | ReplaySet = task.replay;
+      if (replay === null) {
+        // We didn't complete the root so we have nothing to show. We can close
+        // the request;
+        logRecoverableError(request, error);
+        fatalError(request, error);
+      } else {
+        // If the shell aborts 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.
+        replay.pendingTasks--;
+        if (replay.pendingTasks === 0 && replay.nodes.length > 0) {
+          const errorDigest = logRecoverableError(request, error);
+          abortRemainingResumableNodes(
+            request,
+            null,
+            replay.nodes,
+            error,
+            errorDigest,
+          );
+        }
+      }
     }
   } else {
     boundary.pendingTasks--;
     if (boundary.status !== CLIENT_RENDERED) {
       boundary.status = CLIENT_RENDERED;
-      boundary.errorDigest = request.onError(error);
+      boundary.errorDigest = logRecoverableError(request, error);
       if (__DEV__) {
         const errorPrefix =
           'The server did not finish this Suspense boundary: ';
@@ -3145,8 +3359,11 @@ function finishedTask(
       // We can now cancel any pending task on the fallback since we won't need to show it anymore.
       // This needs to happen after we read the parentFlushed flags because aborting can finish
       // work which can trigger user code, which can start flushing, which can change those flags.
-      boundary.fallbackAbortableTasks.forEach(abortTaskSoft, request);
-      boundary.fallbackAbortableTasks.clear();
+      // If the boundary was POSTPONED, we still need to finish the fallback first.
+      if (boundary.status === COMPLETED) {
+        boundary.fallbackAbortableTasks.forEach(abortTaskSoft, request);
+        boundary.fallbackAbortableTasks.clear();
+      }
     } else {
       if (segment !== null && segment.parentFlushed) {
         // Our parent already flushed, so we need to schedule this segment to be emitted.
@@ -3370,7 +3587,12 @@ function retryReplayTask(request: Request, task: ReplayTask): void {
     }
     task.replay.pendingTasks--;
     task.abortSet.delete(task);
-    erroredTask(request, task.blockedBoundary, x);
+    erroredReplay(request, task.blockedBoundary, x, task.replay.nodes);
+    request.allPendingTasks--;
+    if (request.allPendingTasks === 0) {
+      const onAllReady = request.onAllReady;
+      onAllReady();
+    }
     return;
   } finally {
     if (enableFloat) {

filename: scripts/error-codes/codes.json
patch: @@ -472,7 +472,7 @@
   "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.",
-  "487": "Did not expect to see a Suspense boundary in this slot. The tree doesn't match so React will fallback to client rendering.",
+  "487": "We should not have any resumable nodes in the shell. This is a bug in React.",
   "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."