Skip to content

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

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Jun 25, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions packages/react-server/src/ReactFizzComponentStack.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
* @flow
*/

import type {ReactComponentInfo} from 'shared/ReactTypes';
import type {ReactComponentInfo, ReactAsyncInfo} from 'shared/ReactTypes';
import type {LazyComponent} from 'react/src/ReactLazy';

import {
Expand Down Expand Up @@ -37,7 +37,8 @@ export type ComponentStackNode = {
| string
| Function
| LazyComponent<any, any>
| ReactComponentInfo,
| ReactComponentInfo
| ReactAsyncInfo,
owner?: null | ReactComponentInfo | ComponentStackNode, // DEV only
stack?: null | string | Error, // DEV only
};
Expand Down
102 changes: 91 additions & 11 deletions packages/react-server/src/ReactFizzServer.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import type {
ReactFormState,
ReactComponentInfo,
ReactDebugInfo,
ReactAsyncInfo,
ViewTransitionProps,
ActivityProps,
SuspenseProps,
Expand Down Expand Up @@ -181,6 +182,7 @@ import {
enableAsyncIterableChildren,
enableViewTransition,
enableFizzBlockingRender,
enableAsyncDebugInfo,
} from 'shared/ReactFeatureFlags';

import assign from 'shared/assign';
Expand Down Expand Up @@ -985,6 +987,45 @@ function getStackFromNode(stackNode: ComponentStackNode): string {
return getStackByComponentStackNode(stackNode);
}

function pushHaltedAwaitOnComponentStack(
task: Task,
debugInfo: void | null | ReactDebugInfo,
): void {
if (!__DEV__) {
// eslint-disable-next-line react-internal/prod-error-codes
throw new Error(
'pushHaltedAwaitOnComponentStack should never be called in production. This is a bug in React.',
);
}
if (debugInfo != null) {
for (let i = debugInfo.length - 1; i >= 0; i--) {
const info = debugInfo[i];
if (typeof info.name === 'string') {
// This is a Server Component. Any awaits in previous Server Components already resolved.
break;
}
if (typeof info.time === 'number') {
// This had an end time. Any awaits before this must have already resolved.
break;
}
if (info.awaited != null) {
const asyncInfo: ReactAsyncInfo = (info: any);
const bestStack =
asyncInfo.debugStack == null ? asyncInfo.awaited : asyncInfo;
if (bestStack.debugStack !== undefined) {
task.componentStack = {
parent: task.componentStack,
type: asyncInfo,
owner: bestStack.owner,
stack: bestStack.debugStack,
};
task.debugTask = (bestStack.debugTask: any);
}
}
}
}
}

function pushServerComponentStack(
task: Task,
debugInfo: void | null | ReactDebugInfo,
Expand Down Expand Up @@ -4612,6 +4653,20 @@ function abortTask(task: Task, request: Request, error: mixed): void {
}

const errorInfo = getThrownInfo(task.componentStack);
if (__DEV__ && enableAsyncDebugInfo) {
// If the 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.
const node: any = task.node;
if (node !== null && typeof node === 'object') {
// Push a fake component stack frame that represents the await.
pushHaltedAwaitOnComponentStack(task, node._debugInfo);
if (task.thenableState !== null) {
// TODO: If we were stalled inside use() of a Client Component then we should
// rerender to get the stack trace from the use() call.
}
}
}

if (boundary === null) {
if (request.status !== CLOSING && request.status !== CLOSED) {
Expand All @@ -4631,16 +4686,21 @@ function abortTask(task: Task, request: Request, error: mixed): void {
if (trackedPostpones !== null && segment !== null) {
// We are prerendering. We don't want to fatal when the shell postpones
// we just need to mark it as postponed.
logPostpone(request, postponeInstance.message, errorInfo, null);
logPostpone(
request,
postponeInstance.message,
errorInfo,
task.debugTask,
);
trackPostpone(request, trackedPostpones, task, segment);
finishedTask(request, null, task.row, segment);
} else {
const fatal = new Error(
'The render was aborted with postpone when the shell is incomplete. Reason: ' +
postponeInstance.message,
);
logRecoverableError(request, fatal, errorInfo, null);
fatalError(request, fatal, errorInfo, null);
logRecoverableError(request, fatal, errorInfo, task.debugTask);
fatalError(request, fatal, errorInfo, task.debugTask);
}
} else if (
enableHalt &&
Expand All @@ -4650,12 +4710,12 @@ function abortTask(task: Task, request: Request, error: mixed): void {
const trackedPostpones = request.trackedPostpones;
// We are aborting a prerender and must treat the shell as halted
// We log the error but we still resolve the prerender
logRecoverableError(request, error, errorInfo, null);
logRecoverableError(request, error, errorInfo, task.debugTask);
trackPostpone(request, trackedPostpones, task, segment);
finishedTask(request, null, task.row, segment);
} else {
logRecoverableError(request, error, errorInfo, null);
fatalError(request, error, errorInfo, null);
logRecoverableError(request, error, errorInfo, task.debugTask);
fatalError(request, error, errorInfo, task.debugTask);
}
return;
} else {
Expand All @@ -4672,7 +4732,12 @@ function abortTask(task: Task, request: Request, error: mixed): void {
error.$$typeof === REACT_POSTPONE_TYPE
) {
const postponeInstance: Postpone = (error: any);
logPostpone(request, postponeInstance.message, errorInfo, null);
logPostpone(
request,
postponeInstance.message,
errorInfo,
task.debugTask,
);
// TODO: Figure out a better signal than a magic digest value.
errorDigest = 'POSTPONE';
} else {
Expand Down Expand Up @@ -4710,11 +4775,16 @@ function abortTask(task: Task, request: Request, error: mixed): void {
error.$$typeof === REACT_POSTPONE_TYPE
) {
const postponeInstance: Postpone = (error: any);
logPostpone(request, postponeInstance.message, errorInfo, null);
logPostpone(
request,
postponeInstance.message,
errorInfo,
task.debugTask,
);
} else {
// We are aborting a prerender and must halt this boundary.
// We treat this like other postpones during prerendering
logRecoverableError(request, error, errorInfo, null);
logRecoverableError(request, error, errorInfo, task.debugTask);
}
trackPostpone(request, trackedPostpones, task, segment);
// If this boundary was still pending then we haven't already cancelled its fallbacks.
Expand All @@ -4737,7 +4807,12 @@ function abortTask(task: Task, request: Request, error: mixed): void {
error.$$typeof === REACT_POSTPONE_TYPE
) {
const postponeInstance: Postpone = (error: any);
logPostpone(request, postponeInstance.message, errorInfo, null);
logPostpone(
request,
postponeInstance.message,
errorInfo,
task.debugTask,
);
if (request.trackedPostpones !== null && segment !== null) {
trackPostpone(request, request.trackedPostpones, task, segment);
finishedTask(request, task.blockedBoundary, task.row, segment);
Expand All @@ -4753,7 +4828,12 @@ function abortTask(task: Task, request: Request, error: mixed): void {
// TODO: Figure out a better signal than a magic digest value.
errorDigest = 'POSTPONE';
} else {
errorDigest = logRecoverableError(request, error, errorInfo, null);
errorDigest = logRecoverableError(
request,
error,
errorInfo,
task.debugTask,
);
}
boundary.status = CLIENT_RENDERED;
encodeErrorForBoundary(boundary, errorDigest, error, errorInfo, true);
Expand Down
23 changes: 19 additions & 4 deletions packages/react-server/src/ReactFlightServer.js
Original file line number Diff line number Diff line change
Expand Up @@ -2149,7 +2149,11 @@ function visitAsyncNode(
owner: node.owner,
stack: filterStackTrace(request, node.stack),
});
markOperationEndTime(request, task, endTime);
// Mark the end time of the await. If we're aborting then we don't emit this
// to signal that this never resolved inside this render.
if (request.status !== ABORTING) {
markOperationEndTime(request, task, endTime);
}
Comment on lines +2152 to +2156
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does that not cause an infinite span in the performance track?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No because we skip writing it if there's no end time.

However, I do want to add some kind of marker in the track to log a halted entry.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe it would be useful to have some kind of end marker to know what time it was aborted though.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

}
}
}
Expand Down Expand Up @@ -2210,7 +2214,12 @@ function emitAsyncSequence(
}
}
emitDebugChunk(request, task.id, debugInfo);
markOperationEndTime(request, task, awaitedNode.end);
// Mark the end time of the await. If we're aborting then we don't emit this
// to signal that this never resolved inside this render.
if (request.status !== ABORTING) {
// If we're currently aborting, then this never resolved into user space.
markOperationEndTime(request, task, awaitedNode.end);
}
}
}

Expand Down Expand Up @@ -3910,14 +3919,21 @@ function serializeIONode(
// The environment name may have changed from when the I/O was actually started.
const env = (0, request.environmentName)();

const endTime =
ioNode.tag === UNRESOLVED_PROMISE_NODE
? // Mark the end time as now. It's arbitrary since it's not resolved but this
// marks when we stopped trying.
performance.now()
: ioNode.end;

request.pendingChunks++;
const id = request.nextChunkId++;
emitIOInfoChunk(
request,
id,
name,
ioNode.start,
ioNode.end,
endTime,
value,
env,
owner,
Expand Down Expand Up @@ -4741,7 +4757,6 @@ function forwardDebugInfoFromAbortedTask(request: Request, task: Task): void {
env: env,
};
emitDebugChunk(request, task.id, asyncInfo);
markOperationEndTime(request, task, performance.now());
} else {
emitAsyncSequence(request, task, sequence, debugInfo, null, null);
}
Expand Down
Loading