Skip to content

Commit ea67920

Browse files
authored
[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.
1 parent 56922cf commit ea67920

File tree

3 files changed

+101
-1
lines changed

3 files changed

+101
-1
lines changed

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ chrome-user-data
2121
.idea
2222
*.iml
2323
.vscode
24+
.zed
2425
*.swp
2526
*.swo
2627
/tmp
@@ -40,4 +41,3 @@ packages/react-devtools-fusebox/dist
4041
packages/react-devtools-inline/dist
4142
packages/react-devtools-shell/dist
4243
packages/react-devtools-timeline/dist
43-

packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6289,6 +6289,100 @@ describe('ReactDOMFizzServer', () => {
62896289
expect(getVisibleChildren(container)).toEqual('Hi');
62906290
});
62916291

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+
62926386
it('promise as node', async () => {
62936387
const promise = Promise.resolve('Hi');
62946388
await act(async () => {

packages/react-server/src/ReactFizzServer.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4955,8 +4955,14 @@ function finishedTask(
49554955
hoistHoistables(boundaryRow.hoistables, boundary.contentState);
49564956
}
49574957
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++;
49584963
boundary.fallbackAbortableTasks.forEach(abortTaskSoft, request);
49594964
boundary.fallbackAbortableTasks.clear();
4965+
request.allPendingTasks--;
49604966
if (boundaryRow !== null) {
49614967
// If we aren't eligible for outlining, we don't have to wait until we flush it.
49624968
if (--boundaryRow.pendingTasks === 0) {

0 commit comments

Comments
 (0)