@samitouri / QOS-React-1 / commits / 4b073f4887

[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 {