Skip to content

Commit 43bcbf8

Browse files
authored
[Fizz] Allow Pending Work to Specialize Abort Reasons (#36586)
# Allow Pending Work to Specialize Abort Reasons ## Summary Fizz currently reports every unfinished task using the same request-wide abort reason. This makes it possible to observe that a render did not finish, but not to understand why any individual suspended slot remained incomplete. This change allows suspended tasks to report a more specific rejection reason when the wakeable they are blocked on rejects after abort begins and before Fizz finalizes that task. Tasks that do not reject during this window continue to report the general abort reason. This is primarily motivated by partial prerendering, where aborting is not merely an exceptional termination mechanism. It is the API used to intentionally finish a prerender while leaving some work unresolved. ## Motivation For an ordinary server render, a request abort generally means that the result is no longer needed. Reporting the same abort reason for every unfinished task is usually sufficient. For a partial prerender, the meaning is different. The caller intentionally aborts in order to produce a partial result. The unfinished work is then useful information: it identifies which parts of the tree prevented the prerender from completing. Today, all of those tasks receive the same abort reason: ```text slot A -> prerender aborted slot B -> prerender aborted slot C -> prerender aborted ``` That says which work was incomplete, but not whether different slots should be interpreted differently. For example, an application may have: - A slow API that is permitted to miss the prerender deadline and should not produce actionable logging. - Other work that is expected to finish during prerendering and should be reported when it does not. - A data source that can provide additional telemetry about why it did not finish once it learns that the prerender has been aborted. With a single request-wide abort reason, `onError` cannot distinguish these cases. ## Proposed Behavior When abort begins, Fizz still associates a general abort reason with the request. That reason remains the fallback for every unfinished task. However, if a task is suspended on a wakeable and that wakeable rejects during the interval between: 1. The request beginning to abort. 2. Fizz finalizing that task as aborted. then Fizz reports the wakeable's rejection reason for that task instead of the general abort reason. Conceptually: ```text slot A -> rejected during abort with TimeoutError("optional recommendations timed out") slot B -> still pending when abort finishes -> Error("prerender deadline reached") slot C -> rejected during abort with QueryError("inventory lookup canceled") ``` A rejection that arrives after its task has already been finalized is ignored. ## Intended Usage The canonical usage is for the caller to use the same `AbortSignal` both to terminate the prerender and to notify data sources that may still be blocking suspended work. A data source can reject with a more specific error whose `cause` preserves its relationship to the overall abort. ```js const controller = new AbortController(); const abortReason = new Error('prerender deadline reached'); const result = prerender(<App signal={controller.signal} />, { signal: controller.signal, onError(error) { if (error === abortReason) { // This task was unfinished but did not provide a more specific reason. return; } if (error instanceof Error && error.cause === abortReason) { // This task reported a specialized failure caused by the prerender abort. // For example, suppress an expected optional timeout or record telemetry. return; } // Interpret an ordinary rendering error. }, }); controller.abort(abortReason); ``` An interested data source can observe that signal and reject pending work with an operation-specific reason that records the abort as its cause: ```js signal.addEventListener( 'abort', () => { reject( new Error('optional recommendations timed out', { cause: signal.reason, }), ); }, {once: true}, ); ``` If this rejection arrives before Fizz finishes aborting the suspended task, `onError` receives the operation-specific error instead of the general abort reason. Work that does not provide a specialized rejection continues to report `abortReason` directly. This allows applications to: - Suppress logging for intentionally optional or deadline-limited work. - Surface unfinished work that should be investigated. - Include operation-specific telemetry or context in aborted-slot reporting. - Retain an explicit causal relationship between a specialized error and the request-wide abort. ## Causality And Scope Fizz does not attempt to prove that a wakeable rejected because of the abort signal. The precise behavior is temporal: - If a suspended wakeable rejects after abort begins and before its task is finalized, its rejection specializes that task's abort reason. - If it does not reject during that interval, the task receives the general abort reason. - If it rejects after finalization, the rejection is ignored for Fizz error reporting. Using the same `AbortSignal` to notify data sources is the intended protocol, but Fizz cannot distinguish a rejection caused by that signal from any unrelated rejection that happens to occur during the abort window. Likewise, `signal.aborted` in `onError` lets callers distinguish errors observed before abort initiation from errors observed after it began. It does not independently prove causality for an arbitrary rejection. ## Implementation Previously, a suspended task attached the same ping callback for both fulfillment and rejection: ```js wakeable.then(ping, ping); ``` That is correct during ordinary rendering because retrying the task allows a rejected wakeable to throw through the normal render path, preserving regular error handling and stack construction. During abort, however, retrying general work is intentionally suppressed. To preserve a rejection that arrives during the abort window, the task now stores distinct fulfillment and rejection ping callbacks: ```js wakeable.then(ping.resolve, ping.reject); ``` Before abort begins, `ping.reject` retains existing behavior by scheduling the task for retry. After abort begins, `ping.reject` attempts to claim the still-pending aborted task from its owning abort set. If successful, Fizz finalizes that task immediately using the rejection reason. The later scheduled abort finish processes only tasks that remain in their abort sets, using the general abort reason. This avoids adding another top-level property to `Task`, whose production shape is already at the current field-count threshold, while also covering suspension mechanisms such as `React.lazy` that cannot be handled by inspecting `use()` thenable state. ## Tests The tests cover: - A rejected suspended task reporting a specialized reason while unrelated pending work still reports the general abort reason. - Specialization for `React.lazy`, ensuring this is not limited to `use()` suspension. - A rejection arriving after abort finalization being ignored. - The prerender scheduling window in both static Browser and Node APIs, where abort listeners can reject pending work before abort completion.
1 parent 63e95c2 commit 43bcbf8

4 files changed

Lines changed: 127 additions & 12 deletions

File tree

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

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3603,6 +3603,88 @@ describe('ReactDOMFizzServer', () => {
36033603
expect(errors).toEqual(['abort reason', 'abort reason', 'abort reason']);
36043604
});
36053605

3606+
it('uses a rejection reason from a lazy component before the abort finishes', async () => {
3607+
let reject;
3608+
const Lazy = React.lazy(
3609+
() =>
3610+
new Promise((resolve, rejectPromise) => {
3611+
reject = rejectPromise;
3612+
}),
3613+
);
3614+
const haltedPromise = new Promise(() => {});
3615+
function HaltedWait() {
3616+
use(haltedPromise);
3617+
return null;
3618+
}
3619+
3620+
const errors = [];
3621+
let abort;
3622+
await act(() => {
3623+
const controls = renderToPipeableStream(
3624+
<>
3625+
<Suspense fallback="Loading lazy">
3626+
<Lazy />
3627+
</Suspense>
3628+
<Suspense fallback="Loading halted">
3629+
<HaltedWait />
3630+
</Suspense>
3631+
</>,
3632+
{
3633+
onError(error) {
3634+
errors.push(error.message);
3635+
},
3636+
},
3637+
);
3638+
abort = controls.abort;
3639+
controls.pipe(writable);
3640+
});
3641+
3642+
await act(() => {
3643+
abort(new Error('abort reason'));
3644+
reject(new Error('rejected during abort'));
3645+
});
3646+
3647+
expect(errors).toEqual(['rejected during abort', 'abort reason']);
3648+
});
3649+
3650+
it('does not report a rejection reason after abort has finished', async () => {
3651+
let reject;
3652+
const promise = new Promise((resolve, rejectPromise) => {
3653+
reject = rejectPromise;
3654+
});
3655+
function Wait() {
3656+
use(promise);
3657+
return null;
3658+
}
3659+
3660+
const errors = [];
3661+
let abort;
3662+
await act(() => {
3663+
const controls = renderToPipeableStream(
3664+
<Suspense fallback="Loading">
3665+
<Wait />
3666+
</Suspense>,
3667+
{
3668+
onError(error) {
3669+
errors.push(error.message);
3670+
},
3671+
},
3672+
);
3673+
abort = controls.abort;
3674+
controls.pipe(writable);
3675+
});
3676+
3677+
await act(() => {
3678+
abort(new Error('abort reason'));
3679+
});
3680+
3681+
await act(() => {
3682+
reject(new Error('rejected after abort'));
3683+
});
3684+
3685+
expect(errors).toEqual(['abort reason']);
3686+
});
3687+
36063688
it('warns in dev if you access digest from errorInfo in onRecoverableError', async () => {
36073689
await act(() => {
36083690
const {pipe} = renderToPipeableStream(

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -527,7 +527,7 @@ describe('ReactDOMFizzStaticBrowser', () => {
527527
expect(errors).toEqual(['uh oh', 'uh oh']);
528528
});
529529

530-
it('currently uses the abort reason when an abort listener synchronously rejects pending work', async () => {
530+
it('uses a rejection reason when an abort listener rejects pending work before the abort finishes', async () => {
531531
let reject;
532532
const rejectedPromise = new Promise((resolve, rejectPromise) => {
533533
reject = rejectPromise;
@@ -572,7 +572,7 @@ describe('ReactDOMFizzStaticBrowser', () => {
572572
});
573573
await resultPromise;
574574

575-
expect(errors).toEqual(['abort reason', 'abort reason']);
575+
expect(errors).toEqual(['rejected during abort', 'abort reason']);
576576
});
577577

578578
it('logs an error if onHeaders throws but continues the prerender', async () => {

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -464,7 +464,7 @@ describe('ReactDOMFizzStaticNode', () => {
464464
expect(errors).toEqual(['uh oh', 'uh oh']);
465465
});
466466

467-
it('currently uses the abort reason when an abort listener synchronously rejects pending work', async () => {
467+
it('uses a rejection reason when an abort listener rejects pending work before the abort finishes', async () => {
468468
let reject;
469469
const rejectedPromise = new Promise((resolve, rejectPromise) => {
470470
reject = rejectPromise;
@@ -505,10 +505,11 @@ describe('ReactDOMFizzStaticNode', () => {
505505
});
506506
controller.abort(new Error('abort reason'));
507507

508+
await Promise.resolve();
508509
await jest.runAllTimers();
509510
await resultPromise;
510511

511-
expect(errors).toEqual(['abort reason', 'abort reason']);
512+
expect(errors).toEqual(['rejected during abort', 'abort reason']);
512513
});
513514

514515
describe('with real timers', () => {

packages/react-server/src/ReactFizzServer.js

Lines changed: 40 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -276,11 +276,16 @@ type SuspenseBoundary = {
276276
errorComponentStack?: null | string, // the error component stack if it errors
277277
};
278278

279+
type Ping = {
280+
resolve: () => void,
281+
reject: (error: mixed) => void,
282+
};
283+
279284
type RenderTask = {
280285
replay: null,
281286
node: ReactNodeList,
282287
childIndex: number,
283-
ping: () => void,
288+
ping: Ping,
284289
blockedBoundary: Root | SuspenseBoundary,
285290
blockedSegment: Segment, // the segment we'll write to
286291
blockedPreamble: null | PreambleState,
@@ -311,7 +316,7 @@ type ReplayTask = {
311316
replay: ReplaySet,
312317
node: ReactNodeList,
313318
childIndex: number,
314-
ping: () => void,
319+
ping: Ping,
315320
blockedBoundary: Root | SuspenseBoundary,
316321
blockedSegment: null, // we don't write to anything when we replay
317322
blockedPreamble: null,
@@ -810,6 +815,27 @@ function pingTask(request: Request, task: Task): void {
810815
}
811816
}
812817

818+
function pingRejectedTask(request: Request, task: Task, error: mixed): void {
819+
if (!request.aborted) {
820+
// Replaying the task is what gives ordinary render errors their complete
821+
// component stack.
822+
pingTask(request, task);
823+
return;
824+
}
825+
if (!task.abortSet.delete(task)) {
826+
// finishAbort already completed this task with the request's abort reason.
827+
return;
828+
}
829+
// abortTask synchronously claimed this task before abort listeners could
830+
// reject its wakeable. Finish it with the more specific reason before the
831+
// scheduled final abort uses the reason for the whole request.
832+
if (__DEV__) {
833+
finishAbortedTaskDEV(task, request, error);
834+
} else {
835+
finishAbortedTask(task, request, error);
836+
}
837+
}
838+
813839
function createSuspenseBoundary(
814840
request: Request,
815841
row: null | SuspenseListRow,
@@ -889,7 +915,10 @@ function createRenderTask(
889915
replay: null,
890916
node,
891917
childIndex,
892-
ping: () => pingTask(request, task),
918+
ping: {
919+
resolve: () => pingTask(request, task),
920+
reject: error => pingRejectedTask(request, task, error),
921+
},
893922
blockedBoundary,
894923
blockedSegment,
895924
blockedPreamble,
@@ -945,7 +974,10 @@ function createReplayTask(
945974
replay,
946975
node,
947976
childIndex,
948-
ping: () => pingTask(request, task),
977+
ping: {
978+
resolve: () => pingTask(request, task),
979+
reject: error => pingRejectedTask(request, task, error),
980+
},
949981
blockedBoundary,
950982
blockedSegment: null,
951983
blockedPreamble: null,
@@ -4204,7 +4236,7 @@ function renderNode(
42044236
thenableState,
42054237
);
42064238
const ping = newTask.ping;
4207-
wakeable.then(ping, ping);
4239+
wakeable.then(ping.resolve, ping.reject);
42084240

42094241
// Restore the context. We assume that this will be restored by the inner
42104242
// functions in case nothing throws so we don't use "finally" here.
@@ -4305,7 +4337,7 @@ function renderNode(
43054337
thenableState,
43064338
);
43074339
const ping = newTask.ping;
4308-
wakeable.then(ping, ping);
4340+
wakeable.then(ping.resolve, ping.reject);
43094341

43104342
// Restore the context. We assume that this will be restored by the inner
43114343
// functions in case nothing throws so we don't use "finally" here.
@@ -5216,7 +5248,7 @@ function retryRenderTask(
52165248
: null;
52175249
const ping = task.ping;
52185250
// We've asserted that x is a thenable above
5219-
(x: any).then(ping, ping);
5251+
(x: any).then(ping.resolve, ping.reject);
52205252
return;
52215253
}
52225254
}
@@ -5316,7 +5348,7 @@ function retryReplayTask(request: Request, task: ReplayTask): void {
53165348
if (typeof x.then === 'function') {
53175349
// Something suspended again, let's pick it back up later.
53185350
const ping = task.ping;
5319-
x.then(ping, ping);
5351+
x.then(ping.resolve, ping.reject);
53205352
task.thenableState =
53215353
thrownValue === SuspenseException
53225354
? getThenableStateAfterSuspending()

0 commit comments

Comments
 (0)