@samitouri / QOS-React-2 / commits / ddcb58f0c4

[Fizz] Track abort state on Request (#36583)

Previously Fizz represented an active abort using the `ABORTING` request status. This is ambiguous because aborting a task can synchronously fatal the request, transitioning it to `CLOSING` or `CLOSED` while another task is still unwinding from the same abort. Once that happened, the in-flight task no longer observed that the request was aborted and could fail to report its abort error. This change removes the `ABORTING` status and instead tracks whether the request was aborted independently on the Request. The existing `fatalError` field continues to store the abort reason. As a result, tasks that were rendering when an abort occurred continue to observe the abort even if another aborted task has already fataled the request, allowing all relevant unfinished task errors to be reported. DEV stalled replays temporarily mask the aborted state so they can continue to reconstruct suspended call sites as before. This also establishes explicit abort state on the Request for follow-up work that delays abort completion and allows rejected suspended work to provide more specific abort errors.

Josh Story committed May 31, 2026 at 13:48 UTC ddcb58f0c42b449e77dacc01d240297434e471bc
2 files changed +36 -37
packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
+8 -13
@@ -7063,7 +7063,7 @@ describe('ReactDOMFizzServer', () => {
7063 );
7064 });
7065
7066 - it('currently does not report an in-flight root task after another root task fatals while aborting', async () => {
7066 + it('reports an in-flight root task after another root task fatals while aborting', async () => {
7067 const promise = new Promise(() => {});
7068 function SuspendedRoot() {
7069 use(promise);
@@ -7097,10 +7097,10 @@ describe('ReactDOMFizzServer', () => {
7097 abortRef.current = abort;
7098 });
7099
7100 - expect(errors).toEqual(['abort reason']);
7100 + expect(errors).toEqual(['abort reason', 'abort reason']);
7101 });
7102
7103 - it('currently does not report a root task that suspends after aborting during render', async () => {
7103 + it('reports a root task that suspends after aborting during render', async () => {
7104 const promise = new Promise(() => {});
7105 function SuspendedRoot() {
7106 use(promise);
@@ -7135,9 +7135,7 @@ describe('ReactDOMFizzServer', () => {
7135 abortRef.current = abort;
7136 });
7137
7138 - // TODO: Once abort completion is async, this still-suspended task should
7139 - // observe ABORTING and report the abort reason as well.
7140 - expect(errors).toEqual(['abort reason']);
7138 + expect(errors).toEqual(['abort reason', 'abort reason']);
7139 });
7140
7141 it('can abort during render in a lazy initializer for a component', async () => {
@@ -8444,10 +8442,6 @@ describe('ReactDOMFizzServer', () => {
8442 }
8443
8444 expect(thrownError).toBe('boom');
8447 - // TODO there should actually be three errors. One for the pending Suspense, one for the fallback task, and one for the task
8448 - // that does the abort itself. At the moment abort will flush queues and if there is no pending tasks will close the request before
8449 - // the task which initiated the abort can even be processed. This is a bug but not one that I am fixing with the current change
8450 - // so I am asserting the current behavior
8445 expect(errors).toEqual([
8446 {
8447 error: 'boom',
@@ -8467,9 +8461,10 @@ describe('ReactDOMFizzServer', () => {
8461 'html',
8462 'App',
8463 ]),
8470 - // }, {
8471 - // error: 'boom',
8472 - // componentStack: componentStack(['Abort', 'body', 'html', 'App'])
8464 + },
8465 + {
8466 + error: 'boom',
8467 + componentStack: componentStack(['Abort', 'body', 'html', 'App']),
8468 },
8469 ]);
8470
packages/react-server/src/ReactFizzServer.js
+28 -24
@@ -359,10 +359,9 @@ type Segment = {
359
360 const OPENING = 10;
361 const OPEN = 11;
362 -const ABORTING = 12;
363 -const CLOSING = 13;
364 -const CLOSED = 14;
365 -const STALLED_DEV = 15;
362 +const CLOSING = 12;
363 +const CLOSED = 13;
364 +const STALLED_DEV = 14;
365
366 export opaque type Request = {
367 destination: null | Destination,
@@ -371,8 +370,9 @@ export opaque type Request = {
370 +renderState: RenderState,
371 +rootFormatContext: FormatContext,
372 +progressiveChunkSize: number,
374 - status: 10 | 11 | 12 | 13 | 14 | 15,
373 + status: 10 | 11 | 12 | 13 | 14,
374 fatalError: mixed,
375 + aborted: boolean,
376 nextSegmentId: number,
377 allPendingTasks: number, // when it reaches zero, we can close the connection.
378 pendingRootTasks: number, // when this reaches zero, we've finished at least the root boundary.
@@ -530,6 +530,7 @@ function RequestInstance(
530 : progressiveChunkSize;
531 this.status = isWorkLoopExternallyDriven ? OPEN : OPENING;
532 this.fatalError = null;
533 + this.aborted = false;
534 this.nextSegmentId = 0;
535 this.allPendingTasks = 0;
536 this.pendingRootTasks = 0;
@@ -1038,7 +1039,11 @@ function pushHaltedAwaitOnComponentStack(
1039 // performWork + retryTask without mutation
1040 function rerenderStalledTask(request: Request, task: Task): void {
1041 const prevStatus = request.status;
1042 + const prevAborted = request.aborted;
1043 request.status = STALLED_DEV;
1044 + // This diagnostic replay must reach the suspended call site instead of
1045 + // taking the abort path.
1046 + request.aborted = false;
1047
1048 const prevContext = getActiveContext();
1049 const prevDispatcher = ReactSharedInternals.H;
@@ -1082,6 +1087,7 @@ function rerenderStalledTask(request: Request, task: Task): void {
1087 }
1088 currentRequest = prevRequest;
1089 request.status = prevStatus;
1090 + request.aborted = prevAborted;
1091 }
1092 }
1093
@@ -1479,7 +1485,7 @@ function renderSuspenseBoundary(
1485 boundarySegment.status = COMPLETED;
1486 finishedSegment(request, parentBoundary, boundarySegment);
1487 } catch (thrownValue: mixed) {
1482 - if (request.status === ABORTING) {
1488 + if (request.aborted) {
1489 boundarySegment.status = ABORTED;
1490 } else {
1491 boundarySegment.status = ERRORED;
@@ -1589,7 +1595,7 @@ function renderSuspenseBoundary(
1595 } catch (thrownValue: mixed) {
1596 newBoundary.status = CLIENT_RENDERED;
1597 let error: mixed;
1592 - if (request.status === ABORTING) {
1598 + if (request.aborted) {
1599 contentRootSegment.status = ABORTED;
1600 error = request.fatalError;
1601 } else {
@@ -2075,7 +2081,7 @@ function renderSuspenseListRows(
2081 finishSuspenseListRow(request, previousSuspenseListRow);
2082 }
2083 } catch (thrownValue: mixed) {
2078 - if (request.status === ABORTING) {
2084 + if (request.aborted) {
2085 newSegment.status = ABORTED;
2086 } else {
2087 newSegment.status = ERRORED;
@@ -2392,7 +2398,7 @@ function finishClassComponent(
2398 } else {
2399 nextChildren = instance.render();
2400 }
2395 - if (request.status === ABORTING) {
2401 + if (request.aborted) {
2402 // eslint-disable-next-line no-throw-literal
2403 throw null;
2404 }
@@ -2541,7 +2547,7 @@ function renderFunctionComponent(
2547 props,
2548 legacyContext,
2549 );
2544 - if (request.status === ABORTING) {
2550 + if (request.aborted) {
2551 // eslint-disable-next-line no-throw-literal
2552 throw null;
2553 }
@@ -2818,12 +2824,7 @@ function renderLazyComponent(
2824 const init = lazyComponent._init;
2825 Component = init(payload);
2826 }
2821 - if (
2822 - request.status === ABORTING &&
2823 - // We're going to discard this render anyway.
2824 - // We just need to reach the point where we suspended in dev.
2825 - (!__DEV__ || request.status !== STALLED_DEV)
2826 - ) {
2827 + if (request.aborted) {
2828 // eslint-disable-next-line no-throw-literal
2829 throw null;
2830 }
@@ -3441,7 +3442,7 @@ function retryNode(request: Request, task: Task): void {
3442 const init = lazyNode._init;
3443 resolvedNode = init(payload);
3444 }
3444 - if (request.status === ABORTING) {
3445 + if (request.aborted) {
3446 // eslint-disable-next-line no-throw-literal
3447 throw null;
3448 }
@@ -4185,7 +4186,7 @@ function renderNode(
4186 getSuspendedThenable()
4187 : thrownValue;
4188
4188 - if (request.status === ABORTING) {
4189 + if (request.aborted) {
4190 // We are aborting so we can just bubble up to the task by falling through
4191 } else if (typeof x === 'object' && x !== null) {
4192 // $FlowFixMe[method-unbinding]
@@ -4286,7 +4287,7 @@ function renderNode(
4287 getSuspendedThenable()
4288 : thrownValue;
4289
4289 - if (request.status === ABORTING) {
4290 + if (request.aborted) {
4291 // We are aborting so we can just bubble up to the task by falling through
4292 } else if (typeof x === 'object' && x !== null) {
4293 // $FlowFixMe[method-unbinding]
@@ -5124,11 +5125,11 @@ function retryRenderTask(
5125 // (unstable) API for suspending. This implementation detail can change
5126 // later, once we deprecate the old API in favor of `use`.
5127 getSuspendedThenable()
5127 - : request.status === ABORTING
5128 + : request.aborted
5129 ? request.fatalError
5130 : thrownValue;
5131
5131 - if (request.status === ABORTING && request.trackedPostpones !== null) {
5132 + if (request.aborted && request.trackedPostpones !== null) {
5133 // We are aborting a prerender and need to halt this task.
5134 const trackedPostpones = request.trackedPostpones;
5135 const thrownInfo = getThrownInfo(task.componentStack);
@@ -5250,7 +5251,7 @@ function retryReplayTask(request: Request, task: ReplayTask): void {
5251 erroredReplay(
5252 request,
5253 task.blockedBoundary,
5253 - request.status === ABORTING ? request.fatalError : x,
5254 + request.aborted ? request.fatalError : x,
5255 errorInfo,
5256 task.replay.nodes,
5257 task.replay.slots,
@@ -6155,12 +6156,15 @@ export function stopFlowing(request: Request): void {
6156
6157 // This is called to early terminate a request. It puts all pending boundaries in client rendered state.
6158 export function abort(request: Request, reason: mixed): void {
6158 - if (request.status !== OPEN && request.status !== OPENING) {
6159 + if (
6160 + request.aborted ||
6161 + (request.status !== OPEN && request.status !== OPENING)
6162 + ) {
6163 // Only requests that are not already complete or in the process of aborting
6164 // can be aborted. in practice this makes abort callable at most once per render.
6165 return;
6166 }
6163 - request.status = ABORTING;
6167 + request.aborted = true;
6168
6169 try {
6170 const abortableTasks = request.abortableTasks;