[Flight] Ignore async stack frames when determining if a Promise was created from user space (#33739)

We use the stack of a Promise as the start of the I/O instead of the
actual I/O since that can symbolize the start of the operation even if
the actual I/O is batched, deduped or pooled. It can also group multiple
I/O operations into one.

We want the deepest possible Promise since otherwise it would just be
the Component's Promise.

However, we don't really need deeper than the boundary between first
party and third party. We can't just take the outer most that has third
party things on the stack though because third party can have callbacks
into first party and then we want the inner one. So we take the inner
most Promise that depends on I/O that has a first party stack on it.

The realization is that for the purposes of determining whether we have
a first party stack we need to ignore async stack frames. They can
appear on the stack when we resume third party code inside a resumption
frame of a first party stack.

<img width="832" alt="Screenshot 2025-07-08 at 6 34 25 PM"
src="https://github.com/user-attachments/assets/1636f980-be4c-4340-ad49-8d2b31953436"
/>

---------

Co-authored-by: Sebastian Sebbie Silbermann <sebastian.silbermann@vercel.com>
This commit is contained in:
Sebastian Markbåge 2025-07-09 09:08:09 -04:00 committed by GitHub
parent 49ded1d12a
commit 150f022444
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 286 additions and 5 deletions

View File

@ -53,6 +53,15 @@ const React = require('react');
const activeDebugChannels =
process.env.NODE_ENV === 'development' ? new Map() : null;
function filterStackFrame(sourceURL, functionName) {
return (
sourceURL !== '' &&
!sourceURL.startsWith('node:') &&
!sourceURL.includes('node_modules') &&
!sourceURL.endsWith('library.js')
);
}
function getDebugChannel(req) {
if (process.env.NODE_ENV !== 'development') {
return undefined;
@ -123,6 +132,7 @@ async function renderApp(
const payload = {root, returnValue, formState};
const {pipe} = renderToPipeableStream(payload, moduleMap, {
debugChannel: await promiseForDebugChannel,
filterStackFrame,
});
pipe(res);
}
@ -178,7 +188,9 @@ async function prerenderApp(res, returnValue, formState, noCache) {
);
// For client-invoked server actions we refresh the tree and return a return value.
const payload = {root, returnValue, formState};
const {prelude} = await prerenderToNodeStream(payload, moduleMap);
const {prelude} = await prerenderToNodeStream(payload, moduleMap, {
filterStackFrame,
});
prelude.pipe(res);
}

View File

@ -24,6 +24,7 @@ import {GenerateImage} from './GenerateImage.js';
import {like, greet, increment} from './actions.js';
import {getServerState} from './ServerState.js';
import {sdkMethod} from './library.js';
const promisedText = new Promise(resolve =>
setTimeout(() => resolve('deferred text'), 50)
@ -180,6 +181,7 @@ let veryDeepObject = [
export default async function App({prerender, noCache}) {
const res = await fetch('http://localhost:3001/todos');
const todos = await res.json();
await sdkMethod('http://localhost:3001/todos');
console.log('Expand me:', veryDeepObject);

View File

@ -0,0 +1,9 @@
export async function sdkMethod(input, init) {
return fetch(input, init).then(async response => {
await new Promise(resolve => {
setTimeout(resolve, 10);
});
return response;
});
}

View File

@ -260,7 +260,15 @@ function hasUnfilteredFrame(request: Request, stack: ReactStackTrace): boolean {
const url = devirtualizeURL(callsite[1]);
const lineNumber = callsite[2];
const columnNumber = callsite[3];
if (filterStackFrame(url, functionName, lineNumber, columnNumber)) {
// Ignore async stack frames because they're not "real". We'd expect to have at least
// one non-async frame if we're actually executing inside a first party function.
// Otherwise we might just be in the resume of a third party function that resumed
// inside a first party stack.
const isAsync = callsite[6];
if (
!isAsync &&
filterStackFrame(url, functionName, lineNumber, columnNumber)
) {
return true;
}
}

View File

@ -63,7 +63,9 @@ function collectStackTracePrivate(
// Skip everything after the bottom frame since it'll be internals.
break;
} else if (callSite.isNative()) {
result.push([name, '', 0, 0, 0, 0]);
// $FlowFixMe[prop-missing]
const isAsync = callSite.isAsync();
result.push([name, '', 0, 0, 0, 0, isAsync]);
} else {
// We encode complex function calls as if they're part of the function
// name since we cannot simulate the complex ones and they look the same
@ -98,7 +100,17 @@ function collectStackTracePrivate(
typeof callSite.getEnclosingColumnNumber === 'function'
? (callSite: any).getEnclosingColumnNumber() || 0
: 0;
result.push([name, filename, line, col, enclosingLine, enclosingCol]);
// $FlowFixMe[prop-missing]
const isAsync = callSite.isAsync();
result.push([
name,
filename,
line,
col,
enclosingLine,
enclosingCol,
isAsync,
]);
}
}
collectedStackTrace = result;
@ -221,8 +233,12 @@ export function parseStackTrace(
continue;
}
let name = parsed[1] || '';
let isAsync = parsed[8] === 'async ';
if (name === '<anonymous>') {
name = '';
} else if (name.startsWith('async ')) {
name = name.slice(5);
isAsync = true;
}
let filename = parsed[2] || parsed[5] || '';
if (filename === '<anonymous>') {
@ -230,7 +246,7 @@ export function parseStackTrace(
}
const line = +(parsed[3] || parsed[6]);
const col = +(parsed[4] || parsed[7]);
parsedFrames.push([name, filename, line, col, 0, 0]);
parsedFrames.push([name, filename, line, col, 0, 0, isAsync]);
}
stackTraceCache.set(error, parsedFrames);
return parsedFrames;

View File

@ -2515,4 +2515,237 @@ describe('ReactFlightAsyncDebugInfo', () => {
`);
}
});
it('can track IO in third-party code', async () => {
async function thirdParty(endpoint) {
return new Promise(resolve => {
setTimeout(() => {
resolve('third-party ' + endpoint);
}, 10);
}).then(async value => {
await new Promise(resolve => {
setTimeout(resolve, 10);
});
return value;
});
}
async function Component() {
const value = await thirdParty('hi');
return value;
}
const stream = ReactServerDOMServer.renderToPipeableStream(
<Component />,
{},
{
filterStackFrame(filename, functionName) {
if (functionName === 'thirdParty') {
return false;
}
return filterStackFrame(filename, functionName);
},
},
);
const readable = new Stream.PassThrough(streamOptions);
const result = ReactServerDOMClient.createFromNodeStream(readable, {
moduleMap: {},
moduleLoading: {},
});
stream.pipe(readable);
expect(await result).toBe('third-party hi');
await finishLoadingStream(readable);
if (
__DEV__ &&
gate(
flags =>
flags.enableComponentPerformanceTrack && flags.enableAsyncDebugInfo,
)
) {
expect(getDebugInfo(result)).toMatchInlineSnapshot(`
[
{
"time": 0,
},
{
"env": "Server",
"key": null,
"name": "Component",
"props": {},
"stack": [
[
"Object.<anonymous>",
"/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js",
2540,
40,
2519,
42,
],
],
},
{
"time": 0,
},
{
"awaited": {
"end": 0,
"env": "Server",
"name": "",
"owner": {
"env": "Server",
"key": null,
"name": "Component",
"props": {},
"stack": [
[
"Object.<anonymous>",
"/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js",
2540,
40,
2519,
42,
],
],
},
"stack": [
[
"",
"/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js",
2526,
15,
2525,
15,
],
[
"Component",
"/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js",
2535,
19,
2534,
5,
],
],
"start": 0,
"value": {
"value": undefined,
},
},
"env": "Server",
"owner": {
"env": "Server",
"key": null,
"name": "Component",
"props": {},
"stack": [
[
"Object.<anonymous>",
"/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js",
2540,
40,
2519,
42,
],
],
},
"stack": [
[
"",
"/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js",
2526,
15,
2525,
15,
],
[
"Component",
"/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js",
2535,
19,
2534,
5,
],
],
},
{
"time": 0,
},
{
"awaited": {
"end": 0,
"env": "Server",
"name": "thirdParty",
"owner": {
"env": "Server",
"key": null,
"name": "Component",
"props": {},
"stack": [
[
"Object.<anonymous>",
"/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js",
2540,
40,
2519,
42,
],
],
},
"stack": [
[
"Component",
"/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js",
2535,
25,
2534,
5,
],
],
"start": 0,
"value": {
"value": "third-party hi",
},
},
"env": "Server",
"owner": {
"env": "Server",
"key": null,
"name": "Component",
"props": {},
"stack": [
[
"Object.<anonymous>",
"/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js",
2540,
40,
2519,
42,
],
],
},
"stack": [
[
"Component",
"/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js",
2535,
25,
2534,
5,
],
],
},
{
"time": 0,
},
{
"time": 0,
},
]
`);
}
});
});

View File

@ -188,6 +188,7 @@ export type ReactCallSite = [
number, // column number
number, // enclosing line number
number, // enclosing column number
boolean, // async resume
];
export type ReactStackTrace = Array<ReactCallSite>;