[Fizz] Allow Pending Work to Specialize Abort Reasons#36586
Conversation
`abort()` currently performs both the synchronous transition into an aborted request and the reporting/completion of every unfinished task in the same call. This change splits those phases. Aborting now synchronously marks the request as aborted, captures the abort reason, claims pending tasks so already scheduled work cannot continue rendering them, and captures any DEV async debug information needed at the point of abort. Reporting and completing the claimed tasks is then performed from a scheduled `finishAbort()` callback. This split does not yet allow a promise rejected by an abort listener to replace the abort reason: work remains blocked once the request has been aborted, and tests assert that abort-time rejections still report the original abort reason. It establishes the task boundary needed for a follow-up change to selectively process rejected suspended work before completing the remaining aborted tasks. This is observable for streaming renders because abort cleanup may now happen after already available output is read. A Suspense boundary that was previously converted to client rendering before it could be serialized may instead be emitted as pending first and receive its client-render instruction when the scheduled abort completion runs. The scheduled finish must also preserve abort-during-render behavior in renderers whose scheduler executes synchronously. The request tracks its currently executing task, and both abort phases leave that task alone so it can unwind through its normal abort path rather than being completed twice or reporting an internal control-flow value.
When a task calls `abort()` while it is rendering, Fizz intentionally leaves that task alone during the synchronous abort sweep so it can unwind normally. If the task then suspends before reaching a normal abort check, however, it currently remains pending and does not report the abort reason. This change completes an aborted task once it has unwound back to the retry loop. Instead of treating it as an ordinary render error, it is routed through the existing abort task completion path so prerenders continue to postpone aborted work correctly and replay tasks use aborted resume semantics. If the task suspended through `use()`, preserve its thenable state before completing the abort. This allows DEV async debug info to replay the suspended call site and include it in the owner stack, even though the task began aborting before it suspended. Add coverage for render, prerender, and resumed replay tasks that suspend after initiating an abort, including a real-timer test verifying the suspended call site is retained in DEV owner stacks.
Fizz currently reports every unfinished task using the request-wide abort reason. This is generally sufficient for ordinary renders, where aborting primarily means stopping output, but it is limiting for partial prerendering because abort is the API used to intentionally leave parts of the tree unfinished. Callers may want to treat a known slow optional API as an expected prerender miss while still surfacing other unfinished work as actionable feedback, or allow a data source to provide operation-specific telemetry once it learns that prerendering was aborted. This change lets a suspended task report the rejection from the wakeable it was blocked on when that rejection arrives after abort begins and before Fizz finalizes that task. Tasks that remain pending continue to report the request-wide abort reason, and rejections that arrive after finalization are ignored. The intended pattern is to use the same AbortSignal both to abort the prerender and to notify pending data sources, allowing those sources to reject with slot-specific reasons. Fizz does not attempt to prove that a rejection was caused by the abort signal. Any suspended wakeable that rejects during the abort window can specialize the reason for its task. Callers can use signal.aborted in onError to distinguish ordinary render errors observed before abort from unfinished-work errors observed after abort begins, but this does not establish causality for arbitrary asynchronous rejections. To support this without adding another top-level field to Task, ping now contains separate resolve and reject callbacks. Before abort begins, rejected wakeables retain the existing retry behavior so ordinary render errors continue through the normal error path. After abort begins, a rejected ping claims its still-pending task from its abort set and finalizes it using the rejection reason; the scheduled abort finish applies the general abort reason only to remaining tasks. This also covers suspension sources such as React.lazy, which cannot be handled by inspecting use() thenable state alone. Tests cover specialization alongside unrelated pending work that retains the general abort reason, React.lazy specialization, dropping rejections that arrive after abort completion, and the prerender scheduling window that lets abort listeners reject pending work before abort finishes.
There was a problem hiding this comment.
It's nice how simple and clean the final commit is because of the preparatory work that went into the preceding PRs.
One (possibly naïve) question about the causality:
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.
What if we made the contract so that a rejected wakeable would need to use the abort signal's reason as its cause? That would allow us to ignore unrelated rejections that just coincidentally happened to occur between starting and finishing the abort, wouldn't it?
E.g. (building on your "Intended Usage" example):
signal.addEventListener('abort', () => {
reject(new TimeoutError('optional recommendations timed out', {cause: signal.reason}));
});And then in pingRejectedTask we could compare error.cause with request.fatalError, I suppose.
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:
That says which work was incomplete, but not whether different slots should be interpreted differently.
For example, an application may have:
With a single request-wide abort reason,
onErrorcannot 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:
then Fizz reports the wakeable's rejection reason for that task instead of the general abort reason.
Conceptually:
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
AbortSignalboth to terminate the prerender and to notify data sources that may still be blocking suspended work.An interested data source can observe that signal and reject pending work with a reason specific to the blocked operation:
Tasks blocked on such work can now report that specialized reason. Tasks blocked on work that does not participate in the abort still report the request-wide abort reason.
This allows applications to:
Causality And Scope
Fizz does not attempt to prove that a wakeable rejected because of the abort signal.
The precise behavior is temporal:
Using the same
AbortSignalto 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.abortedinonErrorlets 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:
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:
Before abort begins,
ping.rejectretains existing behavior by scheduling the task for retry.After abort begins,
ping.rejectattempts 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 asReact.lazythat cannot be handled by inspectinguse()thenable state.Tests
The tests cover:
React.lazy, ensuring this is not limited touse()suspension.