@samitouri / QOS-React-1 / commits / 150f022444

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

Sebastian Markbåge committed Jul 9, 2025 at 09:08 UTC 150f022444466266bc09302b8fd47c3e4ce4d791
7 files changed +286 -5
fixtures/flight/server/region.js
+13 -1
@@ -53,6 +53,15 @@ const React = require('react');
53 const activeDebugChannels =
54 process.env.NODE_ENV === 'development' ? new Map() : null;
55
56 +function filterStackFrame(sourceURL, functionName) {
57 + return (
58 + sourceURL !== '' &&
59 + !sourceURL.startsWith('node:') &&
60 + !sourceURL.includes('node_modules') &&
61 + !sourceURL.endsWith('library.js')
62 + );
63 +}
64 +
65 function getDebugChannel(req) {
66 if (process.env.NODE_ENV !== 'development') {
67 return undefined;
@@ -123,6 +132,7 @@ async function renderApp(
132 const payload = {root, returnValue, formState};
133 const {pipe} = renderToPipeableStream(payload, moduleMap, {
134 debugChannel: await promiseForDebugChannel,
135 + filterStackFrame,
136 });
137 pipe(res);
138 }
@@ -178,7 +188,9 @@ async function prerenderApp(res, returnValue, formState, noCache) {
188 );
189 // For client-invoked server actions we refresh the tree and return a return value.
190 const payload = {root, returnValue, formState};
181 - const {prelude} = await prerenderToNodeStream(payload, moduleMap);
191 + const {prelude} = await prerenderToNodeStream(payload, moduleMap, {
192 + filterStackFrame,
193 + });
194 prelude.pipe(res);
195 }
196
fixtures/flight/src/App.js
+2
@@ -24,6 +24,7 @@ import {GenerateImage} from './GenerateImage.js';
24 import {like, greet, increment} from './actions.js';
25
26 import {getServerState} from './ServerState.js';
27 +import {sdkMethod} from './library.js';
28
29 const promisedText = new Promise(resolve =>
30 setTimeout(() => resolve('deferred text'), 50)
@@ -180,6 +181,7 @@ let veryDeepObject = [
181 export default async function App({prerender, noCache}) {
182 const res = await fetch('http://localhost:3001/todos');
183 const todos = await res.json();
184 + await sdkMethod('http://localhost:3001/todos');
185
186 console.log('Expand me:', veryDeepObject);
187
fixtures/flight/src/library.js new
+9
@@ -0,0 +1,9 @@
1 +export async function sdkMethod(input, init) {
2 + return fetch(input, init).then(async response => {
3 + await new Promise(resolve => {
4 + setTimeout(resolve, 10);
5 + });
6 +
7 + return response;
8 + });
9 +}
packages/react-server/src/ReactFlightServer.js
+9 -1
@@ -260,7 +260,15 @@ function hasUnfilteredFrame(request: Request, stack: ReactStackTrace): boolean {
260 const url = devirtualizeURL(callsite[1]);
261 const lineNumber = callsite[2];
262 const columnNumber = callsite[3];
263 - if (filterStackFrame(url, functionName, lineNumber, columnNumber)) {
263 + // Ignore async stack frames because they're not "real". We'd expect to have at least
264 + // one non-async frame if we're actually executing inside a first party function.
265 + // Otherwise we might just be in the resume of a third party function that resumed
266 + // inside a first party stack.
267 + const isAsync = callsite[6];
268 + if (
269 + !isAsync &&
270 + filterStackFrame(url, functionName, lineNumber, columnNumber)
271 + ) {
272 return true;
273 }
274 }
packages/react-server/src/ReactFlightStackConfigV8.js
+19 -3
@@ -63,7 +63,9 @@ function collectStackTracePrivate(
63 // Skip everything after the bottom frame since it'll be internals.
64 break;
65 } else if (callSite.isNative()) {
66 - result.push([name, '', 0, 0, 0, 0]);
66 + // $FlowFixMe[prop-missing]
67 + const isAsync = callSite.isAsync();
68 + result.push([name, '', 0, 0, 0, 0, isAsync]);
69 } else {
70 // We encode complex function calls as if they're part of the function
71 // name since we cannot simulate the complex ones and they look the same
@@ -98,7 +100,17 @@ function collectStackTracePrivate(
100 typeof callSite.getEnclosingColumnNumber === 'function'
101 ? (callSite: any).getEnclosingColumnNumber() || 0
102 : 0;
101 - result.push([name, filename, line, col, enclosingLine, enclosingCol]);
103 + // $FlowFixMe[prop-missing]
104 + const isAsync = callSite.isAsync();
105 + result.push([
106 + name,
107 + filename,
108 + line,
109 + col,
110 + enclosingLine,
111 + enclosingCol,
112 + isAsync,
113 + ]);
114 }
115 }
116 collectedStackTrace = result;
@@ -221,8 +233,12 @@ export function parseStackTrace(
233 continue;
234 }
235 let name = parsed[1] || '';
236 + let isAsync = parsed[8] === 'async ';
237 if (name === '<anonymous>') {
238 name = '';
239 + } else if (name.startsWith('async ')) {
240 + name = name.slice(5);
241 + isAsync = true;
242 }
243 let filename = parsed[2] || parsed[5] || '';
244 if (filename === '<anonymous>') {
@@ -230,7 +246,7 @@ export function parseStackTrace(
246 }
247 const line = +(parsed[3] || parsed[6]);
248 const col = +(parsed[4] || parsed[7]);
233 - parsedFrames.push([name, filename, line, col, 0, 0]);
249 + parsedFrames.push([name, filename, line, col, 0, 0, isAsync]);
250 }
251 stackTraceCache.set(error, parsedFrames);
252 return parsedFrames;
packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js
+233
@@ -2515,4 +2515,237 @@ describe('ReactFlightAsyncDebugInfo', () => {
2515 `);
2516 }
2517 });
2518 +
2519 + it('can track IO in third-party code', async () => {
2520 + async function thirdParty(endpoint) {
2521 + return new Promise(resolve => {
2522 + setTimeout(() => {
2523 + resolve('third-party ' + endpoint);
2524 + }, 10);
2525 + }).then(async value => {
2526 + await new Promise(resolve => {
2527 + setTimeout(resolve, 10);
2528 + });
2529 +
2530 + return value;
2531 + });
2532 + }
2533 +
2534 + async function Component() {
2535 + const value = await thirdParty('hi');
2536 + return value;
2537 + }
2538 +
2539 + const stream = ReactServerDOMServer.renderToPipeableStream(
2540 + <Component />,
2541 + {},
2542 + {
2543 + filterStackFrame(filename, functionName) {
2544 + if (functionName === 'thirdParty') {
2545 + return false;
2546 + }
2547 + return filterStackFrame(filename, functionName);
2548 + },
2549 + },
2550 + );
2551 +
2552 + const readable = new Stream.PassThrough(streamOptions);
2553 +
2554 + const result = ReactServerDOMClient.createFromNodeStream(readable, {
2555 + moduleMap: {},
2556 + moduleLoading: {},
2557 + });
2558 + stream.pipe(readable);
2559 +
2560 + expect(await result).toBe('third-party hi');
2561 +
2562 + await finishLoadingStream(readable);
2563 + if (
2564 + __DEV__ &&
2565 + gate(
2566 + flags =>
2567 + flags.enableComponentPerformanceTrack && flags.enableAsyncDebugInfo,
2568 + )
2569 + ) {
2570 + expect(getDebugInfo(result)).toMatchInlineSnapshot(`
2571 + [
2572 + {
2573 + "time": 0,
2574 + },
2575 + {
2576 + "env": "Server",
2577 + "key": null,
2578 + "name": "Component",
2579 + "props": {},
2580 + "stack": [
2581 + [
2582 + "Object.<anonymous>",
2583 + "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js",
2584 + 2540,
2585 + 40,
2586 + 2519,
2587 + 42,
2588 + ],
2589 + ],
2590 + },
2591 + {
2592 + "time": 0,
2593 + },
2594 + {
2595 + "awaited": {
2596 + "end": 0,
2597 + "env": "Server",
2598 + "name": "",
2599 + "owner": {
2600 + "env": "Server",
2601 + "key": null,
2602 + "name": "Component",
2603 + "props": {},
2604 + "stack": [
2605 + [
2606 + "Object.<anonymous>",
2607 + "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js",
2608 + 2540,
2609 + 40,
2610 + 2519,
2611 + 42,
2612 + ],
2613 + ],
2614 + },
2615 + "stack": [
2616 + [
2617 + "",
2618 + "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js",
2619 + 2526,
2620 + 15,
2621 + 2525,
2622 + 15,
2623 + ],
2624 + [
2625 + "Component",
2626 + "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js",
2627 + 2535,
2628 + 19,
2629 + 2534,
2630 + 5,
2631 + ],
2632 + ],
2633 + "start": 0,
2634 + "value": {
2635 + "value": undefined,
2636 + },
2637 + },
2638 + "env": "Server",
2639 + "owner": {
2640 + "env": "Server",
2641 + "key": null,
2642 + "name": "Component",
2643 + "props": {},
2644 + "stack": [
2645 + [
2646 + "Object.<anonymous>",
2647 + "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js",
2648 + 2540,
2649 + 40,
2650 + 2519,
2651 + 42,
2652 + ],
2653 + ],
2654 + },
2655 + "stack": [
2656 + [
2657 + "",
2658 + "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js",
2659 + 2526,
2660 + 15,
2661 + 2525,
2662 + 15,
2663 + ],
2664 + [
2665 + "Component",
2666 + "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js",
2667 + 2535,
2668 + 19,
2669 + 2534,
2670 + 5,
2671 + ],
2672 + ],
2673 + },
2674 + {
2675 + "time": 0,
2676 + },
2677 + {
2678 + "awaited": {
2679 + "end": 0,
2680 + "env": "Server",
2681 + "name": "thirdParty",
2682 + "owner": {
2683 + "env": "Server",
2684 + "key": null,
2685 + "name": "Component",
2686 + "props": {},
2687 + "stack": [
2688 + [
2689 + "Object.<anonymous>",
2690 + "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js",
2691 + 2540,
2692 + 40,
2693 + 2519,
2694 + 42,
2695 + ],
2696 + ],
2697 + },
2698 + "stack": [
2699 + [
2700 + "Component",
2701 + "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js",
2702 + 2535,
2703 + 25,
2704 + 2534,
2705 + 5,
2706 + ],
2707 + ],
2708 + "start": 0,
2709 + "value": {
2710 + "value": "third-party hi",
2711 + },
2712 + },
2713 + "env": "Server",
2714 + "owner": {
2715 + "env": "Server",
2716 + "key": null,
2717 + "name": "Component",
2718 + "props": {},
2719 + "stack": [
2720 + [
2721 + "Object.<anonymous>",
2722 + "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js",
2723 + 2540,
2724 + 40,
2725 + 2519,
2726 + 42,
2727 + ],
2728 + ],
2729 + },
2730 + "stack": [
2731 + [
2732 + "Component",
2733 + "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js",
2734 + 2535,
2735 + 25,
2736 + 2534,
2737 + 5,
2738 + ],
2739 + ],
2740 + },
2741 + {
2742 + "time": 0,
2743 + },
2744 + {
2745 + "time": 0,
2746 + },
2747 + ]
2748 + `);
2749 + }
2750 + });
2751 });
packages/shared/ReactTypes.js
+1
@@ -188,6 +188,7 @@ export type ReactCallSite = [
188 number, // column number
189 number, // enclosing line number
190 number, // enclosing column number
191 + boolean, // async resume
192 ];
193
194 export type ReactStackTrace = Array<ReactCallSite>;