diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
index 076c442f5283..b89724cadd4f 100644
--- a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
+++ b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
@@ -6355,16 +6355,479 @@ describe('ReactDOMFizzServer', () => {
expect(getVisibleChildren(container)).toEqual(
Loading...
);
- const b = new Stream.PassThrough();
- b.setEncoding('utf8');
- b.on('data', chunk => {
+ await act(() => {
+ resumed.pipe(writable);
+ });
+
+ expect(getVisibleChildren(container)).toEqual(Hello
);
+ });
+
+ // @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: };
+ });
+
+ function ReplayError() {
+ if (prerendering) {
+ return ;
+ }
+ if (ssr) {
+ throw new Error('replay error');
+ }
+ return 'Hello';
+ }
+
+ function App() {
+ return (
+
+
+
+
+
+
+ {lazyPostponeAndError}
+
+
+
+
+
+ );
+ }
+
+ const prerenderErrors = [];
+ const prerendered = await ReactDOMFizzStatic.prerenderToNodeStream(
+ ,
+ {
+ onError(x) {
+ prerenderErrors.push(x.message);
+ },
+ },
+ );
+ expect(prerendered.postponed).not.toBe(null);
+
+ prerendering = false;
+
+ const ssrErrors = [];
+
+ const resumed = ReactDOMFizzServer.resumeToPipeableStream(
+ ,
+ 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(
+
+ {'Loading1'}
+ {'Loading2'}
+ {'Loading4'}
+
,
+ );
+
await act(() => {
resumed.pipe(writable);
});
- expect(getVisibleChildren(container)).toEqual(Hello
);
+ expect(prerenderErrors).toEqual([]);
+
+ expect(ssrErrors).toEqual(['server error', 'server error', 'replay error']);
+
+ // Still loading...
+ expect(getVisibleChildren(container)).toEqual(
+
+ {'Loading1'}
+ {'Hello'}
+ {'Loading3'}
+ {'Loading4'}
+
,
+ );
+
+ const recoverableErrors = [];
+
+ ssr = false;
+
+ await clientAct(() => {
+ ReactDOMClient.hydrateRoot(container, , {
+ 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(
+
+ {'Hello'}
+ {'Hello'}
+ {'Hello'}
+ {'Hello'}
+
,
+ );
+ });
+
+ // @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 (
+
+
+
+ );
+ }
+
+ function App() {
+ return (
+
+ );
+ }
+
+ const prerenderErrors = [];
+ const prerendered = await ReactDOMFizzStatic.prerenderToNodeStream(
+ ,
+ {
+ onError(x) {
+ prerenderErrors.push(x.message);
+ },
+ },
+ );
+ expect(prerendered.postponed).not.toBe(null);
+
+ prerendering = false;
+
+ const ssrErrors = [];
+
+ const resumed = ReactDOMFizzServer.resumeToPipeableStream(
+ ,
+ 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(
+
+ {'Loading1'}
+ {'Loading2'}
+ {'Loading3'}
+
,
+ );
+
+ await act(() => {
+ resumed.pipe(writable);
+ });
+
+ const recoverableErrors = [];
+
+ ssr = false;
+
+ await clientAct(() => {
+ ReactDOMClient.hydrateRoot(container, , {
+ onRecoverableError(x) {
+ recoverableErrors.push(x.message);
+ },
+ });
+ });
+
+ expect(recoverableErrors).toEqual([]);
+ expect(prerenderErrors).toEqual([]);
+ expect(ssrErrors).toEqual([]);
+
+ // Still loading...
+ expect(getVisibleChildren(container)).toEqual(
+
+ {'Loading1'}
+ {'Hello'}
+ {'Loading3'}
+
,
+ );
+
+ await clientAct(async () => {
+ await act(() => {
+ resumed.abort(new Error('aborted'));
+ });
+ });
+
+ expect(getVisibleChildren(container)).toEqual(
+
+ {'Hello'}
+ {'Hello'}
+ {'Hello'}
+
,
+ );
+
+ 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 (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+
+ const prerenderErrors = [];
+ const prerendered = await ReactDOMFizzStatic.prerenderToNodeStream(
+ ,
+ {
+ onError(x) {
+ prerenderErrors.push(x.message);
+ },
+ },
+ );
+ expect(prerendered.postponed).not.toBe(null);
+
+ prerendering = false;
+
+ const ssrErrors = [];
+
+ const resumed = ReactDOMFizzServer.resumeToPipeableStream(
+ ,
+ 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(
+
+
+ {'Loading1'}
+ {'Loading2'}
+ {'Loading3'}
+
+
{'Loading4'}
+ {'Loading5'}
+
,
+ );
+
+ await act(() => {
+ resumed.pipe(writable);
+ });
+
+ expect(getVisibleChildren(container)).toEqual(
+
+
+ {'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 */
+ }
+
+
+ {
+ 'Hello' /* This should be able to resume because it's in a different parent. */
+ }
+
+ {'Hello'}
+ {'Loading6' /* The parent could resolve even if the child didn't */}
+
,
+ );
+
+ const recoverableErrors = [];
+
+ ssr = false;
+
+ await clientAct(() => {
+ ReactDOMClient.hydrateRoot(container, , {
+ onRecoverableError(x) {
+ recoverableErrors.push(x.message);
+ },
+ });
+ });
+
+ expect(getVisibleChildren(container)).toEqual(
+
+
+ {'Hello'}
+ {'Hello'}
+ {'Hello'}
+
+
{'Hello'}
+ {'Hello'}
+ {'Hello'}
+
,
+ );
+
+ // 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.',
+ ],
+ );
});
});
diff --git a/packages/react-server/src/ReactFizzServer.js b/packages/react-server/src/ReactFizzServer.js
index cb75725c6b77..59e5636c110c 100644
--- a/packages/react-server/src/ReactFizzServer.js
+++ b/packages/react-server/src/ReactFizzServer.js
@@ -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,13 +2095,8 @@ 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(
@@ -2088,8 +2104,28 @@ function replayElement(
"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,
+ 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,11 +3096,118 @@ 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,
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 {
@@ -3012,30 +3215,41 @@ function abortTask(task: Task, request: Request, error: mixed): void {
// 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) {
@@ -3778,9 +4000,6 @@ function flushCompletedQueues(
// We haven't flushed the root yet so we don't need to check any other branches further down
return;
}
- } else if (request.pendingRootTasks > 0) {
- // We have not yet flushed the root segment so we early return
- return;
}
if (enableFloat) {
diff --git a/scripts/error-codes/codes.json b/scripts/error-codes/codes.json
index 3f7bc8d47a87..6de63d819bdd 100644
--- a/scripts/error-codes/codes.json
+++ b/scripts/error-codes/codes.json
@@ -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."