From 680183af7e85b4a5b9d98f7eba111cb2f3eb4c15 Mon Sep 17 00:00:00 2001 From: Josh Story Date: Sun, 31 May 2026 11:50:28 -0700 Subject: [PATCH] [Fizz] Abort tasks that suspend after aborting during render 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. --- .../src/__tests__/ReactDOMFizzServer-test.js | 6 +- .../ReactDOMFizzStaticBrowser-test.js | 83 +++++++++++++ .../__tests__/ReactDOMFizzStaticNode-test.js | 110 ++++++++++++++++++ packages/react-server/src/ReactFizzServer.js | 67 ++++++++--- 4 files changed, 244 insertions(+), 22 deletions(-) diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js index bd28fa5443bb..b20380321f8b 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js @@ -7138,7 +7138,7 @@ describe('ReactDOMFizzServer', () => { expect(errors).toEqual(['abort reason', 'abort reason']); }); - it('currently does not report a root task that suspends directly after aborting during render', async () => { + it('reports a root task that suspends directly after aborting during render', async () => { const promise = new Promise(() => {}); const abortRef = {current: null}; function ComponentThatAbortsAndSuspends() { @@ -7161,9 +7161,7 @@ describe('ReactDOMFizzServer', () => { abortRef.current = abort; }); - // The task suspends before renderFunctionComponent gets to throw the - // abort reason, and retryRenderTask currently suspends it again. - expect(errors).toEqual([]); + expect(errors).toEqual(['abort reason']); }); it('can abort during render in a lazy initializer for a component', async () => { diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzStaticBrowser-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzStaticBrowser-test.js index b9c9b0fd25b8..8b514bda6b8e 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzStaticBrowser-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzStaticBrowser-test.js @@ -379,6 +379,29 @@ describe('ReactDOMFizzStaticBrowser', () => { expect(content).toBe(''); }); + it('reports the abort reason if a task suspends after aborting a prerender', async () => { + const promise = new Promise(() => {}); + const errors = []; + const controller = new AbortController(); + function App() { + controller.abort(new Error('abort reason')); + React.use(promise); + return null; + } + + const result = await serverAct(() => + ReactDOMFizzStatic.prerender(, { + signal: controller.signal, + onError(error) { + errors.push(error.message); + }, + }), + ); + + expect(errors).toEqual(['abort reason']); + expect(await readContent(result.prelude)).toBe(''); + }); + it('should resolve an empty prelude if passing an already aborted signal', async () => { const errors = []; const controller = new AbortController(); @@ -724,6 +747,66 @@ describe('ReactDOMFizzStaticBrowser', () => { expect(errors).toEqual(['resume abort']); }); + it('can abort and suspend while replaying a prerendered tree', async () => { + const promise = new Promise(() => {}); + let prerendering = true; + const resumeController = new AbortController(); + + function AbortDuringReplay({children}) { + if (!prerendering) { + resumeController.abort('resume abort'); + React.use(promise); + } + return children; + } + + function Wait() { + return React.use(promise); + } + + function App() { + return ( +
+ + + + + +
+ ); + } + + const controller = new AbortController(); + let pendingResult; + await serverAct(() => { + pendingResult = ReactDOMFizzStatic.prerender(, { + signal: controller.signal, + onError() {}, + }); + }); + await serverAct(() => { + controller.abort('prerender abort'); + }); + const prerendered = await pendingResult; + + prerendering = false; + const errors = []; + await serverAct(() => + ReactDOMFizzServer.resume( + , + JSON.parse(JSON.stringify(prerendered.postponed)), + { + signal: resumeController.signal, + onError(error) { + errors.push(error); + }, + }, + ), + ); + + expect(errors).toEqual(['resume abort']); + }); + it('can abort while rendering a resumed segment', async () => { const promise = new Promise(() => {}); let prerendering = true; diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzStaticNode-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzStaticNode-test.js index 56ffa30a0488..2b622062216e 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzStaticNode-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzStaticNode-test.js @@ -14,6 +14,35 @@ let React; let ReactDOMFizzStatic; let Suspense; +function normalizeCodeLocInfo(str) { + return ( + str && + str.replace(/^ +(?:at|in) ([\S]+)[^\n]*/gm, function (m, name) { + const dot = name.lastIndexOf('.'); + if (dot !== -1) { + name = name.slice(dot + 1); + } + return ' in ' + name + (/\d/.test(m) ? ' (at **)' : ''); + }) + ); +} + +function ignoreListStack(str) { + if (!str) { + return str; + } + + let ignoreListedStack = ''; + const lines = str.split('\n'); + // eslint-disable-next-line no-for-of-loops/no-for-of-loops + for (const line of lines) { + if (line.indexOf(__filename) !== -1) { + ignoreListedStack += '\n' + line; + } + } + return ignoreListedStack; +} + describe('ReactDOMFizzStaticNode', () => { beforeEach(() => { jest.resetModules(); @@ -290,6 +319,30 @@ describe('ReactDOMFizzStaticNode', () => { expect(content).toBe(''); }); + it('reports the abort reason if a task suspends after aborting a prerender', async () => { + const promise = new Promise(() => {}); + const errors = []; + const controller = new AbortController(); + function App() { + controller.abort(new Error('abort reason')); + React.use(promise); + return null; + } + + const resultPromise = ReactDOMFizzStatic.prerenderToNodeStream(, { + signal: controller.signal, + onError(error) { + errors.push(error.message); + }, + }); + + await jest.runAllTimers(); + const result = await resultPromise; + + expect(errors).toEqual(['abort reason']); + expect(await readContent(result.prelude)).toBe(''); + }); + it('should resolve with an empty prelude if passing an already aborted signal', async () => { const errors = []; const controller = new AbortController(); @@ -457,4 +510,61 @@ describe('ReactDOMFizzStaticNode', () => { expect(errors).toEqual(['abort reason', 'abort reason']); }); + + describe('with real timers', () => { + beforeEach(() => { + jest.useRealTimers(); + }); + + afterEach(() => { + jest.useFakeTimers(); + }); + + it('includes the suspended call site when aborting in the same rendering task', async () => { + const promise = new Promise(() => {}); + const controller = new AbortController(); + let caughtError; + let componentStack; + let ownerStack; + + function AbortAndSuspend() { + controller.abort(new Error('abort reason')); + React.use(promise); + return null; + } + + function App() { + return ; + } + + const {prelude} = await ReactDOMFizzStatic.prerenderToNodeStream( + , + { + signal: controller.signal, + onError(error, errorInfo) { + caughtError = error; + componentStack = errorInfo.componentStack; + ownerStack = __DEV__ ? React.captureOwnerStack() : null; + }, + }, + ); + + expect(caughtError).toEqual( + expect.objectContaining({message: 'abort reason'}), + ); + expect(await readContent(prelude)).toBe(''); + if (__DEV__) { + expect(normalizeCodeLocInfo(componentStack)).toBe( + '\n in AbortAndSuspend (at **)\n in App', + ); + expect(normalizeCodeLocInfo(ignoreListStack(ownerStack))).toBe( + (gate(flags => flags.enableAsyncDebugInfo) + ? '\n in AbortAndSuspend (at **)' + : '') + '\n in App (at **)', + ); + } else { + expect(ownerStack).toBeNull(); + } + }); + }); }); diff --git a/packages/react-server/src/ReactFizzServer.js b/packages/react-server/src/ReactFizzServer.js index 0cfae20be250..00af10ef4072 100644 --- a/packages/react-server/src/ReactFizzServer.js +++ b/packages/react-server/src/ReactFizzServer.js @@ -4829,6 +4829,23 @@ function abortTaskDEV(task: Task, request: Request): void { } } +function abortUnwoundTask(task: Task, request: Request): void { + // This task was rendering when abort began, so the synchronous abort sweep + // left it alone. It has now unwound from user code and can be completed + // through the normal abort path. + if (__DEV__) { + abortTaskDEV(task, request); + } else { + abortTask(task, request); + } + task.abortSet.delete(task); + if (__DEV__) { + finishAbortedTaskDEV(task, request, request.fatalError); + } else { + finishAbortedTask(task, request, request.fatalError); + } +} + function safelyEmitEarlyPreloads( request: Request, shellComplete: boolean, @@ -5169,25 +5186,22 @@ function retryRenderTask( // (unstable) API for suspending. This implementation detail can change // later, once we deprecate the old API in favor of `use`. getSuspendedThenable() - : request.aborted - ? request.fatalError - : thrownValue; - - if (request.aborted && request.trackedPostpones !== null) { - // We are aborting a prerender and need to halt this task. - const trackedPostpones = request.trackedPostpones; - const thrownInfo = getThrownInfo(task.componentStack); - task.abortSet.delete(task); - - logRecoverableError( - request, - x, - thrownInfo, - __DEV__ ? task.debugTask : null, - ); + : thrownValue; - trackPostpone(request, trackedPostpones, task, segment); - finishedTask(request, task.blockedBoundary, task.row, segment); + if (request.aborted) { + if (thrownValue === SuspenseException) { + // This task was rendering when abort() was called, so it never took + // the normal suspension path below that stores the thenable state. + // Preserve it before finishing the abort so DEV can replay the task + // and include this suspended use() call site in the owner stack. + task.thenableState = getThenableStateAfterSuspending(); + } + // The task has unwound from user code, so it must no longer appear to + // be the currently rendering task while we synchronously finish it. + // Restore the parent instead of clearing this field because finishing + // can reenter Fizz and abort an outer render that is still on the stack. + request.currentTask = prevTask; + abortUnwoundTask(task, request); return; } @@ -5280,6 +5294,23 @@ function retryReplayTask(request: Request, task: ReplayTask): void { getSuspendedThenable() : thrownValue; + if (request.aborted) { + if (thrownValue === SuspenseException) { + // This task was rendering when abort() was called, so it never took + // the normal suspension path below that stores the thenable state. + // Preserve it before finishing the abort so DEV can replay the task + // and include this suspended use() call site in the owner stack. + task.thenableState = getThenableStateAfterSuspending(); + } + // The task has unwound from user code, so it must no longer appear to + // be the currently rendering task while we synchronously finish it. + // Restore the parent instead of clearing this field because finishing + // can reenter Fizz and abort an outer render that is still on the stack. + request.currentTask = prevTask; + abortUnwoundTask(task, request); + return; + } + if (typeof x === 'object' && x !== null) { // $FlowFixMe[method-unbinding] if (typeof x.then === 'function') {