From 9fd466eaa322f93426c25dd9b8b5d586fd68a795 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Sun, 3 Aug 2025 22:12:23 -0400 Subject: [PATCH 1/6] Parse the virtual source location stacks instead of using server stacks --- .../src/backend/fiber/renderer.js | 17 +++++++++++++++-- 1 file changed, 15 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 5ffa5251dca..79456e8b01b 100644 --- a/packages/react-devtools-shared/src/backend/fiber/renderer.js +++ b/packages/react-devtools-shared/src/backend/fiber/renderer.js @@ -59,6 +59,7 @@ import { import { extractLocationFromComponentStack, extractLocationFromOwnerStack, + parseStackTrace, } from 'react-devtools-shared/src/backend/utils/parseStackTrace'; import { cleanForBridge, @@ -4518,14 +4519,26 @@ export function attach( ioOwnerInstance === null ? null : instanceToSerializedElement(ioOwnerInstance), - stack: ioInfo.stack == null ? null : ioInfo.stack, + stack: + ioInfo.debugStack == null + ? null + : // While we have a ReactStackTrace on ioInfo.stack, that will point to the location on + // the server. We need a location that points to the virtual source on the client which + // we can then use to source map to the original location. + parseStackTrace(ioInfo.debugStack, 1), }, env: asyncInfo.env == null ? null : asyncInfo.env, owner: awaitOwnerInstance === null ? null : instanceToSerializedElement(awaitOwnerInstance), - stack: asyncInfo.stack == null ? null : asyncInfo.stack, + stack: + asyncInfo.debugStack == null + ? null + : // While we have a ReactStackTrace on ioInfo.stack, that will point to the location on + // the server. We need a location that points to the virtual source on the client which + // we can then use to source map to the original location. + parseStackTrace(asyncInfo.debugStack, 1), }; } From 29ce8495c32c36cea9fcec82e6f6d2422ca9d580 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Sun, 3 Aug 2025 22:27:30 -0400 Subject: [PATCH 2/6] Symbolicate callsites in stack trace view --- .../views/Components/StackTraceView.js | 28 +++++++++++++++++-- .../src/symbolicateSource.js | 2 +- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/packages/react-devtools-shared/src/devtools/views/Components/StackTraceView.js b/packages/react-devtools-shared/src/devtools/views/Components/StackTraceView.js index 62cf911b9fe..fccc94846f7 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/StackTraceView.js +++ b/packages/react-devtools-shared/src/devtools/views/Components/StackTraceView.js @@ -8,12 +8,21 @@ */ import * as React from 'react'; +import {use, useContext} from 'react'; import useOpenResource from '../useOpenResource'; import styles from './StackTraceView.css'; -import type {ReactStackTrace, ReactCallSite} from 'shared/ReactTypes'; +import type { + ReactStackTrace, + ReactCallSite, + ReactFunctionLocation, +} from 'shared/ReactTypes'; + +import FetchFileWithCachingContext from './FetchFileWithCachingContext'; + +import {symbolicateSourceWithCache} from 'react-devtools-shared/src/symbolicateSource'; import formatLocationForDisplay from './formatLocationForDisplay'; @@ -22,7 +31,22 @@ type CallSiteViewProps = { }; export function CallSiteView({callSite}: CallSiteViewProps): React.Node { - const symbolicatedCallSite: null | ReactCallSite = null; // TODO + const fetchFileWithCaching = useContext(FetchFileWithCachingContext); + + const [, virtualURL, virtualLine, virtualColumn] = callSite; + + const symbolicatedCallSite: null | ReactFunctionLocation = + fetchFileWithCaching !== null + ? use( + symbolicateSourceWithCache( + fetchFileWithCaching, + virtualURL, + virtualLine, + virtualColumn, + ), + ) + : null; + const [linkIsEnabled, viewSource] = useOpenResource( callSite, symbolicatedCallSite, diff --git a/packages/react-devtools-shared/src/symbolicateSource.js b/packages/react-devtools-shared/src/symbolicateSource.js index 62a92fd9adf..e5f469a21a5 100644 --- a/packages/react-devtools-shared/src/symbolicateSource.js +++ b/packages/react-devtools-shared/src/symbolicateSource.js @@ -17,7 +17,7 @@ const symbolicationCache: Map< Promise, > = new Map(); -export async function symbolicateSourceWithCache( +export function symbolicateSourceWithCache( fetchFileWithCaching: FetchFileWithCaching, sourceURL: string, line: number, // 1-based From e98cc3b4234ecd96d0bfe220e9e9d6cc51363bcc Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Sun, 3 Aug 2025 22:33:05 -0400 Subject: [PATCH 3/6] Use function name from stack if we don't have one in the source map This is always the case atm since we're not parsing function names from source maps yet. --- .../src/devtools/views/Components/StackTraceView.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/react-devtools-shared/src/devtools/views/Components/StackTraceView.js b/packages/react-devtools-shared/src/devtools/views/Components/StackTraceView.js index fccc94846f7..4352ad6a827 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/StackTraceView.js +++ b/packages/react-devtools-shared/src/devtools/views/Components/StackTraceView.js @@ -33,7 +33,8 @@ type CallSiteViewProps = { export function CallSiteView({callSite}: CallSiteViewProps): React.Node { const fetchFileWithCaching = useContext(FetchFileWithCachingContext); - const [, virtualURL, virtualLine, virtualColumn] = callSite; + const [virtualFunctionName, virtualURL, virtualLine, virtualColumn] = + callSite; const symbolicatedCallSite: null | ReactFunctionLocation = fetchFileWithCaching !== null @@ -55,7 +56,7 @@ export function CallSiteView({callSite}: CallSiteViewProps): React.Node { symbolicatedCallSite !== null ? symbolicatedCallSite : callSite; return (
- {functionName} + {functionName || virtualFunctionName} {' @ '} Date: Mon, 4 Aug 2025 10:18:29 -0400 Subject: [PATCH 4/6] Prefer I/O stack and show await stack after only if it's a different owner --- .../InspectedElementSharedStyles.css | 7 ++ .../Components/InspectedElementSuspendedBy.js | 73 ++++++++++++------- 2 files changed, 55 insertions(+), 25 deletions(-) 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 ded305bbc66..0fb5107361c 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSharedStyles.css +++ b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSharedStyles.css @@ -123,3 +123,10 @@ .TimeBarSpanErrored { background-color: var(--color-timespan-background-errored); } + +.SmallHeader { + font-family: var(--font-family-monospace); + font-size: var(--font-size-monospace-normal); + padding-left: 1.25rem; + margin-top: 0.25rem; +} 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 c7d0b39df3b..79fdbd1a361 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSuspendedBy.js +++ b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSuspendedBy.js @@ -80,21 +80,13 @@ function SuspendedByRow({ maxTime, }: RowProps) { const [isOpen, setIsOpen] = useState(false); - const name = asyncInfo.awaited.name; - const description = asyncInfo.awaited.description; + const ioInfo = asyncInfo.awaited; + const name = ioInfo.name; + const description = ioInfo.description; const longName = description === '' ? name : name + ' (' + description + ')'; const shortDescription = getShortDescription(name, description); - let stack; - let owner; - if (asyncInfo.stack === null || asyncInfo.stack.length === 0) { - stack = asyncInfo.awaited.stack; - owner = asyncInfo.awaited.owner; - } else { - stack = asyncInfo.stack; - owner = asyncInfo.owner; - } - const start = asyncInfo.awaited.start; - const end = asyncInfo.awaited.end; + const start = ioInfo.start; + const end = ioInfo.end; const timeScale = 100 / (maxTime - minTime); let left = (start - minTime) * timeScale; let width = (end - start) * timeScale; @@ -106,7 +98,19 @@ function SuspendedByRow({ } } - const value: any = asyncInfo.awaited.value; + const ioOwner = ioInfo.owner; + const asyncOwner = asyncInfo.owner; + const showIOStack = ioInfo.stack !== null && ioInfo.stack.length !== 0; + // Only show the awaited stack if the I/O started in a different owner + // than where it was awaited. If it's started by the same component it's + // probably easy enough to infer and less noise in the common case. + const showAwaitStack = + !showIOStack || + (ioOwner === null + ? asyncOwner !== null + : asyncOwner === null || ioOwner.id !== asyncOwner.id); + + const value: any = ioInfo.value; const metaName = value !== null && typeof value === 'object' ? value[meta.name] : null; const isFulfilled = metaName === 'fulfilled Thenable'; @@ -146,20 +150,39 @@ function SuspendedByRow({ {isOpen && (
- {stack !== null && stack.length > 0 && ( - - )} - {owner !== null && owner.id !== inspectedElement.id ? ( + {showIOStack && } + {(showIOStack || !showAwaitStack) && + ioOwner !== null && + ioOwner.id !== inspectedElement.id ? ( ) : null} + {showAwaitStack ? ( + <> +
awaited at:
+ {asyncInfo.stack !== null && asyncInfo.stack.length > 0 && ( + + )} + {asyncOwner !== null && asyncOwner.id !== inspectedElement.id ? ( + + ) : null} + + ) : null}
Date: Mon, 4 Aug 2025 11:14:00 -0400 Subject: [PATCH 5/6] Serialize Suspense suspendedBy in helper We need this so that we can pass the firstInstance as the instance that was depending on the data. That's because it is likely to have the owner above it but the Suspense boundary as the instance might not have the owner above. --- .../src/backend/fiber/renderer.js | 21 +++++++++---------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/packages/react-devtools-shared/src/backend/fiber/renderer.js b/packages/react-devtools-shared/src/backend/fiber/renderer.js index 79456e8b01b..ef1c41b2bdd 100644 --- a/packages/react-devtools-shared/src/backend/fiber/renderer.js +++ b/packages/react-devtools-shared/src/backend/fiber/renderer.js @@ -4442,10 +4442,10 @@ export function attach( function getSuspendedByOfSuspenseNode( suspenseNode: SuspenseNode, - ): Array { + ): Array { // Collect all ReactAsyncInfo that was suspending this SuspenseNode but // isn't also in any parent set. - const result: Array = []; + const result: Array = []; if (!suspenseNode.hasUniqueSuspenders) { return result; } @@ -4470,7 +4470,8 @@ export function attach( ioInfo, ); if (asyncInfo !== null) { - result.push(asyncInfo); + const index = result.length; + result.push(serializeAsyncInfo(asyncInfo, index, firstInstance)); } } }); @@ -4844,8 +4845,11 @@ export function attach( // In this case, this becomes associated with the Client/Host Component where as normally // you'd expect these to be associated with the Server Component that awaited the data. // TODO: Prepend other suspense sources like css, images and use(). - fiberInstance.suspendedBy; - + fiberInstance.suspendedBy === null + ? [] + : fiberInstance.suspendedBy.map((info, index) => + serializeAsyncInfo(info, index, fiberInstance), + ); return { id: fiberInstance.id, @@ -4902,12 +4906,7 @@ export function attach( ? [] : Array.from(componentLogsEntry.warnings.entries()), - suspendedBy: - suspendedBy === null - ? [] - : suspendedBy.map((info, index) => - serializeAsyncInfo(info, index, fiberInstance), - ), + suspendedBy: suspendedBy, // List of owners owners, From 82559925671d0e72a15576f76cbe517746232fcb Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Mon, 4 Aug 2025 11:56:13 -0400 Subject: [PATCH 6/6] Infer a stack trace from the client if there is no await stack/owner This lets us use the parent as the owner or the JSX callsite if the await had no owner/stack otherwise because it was implicit. --- .../src/backend/fiber/renderer.js | 69 ++++++++++++++++--- 1 file changed, 58 insertions(+), 11 deletions(-) diff --git a/packages/react-devtools-shared/src/backend/fiber/renderer.js b/packages/react-devtools-shared/src/backend/fiber/renderer.js index ef1c41b2bdd..a8fe8f24283 100644 --- a/packages/react-devtools-shared/src/backend/fiber/renderer.js +++ b/packages/react-devtools-shared/src/backend/fiber/renderer.js @@ -4488,10 +4488,63 @@ export function attach( parentInstance, ioInfo.owner, ); - const awaitOwnerInstance = findNearestOwnerInstance( - parentInstance, - asyncInfo.owner, - ); + let awaitStack = + asyncInfo.debugStack == null + ? null + : // While we have a ReactStackTrace on ioInfo.stack, that will point to the location on + // the server. We need a location that points to the virtual source on the client which + // we can then use to source map to the original location. + parseStackTrace(asyncInfo.debugStack, 1); + let awaitOwnerInstance: null | FiberInstance | VirtualInstance; + if ( + asyncInfo.owner == null && + (awaitStack === null || awaitStack.length === 0) + ) { + // We had no owner nor stack for the await. This can happen if you render it as a child + // or throw a Promise. Replace it with the parent as the await. + awaitStack = null; + awaitOwnerInstance = + parentInstance.kind === FILTERED_FIBER_INSTANCE ? null : parentInstance; + if ( + parentInstance.kind === FIBER_INSTANCE || + parentInstance.kind === FILTERED_FIBER_INSTANCE + ) { + const fiber = parentInstance.data; + switch (fiber.tag) { + case ClassComponent: + case FunctionComponent: + case IncompleteClassComponent: + case IncompleteFunctionComponent: + case IndeterminateComponent: + case MemoComponent: + case SimpleMemoComponent: + // If we awaited in the child position of a component, then the best stack would be the + // return callsite but we don't have that available so instead we skip. The callsite of + // the JSX would be misleading in this case. The same thing happens with throw-a-Promise. + break; + default: + // If we awaited by passing a Promise to a built-in element, then the JSX callsite is a + // good stack trace to use for the await. + if ( + fiber._debugOwner != null && + fiber._debugStack != null && + typeof fiber._debugStack !== 'string' + ) { + awaitStack = parseStackTrace(fiber._debugStack, 1); + awaitOwnerInstance = findNearestOwnerInstance( + parentInstance, + fiber._debugOwner, + ); + } + } + } + } else { + awaitOwnerInstance = findNearestOwnerInstance( + parentInstance, + asyncInfo.owner, + ); + } + const value: any = ioInfo.value; let resolvedValue = undefined; if ( @@ -4533,13 +4586,7 @@ export function attach( awaitOwnerInstance === null ? null : instanceToSerializedElement(awaitOwnerInstance), - stack: - asyncInfo.debugStack == null - ? null - : // While we have a ReactStackTrace on ioInfo.stack, that will point to the location on - // the server. We need a location that points to the virtual source on the client which - // we can then use to source map to the original location. - parseStackTrace(asyncInfo.debugStack, 1), + stack: awaitStack, }; }