[DevTools] Add structure full stack parsing to DevTools (#34093)

We'll need complete parsing of stack traces for both owner stacks and
async debug info so we need to expand the stack parsing capabilities a
bit. This refactors the source location extraction to use some helpers
we can use for other things too.

This is a fork of `ReactFlightStackConfigV8` which also supports
DevTools requirements like checking both `react_stack_bottom_frame` and
`react-stack-bottom-frame` as well as supporting Firefox stacks.

It also supports extracting the first frame of a component stack or the
last frame of an owner stack for the source location.
This commit is contained in:
Sebastian Markbåge
2025-08-04 09:37:46 -04:00
committed by GitHub
parent d3f800d47a
commit 557745eb0b
5 changed files with 351 additions and 204 deletions

View File

@@ -19,8 +19,8 @@ import {
formatWithStyles,
gt,
gte,
parseSourceFromComponentStack,
} from 'react-devtools-shared/src/backend/utils';
import {extractLocationFromComponentStack} from 'react-devtools-shared/src/backend/utils/parseStackTrace';
import {
REACT_SUSPENSE_LIST_TYPE as SuspenseList,
REACT_STRICT_MODE_TYPE as StrictMode,
@@ -306,20 +306,20 @@ describe('utils', () => {
});
});
describe('parseSourceFromComponentStack', () => {
describe('extractLocationFromComponentStack', () => {
it('should return null if passed empty string', () => {
expect(parseSourceFromComponentStack('')).toEqual(null);
expect(extractLocationFromComponentStack('')).toEqual(null);
});
it('should construct the source from the first frame if available', () => {
expect(
parseSourceFromComponentStack(
extractLocationFromComponentStack(
'at l (https://react.dev/_next/static/chunks/main-78a3b4c2aa4e4850.js:1:10389)\n' +
'at f (https://react.dev/_next/static/chunks/pages/%5B%5B...markdownPath%5D%5D-af2ed613aedf1d57.js:1:8519)\n' +
'at r (https://react.dev/_next/static/chunks/pages/_app-dd0b77ea7bd5b246.js:1:498)\n',
),
).toEqual([
'',
'l',
'https://react.dev/_next/static/chunks/main-78a3b4c2aa4e4850.js',
1,
10389,
@@ -328,7 +328,7 @@ describe('utils', () => {
it('should construct the source from highest available frame', () => {
expect(
parseSourceFromComponentStack(
extractLocationFromComponentStack(
' at Q\n' +
' at a\n' +
' at m (https://react.dev/_next/static/chunks/848-122f91e9565d9ffa.js:5:9236)\n' +
@@ -342,7 +342,7 @@ describe('utils', () => {
' at f (https://react.dev/_next/static/chunks/pages/%5B%5B...markdownPath%5D%5D-af2ed613aedf1d57.js:1:8519)',
),
).toEqual([
'',
'm',
'https://react.dev/_next/static/chunks/848-122f91e9565d9ffa.js',
5,
9236,
@@ -351,7 +351,7 @@ describe('utils', () => {
it('should construct the source from frame, which has only url specified', () => {
expect(
parseSourceFromComponentStack(
extractLocationFromComponentStack(
' at Q\n' +
' at a\n' +
' at https://react.dev/_next/static/chunks/848-122f91e9565d9ffa.js:5:9236\n',
@@ -366,13 +366,13 @@ describe('utils', () => {
it('should parse sourceURL correctly if it includes parentheses', () => {
expect(
parseSourceFromComponentStack(
extractLocationFromComponentStack(
'at HotReload (webpack-internal:///(app-pages-browser)/./node_modules/next/dist/client/components/react-dev-overlay/hot-reloader-client.js:307:11)\n' +
' at Router (webpack-internal:///(app-pages-browser)/./node_modules/next/dist/client/components/app-router.js:181:11)\n' +
' at ErrorBoundaryHandler (webpack-internal:///(app-pages-browser)/./node_modules/next/dist/client/components/error-boundary.js:114:9)',
),
).toEqual([
'',
'HotReload',
'webpack-internal:///(app-pages-browser)/./node_modules/next/dist/client/components/react-dev-overlay/hot-reloader-client.js',
307,
11,
@@ -381,13 +381,13 @@ describe('utils', () => {
it('should support Firefox stack', () => {
expect(
parseSourceFromComponentStack(
extractLocationFromComponentStack(
'tt@https://react.dev/_next/static/chunks/363-3c5f1b553b6be118.js:1:165558\n' +
'f@https://react.dev/_next/static/chunks/pages/%5B%5B...markdownPath%5D%5D-af2ed613aedf1d57.js:1:8535\n' +
'r@https://react.dev/_next/static/chunks/pages/_app-dd0b77ea7bd5b246.js:1:513',
),
).toEqual([
'',
'tt',
'https://react.dev/_next/static/chunks/363-3c5f1b553b6be118.js',
1,
165558,

View File

@@ -54,10 +54,12 @@ import {
formatDurationToMicrosecondsGranularity,
gt,
gte,
parseSourceFromComponentStack,
parseSourceFromOwnerStack,
serializeToString,
} from 'react-devtools-shared/src/backend/utils';
import {
extractLocationFromComponentStack,
extractLocationFromOwnerStack,
} from 'react-devtools-shared/src/backend/utils/parseStackTrace';
import {
cleanForBridge,
copyWithDelete,
@@ -6340,7 +6342,7 @@ export function attach(
if (stackFrame === null) {
return null;
}
const source = parseSourceFromComponentStack(stackFrame);
const source = extractLocationFromComponentStack(stackFrame);
fiberInstance.source = source;
return source;
}
@@ -6369,7 +6371,7 @@ export function attach(
// any intermediate utility functions. This won't point to the top of the component function
// but it's at least somewhere within it.
if (isError(unresolvedSource)) {
return (instance.source = parseSourceFromOwnerStack(
return (instance.source = extractLocationFromOwnerStack(
(unresolvedSource: any),
));
}
@@ -6377,7 +6379,7 @@ export function attach(
const idx = unresolvedSource.lastIndexOf('\n');
const lastLine =
idx === -1 ? unresolvedSource : unresolvedSource.slice(idx + 1);
return (instance.source = parseSourceFromComponentStack(lastLine));
return (instance.source = extractLocationFromComponentStack(lastLine));
}
// $FlowFixMe: refined.

View File

@@ -13,12 +13,9 @@ export function formatOwnerStack(error: Error): string {
const prevPrepareStackTrace = Error.prepareStackTrace;
// $FlowFixMe[incompatible-type] It does accept undefined.
Error.prepareStackTrace = undefined;
const stack = error.stack;
let stack = error.stack;
Error.prepareStackTrace = prevPrepareStackTrace;
return formatOwnerStackString(stack);
}
export function formatOwnerStackString(stack: string): string {
if (stack.startsWith('Error: react-stack-top-frame\n')) {
// V8's default formatting prefixes with the error message which we
// don't want/need.

View File

@@ -12,14 +12,11 @@ import {compareVersions} from 'compare-versions';
import {dehydrate} from 'react-devtools-shared/src/hydration';
import isArray from 'shared/isArray';
import type {ReactFunctionLocation} from 'shared/ReactTypes';
import type {DehydratedData} from 'react-devtools-shared/src/frontend/types';
export {default as formatWithStyles} from './formatWithStyles';
export {default as formatConsoleArguments} from './formatConsoleArguments';
import {formatOwnerStackString} from '../shared/DevToolsOwnerStack';
// TODO: update this to the first React version that has a corresponding DevTools backend
const FIRST_DEVTOOLS_BACKEND_LOCKSTEP_VER = '999.9.9';
export function hasAssignedBackend(version?: string): boolean {
@@ -258,186 +255,6 @@ export const isReactNativeEnvironment = (): boolean => {
return window.document == null;
};
function extractLocation(url: string): null | {
functionName?: string,
sourceURL: string,
line?: string,
column?: string,
} {
if (url.indexOf(':') === -1) {
return null;
}
// remove any parentheses from start and end
const withoutParentheses = url.replace(/^\(+/, '').replace(/\)+$/, '');
const locationParts = /(at )?(.+?)(?::(\d+))?(?::(\d+))?$/.exec(
withoutParentheses,
);
if (locationParts == null) {
return null;
}
const functionName = ''; // TODO: Parse this in the regexp.
const [, , sourceURL, line, column] = locationParts;
return {functionName, sourceURL, line, column};
}
const CHROME_STACK_REGEXP = /^\s*at .*(\S+:\d+|\(native\))/m;
function parseSourceFromChromeStack(
stack: string,
): ReactFunctionLocation | null {
const frames = stack.split('\n');
// eslint-disable-next-line no-for-of-loops/no-for-of-loops
for (const frame of frames) {
const sanitizedFrame = frame.trim();
const locationInParenthesesMatch = sanitizedFrame.match(/ (\(.+\)$)/);
const possibleLocation = locationInParenthesesMatch
? locationInParenthesesMatch[1]
: sanitizedFrame;
const location = extractLocation(possibleLocation);
// Continue the search until at least sourceURL is found
if (location == null) {
continue;
}
const {functionName, sourceURL, line = '1', column = '1'} = location;
return [
functionName || '',
sourceURL,
parseInt(line, 10),
parseInt(column, 10),
];
}
return null;
}
function parseSourceFromFirefoxStack(
stack: string,
): ReactFunctionLocation | null {
const frames = stack.split('\n');
// eslint-disable-next-line no-for-of-loops/no-for-of-loops
for (const frame of frames) {
const sanitizedFrame = frame.trim();
const frameWithoutFunctionName = sanitizedFrame.replace(
/((.*".+"[^@]*)?[^@]*)(?:@)/,
'',
);
const location = extractLocation(frameWithoutFunctionName);
// Continue the search until at least sourceURL is found
if (location == null) {
continue;
}
const {functionName, sourceURL, line = '1', column = '1'} = location;
return [
functionName || '',
sourceURL,
parseInt(line, 10),
parseInt(column, 10),
];
}
return null;
}
export function parseSourceFromComponentStack(
componentStack: string,
): ReactFunctionLocation | null {
if (componentStack.match(CHROME_STACK_REGEXP)) {
return parseSourceFromChromeStack(componentStack);
}
return parseSourceFromFirefoxStack(componentStack);
}
let collectedLocation: ReactFunctionLocation | null = null;
function collectStackTrace(
error: Error,
structuredStackTrace: CallSite[],
): string {
let result: null | ReactFunctionLocation = null;
// Collect structured stack traces from the callsites.
// We mirror how V8 serializes stack frames and how we later parse them.
for (let i = 0; i < structuredStackTrace.length; i++) {
const callSite = structuredStackTrace[i];
const name = callSite.getFunctionName();
if (
name != null &&
(name.includes('react_stack_bottom_frame') ||
name.includes('react-stack-bottom-frame'))
) {
// We pick the last frame that matches before the bottom frame since
// that will be immediately inside the component as opposed to some helper.
// If we don't find a bottom frame then we bail to string parsing.
collectedLocation = result;
// Skip everything after the bottom frame since it'll be internals.
break;
} else {
const sourceURL = callSite.getScriptNameOrSourceURL();
const line =
// $FlowFixMe[prop-missing]
typeof callSite.getEnclosingLineNumber === 'function'
? (callSite: any).getEnclosingLineNumber()
: callSite.getLineNumber();
const col =
// $FlowFixMe[prop-missing]
typeof callSite.getEnclosingColumnNumber === 'function'
? (callSite: any).getEnclosingColumnNumber()
: callSite.getColumnNumber();
if (!sourceURL || !line || !col) {
// Skip eval etc. without source url. They don't have location.
continue;
}
result = [name, sourceURL, line, col];
}
}
// At the same time we generate a string stack trace just in case someone
// else reads it.
const name = error.name || 'Error';
const message = error.message || '';
let stack = name + ': ' + message;
for (let i = 0; i < structuredStackTrace.length; i++) {
stack += '\n at ' + structuredStackTrace[i].toString();
}
return stack;
}
export function parseSourceFromOwnerStack(
error: Error,
): ReactFunctionLocation | null {
// First attempt to collected the structured data using prepareStackTrace.
collectedLocation = null;
const previousPrepare = Error.prepareStackTrace;
Error.prepareStackTrace = collectStackTrace;
let stack;
try {
stack = error.stack;
} catch (e) {
// $FlowFixMe[incompatible-type] It does accept undefined.
Error.prepareStackTrace = undefined;
stack = error.stack;
} finally {
Error.prepareStackTrace = previousPrepare;
}
if (collectedLocation !== null) {
return collectedLocation;
}
if (stack == null) {
return null;
}
// Fallback to parsing the string form.
const componentStack = formatOwnerStackString(stack);
return parseSourceFromComponentStack(componentStack);
}
// 0.123456789 => 0.123
// Expects high-resolution timestamp in milliseconds, like from performance.now()
// Mainly used for optimizing the size of serialized profiling payload

View File

@@ -0,0 +1,331 @@
/**
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow
*/
import type {ReactStackTrace, ReactFunctionLocation} from 'shared/ReactTypes';
function parseStackTraceFromChromeStack(
stack: string,
skipFrames: number,
): ReactStackTrace {
if (stack.startsWith('Error: react-stack-top-frame\n')) {
// V8's default formatting prefixes with the error message which we
// don't want/need.
stack = stack.slice(29);
}
let idx = stack.indexOf('react_stack_bottom_frame');
if (idx === -1) {
idx = stack.indexOf('react-stack-bottom-frame');
}
if (idx !== -1) {
idx = stack.lastIndexOf('\n', idx);
}
if (idx !== -1) {
// Cut off everything after the bottom frame since it'll be internals.
stack = stack.slice(0, idx);
}
const frames = stack.split('\n');
const parsedFrames: ReactStackTrace = [];
// We skip top frames here since they may or may not be parseable but we
// want to skip the same number of frames regardless. I.e. we can't do it
// in the caller.
for (let i = skipFrames; i < frames.length; i++) {
const parsed = chromeFrameRegExp.exec(frames[i]);
if (!parsed) {
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>') {
filename = '';
}
const line = +(parsed[3] || parsed[6]);
const col = +(parsed[4] || parsed[7]);
parsedFrames.push([name, filename, line, col, 0, 0, isAsync]);
}
return parsedFrames;
}
const firefoxFrameRegExp = /^((?:.*".+")?[^@]*)@(.+):(\d+):(\d+)$/;
function parseStackTraceFromFirefoxStack(
stack: string,
skipFrames: number,
): ReactStackTrace {
let idx = stack.indexOf('react_stack_bottom_frame');
if (idx === -1) {
idx = stack.indexOf('react-stack-bottom-frame');
}
if (idx !== -1) {
idx = stack.lastIndexOf('\n', idx);
}
if (idx !== -1) {
// Cut off everything after the bottom frame since it'll be internals.
stack = stack.slice(0, idx);
}
const frames = stack.split('\n');
const parsedFrames: ReactStackTrace = [];
// We skip top frames here since they may or may not be parseable but we
// want to skip the same number of frames regardless. I.e. we can't do it
// in the caller.
for (let i = skipFrames; i < frames.length; i++) {
const parsed = firefoxFrameRegExp.exec(frames[i]);
if (!parsed) {
continue;
}
const name = parsed[1] || '';
const filename = parsed[2] || '';
const line = +parsed[3];
const col = +parsed[4];
parsedFrames.push([name, filename, line, col, 0, 0, false]);
}
return parsedFrames;
}
const CHROME_STACK_REGEXP = /^\s*at .*(\S+:\d+|\(native\))/m;
export function parseStackTraceFromString(
stack: string,
skipFrames: number,
): ReactStackTrace {
if (stack.match(CHROME_STACK_REGEXP)) {
return parseStackTraceFromChromeStack(stack, skipFrames);
}
return parseStackTraceFromFirefoxStack(stack, skipFrames);
}
let framesToSkip: number = 0;
let collectedStackTrace: null | ReactStackTrace = null;
const identifierRegExp = /^[a-zA-Z_$][0-9a-zA-Z_$]*$/;
function getMethodCallName(callSite: CallSite): string {
const typeName = callSite.getTypeName();
const methodName = callSite.getMethodName();
const functionName = callSite.getFunctionName();
let result = '';
if (functionName) {
if (
typeName &&
identifierRegExp.test(functionName) &&
functionName !== typeName
) {
result += typeName + '.';
}
result += functionName;
if (
methodName &&
functionName !== methodName &&
!functionName.endsWith('.' + methodName) &&
!functionName.endsWith(' ' + methodName)
) {
result += ' [as ' + methodName + ']';
}
} else {
if (typeName) {
result += typeName + '.';
}
if (methodName) {
result += methodName;
} else {
result += '<anonymous>';
}
}
return result;
}
function collectStackTrace(
error: Error,
structuredStackTrace: CallSite[],
): string {
const result: ReactStackTrace = [];
// Collect structured stack traces from the callsites.
// We mirror how V8 serializes stack frames and how we later parse them.
for (let i = framesToSkip; i < structuredStackTrace.length; i++) {
const callSite = structuredStackTrace[i];
let name = callSite.getFunctionName() || '<anonymous>';
if (
name.includes('react_stack_bottom_frame') ||
name.includes('react-stack-bottom-frame')
) {
// Skip everything after the bottom frame since it'll be internals.
break;
} else if (callSite.isNative()) {
// $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
// as function names in UIs on the client as well as stacks.
if (callSite.isConstructor()) {
name = 'new ' + name;
} else if (!callSite.isToplevel()) {
name = getMethodCallName(callSite);
}
if (name === '<anonymous>') {
name = '';
}
let filename = callSite.getScriptNameOrSourceURL() || '<anonymous>';
if (filename === '<anonymous>') {
filename = '';
if (callSite.isEval()) {
const origin = callSite.getEvalOrigin();
if (origin) {
filename = origin.toString() + ', <anonymous>';
}
}
}
const line = callSite.getLineNumber() || 0;
const col = callSite.getColumnNumber() || 0;
const enclosingLine: number =
// $FlowFixMe[prop-missing]
typeof callSite.getEnclosingLineNumber === 'function'
? (callSite: any).getEnclosingLineNumber() || 0
: 0;
const enclosingCol: number =
// $FlowFixMe[prop-missing]
typeof callSite.getEnclosingColumnNumber === 'function'
? (callSite: any).getEnclosingColumnNumber() || 0
: 0;
// $FlowFixMe[prop-missing]
const isAsync = callSite.isAsync();
result.push([
name,
filename,
line,
col,
enclosingLine,
enclosingCol,
isAsync,
]);
}
}
collectedStackTrace = result;
// At the same time we generate a string stack trace just in case someone
// else reads it. Ideally, we'd call the previous prepareStackTrace to
// ensure it's in the expected format but it's common for that to be
// source mapped and since we do a lot of eager parsing of errors, it
// would be slow in those environments. We could maybe just rely on those
// environments having to disable source mapping globally to speed things up.
// For now, we just generate a default V8 formatted stack trace without
// source mapping as a fallback.
const name = error.name || 'Error';
const message = error.message || '';
let stack = name + ': ' + message;
for (let i = 0; i < structuredStackTrace.length; i++) {
stack += '\n at ' + structuredStackTrace[i].toString();
}
return stack;
}
// This matches either of these V8 formats.
// at name (filename:0:0)
// at filename:0:0
// at async filename:0:0
const chromeFrameRegExp =
/^ *at (?:(.+) \((?:(.+):(\d+):(\d+)|\<anonymous\>)\)|(?:async )?(.+):(\d+):(\d+)|\<anonymous\>)$/;
const stackTraceCache: WeakMap<Error, ReactStackTrace> = new WeakMap();
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.
collectedStackTrace = null;
framesToSkip = skipFrames;
const previousPrepare = Error.prepareStackTrace;
Error.prepareStackTrace = collectStackTrace;
let stack;
try {
stack = String(error.stack);
} finally {
Error.prepareStackTrace = previousPrepare;
}
if (collectedStackTrace !== null) {
const result = collectedStackTrace;
collectedStackTrace = null;
stackTraceCache.set(error, result);
return result;
}
// If the stack has already been read, or this is not actually a V8 compatible
// engine then we might not get a normalized stack and it might still have been
// source mapped. Regardless we try our best to parse it.
const parsedFrames = parseStackTraceFromString(stack, skipFrames);
stackTraceCache.set(error, parsedFrames);
return parsedFrames;
}
export function extractLocationFromOwnerStack(
error: Error,
): ReactFunctionLocation | null {
const stackTrace = parseStackTrace(error, 0);
const stack = error.stack;
if (
!stack.includes('react_stack_bottom_frame') &&
!stack.includes('react-stack-bottom-frame')
) {
// This didn't have a bottom to it, we can't trust it.
return null;
}
// We start from the bottom since that will have the best location for the owner itself.
for (let i = stackTrace.length - 1; i >= 0; i--) {
const [functionName, fileName, line, col, encLine, encCol] = stackTrace[i];
// Take the first match with a colon in the file name.
if (fileName.indexOf(':') !== -1) {
return [
functionName,
fileName,
// Use enclosing line if available, since that points to the start of the function.
encLine || line,
encCol || col,
];
}
}
return null;
}
export function extractLocationFromComponentStack(
stack: string,
): ReactFunctionLocation | null {
const stackTrace = parseStackTraceFromString(stack, 0);
for (let i = 0; i < stackTrace.length; i++) {
const [functionName, fileName, line, col, encLine, encCol] = stackTrace[i];
// Take the first match with a colon in the file name.
if (fileName.indexOf(':') !== -1) {
return [
functionName,
fileName,
// Use enclosing line if available. (Never the case here because we parse from string.)
encLine || line,
encCol || col,
];
}
}
return null;
}