@samitouri / QOS-React-1 / commits / cee7939b00

[Fizz] Push a stalled await from debug info to the ownerStack/debugTask (#33634)

If an aborted task is not rendering, then this is an async abort. Conceptually it's as if the abort happened inside the async gap. The abort reason's stack frame won't have that on the stack so instead we use the owner stack and debug task of any halted async debug info. One thing that's a bit awkward is that if you do have a sync abort and you use that error as the "reason" then that thing still has a sync stack in a different component. In another approach I was exploring having different error objects for each component but I don't think that's worth it.

Sebastian Markbåge committed Jun 25, 2025 at 11:14 UTC cee7939b0017ff58230e19663c22393bfd9025ef
3 files changed +113 -17
packages/react-server/src/ReactFizzComponentStack.js
+3 -2
@@ -7,7 +7,7 @@
7 * @flow
8 */
9
10 -import type {ReactComponentInfo} from 'shared/ReactTypes';
10 +import type {ReactComponentInfo, ReactAsyncInfo} from 'shared/ReactTypes';
11 import type {LazyComponent} from 'react/src/ReactLazy';
12
13 import {
@@ -37,7 +37,8 @@ export type ComponentStackNode = {
37 | string
38 | Function
39 | LazyComponent<any, any>
40 - | ReactComponentInfo,
40 + | ReactComponentInfo
41 + | ReactAsyncInfo,
42 owner?: null | ReactComponentInfo | ComponentStackNode, // DEV only
43 stack?: null | string | Error, // DEV only
44 };
packages/react-server/src/ReactFizzServer.js
+91 -11
@@ -21,6 +21,7 @@ import type {
21 ReactFormState,
22 ReactComponentInfo,
23 ReactDebugInfo,
24 + ReactAsyncInfo,
25 ViewTransitionProps,
26 ActivityProps,
27 SuspenseProps,
@@ -181,6 +182,7 @@ import {
182 enableAsyncIterableChildren,
183 enableViewTransition,
184 enableFizzBlockingRender,
185 + enableAsyncDebugInfo,
186 } from 'shared/ReactFeatureFlags';
187
188 import assign from 'shared/assign';
@@ -985,6 +987,45 @@ function getStackFromNode(stackNode: ComponentStackNode): string {
987 return getStackByComponentStackNode(stackNode);
988 }
989
990 +function pushHaltedAwaitOnComponentStack(
991 + task: Task,
992 + debugInfo: void | null | ReactDebugInfo,
993 +): void {
994 + if (!__DEV__) {
995 + // eslint-disable-next-line react-internal/prod-error-codes
996 + throw new Error(
997 + 'pushHaltedAwaitOnComponentStack should never be called in production. This is a bug in React.',
998 + );
999 + }
1000 + if (debugInfo != null) {
1001 + for (let i = debugInfo.length - 1; i >= 0; i--) {
1002 + const info = debugInfo[i];
1003 + if (typeof info.name === 'string') {
1004 + // This is a Server Component. Any awaits in previous Server Components already resolved.
1005 + break;
1006 + }
1007 + if (typeof info.time === 'number') {
1008 + // This had an end time. Any awaits before this must have already resolved.
1009 + break;
1010 + }
1011 + if (info.awaited != null) {
1012 + const asyncInfo: ReactAsyncInfo = (info: any);
1013 + const bestStack =
1014 + asyncInfo.debugStack == null ? asyncInfo.awaited : asyncInfo;
1015 + if (bestStack.debugStack !== undefined) {
1016 + task.componentStack = {
1017 + parent: task.componentStack,
1018 + type: asyncInfo,
1019 + owner: bestStack.owner,
1020 + stack: bestStack.debugStack,
1021 + };
1022 + task.debugTask = (bestStack.debugTask: any);
1023 + }
1024 + }
1025 + }
1026 + }
1027 +}
1028 +
1029 function pushServerComponentStack(
1030 task: Task,
1031 debugInfo: void | null | ReactDebugInfo,
@@ -4612,6 +4653,20 @@ function abortTask(task: Task, request: Request, error: mixed): void {
4653 }
4654
4655 const errorInfo = getThrownInfo(task.componentStack);
4656 + if (__DEV__ && enableAsyncDebugInfo) {
4657 + // If the task is not rendering, then this is an async abort. Conceptually it's as if
4658 + // the abort happened inside the async gap. The abort reason's stack frame won't have that
4659 + // on the stack so instead we use the owner stack and debug task of any halted async debug info.
4660 + const node: any = task.node;
4661 + if (node !== null && typeof node === 'object') {
4662 + // Push a fake component stack frame that represents the await.
4663 + pushHaltedAwaitOnComponentStack(task, node._debugInfo);
4664 + if (task.thenableState !== null) {
4665 + // TODO: If we were stalled inside use() of a Client Component then we should
4666 + // rerender to get the stack trace from the use() call.
4667 + }
4668 + }
4669 + }
4670
4671 if (boundary === null) {
4672 if (request.status !== CLOSING && request.status !== CLOSED) {
@@ -4631,7 +4686,12 @@ function abortTask(task: Task, request: Request, error: mixed): void {
4686 if (trackedPostpones !== null && segment !== null) {
4687 // We are prerendering. We don't want to fatal when the shell postpones
4688 // we just need to mark it as postponed.
4634 - logPostpone(request, postponeInstance.message, errorInfo, null);
4689 + logPostpone(
4690 + request,
4691 + postponeInstance.message,
4692 + errorInfo,
4693 + task.debugTask,
4694 + );
4695 trackPostpone(request, trackedPostpones, task, segment);
4696 finishedTask(request, null, task.row, segment);
4697 } else {
@@ -4639,8 +4699,8 @@ function abortTask(task: Task, request: Request, error: mixed): void {
4699 'The render was aborted with postpone when the shell is incomplete. Reason: ' +
4700 postponeInstance.message,
4701 );
4642 - logRecoverableError(request, fatal, errorInfo, null);
4643 - fatalError(request, fatal, errorInfo, null);
4702 + logRecoverableError(request, fatal, errorInfo, task.debugTask);
4703 + fatalError(request, fatal, errorInfo, task.debugTask);
4704 }
4705 } else if (
4706 enableHalt &&
@@ -4650,12 +4710,12 @@ function abortTask(task: Task, request: Request, error: mixed): void {
4710 const trackedPostpones = request.trackedPostpones;
4711 // We are aborting a prerender and must treat the shell as halted
4712 // We log the error but we still resolve the prerender
4653 - logRecoverableError(request, error, errorInfo, null);
4713 + logRecoverableError(request, error, errorInfo, task.debugTask);
4714 trackPostpone(request, trackedPostpones, task, segment);
4715 finishedTask(request, null, task.row, segment);
4716 } else {
4657 - logRecoverableError(request, error, errorInfo, null);
4658 - fatalError(request, error, errorInfo, null);
4717 + logRecoverableError(request, error, errorInfo, task.debugTask);
4718 + fatalError(request, error, errorInfo, task.debugTask);
4719 }
4720 return;
4721 } else {
@@ -4672,7 +4732,12 @@ function abortTask(task: Task, request: Request, error: mixed): void {
4732 error.$$typeof === REACT_POSTPONE_TYPE
4733 ) {
4734 const postponeInstance: Postpone = (error: any);
4675 - logPostpone(request, postponeInstance.message, errorInfo, null);
4735 + logPostpone(
4736 + request,
4737 + postponeInstance.message,
4738 + errorInfo,
4739 + task.debugTask,
4740 + );
4741 // TODO: Figure out a better signal than a magic digest value.
4742 errorDigest = 'POSTPONE';
4743 } else {
@@ -4710,11 +4775,16 @@ function abortTask(task: Task, request: Request, error: mixed): void {
4775 error.$$typeof === REACT_POSTPONE_TYPE
4776 ) {
4777 const postponeInstance: Postpone = (error: any);
4713 - logPostpone(request, postponeInstance.message, errorInfo, null);
4778 + logPostpone(
4779 + request,
4780 + postponeInstance.message,
4781 + errorInfo,
4782 + task.debugTask,
4783 + );
4784 } else {
4785 // We are aborting a prerender and must halt this boundary.
4786 // We treat this like other postpones during prerendering
4717 - logRecoverableError(request, error, errorInfo, null);
4787 + logRecoverableError(request, error, errorInfo, task.debugTask);
4788 }
4789 trackPostpone(request, trackedPostpones, task, segment);
4790 // If this boundary was still pending then we haven't already cancelled its fallbacks.
@@ -4737,7 +4807,12 @@ function abortTask(task: Task, request: Request, error: mixed): void {
4807 error.$$typeof === REACT_POSTPONE_TYPE
4808 ) {
4809 const postponeInstance: Postpone = (error: any);
4740 - logPostpone(request, postponeInstance.message, errorInfo, null);
4810 + logPostpone(
4811 + request,
4812 + postponeInstance.message,
4813 + errorInfo,
4814 + task.debugTask,
4815 + );
4816 if (request.trackedPostpones !== null && segment !== null) {
4817 trackPostpone(request, request.trackedPostpones, task, segment);
4818 finishedTask(request, task.blockedBoundary, task.row, segment);
@@ -4753,7 +4828,12 @@ function abortTask(task: Task, request: Request, error: mixed): void {
4828 // TODO: Figure out a better signal than a magic digest value.
4829 errorDigest = 'POSTPONE';
4830 } else {
4756 - errorDigest = logRecoverableError(request, error, errorInfo, null);
4831 + errorDigest = logRecoverableError(
4832 + request,
4833 + error,
4834 + errorInfo,
4835 + task.debugTask,
4836 + );
4837 }
4838 boundary.status = CLIENT_RENDERED;
4839 encodeErrorForBoundary(boundary, errorDigest, error, errorInfo, true);
packages/react-server/src/ReactFlightServer.js
+19 -4
@@ -2149,7 +2149,11 @@ function visitAsyncNode(
2149 owner: node.owner,
2150 stack: filterStackTrace(request, node.stack),
2151 });
2152 - markOperationEndTime(request, task, endTime);
2152 + // Mark the end time of the await. If we're aborting then we don't emit this
2153 + // to signal that this never resolved inside this render.
2154 + if (request.status !== ABORTING) {
2155 + markOperationEndTime(request, task, endTime);
2156 + }
2157 }
2158 }
2159 }
@@ -2210,7 +2214,12 @@ function emitAsyncSequence(
2214 }
2215 }
2216 emitDebugChunk(request, task.id, debugInfo);
2213 - markOperationEndTime(request, task, awaitedNode.end);
2217 + // Mark the end time of the await. If we're aborting then we don't emit this
2218 + // to signal that this never resolved inside this render.
2219 + if (request.status !== ABORTING) {
2220 + // If we're currently aborting, then this never resolved into user space.
2221 + markOperationEndTime(request, task, awaitedNode.end);
2222 + }
2223 }
2224 }
2225
@@ -3910,6 +3919,13 @@ function serializeIONode(
3919 // The environment name may have changed from when the I/O was actually started.
3920 const env = (0, request.environmentName)();
3921
3922 + const endTime =
3923 + ioNode.tag === UNRESOLVED_PROMISE_NODE
3924 + ? // Mark the end time as now. It's arbitrary since it's not resolved but this
3925 + // marks when we stopped trying.
3926 + performance.now()
3927 + : ioNode.end;
3928 +
3929 request.pendingChunks++;
3930 const id = request.nextChunkId++;
3931 emitIOInfoChunk(
@@ -3917,7 +3933,7 @@ function serializeIONode(
3933 id,
3934 name,
3935 ioNode.start,
3920 - ioNode.end,
3936 + endTime,
3937 value,
3938 env,
3939 owner,
@@ -4741,7 +4757,6 @@ function forwardDebugInfoFromAbortedTask(request: Request, task: Task): void {
4757 env: env,
4758 };
4759 emitDebugChunk(request, task.id, asyncInfo);
4744 - markOperationEndTime(request, task, performance.now());
4760 } else {
4761 emitAsyncSequence(request, task, sequence, debugInfo, null, null);
4762 }