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') {