From 0825d019be044b24504be494620022d972eeb160 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Wed, 6 Aug 2025 11:21:01 -0400 Subject: [PATCH] [DevTools] Prefer I/O stack and show await stack after only if it's a different owner (#34101) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stacked on #34094. This shows the I/O stack if available. If it's not available or if it has a different owner (like if it was passed in) then we show the `"awaited at:"` stack below it so you can see where it started and where it was awaited. If it's the same owner this tends to be unnecessary noise. We could maybe be smarter if the stacks are very different then you might want to show both even with the same owner. Screenshot 2025-08-04 at 11 57 28 AM Additionally, this adds an inferred await if there's no owner and no stack for the await. The inferred await of a function/class component is just the owner. No stack. Because the stack trace would be the return value. This will also be the case if you use throw-a-Promise. The inferred await in the child position of a built-in is the JSX location of that await like if you pass a promise to a child. This inference already happens when you pass a Promise from RSC so in this case it already has an await - so this is mainly for client promises. --- .../src/backend/fiber/renderer.js | 93 +++++++++++++++---- .../InspectedElementSharedStyles.css | 7 ++ .../Components/InspectedElementSuspendedBy.js | 73 ++++++++++----- .../views/Components/StackTraceView.js | 31 ++++++- .../src/symbolicateSource.js | 2 +- 5 files changed, 160 insertions(+), 46 deletions(-) diff --git a/packages/react-devtools-shared/src/backend/fiber/renderer.js b/packages/react-devtools-shared/src/backend/fiber/renderer.js index 7e9f46dc0c..189d504ad5 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, @@ -4746,10 +4747,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; } @@ -4774,7 +4775,8 @@ export function attach( ioInfo, ); if (asyncInfo !== null) { - result.push(asyncInfo); + const index = result.length; + result.push(serializeAsyncInfo(asyncInfo, index, firstInstance)); } } }); @@ -4791,10 +4793,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 ( @@ -4823,14 +4878,20 @@ 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: awaitStack, }; } @@ -5136,8 +5197,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, @@ -5194,12 +5258,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, 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 ded305bbc6..0fb5107361 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 c7d0b39df3..79fdbd1a36 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}
- {functionName} + {functionName || virtualFunctionName} {' @ '} , > = new Map(); -export async function symbolicateSourceWithCache( +export function symbolicateSourceWithCache( fetchFileWithCaching: FetchFileWithCaching, sourceURL: string, line: number, // 1-based