mirror of
https://github.com/zebrajr/react.git
synced 2025-12-06 00:20:04 +01:00
[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:
parent
49ded1d12a
commit
150f022444
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
9
fixtures/flight/src/library.js
Normal file
9
fixtures/flight/src/library.js
Normal 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;
|
||||
});
|
||||
}
|
||||
10
packages/react-server/src/ReactFlightServer.js
vendored
10
packages/react-server/src/ReactFlightServer.js
vendored
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
]
|
||||
`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user