From 6b813700586505a34805a6d4afa43a26c7504c29 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Sat, 31 May 2025 14:47:24 -0400 Subject: [PATCH 1/5] Add name field to ReactIOInfo --- packages/react-client/src/ReactFlightClient.js | 2 +- packages/react-server/src/ReactFlightServer.js | 15 +++++++++++++-- packages/shared/ReactTypes.js | 1 + 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/packages/react-client/src/ReactFlightClient.js b/packages/react-client/src/ReactFlightClient.js index 2afa52f4c7f64..1b3044f5d08f1 100644 --- a/packages/react-client/src/ReactFlightClient.js +++ b/packages/react-client/src/ReactFlightClient.js @@ -675,7 +675,7 @@ function nullRefGetter() { } function getIOInfoTaskName(ioInfo: ReactIOInfo): string { - return ''; // TODO + return ioInfo.name; } function getAsyncInfoTaskName(asyncInfo: ReactAsyncInfo): string { diff --git a/packages/react-server/src/ReactFlightServer.js b/packages/react-server/src/ReactFlightServer.js index 6a27329560882..787c09635242c 100644 --- a/packages/react-server/src/ReactFlightServer.js +++ b/packages/react-server/src/ReactFlightServer.js @@ -3496,6 +3496,7 @@ function outlineComponentInfo( function emitIOInfoChunk( request: Request, id: number, + name: string, start: number, end: number, stack: ?ReactStackTrace, @@ -3532,6 +3533,7 @@ function emitIOInfoChunk( const relativeStartTimestamp = start - request.timeOrigin; const relativeEndTimestamp = end - request.timeOrigin; const debugIOInfo: Omit = { + name: name, start: relativeStartTimestamp, end: relativeEndTimestamp, stack: stack, @@ -3551,7 +3553,14 @@ function outlineIOInfo(request: Request, ioInfo: ReactIOInfo): void { // We can't serialize the ConsoleTask/Error objects so we need to omit them before serializing. request.pendingChunks++; const id = request.nextChunkId++; - emitIOInfoChunk(request, id, ioInfo.start, ioInfo.end, ioInfo.stack); + emitIOInfoChunk( + request, + id, + ioInfo.name, + ioInfo.start, + ioInfo.end, + ioInfo.stack, + ); request.writtenObjects.set(ioInfo, serializeByValueID(id)); } @@ -3566,12 +3575,14 @@ function serializeIONode( } let stack = null; + let name = ''; if (ioNode.stack !== null) { stack = filterStackTrace(request, ioNode.stack, 1); + name = ''; } request.pendingChunks++; const id = request.nextChunkId++; - emitIOInfoChunk(request, id, ioNode.start, ioNode.end, stack); + emitIOInfoChunk(request, id, name, ioNode.start, ioNode.end, stack); const ref = serializeByValueID(id); request.writtenObjects.set(ioNode, ref); return ref; diff --git a/packages/shared/ReactTypes.js b/packages/shared/ReactTypes.js index 793d5dc6e25b0..15863a69cab39 100644 --- a/packages/shared/ReactTypes.js +++ b/packages/shared/ReactTypes.js @@ -231,6 +231,7 @@ export type ReactErrorInfo = ReactErrorInfoProd | ReactErrorInfoDev; // The point where the Async Info started which might not be the same place it was awaited. export type ReactIOInfo = { + +name: string, // the name of the async function being called (e.g. "fetch") +start: number, // the start time +end: number, // the end time (this might be different from the time the await was unblocked) +stack?: null | ReactStackTrace, From 31cfbf64b58323ef041ea3ad6c6cc51349a67b71 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Sat, 31 May 2025 17:11:59 -0400 Subject: [PATCH 2/5] Move stack trace cache inside V8 implementation The fact that we need this cache is really a V8 quirk of only allowing structured data once. It's technically possible that the implementation also doesn't need parsing and doesn't need a cache. It's also not technically correct to use a shared cache for two different requests since the filter is on the request. But mainly I'm doing this so that I can access the filtered frames. --- .../react-server/src/ReactFlightServer.js | 31 ++++++------------- .../src/ReactFlightStackConfigV8.js | 14 +++++++++ 2 files changed, 23 insertions(+), 22 deletions(-) diff --git a/packages/react-server/src/ReactFlightServer.js b/packages/react-server/src/ReactFlightServer.js index 787c09635242c..cb82361942ebd 100644 --- a/packages/react-server/src/ReactFlightServer.js +++ b/packages/react-server/src/ReactFlightServer.js @@ -164,33 +164,18 @@ function defaultFilterStackFrame( ); } -// DEV-only cache of parsed and filtered stack frames. -const stackTraceCache: WeakMap = __DEV__ - ? new WeakMap() - : (null: any); - function filterStackTrace( request: Request, error: Error, skipFrames: number, ): ReactStackTrace { - const existing = stackTraceCache.get(error); - if (existing !== undefined) { - // Return a clone because the Flight protocol isn't yet resilient to deduping - // objects in the debug info. TODO: Support deduping stacks. - const clone = existing.slice(0); - for (let i = 0; i < clone.length; i++) { - // $FlowFixMe[invalid-tuple-arity] - clone[i] = clone[i].slice(0); - } - return clone; - } // Since stacks can be quite large and we pass a lot of them, we filter them out eagerly // to save bandwidth even in DEV. We'll also replay these stacks on the client so by // stripping them early we avoid that overhead. Otherwise we'd normally just rely on // the DevTools or framework's ignore lists to filter them out. const filterStackFrame = request.filterStackFrame; const stack = parseStackTrace(error, skipFrames); + const filteredStack = []; for (let i = 0; i < stack.length; i++) { const callsite = stack[i]; const functionName = callsite[0]; @@ -203,16 +188,18 @@ function filterStackTrace( const envIdx = url.indexOf('/', 12); const suffixIdx = url.lastIndexOf('?'); if (envIdx > -1 && suffixIdx > -1) { - url = callsite[1] = url.slice(envIdx + 1, suffixIdx); + url = url.slice(envIdx + 1, suffixIdx); } } - if (!filterStackFrame(url, functionName)) { - stack.splice(i, 1); - i--; + if (filterStackFrame(url, functionName)) { + // Use a clone because the Flight protocol isn't yet resilient to deduping + // objects in the debug info. TODO: Support deduping stacks. + const clone = callsite.slice(0); + clone[1] = url; + filteredStack.push(clone); } } - stackTraceCache.set(error, stack); - return stack; + return filteredStack; } initAsyncDebugInfo(); diff --git a/packages/react-server/src/ReactFlightStackConfigV8.js b/packages/react-server/src/ReactFlightStackConfigV8.js index 25bc2aba9de8b..4905dfd63281e 100644 --- a/packages/react-server/src/ReactFlightStackConfigV8.js +++ b/packages/react-server/src/ReactFlightStackConfigV8.js @@ -126,10 +126,22 @@ function collectStackTrace( const frameRegExp = /^ {3} at (?:(.+) \((?:(.+):(\d+):(\d+)|\)\)|(?:async )?(.+):(\d+):(\d+)|\)$/; +// DEV-only cache of parsed and filtered stack frames. +const stackTraceCache: WeakMap = __DEV__ + ? new WeakMap() + : (null: any); + export function parseStackTrace( error: Error, skipFrames: number, ): ReactStackTrace { + // We can only get structured data out of error objects once. So we cache the information + // so we can get it again each time. It also helps performance when the same error is + // referenced more than once. + const existing = stackTraceCache.get(error); + if (existing !== undefined) { + return existing; + } // We override Error.prepareStackTrace with our own version that collects // the structured data. We need more information than the raw stack gives us // and we need to ensure that we don't get the source mapped version. @@ -148,6 +160,7 @@ export function parseStackTrace( if (collectedStackTrace !== null) { const result = collectedStackTrace; collectedStackTrace = null; + stackTraceCache.set(error, result); return result; } @@ -191,5 +204,6 @@ export function parseStackTrace( const col = +(parsed[4] || parsed[7]); parsedFrames.push([name, filename, line, col, 0, 0]); } + stackTraceCache.set(error, parsedFrames); return parsedFrames; } From ba03949309d7728e0fd04952df0c6ba7be121d00 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Sat, 31 May 2025 17:19:19 -0400 Subject: [PATCH 3/5] Split filter and parsing filterStackTrace evolved into being both parsing and filtering. Split them so we can use the same parsed value twice without relying on caching. --- .../react-server/src/ReactFlightServer.js | 43 +++++++++++-------- 1 file changed, 25 insertions(+), 18 deletions(-) diff --git a/packages/react-server/src/ReactFlightServer.js b/packages/react-server/src/ReactFlightServer.js index cb82361942ebd..90f3004f2c6c5 100644 --- a/packages/react-server/src/ReactFlightServer.js +++ b/packages/react-server/src/ReactFlightServer.js @@ -64,6 +64,7 @@ import type { ReactAsyncInfo, ReactTimeInfo, ReactStackTrace, + ReactCallSite, ReactFunctionLocation, ReactErrorInfo, ReactErrorInfoDev, @@ -166,16 +167,14 @@ function defaultFilterStackFrame( function filterStackTrace( request: Request, - error: Error, - skipFrames: number, + stack: ReactStackTrace, ): ReactStackTrace { // Since stacks can be quite large and we pass a lot of them, we filter them out eagerly // to save bandwidth even in DEV. We'll also replay these stacks on the client so by // stripping them early we avoid that overhead. Otherwise we'd normally just rely on // the DevTools or framework's ignore lists to filter them out. const filterStackFrame = request.filterStackFrame; - const stack = parseStackTrace(error, skipFrames); - const filteredStack = []; + const filteredStack: ReactStackTrace = []; for (let i = 0; i < stack.length; i++) { const callsite = stack[i]; const functionName = callsite[0]; @@ -194,7 +193,7 @@ function filterStackTrace( if (filterStackFrame(url, functionName)) { // Use a clone because the Flight protocol isn't yet resilient to deduping // objects in the debug info. TODO: Support deduping stacks. - const clone = callsite.slice(0); + const clone: ReactCallSite = (callsite.slice(0): any); clone[1] = url; filteredStack.push(clone); } @@ -227,8 +226,7 @@ function patchConsole(consoleInst: typeof console, methodName: string) { // one stack frame but keeping it simple for now and include all frames. const stack = filterStackTrace( request, - new Error('react-stack-top-frame'), - 1, + parseStackTrace(new Error('react-stack-top-frame'), 1), ); request.pendingChunks++; const owner: null | ReactComponentInfo = resolveOwner(); @@ -1065,7 +1063,7 @@ function callWithDebugContextInDEV( componentDebugInfo.stack = task.debugStack === null ? null - : filterStackTrace(request, task.debugStack, 1); + : filterStackTrace(request, parseStackTrace(task.debugStack, 1)); // $FlowFixMe[cannot-write] componentDebugInfo.debugStack = task.debugStack; // $FlowFixMe[cannot-write] @@ -1266,7 +1264,7 @@ function renderFunctionComponent( componentDebugInfo.stack = task.debugStack === null ? null - : filterStackTrace(request, task.debugStack, 1); + : filterStackTrace(request, parseStackTrace(task.debugStack, 1)); // $FlowFixMe[cannot-write] componentDebugInfo.props = props; // $FlowFixMe[cannot-write] @@ -1602,7 +1600,7 @@ function renderClientElement( task.debugOwner, task.debugStack === null ? null - : filterStackTrace(request, task.debugStack, 1), + : filterStackTrace(request, parseStackTrace(task.debugStack, 1)), validated, ] : [REACT_ELEMENT_TYPE, type, key, props]; @@ -1735,7 +1733,7 @@ function renderElement( stack: task.debugStack === null ? null - : filterStackTrace(request, task.debugStack, 1), + : filterStackTrace(request, parseStackTrace(task.debugStack, 1)), props: props, debugStack: task.debugStack, debugTask: task.debugTask, @@ -1864,7 +1862,10 @@ function visitAsyncNode( // We don't log it yet though. We return it to be logged by the point where it's awaited. // The ioNode might be another PromiseNode in the case where none of the AwaitNode had // unfiltered stacks. - if (filterStackTrace(request, node.stack, 1).length === 0) { + if ( + filterStackTrace(request, parseStackTrace(node.stack, 1)).length === + 0 + ) { // Typically we assume that the outer most Promise that was awaited in user space has the // most actionable stack trace for the start of the operation. However, if this Promise // was created inside only third party code, then try to use the inner node instead. @@ -1885,7 +1886,10 @@ function visitAsyncNode( if (awaited !== null) { const ioNode = visitAsyncNode(request, task, awaited, cutOff, visited); if (ioNode !== null) { - const stack = filterStackTrace(request, node.stack, 1); + const stack = filterStackTrace( + request, + parseStackTrace(node.stack, 1), + ); if (stack.length === 0) { // If this await was fully filtered out, then it was inside third party code // such as in an external library. We return the I/O node and try another await. @@ -3259,7 +3263,7 @@ function emitPostponeChunk( try { // eslint-disable-next-line react-internal/safe-string-coercion reason = String(postponeInstance.message); - stack = filterStackTrace(request, postponeInstance, 0); + stack = filterStackTrace(request, parseStackTrace(postponeInstance, 0)); } catch (x) { stack = []; } @@ -3282,7 +3286,7 @@ function serializeErrorValue(request: Request, error: Error): string { name = error.name; // eslint-disable-next-line react-internal/safe-string-coercion message = String(error.message); - stack = filterStackTrace(request, error, 0); + stack = filterStackTrace(request, parseStackTrace(error, 0)); const errorEnv = (error: any).environmentName; if (typeof errorEnv === 'string') { // This probably came from another FlightClient as a pass through. @@ -3321,7 +3325,7 @@ function emitErrorChunk( name = error.name; // eslint-disable-next-line react-internal/safe-string-coercion message = String(error.message); - stack = filterStackTrace(request, error, 0); + stack = filterStackTrace(request, parseStackTrace(error, 0)); const errorEnv = (error: any).environmentName; if (typeof errorEnv === 'string') { // This probably came from another FlightClient as a pass through. @@ -3564,7 +3568,7 @@ function serializeIONode( let stack = null; let name = ''; if (ioNode.stack !== null) { - stack = filterStackTrace(request, ioNode.stack, 1); + stack = filterStackTrace(request, parseStackTrace(ioNode.stack, 1)); name = ''; } request.pendingChunks++; @@ -3710,7 +3714,10 @@ function renderConsoleValue( let debugStack: null | ReactStackTrace = null; if (element._debugStack != null) { // Outline the debug stack so that it doesn't get cut off. - debugStack = filterStackTrace(request, element._debugStack, 1); + debugStack = filterStackTrace( + request, + parseStackTrace(element._debugStack, 1), + ); doNotLimit.add(debugStack); for (let i = 0; i < debugStack.length; i++) { doNotLimit.add(debugStack[i]); From 96daac29fdec4a0bcb5d0a2b00e04f451bf17ba0 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Sat, 31 May 2025 17:31:12 -0400 Subject: [PATCH 4/5] Extract the first called function name from ignore listed frames The name of the function that is actually called into from first party code is not actually on the stack so we extract that separately. --- .../react-server/src/ReactFlightServer.js | 70 +++++++++++++++---- .../ReactFlightAsyncDebugInfo-test.js | 11 +-- 2 files changed, 63 insertions(+), 18 deletions(-) diff --git a/packages/react-server/src/ReactFlightServer.js b/packages/react-server/src/ReactFlightServer.js index 90f3004f2c6c5..6bdf2caf597df 100644 --- a/packages/react-server/src/ReactFlightServer.js +++ b/packages/react-server/src/ReactFlightServer.js @@ -165,6 +165,50 @@ function defaultFilterStackFrame( ); } +function devirtualizeURL(url: string): string { + if (url.startsWith('rsc://React/')) { + // This callsite is a virtual fake callsite that came from another Flight client. + // We need to reverse it back into the original location by stripping its prefix + // and suffix. We don't need the environment name because it's available on the + // parent object that will contain the stack. + const envIdx = url.indexOf('/', 12); + const suffixIdx = url.lastIndexOf('?'); + if (envIdx > -1 && suffixIdx > -1) { + return url.slice(envIdx + 1, suffixIdx); + } + } + return url; +} + +function findCalledFunctionNameFromStackTrace( + request: Request, + stack: ReactStackTrace, +): string { + // Gets the name of the first function called from first party code. + let bestMatch = ''; + const filterStackFrame = request.filterStackFrame; + for (let i = 0; i < stack.length; i++) { + const callsite = stack[i]; + const functionName = callsite[0]; + const url = devirtualizeURL(callsite[1]); + if (filterStackFrame(url, functionName)) { + if (bestMatch === '') { + // If we had no good stack frames for internal calls, just use the last + // first party function name. + return functionName; + } + return bestMatch; + } else if (functionName === 'new Promise') { + // Ignore Promise constructors. + } else if (url === 'node:internal/async_hooks') { + // Ignore the stack frames from the async hooks themselves. + } else { + bestMatch = functionName; + } + } + return ''; +} + function filterStackTrace( request: Request, stack: ReactStackTrace, @@ -178,18 +222,7 @@ function filterStackTrace( for (let i = 0; i < stack.length; i++) { const callsite = stack[i]; const functionName = callsite[0]; - let url = callsite[1]; - if (url.startsWith('rsc://React/')) { - // This callsite is a virtual fake callsite that came from another Flight client. - // We need to reverse it back into the original location by stripping its prefix - // and suffix. We don't need the environment name because it's available on the - // parent object that will contain the stack. - const envIdx = url.indexOf('/', 12); - const suffixIdx = url.lastIndexOf('?'); - if (envIdx > -1 && suffixIdx > -1) { - url = url.slice(envIdx + 1, suffixIdx); - } - } + const url = devirtualizeURL(callsite[1]); if (filterStackFrame(url, functionName)) { // Use a clone because the Flight protocol isn't yet resilient to deduping // objects in the debug info. TODO: Support deduping stacks. @@ -3568,9 +3601,18 @@ function serializeIONode( let stack = null; let name = ''; if (ioNode.stack !== null) { - stack = filterStackTrace(request, parseStackTrace(ioNode.stack, 1)); - name = ''; + const fullStack = parseStackTrace(ioNode.stack, 1); + stack = filterStackTrace(request, fullStack); + name = findCalledFunctionNameFromStackTrace(request, fullStack); + // The name can include the object that this was called on but sometimes that's + // just unnecessary context. + if (name.startsWith('Window.')) { + name = name.slice(7); + } else if (name.startsWith('.')) { + name = name.slice(7); + } } + request.pendingChunks++; const id = request.nextChunkId++; emitIOInfoChunk(request, id, name, ioNode.start, ioNode.end, stack); diff --git a/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js b/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js index 32eafab78626a..7cf6e24fdae07 100644 --- a/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js +++ b/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js @@ -170,6 +170,7 @@ describe('ReactFlightAsyncDebugInfo', () => { { "awaited": { "end": 0, + "name": "delay", "stack": [ [ "delay", @@ -220,6 +221,7 @@ describe('ReactFlightAsyncDebugInfo', () => { { "awaited": { "end": 0, + "name": "delay", "stack": [ [ "delay", @@ -321,9 +323,9 @@ describe('ReactFlightAsyncDebugInfo', () => { [ "Object.", "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", - 291, + 293, 109, - 278, + 280, 67, ], ], @@ -331,13 +333,14 @@ describe('ReactFlightAsyncDebugInfo', () => { { "awaited": { "end": 0, + "name": "setTimeout", "stack": [ [ "Component", "/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js", - 281, + 283, 7, - 279, + 281, 5, ], ], From fed17d5e625eb467e668f4b39c7f1e3d5b46d1db Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Tue, 3 Jun 2025 14:22:34 -0400 Subject: [PATCH 5/5] Handle empty name in createTask --- packages/react-client/src/ReactFlightClient.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-client/src/ReactFlightClient.js b/packages/react-client/src/ReactFlightClient.js index 1b3044f5d08f1..8d2c215a6b975 100644 --- a/packages/react-client/src/ReactFlightClient.js +++ b/packages/react-client/src/ReactFlightClient.js @@ -675,7 +675,7 @@ function nullRefGetter() { } function getIOInfoTaskName(ioInfo: ReactIOInfo): string { - return ioInfo.name; + return ioInfo.name || 'unknown'; } function getAsyncInfoTaskName(asyncInfo: ReactAsyncInfo): string {