@samitouri / QOS-React-1 / commits / 9c7b10e22e

[Fizz] Clean up row that was blocked by an aborted boundary (#33318)

Fixes a bug that we caused us to hang after an abort because we didn't manage the ref count correctly.

Sebastian Markbåge committed May 20, 2025 at 20:31 UTC 9c7b10e22ea4c184c0465df15705f17ba36b115a
2 files changed +160 -13
packages/react-dom/src/__tests__/ReactDOMFizzSuspenseList-test.js
+131 -1
@@ -27,9 +27,10 @@ let writable;
27 let container;
28 let buffer = '';
29 let hasErrored = false;
30 +let hasCompleted = false;
31 let fatalError = undefined;
32
32 -describe('ReactDOMFizSuspenseList', () => {
33 +describe('ReactDOMFizzSuspenseList', () => {
34 beforeEach(() => {
35 jest.resetModules();
36 JSDOM = require('jsdom').JSDOM;
@@ -59,6 +60,7 @@ describe('ReactDOMFizSuspenseList', () => {
60
61 buffer = '';
62 hasErrored = false;
63 + hasCompleted = false;
64
65 writable = new Stream.PassThrough();
66 writable.setEncoding('utf8');
@@ -69,6 +71,9 @@ describe('ReactDOMFizSuspenseList', () => {
71 hasErrored = true;
72 fatalError = error;
73 });
74 + writable.on('finish', () => {
75 + hasCompleted = true;
76 + });
77 });
78
79 afterEach(() => {
@@ -103,7 +108,12 @@ describe('ReactDOMFizSuspenseList', () => {
108
109 function createAsyncText(text) {
110 let resolved = false;
111 + let error = undefined;
112 const Component = function () {
113 + if (error !== undefined) {
114 + Scheduler.log('Error! [' + error.message + ']');
115 + throw error;
116 + }
117 if (!resolved) {
118 Scheduler.log('Suspend! [' + text + ']');
119 throw promise;
@@ -115,6 +125,10 @@ describe('ReactDOMFizSuspenseList', () => {
125 resolved = true;
126 return resolve();
127 };
128 + Component.reject = function (e) {
129 + error = e;
130 + return resolve();
131 + };
132 });
133 return Component;
134 }
@@ -714,4 +728,120 @@ describe('ReactDOMFizSuspenseList', () => {
728 </div>,
729 );
730 });
731 +
732 + // @gate enableSuspenseList
733 + it('can abort a pending SuspenseList', async () => {
734 + const A = createAsyncText('A');
735 +
736 + function Foo() {
737 + return (
738 + <div>
739 + <SuspenseList revealOrder="forwards">
740 + <Suspense fallback={<Text text="Loading A" />}>
741 + <A />
742 + </Suspense>
743 + <Suspense fallback={<Text text="Loading B" />}>
744 + <Text text="B" />
745 + </Suspense>
746 + </SuspenseList>
747 + </div>
748 + );
749 + }
750 +
751 + const errors = [];
752 + let abortStream;
753 + await serverAct(async () => {
754 + const {pipe, abort} = ReactDOMFizzServer.renderToPipeableStream(<Foo />, {
755 + onError(error) {
756 + errors.push(error.message);
757 + },
758 + });
759 + pipe(writable);
760 + abortStream = abort;
761 + });
762 +
763 + assertLog([
764 + 'Suspend! [A]',
765 + 'B', // TODO: Defer rendering the content after fallback if previous suspended,
766 + 'Loading A',
767 + 'Loading B',
768 + ]);
769 +
770 + expect(getVisibleChildren(container)).toEqual(
771 + <div>
772 + <span>Loading A</span>
773 + <span>Loading B</span>
774 + </div>,
775 + );
776 +
777 + await serverAct(() => {
778 + abortStream();
779 + });
780 +
781 + expect(hasCompleted).toBe(true);
782 + expect(errors).toEqual([
783 + 'The render was aborted by the server without a reason.',
784 + ]);
785 + });
786 +
787 + // @gate enableSuspenseList
788 + it('can error a pending SuspenseList', async () => {
789 + const A = createAsyncText('A');
790 +
791 + function Foo() {
792 + return (
793 + <div>
794 + <SuspenseList revealOrder="forwards">
795 + <Suspense fallback={<Text text="Loading A" />}>
796 + <A />
797 + </Suspense>
798 + <Suspense fallback={<Text text="Loading B" />}>
799 + <Text text="B" />
800 + </Suspense>
801 + </SuspenseList>
802 + </div>
803 + );
804 + }
805 +
806 + const errors = [];
807 + await serverAct(async () => {
808 + const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<Foo />, {
809 + onError(error) {
810 + errors.push(error.message);
811 + },
812 + });
813 + pipe(writable);
814 + });
815 +
816 + assertLog([
817 + 'Suspend! [A]',
818 + 'B', // TODO: Defer rendering the content after fallback if previous suspended,
819 + 'Loading A',
820 + 'Loading B',
821 + ]);
822 +
823 + expect(getVisibleChildren(container)).toEqual(
824 + <div>
825 + <span>Loading A</span>
826 + <span>Loading B</span>
827 + </div>,
828 + );
829 +
830 + await serverAct(async () => {
831 + A.reject(new Error('hi'));
832 + });
833 +
834 + assertLog(['Error! [hi]']);
835 +
836 + expect(getVisibleChildren(container)).toEqual(
837 + <div>
838 + <span>Loading A</span>
839 + <span>B</span>
840 + </div>,
841 + );
842 +
843 + expect(errors).toEqual(['hi']);
844 + expect(hasErrored).toBe(false);
845 + expect(hasCompleted).toBe(true);
846 + });
847 });
packages/react-server/src/ReactFizzServer.js
+29 -12
@@ -4392,6 +4392,14 @@ function erroredTask(
4392 encodeErrorForBoundary(boundary, errorDigest, error, errorInfo, false);
4393 untrackBoundary(request, boundary);
4394
4395 + const boundaryRow = boundary.row;
4396 + if (boundaryRow !== null) {
4397 + // Unblock the SuspenseListRow that was blocked by this boundary.
4398 + if (--boundaryRow.pendingTasks === 0) {
4399 + finishSuspenseListRow(request, boundaryRow);
4400 + }
4401 + }
4402 +
4403 // Regardless of what happens next, this boundary won't be displayed,
4404 // so we can flush it, if the parent already flushed.
4405 if (boundary.parentFlushed) {
@@ -4544,13 +4552,6 @@ function abortTask(task: Task, request: Request, error: mixed): void {
4552 segment.status = ABORTED;
4553 }
4554
4547 - const row = task.row;
4548 - if (row !== null) {
4549 - if (--row.pendingTasks === 0) {
4550 - finishSuspenseListRow(request, row);
4551 - }
4552 - }
4553 -
4555 const errorInfo = getThrownInfo(task.componentStack);
4556
4557 if (boundary === null) {
@@ -4573,7 +4574,7 @@ function abortTask(task: Task, request: Request, error: mixed): void {
4574 // we just need to mark it as postponed.
4575 logPostpone(request, postponeInstance.message, errorInfo, null);
4576 trackPostpone(request, trackedPostpones, task, segment);
4576 - finishedTask(request, null, row, segment);
4577 + finishedTask(request, null, task.row, segment);
4578 } else {
4579 const fatal = new Error(
4580 'The render was aborted with postpone when the shell is incomplete. Reason: ' +
@@ -4592,7 +4593,7 @@ function abortTask(task: Task, request: Request, error: mixed): void {
4593 // We log the error but we still resolve the prerender
4594 logRecoverableError(request, error, errorInfo, null);
4595 trackPostpone(request, trackedPostpones, task, segment);
4595 - finishedTask(request, null, row, segment);
4596 + finishedTask(request, null, task.row, segment);
4597 } else {
4598 logRecoverableError(request, error, errorInfo, null);
4599 fatalError(request, error, errorInfo, null);
@@ -4636,7 +4637,6 @@ function abortTask(task: Task, request: Request, error: mixed): void {
4637 }
4638 }
4639 } else {
4639 - boundary.pendingTasks--;
4640 // We construct an errorInfo from the boundary's componentStack so the error in dev will indicate which
4641 // boundary the message is referring to
4642 const trackedPostpones = request.trackedPostpones;
@@ -4664,7 +4664,7 @@ function abortTask(task: Task, request: Request, error: mixed): void {
4664 abortTask(fallbackTask, request, error),
4665 );
4666 boundary.fallbackAbortableTasks.clear();
4667 - return finishedTask(request, boundary, row, segment);
4667 + return finishedTask(request, boundary, task.row, segment);
4668 }
4669 }
4670 boundary.status = CLIENT_RENDERED;
@@ -4681,7 +4681,7 @@ function abortTask(task: Task, request: Request, error: mixed): void {
4681 logPostpone(request, postponeInstance.message, errorInfo, null);
4682 if (request.trackedPostpones !== null && segment !== null) {
4683 trackPostpone(request, request.trackedPostpones, task, segment);
4684 - finishedTask(request, task.blockedBoundary, row, segment);
4684 + finishedTask(request, task.blockedBoundary, task.row, segment);
4685
4686 // If this boundary was still pending then we haven't already cancelled its fallbacks.
4687 // We'll need to abort the fallbacks, which will also error that parent boundary.
@@ -4706,6 +4706,16 @@ function abortTask(task: Task, request: Request, error: mixed): void {
4706 }
4707 }
4708
4709 + boundary.pendingTasks--;
4710 +
4711 + const boundaryRow = boundary.row;
4712 + if (boundaryRow !== null) {
4713 + // Unblock the SuspenseListRow that was blocked by this boundary.
4714 + if (--boundaryRow.pendingTasks === 0) {
4715 + finishSuspenseListRow(request, boundaryRow);
4716 + }
4717 + }
4718 +
4719 // If this boundary was still pending then we haven't already cancelled its fallbacks.
4720 // We'll need to abort the fallbacks, which will also error that parent boundary.
4721 boundary.fallbackAbortableTasks.forEach(fallbackTask =>
@@ -4714,6 +4724,13 @@ function abortTask(task: Task, request: Request, error: mixed): void {
4724 boundary.fallbackAbortableTasks.clear();
4725 }
4726
4727 + const row = task.row;
4728 + if (row !== null) {
4729 + if (--row.pendingTasks === 0) {
4730 + finishSuspenseListRow(request, row);
4731 + }
4732 + }
4733 +
4734 request.allPendingTasks--;
4735 if (request.allPendingTasks === 0) {
4736 completeAll(request);