@samitouri / QOS-React-2 / commits / 925c66a647

[Fizz] Client render the nearest child or parent suspense boundary if replay errors or is aborted (#27386)

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.

Sebastian Markbåge committed Sep 18, 2023 at 11:25 UTC 925c66a6472a61e7174dabf6c697a9a320ebc170
3 files changed +760 -75
packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
+467 -4
@@ -6355,16 +6355,479 @@ describe('ReactDOMFizzServer', () => {
6355
6356 expect(getVisibleChildren(container)).toEqual(<div>Loading...</div>);
6357
6358 - const b = new Stream.PassThrough();
6359 - b.setEncoding('utf8');
6360 - b.on('data', chunk => {
6358 + await act(() => {
6359 + resumed.pipe(writable);
6360 + });
6361 +
6362 + expect(getVisibleChildren(container)).toEqual(<div>Hello</div>);
6363 + });
6364 +
6365 + // @gate enablePostpone
6366 + it('client renders a component if it errors during resuming', async () => {
6367 + let prerendering = true;
6368 + let ssr = true;
6369 + function PostponeAndError() {
6370 + if (prerendering) {
6371 + React.unstable_postpone();
6372 + }
6373 + if (ssr) {
6374 + throw new Error('server error');
6375 + }
6376 + return 'Hello';
6377 + }
6378 +
6379 + function Postpone() {
6380 + if (prerendering) {
6381 + React.unstable_postpone();
6382 + }
6383 + return 'Hello';
6384 + }
6385 +
6386 + const lazyPostponeAndError = React.lazy(async () => {
6387 + return {default: <PostponeAndError />};
6388 + });
6389 +
6390 + function ReplayError() {
6391 + if (prerendering) {
6392 + return <Postpone />;
6393 + }
6394 + if (ssr) {
6395 + throw new Error('replay error');
6396 + }
6397 + return 'Hello';
6398 + }
6399 +
6400 + function App() {
6401 + return (
6402 + <div>
6403 + <Suspense fallback="Loading1">
6404 + <PostponeAndError />
6405 + </Suspense>
6406 + <Suspense fallback="Loading2">
6407 + <Postpone />
6408 + <Suspense fallback="Loading3">{lazyPostponeAndError}</Suspense>
6409 + </Suspense>
6410 + <Suspense fallback="Loading4">
6411 + <ReplayError />
6412 + </Suspense>
6413 + </div>
6414 + );
6415 + }
6416 +
6417 + const prerenderErrors = [];
6418 + const prerendered = await ReactDOMFizzStatic.prerenderToNodeStream(
6419 + <App />,
6420 + {
6421 + onError(x) {
6422 + prerenderErrors.push(x.message);
6423 + },
6424 + },
6425 + );
6426 + expect(prerendered.postponed).not.toBe(null);
6427 +
6428 + prerendering = false;
6429 +
6430 + const ssrErrors = [];
6431 +
6432 + const resumed = ReactDOMFizzServer.resumeToPipeableStream(
6433 + <App />,
6434 + prerendered.postponed,
6435 + {
6436 + onError(x) {
6437 + ssrErrors.push(x.message);
6438 + },
6439 + },
6440 + );
6441 +
6442 + // Create a separate stream so it doesn't close the writable. I.e. simple concat.
6443 + const preludeWritable = new Stream.PassThrough();
6444 + preludeWritable.setEncoding('utf8');
6445 + preludeWritable.on('data', chunk => {
6446 writable.write(chunk);
6447 });
6448
6449 + await act(() => {
6450 + prerendered.prelude.pipe(preludeWritable);
6451 + });
6452 +
6453 + expect(getVisibleChildren(container)).toEqual(
6454 + <div>
6455 + {'Loading1'}
6456 + {'Loading2'}
6457 + {'Loading4'}
6458 + </div>,
6459 + );
6460 +
6461 await act(() => {
6462 resumed.pipe(writable);
6463 });
6464
6368 - expect(getVisibleChildren(container)).toEqual(<div>Hello</div>);
6465 + expect(prerenderErrors).toEqual([]);
6466 +
6467 + expect(ssrErrors).toEqual(['server error', 'server error', 'replay error']);
6468 +
6469 + // Still loading...
6470 + expect(getVisibleChildren(container)).toEqual(
6471 + <div>
6472 + {'Loading1'}
6473 + {'Hello'}
6474 + {'Loading3'}
6475 + {'Loading4'}
6476 + </div>,
6477 + );
6478 +
6479 + const recoverableErrors = [];
6480 +
6481 + ssr = false;
6482 +
6483 + await clientAct(() => {
6484 + ReactDOMClient.hydrateRoot(container, <App />, {
6485 + onRecoverableError(x) {
6486 + recoverableErrors.push(x.message);
6487 + },
6488 + });
6489 + });
6490 +
6491 + expect(recoverableErrors).toEqual(
6492 + __DEV__
6493 + ? ['server error', 'replay error', 'server error']
6494 + : [
6495 + 'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.',
6496 + 'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.',
6497 + 'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.',
6498 + ],
6499 + );
6500 + expect(getVisibleChildren(container)).toEqual(
6501 + <div>
6502 + {'Hello'}
6503 + {'Hello'}
6504 + {'Hello'}
6505 + {'Hello'}
6506 + </div>,
6507 + );
6508 + });
6509 +
6510 + // @gate enablePostpone
6511 + it('client renders a component if we abort before resuming', async () => {
6512 + let prerendering = true;
6513 + let ssr = true;
6514 + const promise = new Promise(() => {});
6515 + function PostponeAndSuspend() {
6516 + if (prerendering) {
6517 + React.unstable_postpone();
6518 + }
6519 + if (ssr) {
6520 + React.use(promise);
6521 + }
6522 + return 'Hello';
6523 + }
6524 +
6525 + function Postpone() {
6526 + if (prerendering) {
6527 + React.unstable_postpone();
6528 + }
6529 + return 'Hello';
6530 + }
6531 +
6532 + function DelayedBoundary() {
6533 + if (!prerendering && ssr) {
6534 + // We delay discovery of the boundary so we can abort before finding it.
6535 + React.use(promise);
6536 + }
6537 + return (
6538 + <Suspense fallback="Loading3">
6539 + <Postpone />
6540 + </Suspense>
6541 + );
6542 + }
6543 +
6544 + function App() {
6545 + return (
6546 + <div>
6547 + <Suspense fallback="Loading1">
6548 + <PostponeAndSuspend />
6549 + </Suspense>
6550 + <Suspense fallback="Loading2">
6551 + <Postpone />
6552 + </Suspense>
6553 + <Suspense fallback="Not used">
6554 + <DelayedBoundary />
6555 + </Suspense>
6556 + </div>
6557 + );
6558 + }
6559 +
6560 + const prerenderErrors = [];
6561 + const prerendered = await ReactDOMFizzStatic.prerenderToNodeStream(
6562 + <App />,
6563 + {
6564 + onError(x) {
6565 + prerenderErrors.push(x.message);
6566 + },
6567 + },
6568 + );
6569 + expect(prerendered.postponed).not.toBe(null);
6570 +
6571 + prerendering = false;
6572 +
6573 + const ssrErrors = [];
6574 +
6575 + const resumed = ReactDOMFizzServer.resumeToPipeableStream(
6576 + <App />,
6577 + prerendered.postponed,
6578 + {
6579 + onError(x) {
6580 + ssrErrors.push(x.message);
6581 + },
6582 + },
6583 + );
6584 +
6585 + // Create a separate stream so it doesn't close the writable. I.e. simple concat.
6586 + const preludeWritable = new Stream.PassThrough();
6587 + preludeWritable.setEncoding('utf8');
6588 + preludeWritable.on('data', chunk => {
6589 + writable.write(chunk);
6590 + });
6591 +
6592 + await act(() => {
6593 + prerendered.prelude.pipe(preludeWritable);
6594 + });
6595 +
6596 + expect(getVisibleChildren(container)).toEqual(
6597 + <div>
6598 + {'Loading1'}
6599 + {'Loading2'}
6600 + {'Loading3'}
6601 + </div>,
6602 + );
6603 +
6604 + await act(() => {
6605 + resumed.pipe(writable);
6606 + });
6607 +
6608 + const recoverableErrors = [];
6609 +
6610 + ssr = false;
6611 +
6612 + await clientAct(() => {
6613 + ReactDOMClient.hydrateRoot(container, <App />, {
6614 + onRecoverableError(x) {
6615 + recoverableErrors.push(x.message);
6616 + },
6617 + });
6618 + });
6619 +
6620 + expect(recoverableErrors).toEqual([]);
6621 + expect(prerenderErrors).toEqual([]);
6622 + expect(ssrErrors).toEqual([]);
6623 +
6624 + // Still loading...
6625 + expect(getVisibleChildren(container)).toEqual(
6626 + <div>
6627 + {'Loading1'}
6628 + {'Hello'}
6629 + {'Loading3'}
6630 + </div>,
6631 + );
6632 +
6633 + await clientAct(async () => {
6634 + await act(() => {
6635 + resumed.abort(new Error('aborted'));
6636 + });
6637 + });
6638 +
6639 + expect(getVisibleChildren(container)).toEqual(
6640 + <div>
6641 + {'Hello'}
6642 + {'Hello'}
6643 + {'Hello'}
6644 + </div>,
6645 + );
6646 +
6647 + expect(prerenderErrors).toEqual([]);
6648 + expect(ssrErrors).toEqual(['aborted', 'aborted']);
6649 + expect(recoverableErrors).toEqual(
6650 + __DEV__
6651 + ? [
6652 + 'The server did not finish this Suspense boundary: aborted',
6653 + 'The server did not finish this Suspense boundary: aborted',
6654 + ]
6655 + : [
6656 + 'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.',
6657 + 'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.',
6658 + ],
6659 + );
6660 + });
6661 +
6662 + // @gate enablePostpone
6663 + it('client renders remaining boundaries below the error in shell', async () => {
6664 + let prerendering = true;
6665 + let ssr = true;
6666 + function Postpone() {
6667 + if (prerendering) {
6668 + React.unstable_postpone();
6669 + }
6670 + return 'Hello';
6671 + }
6672 +
6673 + function ReplayError({children}) {
6674 + if (!prerendering && ssr) {
6675 + throw new Error('replay error');
6676 + }
6677 + return children;
6678 + }
6679 +
6680 + function App() {
6681 + return (
6682 + <div>
6683 + <div>
6684 + <Suspense fallback="Loading1">
6685 + <Postpone />
6686 + </Suspense>
6687 + <ReplayError>
6688 + <Suspense fallback="Loading2">
6689 + <Postpone />
6690 + </Suspense>
6691 + </ReplayError>
6692 + <Suspense fallback="Loading3">
6693 + <Postpone />
6694 + </Suspense>
6695 + </div>
6696 + <Suspense fallback="Not used">
6697 + <div>
6698 + <Suspense fallback="Loading4">
6699 + <Postpone />
6700 + </Suspense>
6701 + </div>
6702 + </Suspense>
6703 + <Suspense fallback="Loading5">
6704 + <Postpone />
6705 + <ReplayError>
6706 + <Suspense fallback="Loading6">
6707 + <Postpone />
6708 + </Suspense>
6709 + </ReplayError>
6710 + </Suspense>
6711 + </div>
6712 + );
6713 + }
6714 +
6715 + const prerenderErrors = [];
6716 + const prerendered = await ReactDOMFizzStatic.prerenderToNodeStream(
6717 + <App />,
6718 + {
6719 + onError(x) {
6720 + prerenderErrors.push(x.message);
6721 + },
6722 + },
6723 + );
6724 + expect(prerendered.postponed).not.toBe(null);
6725 +
6726 + prerendering = false;
6727 +
6728 + const ssrErrors = [];
6729 +
6730 + const resumed = ReactDOMFizzServer.resumeToPipeableStream(
6731 + <App />,
6732 + prerendered.postponed,
6733 + {
6734 + onError(x) {
6735 + ssrErrors.push(x.message);
6736 + },
6737 + },
6738 + );
6739 +
6740 + // Create a separate stream so it doesn't close the writable. I.e. simple concat.
6741 + const preludeWritable = new Stream.PassThrough();
6742 + preludeWritable.setEncoding('utf8');
6743 + preludeWritable.on('data', chunk => {
6744 + writable.write(chunk);
6745 + });
6746 +
6747 + await act(() => {
6748 + prerendered.prelude.pipe(preludeWritable);
6749 + });
6750 +
6751 + expect(getVisibleChildren(container)).toEqual(
6752 + <div>
6753 + <div>
6754 + {'Loading1'}
6755 + {'Loading2'}
6756 + {'Loading3'}
6757 + </div>
6758 + <div>{'Loading4'}</div>
6759 + {'Loading5'}
6760 + </div>,
6761 + );
6762 +
6763 + await act(() => {
6764 + resumed.pipe(writable);
6765 + });
6766 +
6767 + expect(getVisibleChildren(container)).toEqual(
6768 + <div>
6769 + <div>
6770 + {'Hello' /* This was matched and completed before the error */}
6771 + {
6772 + 'Loading2' /* This will be client rendered because its parent errored during replay */
6773 + }
6774 + {
6775 + 'Hello' /* This should be renderable since we matched which previous sibling errored */
6776 + }
6777 + </div>
6778 + <div>
6779 + {
6780 + 'Hello' /* This should be able to resume because it's in a different parent. */
6781 + }
6782 + </div>
6783 + {'Hello'}
6784 + {'Loading6' /* The parent could resolve even if the child didn't */}
6785 + </div>,
6786 + );
6787 +
6788 + const recoverableErrors = [];
6789 +
6790 + ssr = false;
6791 +
6792 + await clientAct(() => {
6793 + ReactDOMClient.hydrateRoot(container, <App />, {
6794 + onRecoverableError(x) {
6795 + recoverableErrors.push(x.message);
6796 + },
6797 + });
6798 + });
6799 +
6800 + expect(getVisibleChildren(container)).toEqual(
6801 + <div>
6802 + <div>
6803 + {'Hello'}
6804 + {'Hello'}
6805 + {'Hello'}
6806 + </div>
6807 + <div>{'Hello'}</div>
6808 + {'Hello'}
6809 + {'Hello'}
6810 + </div>,
6811 + );
6812 +
6813 + // We should've logged once for each boundary that this affected.
6814 + expect(prerenderErrors).toEqual([]);
6815 + expect(ssrErrors).toEqual([
6816 + // This error triggered in two replay components.
6817 + 'replay error',
6818 + 'replay error',
6819 + ]);
6820 + expect(recoverableErrors).toEqual(
6821 + // It surfaced in two different suspense boundaries.
6822 + __DEV__
6823 + ? [
6824 + 'The server did not finish this Suspense boundary: replay error',
6825 + 'The server did not finish this Suspense boundary: replay error',
6826 + ]
6827 + : [
6828 + 'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.',
6829 + 'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.',
6830 + ],
6831 + );
6832 });
6833 });
packages/react-server/src/ReactFizzServer.js
+292 -70
@@ -816,10 +816,18 @@ function renderSuspenseBoundary(
816 props: Object,
817 ): void {
818 if (someTask.replay !== null) {
819 - throw new Error(
820 - 'Did not expect to see a Suspense boundary in this slot. ' +
821 - "The tree doesn't match so React will fallback to client rendering.",
822 - );
819 + // If we're replaying through this pass, it means we're replaying through
820 + // an already completed Suspense boundary. It's too late to do anything about it
821 + // so we can just render through it.
822 + const prevKeyPath = someTask.keyPath;
823 + someTask.keyPath = keyPath;
824 + const content: ReactNodeList = props.children;
825 + try {
826 + renderNode(request, someTask, content, -1);
827 + } finally {
828 + someTask.keyPath = prevKeyPath;
829 + }
830 + return;
831 }
832 // $FlowFixMe: Refined.
833 const task: RenderTask = someTask;
@@ -975,11 +983,13 @@ function renderSuspenseBoundary(
983 function replaySuspenseBoundary(
984 request: Request,
985 task: ReplayTask,
986 + keyPath: Root | KeyNode,
987 props: Object,
988 replayNode: ReplaySuspenseBoundary,
989 ): void {
990 pushBuiltInComponentStackInDEV(task, 'Suspense');
991
992 + const prevKeyPath = task.keyPath;
993 const previousReplaySet: ReplaySet = task.replay;
994
995 const parentBoundary = task.blockedBoundary;
@@ -1003,7 +1013,6 @@ function replaySuspenseBoundary(
1013 task.blockedBoundary = resumedBoundary;
1014 task.replay = {nodes: replayNode[3], pendingTasks: 1};
1015 if (enableFloat) {
1006 - // Does this even matter for replaying?
1016 setCurrentlyRenderingBoundaryResourcesTarget(
1017 request.renderState,
1018 resumedBoundary.resources,
@@ -1049,6 +1058,9 @@ function replaySuspenseBoundary(
1058
1059 task.replay.pendingTasks--;
1060
1061 + // The parent already flushed in the prerender so we need to schedule this to be emitted.
1062 + request.clientRenderedBoundaries.push(resumedBoundary);
1063 +
1064 // We don't need to decrement any task numbers because we didn't spawn any new task.
1065 // We don't need to schedule any task because we know the parent has written yet.
1066 // We do need to fallthrough to create the fallback though.
@@ -1061,6 +1073,7 @@ function replaySuspenseBoundary(
1073 }
1074 task.blockedBoundary = parentBoundary;
1075 task.replay = previousReplaySet;
1076 + task.keyPath = prevKeyPath;
1077 }
1078 // TODO: Should this be in the finally?
1079 popComponentStackInDEV(task);
@@ -1069,11 +1082,13 @@ function replaySuspenseBoundary(
1082 function resumeSuspenseBoundary(
1083 request: Request,
1084 task: ReplayTask,
1085 + keyPath: Root | KeyNode,
1086 props: Object,
1087 replayNode: ResumeSuspenseBoundary,
1088 ): void {
1089 pushBuiltInComponentStackInDEV(task, 'Suspense');
1090
1091 + const prevKeyPath = task.keyPath;
1092 const previousReplaySet: ReplaySet = task.replay;
1093
1094 const parentBoundary = task.blockedBoundary;
@@ -1113,6 +1128,7 @@ function resumeSuspenseBoundary(
1128 resumedBoundary.resources,
1129 );
1130 }
1131 + task.keyPath = keyPath;
1132 try {
1133 // Convert the current ReplayTask to a RenderTask.
1134 const renderTask: RenderTask = (task: any);
@@ -1150,6 +1166,9 @@ function resumeSuspenseBoundary(
1166 captureBoundaryErrorDetailsDev(resumedBoundary, error);
1167 }
1168
1169 + // The parent already flushed in the prerender so we need to schedule this to be emitted.
1170 + request.clientRenderedBoundaries.push(resumedBoundary);
1171 +
1172 // We don't need to decrement any task numbers because we didn't spawn any new task.
1173 // We don't need to schedule any task because we know the parent has written yet.
1174 // We do need to fallthrough to create the fallback though.
@@ -1164,6 +1183,7 @@ function resumeSuspenseBoundary(
1183 // Restore to a ReplayTask
1184 task.blockedSegment = null;
1185 task.replay = previousReplaySet;
1186 + task.keyPath = prevKeyPath;
1187 }
1188 // TODO: Should this be in the finally?
1189 popComponentStackInDEV(task);
@@ -2063,7 +2083,8 @@ function replayElement(
2083 );
2084 }
2085 // Matched a replayable path.
2066 - task.replay = {nodes: node[3], pendingTasks: 1};
2086 + const childNodes = node[3];
2087 + task.replay = {nodes: childNodes, pendingTasks: 1};
2088 try {
2089 renderElement(
2090 request,
@@ -2074,13 +2095,8 @@ function replayElement(
2095 props,
2096 ref,
2097 );
2077 - // We finished rendering this node, so now we can consume this
2078 - // slot. This must happen after in case we rerender this task.
2079 - replayNodes.splice(i, 1);
2080 - } finally {
2081 - task.replay.pendingTasks--;
2098 if (
2083 - task.replay.pendingTasks === 0 &&
2099 + task.replay.pendingTasks === 1 &&
2100 task.replay.nodes.length > 0
2101 ) {
2102 throw new Error(
@@ -2088,8 +2104,28 @@ function replayElement(
2104 "The tree doesn't match so React will fallback to client rendering.",
2105 );
2106 }
2107 + } catch (x) {
2108 + if (
2109 + typeof x === 'object' &&
2110 + x !== null &&
2111 + (x === SuspenseException || typeof x.then === 'function')
2112 + ) {
2113 + // Suspend
2114 + throw x;
2115 + }
2116 + // Unlike regular render, we don't terminate the siblings if we error
2117 + // during a replay. That's because this component didn't actually error
2118 + // in the original prerender. What's unable to complete is the child
2119 + // replay nodes which might be Suspense boundaries which are able to
2120 + // absorb the error and we can still continue with siblings.
2121 + erroredReplay(request, task.blockedBoundary, x, childNodes);
2122 + } finally {
2123 + task.replay.pendingTasks--;
2124 task.replay = replay;
2125 }
2126 + // We finished rendering this node, so now we can consume this
2127 + // slot. This must happen after in case we rerender this task.
2128 + replayNodes.splice(i, 1);
2129 }
2130 continue;
2131 }
@@ -2104,7 +2140,7 @@ function replayElement(
2140 );
2141 }
2142 // Matched a replayable path.
2107 - replaySuspenseBoundary(request, task, props, node);
2143 + replaySuspenseBoundary(request, task, keyPath, props, node);
2144 // We finished rendering this node, so now we can consume this
2145 // slot. This must happen after in case we rerender this task.
2146 replayNodes.splice(i, 1);
@@ -2155,7 +2191,7 @@ function replayElement(
2191 );
2192 }
2193 // Matched a resumable suspense boundary.
2158 - resumeSuspenseBoundary(request, task, props, node);
2194 + resumeSuspenseBoundary(request, task, keyPath, props, node);
2195
2196 // We finished rendering this node, so now we can consume this
2197 // slot. This must happen after in case we rerender this task.
@@ -2466,6 +2502,62 @@ function renderNodeDestructiveImpl(
2502 }
2503 }
2504
2505 +function replayFragment(
2506 + request: Request,
2507 + task: ReplayTask,
2508 + children: Array<any>,
2509 + childIndex: number,
2510 +): void {
2511 + // If we're supposed follow this array, we'd expect to see a ReplayNode matching
2512 + // this fragment.
2513 + const replay = task.replay;
2514 + const replayNodes = replay.nodes;
2515 + for (let j = 0; j < replayNodes.length; j++) {
2516 + const replayNode = replayNodes[j];
2517 + if (replayNode[0] !== REPLAY_NODE) {
2518 + continue;
2519 + }
2520 + const node: ReplayNode = (replayNode: any);
2521 + if (node[2] !== childIndex) {
2522 + continue;
2523 + }
2524 + // Matched a replayable path.
2525 + const childNodes = node[3];
2526 + task.replay = {nodes: childNodes, pendingTasks: 1};
2527 + try {
2528 + renderChildrenArray(request, task, children, -1);
2529 + if (task.replay.pendingTasks === 1 && task.replay.nodes.length > 0) {
2530 + throw new Error(
2531 + "Couldn't find all resumable slots by key/index during replaying. " +
2532 + "The tree doesn't match so React will fallback to client rendering.",
2533 + );
2534 + }
2535 + } catch (x) {
2536 + if (
2537 + typeof x === 'object' &&
2538 + x !== null &&
2539 + (x === SuspenseException || typeof x.then === 'function')
2540 + ) {
2541 + // Suspend
2542 + throw x;
2543 + }
2544 + // Unlike regular render, we don't terminate the siblings if we error
2545 + // during a replay. That's because this component didn't actually error
2546 + // in the original prerender. What's unable to complete is the child
2547 + // replay nodes which might be Suspense boundaries which are able to
2548 + // absorb the error and we can still continue with siblings.
2549 + erroredReplay(request, task.blockedBoundary, x, childNodes);
2550 + } finally {
2551 + task.replay.pendingTasks--;
2552 + task.replay = replay;
2553 + }
2554 + // We finished rendering this node, so now we can consume this
2555 + // slot. This must happen after in case we rerender this task.
2556 + replayNodes.splice(j, 1);
2557 + break;
2558 + }
2559 +}
2560 +
2561 function renderChildrenArray(
2562 request: Request,
2563 task: Task,
@@ -2476,42 +2568,13 @@ function renderChildrenArray(
2568 if (childIndex !== -1) {
2569 task.keyPath = [task.keyPath, 'Fragment', childIndex];
2570 if (task.replay !== null) {
2479 - // If we're supposed follow this array, we'd expect to see a ReplayNode matching
2480 - // this fragment.
2481 - const replayTask: ReplayTask = task;
2482 - const replay = task.replay;
2483 - const replayNodes = replay.nodes;
2484 - for (let j = 0; j < replayNodes.length; j++) {
2485 - const replayNode = replayNodes[j];
2486 - if (replayNode[0] !== REPLAY_NODE) {
2487 - continue;
2488 - }
2489 - const node: ReplayNode = (replayNode: any);
2490 - if (node[2] !== childIndex) {
2491 - continue;
2492 - }
2493 - // Matched a replayable path.
2494 - replayTask.replay = {nodes: node[3], pendingTasks: 1};
2495 - try {
2496 - renderChildrenArray(request, task, children, -1);
2497 - } finally {
2498 - replayTask.replay.pendingTasks--;
2499 - if (
2500 - replayTask.replay.pendingTasks === 0 &&
2501 - replayTask.replay.nodes.length > 0
2502 - ) {
2503 - throw new Error(
2504 - "Couldn't find all resumable slots by key/index during replaying. " +
2505 - "The tree doesn't match so React will fallback to client rendering.",
2506 - );
2507 - }
2508 - replayTask.replay = replay;
2509 - }
2510 - // We finished rendering this node, so now we can consume this
2511 - // slot. This must happen after in case we rerender this task.
2512 - replayNodes.splice(j, 1);
2513 - break;
2514 - }
2571 + replayFragment(
2572 + request,
2573 + // $FlowFixMe: Refined.
2574 + task,
2575 + children,
2576 + childIndex,
2577 + );
2578 task.keyPath = prevKeyPath;
2579 return;
2580 }
@@ -2934,6 +2997,42 @@ function renderNode(
2997 throw x;
2998 }
2999
3000 +function erroredReplay(
3001 + request: Request,
3002 + boundary: Root | SuspenseBoundary,
3003 + error: mixed,
3004 + replayNodes: ResumableNode[],
3005 +): void {
3006 + // Erroring during a replay doesn't actually cause an error by itself because
3007 + // that component has already rendered. What causes the error is the resumable
3008 + // points that we did not yet finish which will be below the point of the reset.
3009 + // For example, if we're replaying a path to a Suspense boundary that is not done
3010 + // that doesn't error the parent Suspense boundary.
3011 + // This might be a bit strange that the error in a parent gets thrown at a child.
3012 + // We log it only once and reuse the digest.
3013 + let errorDigest;
3014 + if (
3015 + enablePostpone &&
3016 + typeof error === 'object' &&
3017 + error !== null &&
3018 + error.$$typeof === REACT_POSTPONE_TYPE
3019 + ) {
3020 + const postponeInstance: Postpone = (error: any);
3021 + logPostpone(request, postponeInstance.message);
3022 + // TODO: Figure out a better signal than a magic digest value.
3023 + errorDigest = 'POSTPONE';
3024 + } else {
3025 + errorDigest = logRecoverableError(request, error);
3026 + }
3027 + abortRemainingResumableNodes(
3028 + request,
3029 + boundary,
3030 + replayNodes,
3031 + error,
3032 + errorDigest,
3033 + );
3034 +}
3035 +
3036 function erroredTask(
3037 request: Request,
3038 boundary: Root | SuspenseBoundary,
@@ -2955,9 +3054,6 @@ function erroredTask(
3054 errorDigest = logRecoverableError(request, error);
3055 }
3056 if (boundary === null) {
2958 - // TODO: If the shell errors during a replay, that's not a fatal error. Instead
2959 - // we should be able to recover by client rendering all the root boundaries in
2960 - // the ReplaySet and any already matched.
3057 fatalError(request, error);
3058 } else {
3059 boundary.pendingTasks--;
@@ -3000,11 +3096,118 @@ function abortTaskSoft(this: Request, task: Task): void {
3096 }
3097 }
3098
3099 +function abortRemainingSuspenseBoundary(
3100 + request: Request,
3101 + id: SuspenseBoundaryID,
3102 + rootSegmentID: number,
3103 + error: mixed,
3104 + errorDigest: ?string,
3105 +): void {
3106 + const resumedBoundary = createSuspenseBoundary(
3107 + request,
3108 + new Set(),
3109 + null, // The keyPath doesn't matter at this point so we don't bother rebuilding it.
3110 + );
3111 + resumedBoundary.parentFlushed = true;
3112 + // We restore the same id of this boundary as was used during prerender.
3113 + resumedBoundary.id = id;
3114 + resumedBoundary.rootSegmentID = rootSegmentID;
3115 +
3116 + resumedBoundary.status = CLIENT_RENDERED;
3117 + resumedBoundary.errorDigest = errorDigest;
3118 + if (__DEV__) {
3119 + const errorPrefix = 'The server did not finish this Suspense boundary: ';
3120 + let errorMessage;
3121 + if (error && typeof error.message === 'string') {
3122 + errorMessage = errorPrefix + error.message;
3123 + } else {
3124 + // eslint-disable-next-line react-internal/safe-string-coercion
3125 + errorMessage = errorPrefix + String(error);
3126 + }
3127 + const previousTaskInDev = currentTaskInDEV;
3128 + currentTaskInDEV = null;
3129 + try {
3130 + captureBoundaryErrorDetailsDev(resumedBoundary, errorMessage);
3131 + } finally {
3132 + currentTaskInDEV = previousTaskInDev;
3133 + }
3134 + }
3135 + if (resumedBoundary.parentFlushed) {
3136 + request.clientRenderedBoundaries.push(resumedBoundary);
3137 + }
3138 +}
3139 +
3140 function abortRemainingResumableNodes(
3141 + request: Request,
3142 + boundary: Root | SuspenseBoundary,
3143 nodes: Array<ResumableNode>,
3144 error: mixed,
3145 + errorDigest: ?string,
3146 ): void {
3007 - // TODO: Abort any undiscovered Suspense boundaries in the ReplaySet.
3147 + for (let i = 0; i < nodes.length; i++) {
3148 + const node: any = nodes[i];
3149 + switch (node[0]) {
3150 + case REPLAY_NODE: {
3151 + abortRemainingResumableNodes(
3152 + request,
3153 + boundary,
3154 + node[3],
3155 + error,
3156 + errorDigest,
3157 + );
3158 + continue;
3159 + }
3160 + case REPLAY_SUSPENSE_BOUNDARY: {
3161 + const boundaryNode: ReplaySuspenseBoundary = node;
3162 + const id = boundaryNode[4];
3163 + const rootSegmentID = boundaryNode[5];
3164 + abortRemainingSuspenseBoundary(
3165 + request,
3166 + id,
3167 + rootSegmentID,
3168 + error,
3169 + errorDigest,
3170 + );
3171 + continue;
3172 + }
3173 + case RESUME_SUSPENSE_BOUNDARY: {
3174 + const boundaryNode: ResumeSuspenseBoundary = node;
3175 + const id = boundaryNode[3];
3176 + const rootSegmentID = boundaryNode[4];
3177 + abortRemainingSuspenseBoundary(
3178 + request,
3179 + id,
3180 + rootSegmentID,
3181 + error,
3182 + errorDigest,
3183 + );
3184 + continue;
3185 + }
3186 + case RESUME_ELEMENT:
3187 + case RESUME_SLOT: {
3188 + // We had something still to resume in the parent boundary. We must trigger
3189 + // the error on the parent boundary since it's not able to complete.
3190 + if (boundary === null) {
3191 + throw new Error(
3192 + 'We should not have any resumable nodes in the shell. ' +
3193 + 'This is a bug in React.',
3194 + );
3195 + } else if (boundary.status !== CLIENT_RENDERED) {
3196 + boundary.status = CLIENT_RENDERED;
3197 + boundary.errorDigest = errorDigest;
3198 + if (__DEV__) {
3199 + captureBoundaryErrorDetailsDev(boundary, error);
3200 + }
3201 + if (boundary.parentFlushed) {
3202 + request.clientRenderedBoundaries.push(boundary);
3203 + }
3204 + }
3205 + continue;
3206 + }
3207 + }
3208 + }
3209 + // Empty the set, since we've cleared it now.
3210 + nodes.length = 0;
3211 }
3212
3213 function abortTask(task: Task, request: Request, error: mixed): void {
@@ -3012,30 +3215,41 @@ function abortTask(task: Task, request: Request, error: mixed): void {
3215 // client rendered mode.
3216 const boundary = task.blockedBoundary;
3217 const segment = task.blockedSegment;
3015 - if (segment === null) {
3016 - // $FlowFixMe: Refined.
3017 - const replay: ReplaySet = task.replay;
3018 - replay.pendingTasks--;
3019 - if (replay.pendingTasks === 0) {
3020 - abortRemainingResumableNodes(replay.nodes, error);
3021 - }
3022 - } else {
3218 + if (segment !== null) {
3219 segment.status = ABORTED;
3220 }
3221
3222 if (boundary === null) {
3223 request.allPendingTasks--;
3028 - // We didn't complete the root so we have nothing to show. We can close
3029 - // the request;
3224 if (request.status !== CLOSING && request.status !== CLOSED) {
3031 - logRecoverableError(request, error);
3032 - fatalError(request, error);
3225 + const replay: null | ReplaySet = task.replay;
3226 + if (replay === null) {
3227 + // We didn't complete the root so we have nothing to show. We can close
3228 + // the request;
3229 + logRecoverableError(request, error);
3230 + fatalError(request, error);
3231 + } else {
3232 + // If the shell aborts during a replay, that's not a fatal error. Instead
3233 + // we should be able to recover by client rendering all the root boundaries in
3234 + // the ReplaySet.
3235 + replay.pendingTasks--;
3236 + if (replay.pendingTasks === 0 && replay.nodes.length > 0) {
3237 + const errorDigest = logRecoverableError(request, error);
3238 + abortRemainingResumableNodes(
3239 + request,
3240 + null,
3241 + replay.nodes,
3242 + error,
3243 + errorDigest,
3244 + );
3245 + }
3246 + }
3247 }
3248 } else {
3249 boundary.pendingTasks--;
3250 if (boundary.status !== CLIENT_RENDERED) {
3251 boundary.status = CLIENT_RENDERED;
3038 - boundary.errorDigest = request.onError(error);
3252 + boundary.errorDigest = logRecoverableError(request, error);
3253 if (__DEV__) {
3254 const errorPrefix =
3255 'The server did not finish this Suspense boundary: ';
@@ -3145,8 +3359,11 @@ function finishedTask(
3359 // We can now cancel any pending task on the fallback since we won't need to show it anymore.
3360 // This needs to happen after we read the parentFlushed flags because aborting can finish
3361 // work which can trigger user code, which can start flushing, which can change those flags.
3148 - boundary.fallbackAbortableTasks.forEach(abortTaskSoft, request);
3149 - boundary.fallbackAbortableTasks.clear();
3362 + // If the boundary was POSTPONED, we still need to finish the fallback first.
3363 + if (boundary.status === COMPLETED) {
3364 + boundary.fallbackAbortableTasks.forEach(abortTaskSoft, request);
3365 + boundary.fallbackAbortableTasks.clear();
3366 + }
3367 } else {
3368 if (segment !== null && segment.parentFlushed) {
3369 // 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 {
3587 }
3588 task.replay.pendingTasks--;
3589 task.abortSet.delete(task);
3373 - erroredTask(request, task.blockedBoundary, x);
3590 + erroredReplay(request, task.blockedBoundary, x, task.replay.nodes);
3591 + request.allPendingTasks--;
3592 + if (request.allPendingTasks === 0) {
3593 + const onAllReady = request.onAllReady;
3594 + onAllReady();
3595 + }
3596 return;
3597 } finally {
3598 if (enableFloat) {
scripts/error-codes/codes.json
+1 -1
@@ -472,7 +472,7 @@
472 "484": "A Server Component was postponed. The reason is omitted in production builds to avoid leaking sensitive details.",
473 "485": "Cannot update form state while rendering.",
474 "486": "It should not be possible to postpone at the root. This is a bug in React.",
475 - "487": "Did not expect to see a Suspense boundary in this slot. The tree doesn't match so React will fallback to client rendering.",
475 + "487": "We should not have any resumable nodes in the shell. This is a bug in React.",
476 "488": "Couldn't find all resumable slots by key/index during replaying. The tree doesn't match so React will fallback to client rendering.",
477 "489": "Expected to see a component of type \"%s\" in this slot. The tree doesn't match so React will fallback to client rendering.",
478 "490": "Expected to see a Suspense boundary in this slot. The tree doesn't match so React will fallback to client rendering."