@samitouri / QOS-React / commits / 997c7bc930

[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