From d90421e74799cf59e61bbb1d942dcd4d72431b48 Mon Sep 17 00:00:00 2001 From: Sebastian Sebbie Silbermann Date: Tue, 8 Jul 2025 16:19:37 +0200 Subject: [PATCH 1/3] [Flight] await in thirdParty --- fixtures/flight/server/region.js | 14 +- fixtures/flight/src/App.js | 2 + fixtures/flight/src/library.js | 9 + .../ReactFlightAsyncDebugInfo-test.js | 233 ++++++++++++++++++ 4 files changed, 257 insertions(+), 1 deletion(-) create mode 100644 fixtures/flight/src/library.js diff --git a/fixtures/flight/server/region.js b/fixtures/flight/server/region.js index 3cc15c01cabb5..564c7b78dd9d0 100644 --- a/fixtures/flight/server/region.js +++ b/fixtures/flight/server/region.js @@ -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); } diff --git a/fixtures/flight/src/App.js b/fixtures/flight/src/App.js index 935f77fa96a22..e6366f4bd0ea4 100644 --- a/fixtures/flight/src/App.js +++ b/fixtures/flight/src/App.js @@ -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); diff --git a/fixtures/flight/src/library.js b/fixtures/flight/src/library.js new file mode 100644 index 0000000000000..744205d1c40fe --- /dev/null +++ b/fixtures/flight/src/library.js @@ -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; + }); +} diff --git a/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js b/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js index 9dda51f56531a..671619060a464 100644 --- a/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js +++ b/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js @@ -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( + , + {}, + { + 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.", + "/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.", + "/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.", + "/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.", + "/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.", + "/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, + }, + ] + `); + } + }); }); From c80e3d8eae1ba441a37c4b99eb3141b900196fce Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Tue, 8 Jul 2025 18:27:28 -0400 Subject: [PATCH 2/3] Track if a callsite was async --- .../src/ReactFlightStackConfigV8.js | 22 ++++++++++++++++--- packages/shared/ReactTypes.js | 1 + 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/packages/react-server/src/ReactFlightStackConfigV8.js b/packages/react-server/src/ReactFlightStackConfigV8.js index 01f9bb5218bc3..d03a3fe2035c5 100644 --- a/packages/react-server/src/ReactFlightStackConfigV8.js +++ b/packages/react-server/src/ReactFlightStackConfigV8.js @@ -63,7 +63,9 @@ function collectStackTrace( // 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 collectStackTrace( 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, + ]); } } // At the same time we generate a string stack trace just in case someone @@ -193,8 +205,12 @@ export function parseStackTrace( continue; } let name = parsed[1] || ''; + let isAsync = parsed[8] === 'async '; if (name === '') { name = ''; + } else if (name.startsWith('async ')) { + name = name.slice(5); + isAsync = true; } let filename = parsed[2] || parsed[5] || ''; if (filename === '') { @@ -202,7 +218,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; diff --git a/packages/shared/ReactTypes.js b/packages/shared/ReactTypes.js index 64eb0524a41df..a55cb66a1e92a 100644 --- a/packages/shared/ReactTypes.js +++ b/packages/shared/ReactTypes.js @@ -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; From 74d3174305e4af12843c2a1ca822093d69ee6d67 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Tue, 8 Jul 2025 18:33:18 -0400 Subject: [PATCH 3/3] Ignore async stack frames when determining whether we have unfiltered frames on a Promise --- packages/react-server/src/ReactFlightServer.js | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/react-server/src/ReactFlightServer.js b/packages/react-server/src/ReactFlightServer.js index c2ae2f6fb9695..eeafe7f21e505 100644 --- a/packages/react-server/src/ReactFlightServer.js +++ b/packages/react-server/src/ReactFlightServer.js @@ -259,7 +259,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; } }