[Fizz] add additional task reentrancy protections (#36291)
The prior fix for finishedTask reentrancy solved an observed failure. This change adds a bit of defensive bookeeping to protect against other theoretical reentrant task finishing that might fail in simlar ways but where we don't have a clear demonstration of the bug.
Josh Story committed
Apr 16, 2026 at 14:15 UTC
4b073f4887f48aabf040ac56b980160b801c8099
1 file changed
+18
-5
packages/react-server/src/ReactFizzServer.js
+18
-5
@@ -4438,9 +4438,15 @@ function erroredTask(
4438
const boundaryRow = boundary.row;
4439
if (boundaryRow !== null) {
4440
// Unblock the SuspenseListRow that was blocked by this boundary.
4441
+ // finishSuspenseListRow → unblockSuspenseListRow → finishedTask reenters
4442
+ // and decrements allPendingTasks. Pin the counter above zero so those
4443
+ // nested calls can't trip completeAll before this outer frame's own
4444
+ // zero check at the end.
4445
+ request.allPendingTasks++;
4446
if (--boundaryRow.pendingTasks === 0) {
4447
finishSuspenseListRow(request, boundaryRow);
4448
}
4449
+ request.allPendingTasks--;
4450
}
4451
4452
// Regardless of what happens next, this boundary won't be displayed,
@@ -4955,20 +4961,21 @@ function finishedTask(
4961
hoistHoistables(boundaryRow.hoistables, boundary.contentState);
4962
}
4963
if (!isEligibleForOutlining(request, boundary)) {
4958
- // abortTaskSoft reenters finishedTask for each aborted task, which
4959
- // decrements allPendingTasks. Ensure that these reentrant finsihedTask
4960
- // calls do not call `completeAll` too early by forcing the task counter
4961
- // above zero for their duration.
4964
+ // abortTaskSoft (below) and finishSuspenseListRow → unblockSuspenseListRow
4965
+ // → finishedTask (further below) both reenter finishedTask and decrement
4966
+ // allPendingTasks. Pin the counter above zero for the duration of these
4967
+ // fan-outs so a nested finishedTask can't observe 0 and call completeAll
4968
+ // before this outer call reaches its own zero check.
4969
request.allPendingTasks++;
4970
boundary.fallbackAbortableTasks.forEach(abortTaskSoft, request);
4971
boundary.fallbackAbortableTasks.clear();
4965
- request.allPendingTasks--;
4972
if (boundaryRow !== null) {
4973
// If we aren't eligible for outlining, we don't have to wait until we flush it.
4974
if (--boundaryRow.pendingTasks === 0) {
4975
finishSuspenseListRow(request, boundaryRow);
4976
}
4977
}
4978
+ request.allPendingTasks--;
4979
}
4980
4981
if (
@@ -4994,11 +5001,17 @@ function finishedTask(
5001
boundaryRow.next,
5002
);
5003
}
5004
+ // finishSuspenseListRow → unblockSuspenseListRow → finishedTask reenters
5005
+ // and decrements allPendingTasks. Pin the counter above zero so those
5006
+ // nested calls can't trip completeAll before this outer frame's own
5007
+ // zero check at the end.
5008
+ request.allPendingTasks++;
5009
if (--boundaryRow.pendingTasks === 0) {
5010
// This is really unnecessary since we've already postponed the boundaries but
5011
// for pairity with other track+finish paths. We might end up using the hoisting.
5012
finishSuspenseListRow(request, boundaryRow);
5013
}
5014
+ request.allPendingTasks--;
5015
}
5016
}
5017
} else {