[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);