From 041754697c568b8e00fdc1d519a68e3b1e570c38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Mon, 4 Aug 2025 09:26:12 -0400 Subject: [PATCH 1/7] [DevTools] Only show state for ClassComponents (#34091) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The only thing that uses `memoizedState` as a public API is ClassComponents. Everything else uses it as internals. We shouldn't ever show those internals. Before those internals showed up for example on a suspended Suspense boundary: Screenshot 2025-08-03 at 8 13 37 PM --- packages/react-devtools-shared/src/backend/fiber/renderer.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/react-devtools-shared/src/backend/fiber/renderer.js b/packages/react-devtools-shared/src/backend/fiber/renderer.js index 5abfcc8c8b72a..887fe2b0367db 100644 --- a/packages/react-devtools-shared/src/backend/fiber/renderer.js +++ b/packages/react-devtools-shared/src/backend/fiber/renderer.js @@ -969,7 +969,6 @@ export function attach( } = getInternalReactConstants(version); const { ActivityComponent, - CacheComponent, ClassComponent, ContextConsumer, DehydratedSuspenseComponent, @@ -4618,7 +4617,8 @@ export function attach( // TODO Show custom UI for Cache like we do for Suspense // For now, just hide state data entirely since it's not meant to be inspected. - const showState = !usesHooks && tag !== CacheComponent; + const showState = + tag === ClassComponent || tag === IncompleteClassComponent; const typeSymbol = getTypeSymbol(type); From 8e3db095aa99ffdf8ae2bc3944d1dffcebefbbee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Mon, 4 Aug 2025 09:27:37 -0400 Subject: [PATCH 2/7] [DevTools] Make a non-editable name of KeyValue clickable (#34095) This has been bothering me. You can click the arrow and the value to expand/collapse a KeyValue row but not the name. When the name is not editable it should be clickable. Such as when inspecting a Promise value. --- .../src/devtools/views/Components/KeyValue.js | 51 ++++++++++++------- 1 file changed, 32 insertions(+), 19 deletions(-) diff --git a/packages/react-devtools-shared/src/devtools/views/Components/KeyValue.js b/packages/react-devtools-shared/src/devtools/views/Components/KeyValue.js index af9e4b27e6181..89342ccffc1f1 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/KeyValue.js +++ b/packages/react-devtools-shared/src/devtools/views/Components/KeyValue.js @@ -210,6 +210,13 @@ export default function KeyValue({ canRenameTheCurrentPath = canRenamePathsAtDepth(depth); } + const hasChildren = + typeof value === 'object' && + value !== null && + (canEditValues || + (isArray(value) && value.length > 0) || + Object.entries(value).length > 0); + let renderedName; if (isDirectChildOfAnArray) { if (canDeletePaths) { @@ -218,27 +225,37 @@ export default function KeyValue({ ); } else { renderedName = ( - + {name} {!!hookName && ({hookName})} + : ); } } else if (canRenameTheCurrentPath) { renderedName = ( - + <> + + : + ); } else { renderedName = ( - + {name} {!!hookName && ({hookName})} + : ); } @@ -286,7 +303,6 @@ export default function KeyValue({ style={style}>
{renderedName} -
:
{canEditValues ? (
{renderedName} -
:
{ @@ -365,7 +380,6 @@ export default function KeyValue({
)} {renderedName} -
:
@@ -388,7 +402,6 @@ export default function KeyValue({ } } else { if (isArray(value)) { - const hasChildren = value.length > 0 || canEditValues; const displayName = getMetaValueLabel(value); children = value.map((innerValue, index) => ( @@ -449,12 +462,11 @@ export default function KeyValue({ ref={contextMenuTriggerRef} style={style}> {hasChildren ? ( - + ) : (
)} {renderedName} -
:
@@ -472,7 +484,6 @@ export default function KeyValue({ entries.sort(alphaSortEntries); } - const hasChildren = entries.length > 0 || canEditValues; const displayName = getMetaValueLabel(value); children = entries.map(([key, keyValue]): ReactElement => ( @@ -531,12 +542,11 @@ export default function KeyValue({ ref={contextMenuTriggerRef} style={style}> {hasChildren ? ( - + ) : (
)} {renderedName} -
:
@@ -567,7 +577,10 @@ function DeleteToggle({deletePath, name, path}) { title="Delete entry"> - {name} + + {name} + : + ); } From d3f800d47a9f98f03e44bf30b0c3ad524110ba6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Mon, 4 Aug 2025 09:28:31 -0400 Subject: [PATCH 3/7] [DevTools] Style clickable Owner components with angle brackets and bold (#34096) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We have two type of links that appear next to each other now. One type of link jumps to a Component instance in the DevTools. The other opens a source location - e.g. in your editor. This clarifies that something will jump to the Component instance by marking it as bold and using angle brackets around the name. This can be seen in the "rendered by" list of owner as well as in the async stack traces when the stack was in a different owner than the one currently selected. Screenshot 2025-08-03 at 11 27 38 PM The idea is to connect this styling to the owner stacks using `createTask` where this same pattern occurs (albeit the task name is not clickable): Screenshot 2025-08-03 at 11 23 45 PM In fact, I was going to add the stack traces to the "rendered by" list to give the ability to jump to the JSX location in the owner stack so that it becomes this same view. --- .../__tests__/__e2e__/components.test.js | 8 ++++---- .../src/devtools/views/Components/OwnerView.css | 1 + .../src/devtools/views/Components/OwnerView.js | 2 +- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/react-devtools-inline/__tests__/__e2e__/components.test.js b/packages/react-devtools-inline/__tests__/__e2e__/components.test.js index 9a6b67d8ec65b..ae451d29587b0 100644 --- a/packages/react-devtools-inline/__tests__/__e2e__/components.test.js +++ b/packages/react-devtools-inline/__tests__/__e2e__/components.test.js @@ -52,7 +52,7 @@ test.describe('Components', () => { test('Should allow elements to be inspected', async () => { // Select the first list item in DevTools. - await devToolsUtils.selectElement(page, 'ListItem', 'List\nApp'); + await devToolsUtils.selectElement(page, 'ListItem', '\n'); // Prop names/values may not be editable based on the React version. // If they're not editable, make sure they degrade gracefully @@ -119,7 +119,7 @@ test.describe('Components', () => { runOnlyForReactRange('>=16.8'); // Select the first list item in DevTools. - await devToolsUtils.selectElement(page, 'ListItem', 'List\nApp', true); + await devToolsUtils.selectElement(page, 'ListItem', '\n', true); // Then read the inspected values. const sourceText = await page.evaluate(() => { @@ -142,7 +142,7 @@ test.describe('Components', () => { runOnlyForReactRange('>=16.8'); // Select the first list item in DevTools. - await devToolsUtils.selectElement(page, 'ListItem', 'List\nApp'); + await devToolsUtils.selectElement(page, 'ListItem', '\n'); // Then edit the label prop. await page.evaluate(() => { @@ -177,7 +177,7 @@ test.describe('Components', () => { runOnlyForReactRange('>=16.8'); // Select the List component DevTools. - await devToolsUtils.selectElement(page, 'List', 'App'); + await devToolsUtils.selectElement(page, 'List', ''); // Then click to load and parse hook names. await devToolsUtils.clickButton(page, 'LoadHookNamesButton'); diff --git a/packages/react-devtools-shared/src/devtools/views/Components/OwnerView.css b/packages/react-devtools-shared/src/devtools/views/Components/OwnerView.css index b4e5cd157f3c4..985fe4b9fb5d5 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/OwnerView.css +++ b/packages/react-devtools-shared/src/devtools/views/Components/OwnerView.css @@ -2,6 +2,7 @@ color: var(--color-component-name); font-family: var(--font-family-monospace); font-size: var(--font-size-monospace-normal); + font-weight: bold; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; diff --git a/packages/react-devtools-shared/src/devtools/views/Components/OwnerView.js b/packages/react-devtools-shared/src/devtools/views/Components/OwnerView.js index 561e8a6651362..b6e7a7bb1c9f4 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/OwnerView.js +++ b/packages/react-devtools-shared/src/devtools/views/Components/OwnerView.js @@ -59,7 +59,7 @@ export default function OwnerView({ - {displayName} + {'<' + displayName + '>'} Date: Mon, 4 Aug 2025 09:37:46 -0400 Subject: [PATCH 4/7] [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. --- .../src/__tests__/utils-test.js | 24 +- .../src/backend/fiber/renderer.js | 12 +- .../src/backend/shared/DevToolsOwnerStack.js | 5 +- .../src/backend/utils/index.js | 183 ---------- .../src/backend/utils/parseStackTrace.js | 331 ++++++++++++++++++ 5 files changed, 351 insertions(+), 204 deletions(-) create mode 100644 packages/react-devtools-shared/src/backend/utils/parseStackTrace.js diff --git a/packages/react-devtools-shared/src/__tests__/utils-test.js b/packages/react-devtools-shared/src/__tests__/utils-test.js index 57865f90f825d..83b31903e06a8 100644 --- a/packages/react-devtools-shared/src/__tests__/utils-test.js +++ b/packages/react-devtools-shared/src/__tests__/utils-test.js @@ -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, diff --git a/packages/react-devtools-shared/src/backend/fiber/renderer.js b/packages/react-devtools-shared/src/backend/fiber/renderer.js index 887fe2b0367db..5ffa5251dcae9 100644 --- a/packages/react-devtools-shared/src/backend/fiber/renderer.js +++ b/packages/react-devtools-shared/src/backend/fiber/renderer.js @@ -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. diff --git a/packages/react-devtools-shared/src/backend/shared/DevToolsOwnerStack.js b/packages/react-devtools-shared/src/backend/shared/DevToolsOwnerStack.js index fdd7bce2f8d9e..36102dcf963be 100644 --- a/packages/react-devtools-shared/src/backend/shared/DevToolsOwnerStack.js +++ b/packages/react-devtools-shared/src/backend/shared/DevToolsOwnerStack.js @@ -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. diff --git a/packages/react-devtools-shared/src/backend/utils/index.js b/packages/react-devtools-shared/src/backend/utils/index.js index 490790e89d9b8..fcb8d448a0c1e 100644 --- a/packages/react-devtools-shared/src/backend/utils/index.js +++ b/packages/react-devtools-shared/src/backend/utils/index.js @@ -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 diff --git a/packages/react-devtools-shared/src/backend/utils/parseStackTrace.js b/packages/react-devtools-shared/src/backend/utils/parseStackTrace.js new file mode 100644 index 0000000000000..92b4156de7fa1 --- /dev/null +++ b/packages/react-devtools-shared/src/backend/utils/parseStackTrace.js @@ -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 === '') { + name = ''; + } else if (name.startsWith('async ')) { + name = name.slice(5); + isAsync = true; + } + let filename = parsed[2] || parsed[5] || ''; + if (filename === '') { + 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 += ''; + } + } + 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() || ''; + 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 === '') { + name = ''; + } + let filename = callSite.getScriptNameOrSourceURL() || ''; + if (filename === '') { + filename = ''; + if (callSite.isEval()) { + const origin = callSite.getEvalOrigin(); + if (origin) { + filename = origin.toString() + ', '; + } + } + } + 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+)|\)\)|(?:async )?(.+):(\d+):(\d+)|\)$/; + +const stackTraceCache: WeakMap = 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; +} From be11cb5c4b36b42dcc4c8bdcbc67d9a9b4ac2e14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Mon, 4 Aug 2025 09:42:48 -0400 Subject: [PATCH 5/7] [DevTools] Tweak the presentation of the Promise value (#34097) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Show the value as "fulfilled: Type" or "rejected: Type" immediately instead of having to expand it twice. We could show all the properties of the object immediately like we do in the Performance Track but it's not always particularly interesting data in the value that isn't already in the header. I also moved it to the end after the stack traces since I think the stack is more interesting but I'm also visually trying to connect the stack trace with the "name" since typically the "name" will come from part of the stack trace. Before: Screenshot 2025-08-03 at 11 39 49 PM After: Screenshot 2025-08-03 at 11 58 35 PM --- .../src/ReactFlightPerformanceTrack.js | 4 +- .../InspectedElementSharedStyles.css | 4 +- .../Components/InspectedElementSuspendedBy.js | 59 +++++++++++-------- .../react-devtools-shared/src/hydration.js | 11 ++++ 4 files changed, 51 insertions(+), 27 deletions(-) diff --git a/packages/react-client/src/ReactFlightPerformanceTrack.js b/packages/react-client/src/ReactFlightPerformanceTrack.js index 2b35e82363e91..717d536dc94a1 100644 --- a/packages/react-client/src/ReactFlightPerformanceTrack.js +++ b/packages/react-client/src/ReactFlightPerformanceTrack.js @@ -490,7 +490,7 @@ export function logComponentAwait( if (typeof value === 'object' && value !== null) { addObjectToProperties(value, properties, 0, ''); } else if (value !== undefined) { - addValueToProperties('Resolved', value, properties, 0, ''); + addValueToProperties('awaited value', value, properties, 0, ''); } const tooltipText = getIOLongName( asyncInfo.awaited, @@ -547,7 +547,7 @@ export function logIOInfoErrored( String(error.message) : // eslint-disable-next-line react-internal/safe-string-coercion String(error); - const properties = [['Rejected', message]]; + const properties = [['rejected with', message]]; const tooltipText = getIOLongName(ioInfo, description, ioInfo.env, rootEnv) + ' Rejected'; debugTask.run( diff --git a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSharedStyles.css b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSharedStyles.css index 41e510b7c181a..ded305bbc66ca 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSharedStyles.css +++ b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSharedStyles.css @@ -97,11 +97,11 @@ } .CollapsableContent { - padding: 0.25rem 0; + margin-top: -0.25rem; } .PreviewContainer { - padding: 0 0.25rem 0.25rem 0.25rem; + padding: 0.25rem; } .TimeBarContainer { diff --git a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSuspendedBy.js b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSuspendedBy.js index 9deddef14b098..c7d0b39df3b83 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSuspendedBy.js +++ b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSuspendedBy.js @@ -107,11 +107,10 @@ function SuspendedByRow({ } const value: any = asyncInfo.awaited.value; - const isErrored = - value !== null && - typeof value === 'object' && - value[meta.name] === 'rejected Thenable'; - + const metaName = + value !== null && typeof value === 'object' ? value[meta.name] : null; + const isFulfilled = metaName === 'fulfilled Thenable'; + const isRejected = metaName === 'rejected Thenable'; return (