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