@samitouri / QOS-React-1 / commits / ea6792026f

[Fizz] prevent reentrant finishedTask from calling completeAll multiple times (#36287)

It is possible for the fallback tasks from a Suspense boundary to trigger an early `completeAll` call which is later repeated due to `finishedTask` reentrancy. For node.js in particular this might be problematic since we invoke a callback on each `completeAll` call but in general it just isn't the right semantics since the call is running slightly earlier than the completion of the last `finishedTask` invocation. This change ensures that any reentrant `finishedTask` calls (due to soft aborting fallback tasks) omit the `completeAll` call by temporarily incrementing the total pending tasks.

Josh Story committed Apr 16, 2026 at 13:26 UTC ea6792026ff7bc4c9c663fd09149cc523490cb1a
3 files changed +101 -1
.gitignore
+1 -1
@@ -21,6 +21,7 @@ chrome-user-data
21 .idea
22 *.iml
23 .vscode
24 +.zed
25 *.swp
26 *.swo
27 /tmp
@@ -40,4 +41,3 @@ packages/react-devtools-fusebox/dist
41 packages/react-devtools-inline/dist
42 packages/react-devtools-shell/dist
43 packages/react-devtools-timeline/dist
43 -
packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
+94
@@ -6289,6 +6289,100 @@ describe('ReactDOMFizzServer', () => {
6289 expect(getVisibleChildren(container)).toEqual('Hi');
6290 });
6291
6292 + // Regression: finishedTask aborting remaining fallback tasks from a
6293 + // completed boundary could reenter itself via abortTaskSoft and fire
6294 + // onAllReady twice (the inner call drained allPendingTasks to 0 and
6295 + // called completeAll, then the outer call re-observed the same 0).
6296 + it('only fires onAllReady once when a boundary with an instrumented sync-resolving thenable completes', async () => {
6297 + // Mirrors Flight-client chunk behavior: the status-probe .then() in
6298 + // trackUsedThenable stays pending, but the ping-attaching .then() in
6299 + // renderNode's catch resolves synchronously. This reorders the work
6300 + // queue so the fallback task is still in fallbackAbortableTasks when
6301 + // the content task completes.
6302 + function createDeferredSyncThenable(value) {
6303 + let thenCallCount = 0;
6304 + return {
6305 + status: 'pending',
6306 + value: undefined,
6307 + then(resolve) {
6308 + thenCallCount++;
6309 + if (thenCallCount > 1) {
6310 + this.status = 'fulfilled';
6311 + this.value = value;
6312 + resolve(value);
6313 + }
6314 + },
6315 + };
6316 + }
6317 +
6318 + const thenable = createDeferredSyncThenable('hello');
6319 + function AsyncContent() {
6320 + return <Text text={use(thenable)} />;
6321 + }
6322 +
6323 + let allReadyCount = 0;
6324 + await act(() => {
6325 + const {pipe} = renderToPipeableStream(
6326 + <Suspense fallback={<Text text="Loading..." />}>
6327 + <AsyncContent />
6328 + </Suspense>,
6329 + {
6330 + onAllReady() {
6331 + allReadyCount++;
6332 + },
6333 + },
6334 + );
6335 + pipe(writable);
6336 + });
6337 +
6338 + expect(allReadyCount).toBe(1);
6339 + expect(getVisibleChildren(container)).toEqual('hello');
6340 + });
6341 +
6342 + // Same bug, hit without any sync-thenable trickery: if the fallback
6343 + // also suspends, its spawned sub-task lives in fallbackAbortableTasks
6344 + // and can still be there when the content task completes first.
6345 + it('only fires onAllReady once when both content and fallback suspend on real promises', async () => {
6346 + let resolveContent;
6347 + const contentPromise = new Promise(r => (resolveContent = r));
6348 + // The fallback promise never resolves — the fallback-sub-task gets
6349 + // soft-aborted when the content completes, so we never need it.
6350 + const fallbackPromise = new Promise(() => {});
6351 +
6352 + function AsyncContent() {
6353 + return <Text text={use(contentPromise)} />;
6354 + }
6355 + function AsyncFallback() {
6356 + return <Text text={use(fallbackPromise)} />;
6357 + }
6358 +
6359 + let allReadyCount = 0;
6360 + await act(() => {
6361 + const {pipe} = renderToPipeableStream(
6362 + <Suspense fallback={<AsyncFallback />}>
6363 + <AsyncContent />
6364 + </Suspense>,
6365 + {
6366 + onAllReady() {
6367 + allReadyCount++;
6368 + },
6369 + },
6370 + );
6371 + pipe(writable);
6372 + });
6373 +
6374 + // Resolving content alone is enough: the fallback-sub-task is still
6375 + // in fallbackAbortableTasks when the content task completes, and
6376 + // abortTaskSoft on it reenters finishedTask.
6377 + await act(async () => {
6378 + resolveContent('hello');
6379 + await contentPromise;
6380 + });
6381 +
6382 + expect(allReadyCount).toBe(1);
6383 + expect(getVisibleChildren(container)).toEqual('hello');
6384 + });
6385 +
6386 it('promise as node', async () => {
6387 const promise = Promise.resolve('Hi');
6388 await act(async () => {
packages/react-server/src/ReactFizzServer.js
+6
@@ -4955,8 +4955,14 @@ function finishedTask(
4955 hoistHoistables(boundaryRow.hoistables, boundary.contentState);
4956 }
4957 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.
4962 + request.allPendingTasks++;
4963 boundary.fallbackAbortableTasks.forEach(abortTaskSoft, request);
4964 boundary.fallbackAbortableTasks.clear();
4965 + request.allPendingTasks--;
4966 if (boundaryRow !== null) {
4967 // If we aren't eligible for outlining, we don't have to wait until we flush it.
4968 if (--boundaryRow.pendingTasks === 0) {