[DevTools] Get source location from structured callsites in prepareStackTrace (#33143)
When we get the source location for "View source for this element" we should be using the enclosing function of the callsite of the child. So that we don't just point to some random line within the component. This is similar to the technique in #33136. This technique is now really better than the fake throw technique, when available. So I now favor the owner technique. The only problem it's only available in DEV and only if it has a child that's owned (and not filtered). We could implement this same technique for the error that's thrown in the fake throwing solution. However, we really shouldn't need that at all because for client components we should be able to call `inspect(fn)` at least in Chrome which is even better.
Sebastian Markbåge committed
May 13, 2025 at 12:39 UTC
997c7bc930304142b3af37bcb21599181124aeb4
3 files changed
+90
-15
packages/react-devtools-shared/src/backend/fiber/renderer.js
+12
-14
@@ -50,6 +50,7 @@ import {
50
gt,
51
gte,
52
parseSourceFromComponentStack,
53
+ parseSourceFromOwnerStack,
54
serializeToString,
55
} from 'react-devtools-shared/src/backend/utils';
56
import {
@@ -5805,15 +5806,13 @@ export function attach(
5806
function getSourceForFiberInstance(
5807
fiberInstance: FiberInstance,
5808
): Source | null {
5808
- const unresolvedSource = fiberInstance.source;
5809
- if (
5810
- unresolvedSource !== null &&
5811
- typeof unresolvedSource === 'object' &&
5812
- !isError(unresolvedSource)
5813
- ) {
5814
- // $FlowFixMe: isError should have refined it.
5815
- return unresolvedSource;
5809
+ // Favor the owner source if we have one.
5810
+ const ownerSource = getSourceForInstance(fiberInstance);
5811
+ if (ownerSource !== null) {
5812
+ return ownerSource;
5813
}
5814
+
5815
+ // Otherwise fallback to the throwing trick.
5816
const dispatcherRef = getDispatcherRef(renderer);
5817
const stackFrame =
5818
dispatcherRef == null
@@ -5824,10 +5823,7 @@ export function attach(
5823
dispatcherRef,
5824
);
5825
if (stackFrame === null) {
5827
- // If we don't find a source location by throwing, try to get one
5828
- // from an owned child if possible. This is the same branch as
5829
- // for virtual instances.
5830
- return getSourceForInstance(fiberInstance);
5826
+ return null;
5827
}
5828
const source = parseSourceFromComponentStack(stackFrame);
5829
fiberInstance.source = source;
@@ -5835,7 +5831,7 @@ export function attach(
5831
}
5832
5833
function getSourceForInstance(instance: DevToolsInstance): Source | null {
5838
- let unresolvedSource = instance.source;
5834
+ const unresolvedSource = instance.source;
5835
if (unresolvedSource === null) {
5836
// We don't have any source yet. We can try again later in case an owned child mounts later.
5837
// TODO: We won't have any information here if the child is filtered.
@@ -5848,7 +5844,9 @@ export function attach(
5844
// any intermediate utility functions. This won't point to the top of the component function
5845
// but it's at least somewhere within it.
5846
if (isError(unresolvedSource)) {
5851
- unresolvedSource = formatOwnerStack((unresolvedSource: any));
5847
+ return (instance.source = parseSourceFromOwnerStack(
5848
+ (unresolvedSource: any),
5849
+ ));
5850
}
5851
if (typeof unresolvedSource === 'string') {
5852
const idx = unresolvedSource.lastIndexOf('\n');
packages/react-devtools-shared/src/backend/shared/DevToolsOwnerStack.js
+5
-1
@@ -13,8 +13,12 @@ export function formatOwnerStack(error: Error): string {
13
const prevPrepareStackTrace = Error.prepareStackTrace;
14
// $FlowFixMe[incompatible-type] It does accept undefined.
15
Error.prepareStackTrace = undefined;
16
- let stack = error.stack;
16
+ const stack = error.stack;
17
Error.prepareStackTrace = prevPrepareStackTrace;
18
+ return formatOwnerStackString(stack);
19
+}
20
+
21
+export function formatOwnerStackString(stack: string): string {
22
if (stack.startsWith('Error: react-stack-top-frame\n')) {
23
// V8's default formatting prefixes with the error message which we
24
// don't want/need.
packages/react-devtools-shared/src/backend/utils/index.js
+73
@@ -18,6 +18,8 @@ import type {DehydratedData} from 'react-devtools-shared/src/frontend/types';
18
export {default as formatWithStyles} from './formatWithStyles';
19
export {default as formatConsoleArguments} from './formatConsoleArguments';
20
21
+import {formatOwnerStackString} from '../shared/DevToolsOwnerStack';
22
+
23
// TODO: update this to the first React version that has a corresponding DevTools backend
24
const FIRST_DEVTOOLS_BACKEND_LOCKSTEP_VER = '999.9.9';
25
export function hasAssignedBackend(version?: string): boolean {
@@ -345,6 +347,77 @@ export function parseSourceFromComponentStack(
347
return parseSourceFromFirefoxStack(componentStack);
348
}
349
350
+let collectedLocation: Source | null = null;
351
+
352
+function collectStackTrace(
353
+ error: Error,
354
+ structuredStackTrace: CallSite[],
355
+): string {
356
+ let result: null | Source = null;
357
+ // Collect structured stack traces from the callsites.
358
+ // We mirror how V8 serializes stack frames and how we later parse them.
359
+ for (let i = 0; i < structuredStackTrace.length; i++) {
360
+ const callSite = structuredStackTrace[i];
361
+ if (callSite.getFunctionName() === 'react-stack-bottom-frame') {
362
+ // We pick the last frame that matches before the bottom frame since
363
+ // that will be immediately inside the component as opposed to some helper.
364
+ // If we don't find a bottom frame then we bail to string parsing.
365
+ collectedLocation = result;
366
+ // Skip everything after the bottom frame since it'll be internals.
367
+ break;
368
+ } else {
369
+ const sourceURL = callSite.getScriptNameOrSourceURL();
370
+ const line =
371
+ // $FlowFixMe[prop-missing]
372
+ typeof callSite.getEnclosingLineNumber === 'function'
373
+ ? (callSite: any).getEnclosingLineNumber()
374
+ : callSite.getLineNumber();
375
+ const col =
376
+ // $FlowFixMe[prop-missing]
377
+ typeof callSite.getEnclosingColumnNumber === 'function'
378
+ ? (callSite: any).getEnclosingColumnNumber()
379
+ : callSite.getLineNumber();
380
+ if (!sourceURL || !line || !col) {
381
+ // Skip eval etc. without source url. They don't have location.
382
+ continue;
383
+ }
384
+ result = {
385
+ sourceURL,
386
+ line: line,
387
+ column: col,
388
+ };
389
+ }
390
+ }
391
+ // At the same time we generate a string stack trace just in case someone
392
+ // else reads it.
393
+ const name = error.name || 'Error';
394
+ const message = error.message || '';
395
+ let stack = name + ': ' + message;
396
+ for (let i = 0; i < structuredStackTrace.length; i++) {
397
+ stack += '\n at ' + structuredStackTrace[i].toString();
398
+ }
399
+ return stack;
400
+}
401
+
402
+export function parseSourceFromOwnerStack(error: Error): Source | null {
403
+ // First attempt to collected the structured data using prepareStackTrace.
404
+ collectedLocation = null;
405
+ const previousPrepare = Error.prepareStackTrace;
406
+ Error.prepareStackTrace = collectStackTrace;
407
+ let stack;
408
+ try {
409
+ stack = error.stack;
410
+ } finally {
411
+ Error.prepareStackTrace = previousPrepare;
412
+ }
413
+ if (collectedLocation !== null) {
414
+ return collectedLocation;
415
+ }
416
+ // Fallback to parsing the string form.
417
+ const componentStack = formatOwnerStackString(stack);
418
+ return parseSourceFromComponentStack(componentStack);
419
+}
420
+
421
// 0.123456789 => 0.123
422
// Expects high-resolution timestamp in milliseconds, like from performance.now()
423
// Mainly used for optimizing the size of serialized profiling payload