@samitouri / QOS-React-2 / commits / 557e28fae7

[Fizz] Abort tasks that suspend after aborting during render (#36585)

Stacked on #36580 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.

Josh Story committed Jun 1, 2026 at 08:18 UTC 557e28fae7cbd4cf2714d556f6d8a3a42f7d8ce2
4 files changed +244 -22
packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js
+2 -4
@@ -7138,7 +7138,7 @@ describe('ReactDOMFizzServer', () => {
7138 expect(errors).toEqual(['abort reason', 'abort reason']);
7139 });
7140
7141 - it('currently does not report a root task that suspends directly after aborting during render', async () => {
7141 + it('reports a root task that suspends directly after aborting during render', async () => {
7142 const promise = new Promise(() => {});
7143 const abortRef = {current: null};
7144 function ComponentThatAbortsAndSuspends() {
@@ -7161,9 +7161,7 @@ describe('ReactDOMFizzServer', () => {
7161 abortRef.current = abort;
7162 });
7163
7164 - // The task suspends before renderFunctionComponent gets to throw the
7165 - // abort reason, and retryRenderTask currently suspends it again.
7166 - expect(errors).toEqual([]);
7164 + expect(errors).toEqual(['abort reason']);
7165 });
7166
7167 it('can abort during render in a lazy initializer for a component', async () => {
packages/react-dom/src/__tests__/ReactDOMFizzStaticBrowser-test.js
+83
@@ -379,6 +379,29 @@ describe('ReactDOMFizzStaticBrowser', () => {
379 expect(content).toBe('');
380 });
381
382 + it('reports the abort reason if a task suspends after aborting a prerender', async () => {
383 + const promise = new Promise(() => {});
384 + const errors = [];
385 + const controller = new AbortController();
386 + function App() {
387 + controller.abort(new Error('abort reason'));
388 + React.use(promise);
389 + return null;
390 + }
391 +
392 + const result = await serverAct(() =>
393 + ReactDOMFizzStatic.prerender(<App />, {
394 + signal: controller.signal,
395 + onError(error) {
396 + errors.push(error.message);
397 + },
398 + }),
399 + );
400 +
401 + expect(errors).toEqual(['abort reason']);
402 + expect(await readContent(result.prelude)).toBe('');
403 + });
404 +
405 it('should resolve an empty prelude if passing an already aborted signal', async () => {
406 const errors = [];
407 const controller = new AbortController();
@@ -724,6 +747,66 @@ describe('ReactDOMFizzStaticBrowser', () => {
747 expect(errors).toEqual(['resume abort']);
748 });
749
750 + it('can abort and suspend while replaying a prerendered tree', async () => {
751 + const promise = new Promise(() => {});
752 + let prerendering = true;
753 + const resumeController = new AbortController();
754 +
755 + function AbortDuringReplay({children}) {
756 + if (!prerendering) {
757 + resumeController.abort('resume abort');
758 + React.use(promise);
759 + }
760 + return children;
761 + }
762 +
763 + function Wait() {
764 + return React.use(promise);
765 + }
766 +
767 + function App() {
768 + return (
769 + <div>
770 + <AbortDuringReplay>
771 + <Suspense fallback="Loading...">
772 + <Wait />
773 + </Suspense>
774 + </AbortDuringReplay>
775 + </div>
776 + );
777 + }
778 +
779 + const controller = new AbortController();
780 + let pendingResult;
781 + await serverAct(() => {
782 + pendingResult = ReactDOMFizzStatic.prerender(<App />, {
783 + signal: controller.signal,
784 + onError() {},
785 + });
786 + });
787 + await serverAct(() => {
788 + controller.abort('prerender abort');
789 + });
790 + const prerendered = await pendingResult;
791 +
792 + prerendering = false;
793 + const errors = [];
794 + await serverAct(() =>
795 + ReactDOMFizzServer.resume(
796 + <App />,
797 + JSON.parse(JSON.stringify(prerendered.postponed)),
798 + {
799 + signal: resumeController.signal,
800 + onError(error) {
801 + errors.push(error);
802 + },
803 + },
804 + ),
805 + );
806 +
807 + expect(errors).toEqual(['resume abort']);
808 + });
809 +
810 it('can abort while rendering a resumed segment', async () => {
811 const promise = new Promise(() => {});
812 let prerendering = true;
packages/react-dom/src/__tests__/ReactDOMFizzStaticNode-test.js
+110
@@ -14,6 +14,35 @@ let React;
14 let ReactDOMFizzStatic;
15 let Suspense;
16
17 +function normalizeCodeLocInfo(str) {
18 + return (
19 + str &&
20 + str.replace(/^ +(?:at|in) ([\S]+)[^\n]*/gm, function (m, name) {
21 + const dot = name.lastIndexOf('.');
22 + if (dot !== -1) {
23 + name = name.slice(dot + 1);
24 + }
25 + return ' in ' + name + (/\d/.test(m) ? ' (at **)' : '');
26 + })
27 + );
28 +}
29 +
30 +function ignoreListStack(str) {
31 + if (!str) {
32 + return str;
33 + }
34 +
35 + let ignoreListedStack = '';
36 + const lines = str.split('\n');
37 + // eslint-disable-next-line no-for-of-loops/no-for-of-loops
38 + for (const line of lines) {
39 + if (line.indexOf(__filename) !== -1) {
40 + ignoreListedStack += '\n' + line;
41 + }
42 + }
43 + return ignoreListedStack;
44 +}
45 +
46 describe('ReactDOMFizzStaticNode', () => {
47 beforeEach(() => {
48 jest.resetModules();
@@ -290,6 +319,30 @@ describe('ReactDOMFizzStaticNode', () => {
319 expect(content).toBe('');
320 });
321
322 + it('reports the abort reason if a task suspends after aborting a prerender', async () => {
323 + const promise = new Promise(() => {});
324 + const errors = [];
325 + const controller = new AbortController();
326 + function App() {
327 + controller.abort(new Error('abort reason'));
328 + React.use(promise);
329 + return null;
330 + }
331 +
332 + const resultPromise = ReactDOMFizzStatic.prerenderToNodeStream(<App />, {
333 + signal: controller.signal,
334 + onError(error) {
335 + errors.push(error.message);
336 + },
337 + });
338 +
339 + await jest.runAllTimers();
340 + const result = await resultPromise;
341 +
342 + expect(errors).toEqual(['abort reason']);
343 + expect(await readContent(result.prelude)).toBe('');
344 + });
345 +
346 it('should resolve with an empty prelude if passing an already aborted signal', async () => {
347 const errors = [];
348 const controller = new AbortController();
@@ -457,4 +510,61 @@ describe('ReactDOMFizzStaticNode', () => {
510
511 expect(errors).toEqual(['abort reason', 'abort reason']);
512 });
513 +
514 + describe('with real timers', () => {
515 + beforeEach(() => {
516 + jest.useRealTimers();
517 + });
518 +
519 + afterEach(() => {
520 + jest.useFakeTimers();
521 + });
522 +
523 + it('includes the suspended call site when aborting in the same rendering task', async () => {
524 + const promise = new Promise(() => {});
525 + const controller = new AbortController();
526 + let caughtError;
527 + let componentStack;
528 + let ownerStack;
529 +
530 + function AbortAndSuspend() {
531 + controller.abort(new Error('abort reason'));
532 + React.use(promise);
533 + return null;
534 + }
535 +
536 + function App() {
537 + return <AbortAndSuspend />;
538 + }
539 +
540 + const {prelude} = await ReactDOMFizzStatic.prerenderToNodeStream(
541 + <App />,
542 + {
543 + signal: controller.signal,
544 + onError(error, errorInfo) {
545 + caughtError = error;
546 + componentStack = errorInfo.componentStack;
547 + ownerStack = __DEV__ ? React.captureOwnerStack() : null;
548 + },
549 + },
550 + );
551 +
552 + expect(caughtError).toEqual(
553 + expect.objectContaining({message: 'abort reason'}),
554 + );
555 + expect(await readContent(prelude)).toBe('');
556 + if (__DEV__) {
557 + expect(normalizeCodeLocInfo(componentStack)).toBe(
558 + '\n in AbortAndSuspend (at **)\n in App',
559 + );
560 + expect(normalizeCodeLocInfo(ignoreListStack(ownerStack))).toBe(
561 + (gate(flags => flags.enableAsyncDebugInfo)
562 + ? '\n in AbortAndSuspend (at **)'
563 + : '') + '\n in App (at **)',
564 + );
565 + } else {
566 + expect(ownerStack).toBeNull();
567 + }
568 + });
569 + });
570 });
packages/react-server/src/ReactFizzServer.js
+49 -18
@@ -4829,6 +4829,23 @@ function abortTaskDEV(task: Task, request: Request): void {
4829 }
4830 }
4831
4832 +function abortUnwoundTask(task: Task, request: Request): void {
4833 + // This task was rendering when abort began, so the synchronous abort sweep
4834 + // left it alone. It has now unwound from user code and can be completed
4835 + // through the normal abort path.
4836 + if (__DEV__) {
4837 + abortTaskDEV(task, request);
4838 + } else {
4839 + abortTask(task, request);
4840 + }
4841 + task.abortSet.delete(task);
4842 + if (__DEV__) {
4843 + finishAbortedTaskDEV(task, request, request.fatalError);
4844 + } else {
4845 + finishAbortedTask(task, request, request.fatalError);
4846 + }
4847 +}
4848 +
4849 function safelyEmitEarlyPreloads(
4850 request: Request,
4851 shellComplete: boolean,
@@ -5169,25 +5186,22 @@ function retryRenderTask(
5186 // (unstable) API for suspending. This implementation detail can change
5187 // later, once we deprecate the old API in favor of `use`.
5188 getSuspendedThenable()
5172 - : request.aborted
5173 - ? request.fatalError
5174 - : thrownValue;
5175 -
5176 - if (request.aborted && request.trackedPostpones !== null) {
5177 - // We are aborting a prerender and need to halt this task.
5178 - const trackedPostpones = request.trackedPostpones;
5179 - const thrownInfo = getThrownInfo(task.componentStack);
5180 - task.abortSet.delete(task);
5181 -
5182 - logRecoverableError(
5183 - request,
5184 - x,
5185 - thrownInfo,
5186 - __DEV__ ? task.debugTask : null,
5187 - );
5189 + : thrownValue;
5190
5189 - trackPostpone(request, trackedPostpones, task, segment);
5190 - finishedTask(request, task.blockedBoundary, task.row, segment);
5191 + if (request.aborted) {
5192 + if (thrownValue === SuspenseException) {
5193 + // This task was rendering when abort() was called, so it never took
5194 + // the normal suspension path below that stores the thenable state.
5195 + // Preserve it before finishing the abort so DEV can replay the task
5196 + // and include this suspended use() call site in the owner stack.
5197 + task.thenableState = getThenableStateAfterSuspending();
5198 + }
5199 + // The task has unwound from user code, so it must no longer appear to
5200 + // be the currently rendering task while we synchronously finish it.
5201 + // Restore the parent instead of clearing this field because finishing
5202 + // can reenter Fizz and abort an outer render that is still on the stack.
5203 + request.currentTask = prevTask;
5204 + abortUnwoundTask(task, request);
5205 return;
5206 }
5207
@@ -5280,6 +5294,23 @@ function retryReplayTask(request: Request, task: ReplayTask): void {
5294 getSuspendedThenable()
5295 : thrownValue;
5296
5297 + if (request.aborted) {
5298 + if (thrownValue === SuspenseException) {
5299 + // This task was rendering when abort() was called, so it never took
5300 + // the normal suspension path below that stores the thenable state.
5301 + // Preserve it before finishing the abort so DEV can replay the task
5302 + // and include this suspended use() call site in the owner stack.
5303 + task.thenableState = getThenableStateAfterSuspending();
5304 + }
5305 + // The task has unwound from user code, so it must no longer appear to
5306 + // be the currently rendering task while we synchronously finish it.
5307 + // Restore the parent instead of clearing this field because finishing
5308 + // can reenter Fizz and abort an outer render that is still on the stack.
5309 + request.currentTask = prevTask;
5310 + abortUnwoundTask(task, request);
5311 + return;
5312 + }
5313 +
5314 if (typeof x === 'object' && x !== null) {
5315 // $FlowFixMe[method-unbinding]
5316 if (typeof x.then === 'function') {