Skip to content

Commit 5e89b83

Browse files
committed
Fix stacks for unresolved I/O during abort
1 parent 1669fc2 commit 5e89b83

File tree

2 files changed

+81
-14
lines changed

2 files changed

+81
-14
lines changed

packages/react-server/src/ReactFlightServer.js

Lines changed: 28 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ import type {
7575
AsyncSequence,
7676
IONode,
7777
PromiseNode,
78+
UnresolvedAwaitNode,
7879
UnresolvedPromiseNode,
7980
} from './ReactFlightAsyncSequence';
8081

@@ -95,6 +96,7 @@ import {
9596
markAsyncSequenceRootTask,
9697
getCurrentAsyncSequence,
9798
getAsyncSequenceFromPromise,
99+
getInternalAwaitNode,
98100
parseStackTrace,
99101
parseStackTracePrivate,
100102
supportsComponentStorage,
@@ -2305,7 +2307,18 @@ function visitAsyncNode(
23052307
// We aborted this render. If this Promise spanned the abort time it was probably the
23062308
// Promise that was aborted. This won't necessarily have I/O associated with it but
23072309
// it's a point of interest.
2308-
match = node;
2310+
// However, if the Promise and IO node have the same owner, it likely means a sync component
2311+
// created both the Promise and initiated the I/O. Prefer the IO node for more specific info.
2312+
if (
2313+
ioNode !== null &&
2314+
ioNode.tag === IO_NODE &&
2315+
node.owner !== null &&
2316+
ioNode.owner === node.owner
2317+
) {
2318+
match = ioNode;
2319+
} else {
2320+
match = node;
2321+
}
23092322
}
23102323
} else if (ioNode !== null) {
23112324
// This Promise was blocked on I/O. That's a signal that this Promise is interesting to log.
@@ -4417,7 +4430,7 @@ function outlineIOInfo(request: Request, ioInfo: ReactIOInfo): void {
44174430

44184431
function serializeIONode(
44194432
request: Request,
4420-
ioNode: IONode | PromiseNode | UnresolvedPromiseNode,
4433+
ioNode: IONode | PromiseNode | UnresolvedPromiseNode | UnresolvedAwaitNode,
44214434
promiseRef: null | WeakRef<Promise<mixed>>,
44224435
): string {
44234436
const existingRef = request.writtenDebugObjects.get(ioNode);
@@ -5383,26 +5396,29 @@ function forwardDebugInfoFromAbortedTask(request: Request, task: Task): void {
53835396
// See if any of the dependencies are resolved yet.
53845397
node = node.awaited;
53855398
}
5399+
// For unresolved Promises, check if we have an internal await node that shows what
5400+
// the async function is currently blocked on. For resolved Promises, the regular
5401+
// awaited field already contains the necessary information.
53865402
if (node.tag === UNRESOLVED_PROMISE_NODE) {
5387-
// We don't know what Promise will eventually end up resolving this Promise and if it
5388-
// was I/O at all. However, we assume that it was some kind of I/O since it didn't
5389-
// complete in time before aborting.
5390-
// The best we can do is try to emit the stack of where this Promise was created.
5403+
const internalAwait = getInternalAwaitNode(node);
5404+
if (internalAwait !== null) {
5405+
node = internalAwait;
5406+
}
5407+
}
5408+
if (node.tag === UNRESOLVED_AWAIT_NODE) {
5409+
// We found the await that's blocking. Use its stack to show where the component is stuck.
53915410
serializeIONode(request, node, null);
53925411
request.pendingChunks++;
53935412
const env = (0, request.environmentName)();
53945413
const asyncInfo: ReactAsyncInfo = {
5395-
awaited: ((node: any): ReactIOInfo), // This is deduped by this reference.
5414+
awaited: ((node: any): ReactIOInfo),
53965415
env: env,
53975416
};
5398-
// We don't have a start time for this await but in case there was no start time emitted
5399-
// we need to include something. TODO: We should maybe ideally track the time when we
5400-
// called .then() but without updating the task.time field since that's used for the cutoff.
54015417
advanceTaskTime(request, task, task.time);
54025418
emitDebugChunk(request, task.id, asyncInfo);
54035419
} else {
5404-
// We have a resolved Promise. Its debug info can include both awaited data and rejected
5405-
// promises after the abort.
5420+
// We have a resolved or unresolved Promise. Its debug info can include both awaited
5421+
// data and rejected promises after the abort.
54065422
emitAsyncSequence(request, task, sequence, debugInfo, null, null);
54075423
}
54085424
}

packages/react-server/src/ReactFlightServerConfigDebugNode.js

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
* @flow
88
*/
99

10-
import type {ReactStackTrace} from 'shared/ReactTypes';
10+
import type {ReactStackTrace, ReactComponentInfo} from 'shared/ReactTypes';
1111

1212
import type {
1313
AsyncSequence,
@@ -40,6 +40,35 @@ const pendingOperations: Map<number, AsyncSequence> =
4040
// Keep the last resolved await as a workaround for async functions missing data.
4141
let lastRanAwait: null | AwaitNode = null;
4242

43+
// These two maps work together to track what async functions are blocked on when aborting:
44+
//
45+
// 1. unresolvedPromiseNodesByOwner: Maps owner -> Promise (to find the Promise to link)
46+
// When a Promise is created, we track it by its owner. This typically captures async
47+
// function return Promises. Sync components may also have Promises tracked here, but
48+
// they won't be linked since sync functions can't have awaits with matching owners.
49+
//
50+
// 2. internalAwaitNodesByPromise: Maps Promise -> await (stores the actual link)
51+
// When an await happens with the same owner as a tracked Promise, we link that Promise
52+
// to the await. This shows what the async function is currently blocked on.
53+
//
54+
// By storing the links separately from the regular awaited field, we can use this information
55+
// only during abort scenarios without affecting normal rendering.
56+
const unresolvedPromiseNodesByOwner: WeakMap<
57+
ReactComponentInfo,
58+
UnresolvedPromiseNode,
59+
> = new WeakMap();
60+
const internalAwaitNodesByPromise: WeakMap<
61+
UnresolvedPromiseNode | PromiseNode,
62+
UnresolvedAwaitNode | AwaitNode,
63+
> = new WeakMap();
64+
65+
export function getInternalAwaitNode(
66+
promiseNode: UnresolvedPromiseNode | PromiseNode,
67+
): null | UnresolvedAwaitNode | AwaitNode {
68+
const awaitNode = internalAwaitNodesByPromise.get(promiseNode);
69+
return awaitNode === undefined ? null : awaitNode;
70+
}
71+
4372
function resolvePromiseOrAwaitNode(
4473
unresolvedNode: UnresolvedAwaitNode | UnresolvedPromiseNode,
4574
endTime: number,
@@ -114,16 +143,27 @@ export function initAsyncDebugInfo(): void {
114143
}
115144
}
116145
const current = pendingOperations.get(currentAsyncId);
146+
const owner = resolveOwner();
117147
node = ({
118148
tag: UNRESOLVED_AWAIT_NODE,
119-
owner: resolveOwner(),
149+
owner: owner,
120150
stack: stack,
121151
start: performance.now(),
122152
end: -1.1, // set when resolved.
123153
promise: promiseRef,
124154
awaited: trigger, // The thing we're awaiting on. Might get overrriden when we resolve.
125155
previous: current === undefined ? null : current, // The path that led us here.
126156
}: UnresolvedAwaitNode);
157+
// Link the owner's Promise to this await so we can track what it's blocked on.
158+
// This only links when the await and Promise have the same owner (i.e., async functions
159+
// awaiting within themselves). Promises from sync components won't match any awaits.
160+
// We store this in a separate WeakMap to avoid affecting normal rendering.
161+
if (owner !== null) {
162+
const ownerPromiseNode = unresolvedPromiseNodesByOwner.get(owner);
163+
if (ownerPromiseNode !== undefined) {
164+
internalAwaitNodesByPromise.set(ownerPromiseNode, node);
165+
}
166+
}
127167
} else {
128168
const owner = resolveOwner();
129169
node = ({
@@ -140,6 +180,17 @@ export function initAsyncDebugInfo(): void {
140180
: trigger,
141181
previous: null,
142182
}: UnresolvedPromiseNode);
183+
// Track Promises by owner so awaits with matching owners can link to them.
184+
// Only track the first Promise per owner. This typically captures async function
185+
// return Promises, but may also track Promises from sync components - those won't
186+
// be linked since sync functions can't have awaits with matching owners.
187+
if (
188+
owner !== null &&
189+
trigger === undefined &&
190+
!unresolvedPromiseNodesByOwner.has(owner)
191+
) {
192+
unresolvedPromiseNodesByOwner.set(owner, node);
193+
}
143194
}
144195
} else if (
145196
type !== 'Microtask' &&

0 commit comments

Comments
 (0)