[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
}