Skip to content

Commit 557745e

Browse files
authored
[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.
1 parent d3f800d commit 557745e

File tree

5 files changed

+351
-204
lines changed

5 files changed

+351
-204
lines changed

packages/react-devtools-shared/src/__tests__/utils-test.js

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,8 @@ import {
1919
formatWithStyles,
2020
gt,
2121
gte,
22-
parseSourceFromComponentStack,
2322
} from 'react-devtools-shared/src/backend/utils';
23+
import {extractLocationFromComponentStack} from 'react-devtools-shared/src/backend/utils/parseStackTrace';
2424
import {
2525
REACT_SUSPENSE_LIST_TYPE as SuspenseList,
2626
REACT_STRICT_MODE_TYPE as StrictMode,
@@ -306,20 +306,20 @@ describe('utils', () => {
306306
});
307307
});
308308

309-
describe('parseSourceFromComponentStack', () => {
309+
describe('extractLocationFromComponentStack', () => {
310310
it('should return null if passed empty string', () => {
311-
expect(parseSourceFromComponentStack('')).toEqual(null);
311+
expect(extractLocationFromComponentStack('')).toEqual(null);
312312
});
313313

314314
it('should construct the source from the first frame if available', () => {
315315
expect(
316-
parseSourceFromComponentStack(
316+
extractLocationFromComponentStack(
317317
'at l (https://react.dev/_next/static/chunks/main-78a3b4c2aa4e4850.js:1:10389)\n' +
318318
'at f (https://react.dev/_next/static/chunks/pages/%5B%5B...markdownPath%5D%5D-af2ed613aedf1d57.js:1:8519)\n' +
319319
'at r (https://react.dev/_next/static/chunks/pages/_app-dd0b77ea7bd5b246.js:1:498)\n',
320320
),
321321
).toEqual([
322-
'',
322+
'l',
323323
'https://react.dev/_next/static/chunks/main-78a3b4c2aa4e4850.js',
324324
1,
325325
10389,
@@ -328,7 +328,7 @@ describe('utils', () => {
328328

329329
it('should construct the source from highest available frame', () => {
330330
expect(
331-
parseSourceFromComponentStack(
331+
extractLocationFromComponentStack(
332332
' at Q\n' +
333333
' at a\n' +
334334
' at m (https://react.dev/_next/static/chunks/848-122f91e9565d9ffa.js:5:9236)\n' +
@@ -342,7 +342,7 @@ describe('utils', () => {
342342
' at f (https://react.dev/_next/static/chunks/pages/%5B%5B...markdownPath%5D%5D-af2ed613aedf1d57.js:1:8519)',
343343
),
344344
).toEqual([
345-
'',
345+
'm',
346346
'https://react.dev/_next/static/chunks/848-122f91e9565d9ffa.js',
347347
5,
348348
9236,
@@ -351,7 +351,7 @@ describe('utils', () => {
351351

352352
it('should construct the source from frame, which has only url specified', () => {
353353
expect(
354-
parseSourceFromComponentStack(
354+
extractLocationFromComponentStack(
355355
' at Q\n' +
356356
' at a\n' +
357357
' at https://react.dev/_next/static/chunks/848-122f91e9565d9ffa.js:5:9236\n',
@@ -366,13 +366,13 @@ describe('utils', () => {
366366

367367
it('should parse sourceURL correctly if it includes parentheses', () => {
368368
expect(
369-
parseSourceFromComponentStack(
369+
extractLocationFromComponentStack(
370370
'at HotReload (webpack-internal:///(app-pages-browser)/./node_modules/next/dist/client/components/react-dev-overlay/hot-reloader-client.js:307:11)\n' +
371371
' at Router (webpack-internal:///(app-pages-browser)/./node_modules/next/dist/client/components/app-router.js:181:11)\n' +
372372
' at ErrorBoundaryHandler (webpack-internal:///(app-pages-browser)/./node_modules/next/dist/client/components/error-boundary.js:114:9)',
373373
),
374374
).toEqual([
375-
'',
375+
'HotReload',
376376
'webpack-internal:///(app-pages-browser)/./node_modules/next/dist/client/components/react-dev-overlay/hot-reloader-client.js',
377377
307,
378378
11,
@@ -381,13 +381,13 @@ describe('utils', () => {
381381

382382
it('should support Firefox stack', () => {
383383
expect(
384-
parseSourceFromComponentStack(
384+
extractLocationFromComponentStack(
385385
'tt@https://react.dev/_next/static/chunks/363-3c5f1b553b6be118.js:1:165558\n' +
386386
'f@https://react.dev/_next/static/chunks/pages/%5B%5B...markdownPath%5D%5D-af2ed613aedf1d57.js:1:8535\n' +
387387
'r@https://react.dev/_next/static/chunks/pages/_app-dd0b77ea7bd5b246.js:1:513',
388388
),
389389
).toEqual([
390-
'',
390+
'tt',
391391
'https://react.dev/_next/static/chunks/363-3c5f1b553b6be118.js',
392392
1,
393393
165558,

packages/react-devtools-shared/src/backend/fiber/renderer.js

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -54,10 +54,12 @@ import {
5454
formatDurationToMicrosecondsGranularity,
5555
gt,
5656
gte,
57-
parseSourceFromComponentStack,
58-
parseSourceFromOwnerStack,
5957
serializeToString,
6058
} from 'react-devtools-shared/src/backend/utils';
59+
import {
60+
extractLocationFromComponentStack,
61+
extractLocationFromOwnerStack,
62+
} from 'react-devtools-shared/src/backend/utils/parseStackTrace';
6163
import {
6264
cleanForBridge,
6365
copyWithDelete,
@@ -6340,7 +6342,7 @@ export function attach(
63406342
if (stackFrame === null) {
63416343
return null;
63426344
}
6343-
const source = parseSourceFromComponentStack(stackFrame);
6345+
const source = extractLocationFromComponentStack(stackFrame);
63446346
fiberInstance.source = source;
63456347
return source;
63466348
}
@@ -6369,15 +6371,15 @@ export function attach(
63696371
// any intermediate utility functions. This won't point to the top of the component function
63706372
// but it's at least somewhere within it.
63716373
if (isError(unresolvedSource)) {
6372-
return (instance.source = parseSourceFromOwnerStack(
6374+
return (instance.source = extractLocationFromOwnerStack(
63736375
(unresolvedSource: any),
63746376
));
63756377
}
63766378
if (typeof unresolvedSource === 'string') {
63776379
const idx = unresolvedSource.lastIndexOf('\n');
63786380
const lastLine =
63796381
idx === -1 ? unresolvedSource : unresolvedSource.slice(idx + 1);
6380-
return (instance.source = parseSourceFromComponentStack(lastLine));
6382+
return (instance.source = extractLocationFromComponentStack(lastLine));
63816383
}
63826384

63836385
// $FlowFixMe: refined.

packages/react-devtools-shared/src/backend/shared/DevToolsOwnerStack.js

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,9 @@ export function formatOwnerStack(error: Error): string {
1313
const prevPrepareStackTrace = Error.prepareStackTrace;
1414
// $FlowFixMe[incompatible-type] It does accept undefined.
1515
Error.prepareStackTrace = undefined;
16-
const stack = error.stack;
16+
let stack = error.stack;
1717
Error.prepareStackTrace = prevPrepareStackTrace;
18-
return formatOwnerStackString(stack);
19-
}
2018

21-
export function formatOwnerStackString(stack: string): string {
2219
if (stack.startsWith('Error: react-stack-top-frame\n')) {
2320
// V8's default formatting prefixes with the error message which we
2421
// don't want/need.

packages/react-devtools-shared/src/backend/utils/index.js

Lines changed: 0 additions & 183 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,11 @@ import {compareVersions} from 'compare-versions';
1212
import {dehydrate} from 'react-devtools-shared/src/hydration';
1313
import isArray from 'shared/isArray';
1414

15-
import type {ReactFunctionLocation} from 'shared/ReactTypes';
1615
import type {DehydratedData} from 'react-devtools-shared/src/frontend/types';
1716

1817
export {default as formatWithStyles} from './formatWithStyles';
1918
export {default as formatConsoleArguments} from './formatConsoleArguments';
2019

21-
import {formatOwnerStackString} from '../shared/DevToolsOwnerStack';
22-
2320
// TODO: update this to the first React version that has a corresponding DevTools backend
2421
const FIRST_DEVTOOLS_BACKEND_LOCKSTEP_VER = '999.9.9';
2522
export function hasAssignedBackend(version?: string): boolean {
@@ -258,186 +255,6 @@ export const isReactNativeEnvironment = (): boolean => {
258255
return window.document == null;
259256
};
260257

261-
function extractLocation(url: string): null | {
262-
functionName?: string,
263-
sourceURL: string,
264-
line?: string,
265-
column?: string,
266-
} {
267-
if (url.indexOf(':') === -1) {
268-
return null;
269-
}
270-
271-
// remove any parentheses from start and end
272-
const withoutParentheses = url.replace(/^\(+/, '').replace(/\)+$/, '');
273-
const locationParts = /(at )?(.+?)(?::(\d+))?(?::(\d+))?$/.exec(
274-
withoutParentheses,
275-
);
276-
277-
if (locationParts == null) {
278-
return null;
279-
}
280-
281-
const functionName = ''; // TODO: Parse this in the regexp.
282-
const [, , sourceURL, line, column] = locationParts;
283-
return {functionName, sourceURL, line, column};
284-
}
285-
286-
const CHROME_STACK_REGEXP = /^\s*at .*(\S+:\d+|\(native\))/m;
287-
function parseSourceFromChromeStack(
288-
stack: string,
289-
): ReactFunctionLocation | null {
290-
const frames = stack.split('\n');
291-
// eslint-disable-next-line no-for-of-loops/no-for-of-loops
292-
for (const frame of frames) {
293-
const sanitizedFrame = frame.trim();
294-
295-
const locationInParenthesesMatch = sanitizedFrame.match(/ (\(.+\)$)/);
296-
const possibleLocation = locationInParenthesesMatch
297-
? locationInParenthesesMatch[1]
298-
: sanitizedFrame;
299-
300-
const location = extractLocation(possibleLocation);
301-
// Continue the search until at least sourceURL is found
302-
if (location == null) {
303-
continue;
304-
}
305-
306-
const {functionName, sourceURL, line = '1', column = '1'} = location;
307-
308-
return [
309-
functionName || '',
310-
sourceURL,
311-
parseInt(line, 10),
312-
parseInt(column, 10),
313-
];
314-
}
315-
316-
return null;
317-
}
318-
319-
function parseSourceFromFirefoxStack(
320-
stack: string,
321-
): ReactFunctionLocation | null {
322-
const frames = stack.split('\n');
323-
// eslint-disable-next-line no-for-of-loops/no-for-of-loops
324-
for (const frame of frames) {
325-
const sanitizedFrame = frame.trim();
326-
const frameWithoutFunctionName = sanitizedFrame.replace(
327-
/((.*".+"[^@]*)?[^@]*)(?:@)/,
328-
'',
329-
);
330-
331-
const location = extractLocation(frameWithoutFunctionName);
332-
// Continue the search until at least sourceURL is found
333-
if (location == null) {
334-
continue;
335-
}
336-
337-
const {functionName, sourceURL, line = '1', column = '1'} = location;
338-
339-
return [
340-
functionName || '',
341-
sourceURL,
342-
parseInt(line, 10),
343-
parseInt(column, 10),
344-
];
345-
}
346-
347-
return null;
348-
}
349-
350-
export function parseSourceFromComponentStack(
351-
componentStack: string,
352-
): ReactFunctionLocation | null {
353-
if (componentStack.match(CHROME_STACK_REGEXP)) {
354-
return parseSourceFromChromeStack(componentStack);
355-
}
356-
357-
return parseSourceFromFirefoxStack(componentStack);
358-
}
359-
360-
let collectedLocation: ReactFunctionLocation | null = null;
361-
362-
function collectStackTrace(
363-
error: Error,
364-
structuredStackTrace: CallSite[],
365-
): string {
366-
let result: null | ReactFunctionLocation = null;
367-
// Collect structured stack traces from the callsites.
368-
// We mirror how V8 serializes stack frames and how we later parse them.
369-
for (let i = 0; i < structuredStackTrace.length; i++) {
370-
const callSite = structuredStackTrace[i];
371-
const name = callSite.getFunctionName();
372-
if (
373-
name != null &&
374-
(name.includes('react_stack_bottom_frame') ||
375-
name.includes('react-stack-bottom-frame'))
376-
) {
377-
// We pick the last frame that matches before the bottom frame since
378-
// that will be immediately inside the component as opposed to some helper.
379-
// If we don't find a bottom frame then we bail to string parsing.
380-
collectedLocation = result;
381-
// Skip everything after the bottom frame since it'll be internals.
382-
break;
383-
} else {
384-
const sourceURL = callSite.getScriptNameOrSourceURL();
385-
const line =
386-
// $FlowFixMe[prop-missing]
387-
typeof callSite.getEnclosingLineNumber === 'function'
388-
? (callSite: any).getEnclosingLineNumber()
389-
: callSite.getLineNumber();
390-
const col =
391-
// $FlowFixMe[prop-missing]
392-
typeof callSite.getEnclosingColumnNumber === 'function'
393-
? (callSite: any).getEnclosingColumnNumber()
394-
: callSite.getColumnNumber();
395-
if (!sourceURL || !line || !col) {
396-
// Skip eval etc. without source url. They don't have location.
397-
continue;
398-
}
399-
result = [name, sourceURL, line, col];
400-
}
401-
}
402-
// At the same time we generate a string stack trace just in case someone
403-
// else reads it.
404-
const name = error.name || 'Error';
405-
const message = error.message || '';
406-
let stack = name + ': ' + message;
407-
for (let i = 0; i < structuredStackTrace.length; i++) {
408-
stack += '\n at ' + structuredStackTrace[i].toString();
409-
}
410-
return stack;
411-
}
412-
413-
export function parseSourceFromOwnerStack(
414-
error: Error,
415-
): ReactFunctionLocation | null {
416-
// First attempt to collected the structured data using prepareStackTrace.
417-
collectedLocation = null;
418-
const previousPrepare = Error.prepareStackTrace;
419-
Error.prepareStackTrace = collectStackTrace;
420-
let stack;
421-
try {
422-
stack = error.stack;
423-
} catch (e) {
424-
// $FlowFixMe[incompatible-type] It does accept undefined.
425-
Error.prepareStackTrace = undefined;
426-
stack = error.stack;
427-
} finally {
428-
Error.prepareStackTrace = previousPrepare;
429-
}
430-
if (collectedLocation !== null) {
431-
return collectedLocation;
432-
}
433-
if (stack == null) {
434-
return null;
435-
}
436-
// Fallback to parsing the string form.
437-
const componentStack = formatOwnerStackString(stack);
438-
return parseSourceFromComponentStack(componentStack);
439-
}
440-
441258
// 0.123456789 => 0.123
442259
// Expects high-resolution timestamp in milliseconds, like from performance.now()
443260
// Mainly used for optimizing the size of serialized profiling payload

0 commit comments

Comments
 (0)