@samitouri / QOS-React-2 / commits / 453a19a107

[Flight] Collect Debug Info from Rejections in Aborted Render (#33708)

This delays the abort by splitting the abort into a first step that just flags a task as abort and tracks the time that we aborted. This first step also invokes the `cacheSignal()` abort handler. Then in a macrotask do we finish flushing the abort (or halt). This ensures that any microtasks after the abort signal can finish flushing which may emit rejections or fulfill (e.g. if you try/catch the abort or if it was allSettled). These rejections are themselves signals for which promise was blocked on what promise which forms a graph that we can use for debug info. Notably this doesn't include any additional data in the output since we don't include any data produced after the abort. It just uses the additional execution to collect more debug info. The abort itself might not have been spawned from I/O but it's still interesting to mark Promises that aborted as interesting since they may have been blocked on I/O. So we take the inner most Promise that resolved after the end time (presumably due to the abort signal but also could've just finished after but that's still after the abort). Since the microtasks can spawn new Promises after the ones that reject we ignore any of those that started after the abort.

Sebastian Markbåge committed Jul 5, 2025 at 17:01 UTC 453a19a107d02dbcde1f722361918db24426de64
6 files changed +296 -45
packages/react-server-dom-webpack/src/__tests__/ReactFlightDOM-test.js
+3 -1
@@ -2876,7 +2876,9 @@ describe('ReactFlightDOM', () => {
2876 };
2877 });
2878
2879 - controller.abort('boom');
2879 + await serverAct(() => {
2880 + controller.abort('boom');
2881 + });
2882 resolveGreeting();
2883 const {prelude} = await pendingResult;
2884
packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMBrowser-test.js
+1 -1
@@ -2549,7 +2549,7 @@ describe('ReactFlightDOMBrowser', () => {
2549
2550 controller.abort('boom');
2551 resolveGreeting();
2552 - const {prelude} = await pendingResult;
2552 + const {prelude} = await serverAct(() => pendingResult);
2553 expect(errors).toEqual([]);
2554
2555 function ClientRoot({response}) {
packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js
+4 -2
@@ -1437,7 +1437,9 @@ describe('ReactFlightDOMEdge', () => {
1437 };
1438 });
1439
1440 - controller.abort('boom');
1440 + await serverAct(() => {
1441 + controller.abort('boom');
1442 + });
1443 resolveGreeting();
1444 const {prelude} = await pendingResult;
1445
@@ -1497,7 +1499,7 @@ describe('ReactFlightDOMEdge', () => {
1499 });
1500
1501 controller.abort();
1500 - const {prelude} = await pendingResult;
1502 + const {prelude} = await serverAct(() => pendingResult);
1503
1504 expect(errors).toEqual([]);
1505
packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js
+153 -1
@@ -77,6 +77,17 @@ describe('ReactFlightDOMNode', () => {
77 use = React.use;
78 });
79
80 + function filterStackFrame(filename, functionName) {
81 + return (
82 + filename !== '' &&
83 + !filename.startsWith('node:') &&
84 + !filename.includes('node_modules') &&
85 + // Filter out our own internal source code since it'll typically be in node_modules
86 + (!filename.includes('/packages/') || filename.includes('/__tests__/')) &&
87 + !filename.includes('/build/')
88 + );
89 + }
90 +
91 function normalizeCodeLocInfo(str) {
92 return (
93 str &&
@@ -560,7 +571,7 @@ describe('ReactFlightDOMNode', () => {
571
572 controller.abort('boom');
573 resolveGreeting();
563 - const {prelude} = await pendingResult;
574 + const {prelude} = await serverAct(() => pendingResult);
575 expect(errors).toEqual([]);
576
577 function ClientRoot({response}) {
@@ -711,4 +722,145 @@ describe('ReactFlightDOMNode', () => {
722 expect(ownerStack).toBeNull();
723 }
724 });
725 +
726 + // @gate enableHalt && enableAsyncDebugInfo
727 + it('includes deeper location for aborted stacks', async () => {
728 + async function getData() {
729 + const signal = ReactServer.cacheSignal();
730 + await new Promise((resolve, reject) => {
731 + signal.addEventListener('abort', () => reject(signal.reason));
732 + });
733 + }
734 +
735 + async function thisShouldNotBeInTheStack() {
736 + await new Promise((resolve, reject) => {
737 + resolve();
738 + });
739 + }
740 +
741 + async function Component() {
742 + try {
743 + await getData();
744 + } catch (x) {
745 + await thisShouldNotBeInTheStack(); // This is issued after the rejection so should not be included.
746 + }
747 + return null;
748 + }
749 +
750 + function App() {
751 + return ReactServer.createElement(
752 + 'html',
753 + null,
754 + ReactServer.createElement(
755 + 'body',
756 + null,
757 + ReactServer.createElement(
758 + ReactServer.Suspense,
759 + {fallback: 'Loading...'},
760 + ReactServer.createElement(Component, null),
761 + ),
762 + ),
763 + );
764 + }
765 +
766 + const errors = [];
767 + const serverAbortController = new AbortController();
768 + const {pendingResult} = await serverAct(async () => {
769 + // destructure trick to avoid the act scope from awaiting the returned value
770 + return {
771 + pendingResult: ReactServerDOMStaticServer.unstable_prerender(
772 + ReactServer.createElement(App, null),
773 + webpackMap,
774 + {
775 + signal: serverAbortController.signal,
776 + onError(error) {
777 + errors.push(error);
778 + },
779 + filterStackFrame,
780 + },
781 + ),
782 + };
783 + });
784 +
785 + await serverAct(
786 + () =>
787 + new Promise(resolve => {
788 + setImmediate(() => {
789 + serverAbortController.abort();
790 + resolve();
791 + });
792 + }),
793 + );
794 +
795 + const {prelude} = await pendingResult;
796 +
797 + expect(errors).toEqual([]);
798 +
799 + function ClientRoot({response}) {
800 + return use(response);
801 + }
802 +
803 + const prerenderResponse = ReactServerDOMClient.createFromReadableStream(
804 + await createBufferedUnclosingStream(prelude),
805 + {
806 + serverConsumerManifest: {
807 + moduleMap: null,
808 + moduleLoading: null,
809 + },
810 + },
811 + );
812 +
813 + let componentStack;
814 + let ownerStack;
815 +
816 + const clientAbortController = new AbortController();
817 +
818 + const fizzPrerenderStreamResult = ReactDOMFizzStatic.prerender(
819 + React.createElement(ClientRoot, {response: prerenderResponse}),
820 + {
821 + signal: clientAbortController.signal,
822 + onError(error, errorInfo) {
823 + componentStack = errorInfo.componentStack;
824 + ownerStack = React.captureOwnerStack
825 + ? React.captureOwnerStack()
826 + : null;
827 + },
828 + },
829 + );
830 +
831 + await await serverAct(
832 + async () =>
833 + new Promise(resolve => {
834 + setImmediate(() => {
835 + clientAbortController.abort();
836 + resolve();
837 + });
838 + }),
839 + );
840 +
841 + const fizzPrerenderStream = await fizzPrerenderStreamResult;
842 + const prerenderHTML = await readWebResult(fizzPrerenderStream.prelude);
843 +
844 + expect(prerenderHTML).toContain('Loading...');
845 +
846 + if (__DEV__) {
847 + expect(normalizeCodeLocInfo(componentStack)).toBe(
848 + '\n in Component (at **)\n in Suspense\n in body\n in html\n in ClientRoot (at **)',
849 + );
850 + } else {
851 + expect(normalizeCodeLocInfo(componentStack)).toBe(
852 + '\n in Suspense\n in body\n in html\n in ClientRoot (at **)',
853 + );
854 + }
855 +
856 + if (__DEV__) {
857 + expect(normalizeCodeLocInfo(ownerStack)).toBe(
858 + '\n in getData (at **)' +
859 + '\n in Component (at **)' +
860 + '\n in App (at **)',
861 + );
862 + } else {
863 + expect(ownerStack).toBeNull();
864 + }
865 + });
866 });
packages/react-server/src/ReactFizzServer.js
+1
@@ -1019,6 +1019,7 @@ function pushHaltedAwaitOnComponentStack(
1019 stack: bestStack.debugStack,
1020 };
1021 task.debugTask = (bestStack.debugTask: any);
1022 + break;
1023 }
1024 }
1025 }
packages/react-server/src/ReactFlightServer.js
+134 -40
@@ -462,6 +462,7 @@ export type Request = {
462 onFatalError: mixed => void,
463 // Profiling-only
464 timeOrigin: number,
465 + abortTime: number,
466 // DEV-only
467 completedDebugChunks: Array<Chunk | BinaryChunk>,
468 environmentName: () => string,
@@ -613,6 +614,7 @@ function RequestInstance(
614 // $FlowFixMe[prop-missing]
615 performance.timeOrigin,
616 );
617 + this.abortTime = -0.0;
618 } else {
619 timeOrigin = 0;
620 }
@@ -850,9 +852,11 @@ function serializeThenable(
852 request.abortableTasks.delete(newTask);
853 if (enableHalt && request.type === PRERENDER) {
854 haltTask(newTask, request);
855 + finishHaltedTask(newTask, request);
856 } else {
857 const errorId: number = (request.fatalError: any);
858 abortTask(newTask, request, errorId);
859 + finishAbortedTask(newTask, request, errorId);
860 }
861 return newTask.id;
862 }
@@ -2041,7 +2045,7 @@ function visitAsyncNode(
2045 node: AsyncSequence,
2046 visited: Set<AsyncSequence | ReactDebugInfo>,
2047 cutOff: number,
2044 -): null | PromiseNode | IONode {
2048 +): void | null | PromiseNode | IONode {
2049 if (visited.has(node)) {
2050 // It's possible to visit them same node twice when it's part of both an "awaited" path
2051 // and a "previous" path. This also gracefully handles cycles which would be a bug.
@@ -2050,10 +2054,22 @@ function visitAsyncNode(
2054 visited.add(node);
2055 // First visit anything that blocked this sequence to start in the first place.
2056 if (node.previous !== null && node.end > request.timeOrigin) {
2053 - // We ignore the return value here because if it wasn't awaited in user space, then we don't log it.
2054 - // It also means that it can just have been part of a previous component's render.
2057 + // We ignore the returned io nodes here because if it wasn't awaited in user space,
2058 + // then we don't log it. It also means that it can just have been part of a previous
2059 + // component's render.
2060 // TODO: This means that some I/O can get lost that was still blocking the sequence.
2056 - visitAsyncNode(request, task, node.previous, visited, cutOff);
2061 + const ioNode = visitAsyncNode(
2062 + request,
2063 + task,
2064 + node.previous,
2065 + visited,
2066 + cutOff,
2067 + );
2068 + if (ioNode === undefined) {
2069 + // Undefined is used as a signal that we found a suitable aborted node and we don't have to find
2070 + // further aborted nodes.
2071 + return undefined;
2072 + }
2073 }
2074 switch (node.tag) {
2075 case IO_NODE: {
@@ -2073,7 +2089,11 @@ function visitAsyncNode(
2089 let match = null;
2090 if (awaited !== null) {
2091 const ioNode = visitAsyncNode(request, task, awaited, visited, cutOff);
2076 - if (ioNode !== null) {
2092 + if (ioNode === undefined) {
2093 + // Undefined is used as a signal that we found a suitable aborted node and we don't have to find
2094 + // further aborted nodes.
2095 + return undefined;
2096 + } else if (ioNode !== null) {
2097 // This Promise was blocked on I/O. That's a signal that this Promise is interesting to log.
2098 // We don't log it yet though. We return it to be logged by the point where it's awaited.
2099 // The ioNode might be another PromiseNode in the case where none of the AwaitNode had
@@ -2090,6 +2110,15 @@ function visitAsyncNode(
2110 } else {
2111 match = node;
2112 }
2113 + } else if (request.status === ABORTING) {
2114 + if (node.start < request.abortTime && node.end > request.abortTime) {
2115 + // We aborted this render. If this Promise spanned the abort time it was probably the
2116 + // Promise that was aborted. This won't necessarily have I/O associated with it but
2117 + // it's a point of interest.
2118 + if (filterStackTrace(request, node.stack).length > 0) {
2119 + match = node;
2120 + }
2121 + }
2122 }
2123 }
2124 // We need to forward after we visit awaited nodes because what ever I/O we requested that's
@@ -2112,7 +2141,11 @@ function visitAsyncNode(
2141 let match = null;
2142 if (awaited !== null) {
2143 const ioNode = visitAsyncNode(request, task, awaited, visited, cutOff);
2115 - if (ioNode !== null) {
2144 + if (ioNode === undefined) {
2145 + // Undefined is used as a signal that we found a suitable aborted node and we don't have to find
2146 + // further aborted nodes.
2147 + return undefined;
2148 + } else if (ioNode !== null) {
2149 const startTime: number = node.start;
2150 const endTime: number = node.end;
2151 if (endTime <= request.timeOrigin) {
@@ -2145,6 +2178,11 @@ function visitAsyncNode(
2178 // If this await was fully filtered out, then it was inside third party code
2179 // such as in an external library. We return the I/O node and try another await.
2180 match = ioNode;
2181 + } else if (
2182 + request.status === ABORTING &&
2183 + startTime > request.abortTime
2184 + ) {
2185 + // This was awaited after aborting so we skip it.
2186 } else {
2187 // We found a user space await.
2188
@@ -2170,6 +2208,11 @@ function visitAsyncNode(
2208 // Mark the end time of the await. If we're aborting then we don't emit this
2209 // to signal that this never resolved inside this render.
2210 markOperationEndTime(request, task, endTime);
2211 + if (request.status === ABORTING) {
2212 + // Undefined is used as a signal that we found a suitable aborted node and we don't have to find
2213 + // further aborted nodes.
2214 + match = undefined;
2215 + }
2216 }
2217 }
2218 }
@@ -2206,7 +2249,10 @@ function emitAsyncSequence(
2249 visited.add(alreadyForwardedDebugInfo);
2250 }
2251 const awaitedNode = visitAsyncNode(request, task, node, visited, task.time);
2209 - if (awaitedNode !== null) {
2252 + if (awaitedNode === undefined) {
2253 + // Undefined is used as a signal that we found an aborted await and that's good enough
2254 + // anything derived from that aborted node might be irrelevant.
2255 + } else if (awaitedNode !== null) {
2256 // Nothing in user space (unfiltered stack) awaited this.
2257 serializeIONode(request, awaitedNode, awaitedNode.promise);
2258 request.pendingChunks++;
@@ -4102,8 +4148,8 @@ function serializeIONode(
4148 const endTime =
4149 ioNode.tag === UNRESOLVED_PROMISE_NODE
4150 ? // Mark the end time as now. It's arbitrary since it's not resolved but this
4105 - // marks when we stopped trying.
4106 - performance.now()
4151 + // marks when we called abort and therefore stopped trying.
4152 + request.abortTime
4153 : ioNode.end;
4154
4155 request.pendingChunks++;
@@ -4916,17 +4962,8 @@ function forwardDebugInfoFromAbortedTask(request: Request, task: Task): void {
4962 thenable = (model: any);
4963 } else if (model.$$typeof === REACT_LAZY_TYPE) {
4964 const payload = model._payload;
4919 - const init = model._init;
4920 - try {
4921 - init(payload);
4922 - } catch (x) {
4923 - if (
4924 - typeof x === 'object' &&
4925 - x !== null &&
4926 - typeof x.then === 'function'
4927 - ) {
4928 - thenable = (x: any);
4929 - }
4965 + if (typeof payload.then === 'function') {
4966 + thenable = payload;
4967 }
4968 }
4969 if (thenable !== null) {
@@ -4955,6 +4992,8 @@ function forwardDebugInfoFromAbortedTask(request: Request, task: Task): void {
4992 advanceTaskTime(request, task, task.time);
4993 emitDebugChunk(request, task.id, asyncInfo);
4994 } else {
4995 + // We have a resolved Promise. Its debug info can include both awaited data and rejected
4996 + // promises after the abort.
4997 emitAsyncSequence(request, task, sequence, debugInfo, null, null);
4998 }
4999 }
@@ -5005,7 +5044,7 @@ function markOperationEndTime(request: Request, task: Task, timestamp: number) {
5044 }
5045 // This is like advanceTaskTime() but always emits a timing chunk even if it doesn't advance.
5046 // This ensures that the end time of the previous entry isn't implied to be the start of the next one.
5008 - if (request.status === ABORTING) {
5047 + if (request.status === ABORTING && timestamp > request.abortTime) {
5048 // If we're aborting then we don't emit any end times that happened after.
5049 return;
5050 }
@@ -5222,10 +5261,12 @@ function retryTask(request: Request, task: Task): void {
5261 // When aborting a prerener with halt semantics we don't emit
5262 // anything into the slot for a task that aborts, it remains unresolved
5263 haltTask(task, request);
5264 + finishHaltedTask(task, request);
5265 } else {
5266 // Otherwise we emit an error chunk into the task slot.
5267 const errorId: number = (request.fatalError: any);
5268 abortTask(task, request, errorId);
5269 + finishAbortedTask(task, request, errorId);
5270 }
5271 return;
5272 }
@@ -5309,16 +5350,27 @@ function performWork(request: Request): void {
5350 }
5351
5352 function abortTask(task: Task, request: Request, errorId: number): void {
5312 - if (task.status === RENDERING) {
5313 - // This task will be aborted by the render
5353 + if (task.status !== PENDING) {
5354 + // If this is already completed/errored we don't abort it.
5355 + // If currently rendering it will be aborted by the render
5356 return;
5357 }
5358 task.status = ABORTED;
5359 +}
5360 +
5361 +function finishAbortedTask(
5362 + task: Task,
5363 + request: Request,
5364 + errorId: number,
5365 +): void {
5366 + if (task.status !== ABORTED) {
5367 + return;
5368 + }
5369 forwardDebugInfoFromAbortedTask(request, task);
5370 // Track when we aborted this task as its end time.
5371 if (enableProfilerTimer && enableComponentPerformanceTrack) {
5372 if (task.timed) {
5321 - markOperationEndTime(request, task, performance.now());
5373 + markOperationEndTime(request, task, request.abortTime);
5374 }
5375 }
5376 // Instead of emitting an error per task.id, we emit a model that only
@@ -5329,11 +5381,18 @@ function abortTask(task: Task, request: Request, errorId: number): void {
5381 }
5382
5383 function haltTask(task: Task, request: Request): void {
5332 - if (task.status === RENDERING) {
5333 - // this task will be halted by the render
5384 + if (task.status !== PENDING) {
5385 + // If this is already completed/errored we don't abort it.
5386 + // If currently rendering it will be aborted by the render
5387 return;
5388 }
5389 task.status = ABORTED;
5390 +}
5391 +
5392 +function finishHaltedTask(task: Task, request: Request): void {
5393 + if (task.status !== ABORTED) {
5394 + return;
5395 + }
5396 forwardDebugInfoFromAbortedTask(request, task);
5397 // We don't actually emit anything for this task id because we are intentionally
5398 // leaving the reference unfulfilled.
@@ -5518,22 +5577,56 @@ export function stopFlowing(request: Request): void {
5577 request.destination = null;
5578 }
5579
5580 +function finishHalt(request: Request, abortedTasks: Set<Task>): void {
5581 + try {
5582 + abortedTasks.forEach(task => finishHaltedTask(task, request));
5583 + const onAllReady = request.onAllReady;
5584 + onAllReady();
5585 + if (request.destination !== null) {
5586 + flushCompletedChunks(request, request.destination);
5587 + }
5588 + } catch (error) {
5589 + logRecoverableError(request, error, null);
5590 + fatalError(request, error);
5591 + }
5592 +}
5593 +
5594 +function finishAbort(
5595 + request: Request,
5596 + abortedTasks: Set<Task>,
5597 + errorId: number,
5598 +): void {
5599 + try {
5600 + abortedTasks.forEach(task => finishAbortedTask(task, request, errorId));
5601 + const onAllReady = request.onAllReady;
5602 + onAllReady();
5603 + if (request.destination !== null) {
5604 + flushCompletedChunks(request, request.destination);
5605 + }
5606 + } catch (error) {
5607 + logRecoverableError(request, error, null);
5608 + fatalError(request, error);
5609 + }
5610 +}
5611 +
5612 export function abort(request: Request, reason: mixed): void {
5613 + // We define any status below OPEN as OPEN equivalent
5614 + if (request.status > OPEN) {
5615 + return;
5616 + }
5617 try {
5523 - // We define any status below OPEN as OPEN equivalent
5524 - if (request.status <= OPEN) {
5525 - request.status = ABORTING;
5526 - request.cacheController.abort(reason);
5527 - callOnAllReadyIfReady(request);
5618 + request.status = ABORTING;
5619 + if (enableProfilerTimer && enableComponentPerformanceTrack) {
5620 + request.abortTime = performance.now();
5621 }
5622 + request.cacheController.abort(reason);
5623 const abortableTasks = request.abortableTasks;
5624 if (abortableTasks.size > 0) {
5625 if (enableHalt && request.type === PRERENDER) {
5626 // When prerendering with halt semantics we simply halt the task
5627 // and leave the reference unfulfilled.
5628 abortableTasks.forEach(task => haltTask(task, request));
5535 - abortableTasks.clear();
5536 - callOnAllReadyIfReady(request);
5629 + scheduleWork(() => finishHalt(request, abortableTasks));
5630 } else if (
5631 enablePostpone &&
5632 typeof reason === 'object' &&
@@ -5549,8 +5642,7 @@ export function abort(request: Request, reason: mixed): void {
5642 request.pendingChunks++;
5643 emitPostponeChunk(request, errorId, postponeInstance);
5644 abortableTasks.forEach(task => abortTask(task, request, errorId));
5552 - abortableTasks.clear();
5553 - callOnAllReadyIfReady(request);
5645 + scheduleWork(() => finishAbort(request, abortableTasks, errorId));
5646 } else {
5647 const error =
5648 reason === undefined
@@ -5572,12 +5664,14 @@ export function abort(request: Request, reason: mixed): void {
5664 request.pendingChunks++;
5665 emitErrorChunk(request, errorId, digest, error, false);
5666 abortableTasks.forEach(task => abortTask(task, request, errorId));
5575 - abortableTasks.clear();
5576 - callOnAllReadyIfReady(request);
5667 + scheduleWork(() => finishAbort(request, abortableTasks, errorId));
5668 + }
5669 + } else {
5670 + const onAllReady = request.onAllReady;
5671 + onAllReady();
5672 + if (request.destination !== null) {
5673 + flushCompletedChunks(request, request.destination);
5674 }
5578 - }
5579 - if (request.destination !== null) {
5580 - flushCompletedChunks(request, request.destination);
5675 }
5676 } catch (error) {
5677 logRecoverableError(request, error, null);