From 2c9a42dfd7dc6f26be9694ffc6cb2ebf8ef8472b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Mon, 11 Aug 2025 12:28:10 -0400 Subject: [PATCH] [DevTools] If the await doesn't have a stack use the stack from use() if any (#34162) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stacked on #34148. This picks up the stack for the await from the `use()` Hook if one was used to get this async info. When you select a component that used hooks, we already collect this information. If you select a Suspense boundary, this lazily invokes the first component that awaited this data to inspects its hooks and produce a stack trace for the use(). When all we have for the name is "Promise" I also use the name of the first callsite in the stack trace if there's more than one. Which in practice will be the name of the custom Hook that called it. Ideally we'd use source mapping and ignore listing for this but that would require suspending the display. We could maybe make the SuspendedByRow wrapped in a Suspense boundary for this case. Screenshot 2025-08-10 at 10 07 55 PM --- .../src/backend/fiber/renderer.js | 162 ++++++++++++++---- .../Components/InspectedElementSuspendedBy.js | 16 +- 2 files changed, 146 insertions(+), 32 deletions(-) diff --git a/packages/react-devtools-shared/src/backend/fiber/renderer.js b/packages/react-devtools-shared/src/backend/fiber/renderer.js index 41436e8a6e..7634c6c472 100644 --- a/packages/react-devtools-shared/src/backend/fiber/renderer.js +++ b/packages/react-devtools-shared/src/backend/fiber/renderer.js @@ -13,8 +13,12 @@ import type { ReactDebugInfo, ReactAsyncInfo, ReactIOInfo, + ReactStackTrace, + ReactCallSite, } from 'shared/ReactTypes'; +import type {HooksTree} from 'react-debug-tools/src/ReactDebugHooks'; + import { ComponentFilterDisplayName, ComponentFilterElementType, @@ -5187,6 +5191,32 @@ export function attach( return null; } + function inspectHooks(fiber: Fiber): HooksTree { + const originalConsoleMethods: {[string]: $FlowFixMe} = {}; + + // Temporarily disable all console logging before re-running the hook. + for (const method in console) { + try { + // $FlowFixMe[invalid-computed-prop] + originalConsoleMethods[method] = console[method]; + // $FlowFixMe[prop-missing] + console[method] = () => {}; + } catch (error) {} + } + + try { + return inspectHooksOfFiber(fiber, getDispatcherRef(renderer)); + } finally { + // Restore original console functionality. + for (const method in originalConsoleMethods) { + try { + // $FlowFixMe[prop-missing] + console[method] = originalConsoleMethods[method]; + } catch (error) {} + } + } + } + function getSuspendedByOfSuspenseNode( suspenseNode: SuspenseNode, ): Array { @@ -5196,6 +5226,11 @@ export function attach( if (!suspenseNode.hasUniqueSuspenders) { return result; } + // Cache the inspection of Hooks in case we need it for multiple entries. + // We don't need a full map here since it's likely that every ioInfo that's unique + // to a specific instance will have those appear in order of when that instance was discovered. + let hooksCacheKey: null | DevToolsInstance = null; + let hooksCache: null | HooksTree = null; suspenseNode.suspendedBy.forEach((set, ioInfo) => { let parentNode = suspenseNode.parent; while (parentNode !== null) { @@ -5217,18 +5252,100 @@ export function attach( ioInfo, ); if (asyncInfo !== null) { - const index = result.length; - result.push(serializeAsyncInfo(asyncInfo, index, firstInstance)); + let hooks: null | HooksTree = null; + if (asyncInfo.stack == null && asyncInfo.owner == null) { + if (hooksCacheKey === firstInstance) { + hooks = hooksCache; + } else if (firstInstance.kind !== VIRTUAL_INSTANCE) { + const fiber = firstInstance.data; + if ( + fiber.dependencies && + fiber.dependencies._debugThenableState + ) { + // This entry had no stack nor owner but this Fiber used Hooks so we might + // be able to get the stack from the Hook. + hooksCacheKey = firstInstance; + hooksCache = hooks = inspectHooks(fiber); + } + } + } + result.push(serializeAsyncInfo(asyncInfo, firstInstance, hooks)); } } }); return result; } + function getAwaitStackFromHooks( + hooks: HooksTree, + asyncInfo: ReactAsyncInfo, + ): null | ReactStackTrace { + // TODO: We search through the hooks tree generated by inspectHooksOfFiber so that we can + // use the information already extracted but ideally this search would be faster since we + // could know which index to extract from the debug state. + for (let i = 0; i < hooks.length; i++) { + const node = hooks[i]; + const debugInfo = node.debugInfo; + if (debugInfo != null && debugInfo.indexOf(asyncInfo) !== -1) { + // Found a matching Hook. We'll now use its source location to construct a stack. + const source = node.hookSource; + if ( + source != null && + source.functionName !== null && + source.fileName !== null && + source.lineNumber !== null && + source.columnNumber !== null + ) { + // Unfortunately this is in a slightly different format. TODO: Unify HookNode with ReactCallSite. + const callSite: ReactCallSite = [ + source.functionName, + source.fileName, + source.lineNumber, + source.columnNumber, + 0, + 0, + false, + ]; + // As we return we'll add any custom hooks parent stacks to the array. + return [callSite]; + } else { + return []; + } + } + // Otherwise, search the sub hooks of any custom hook. + const matchedStack = getAwaitStackFromHooks(node.subHooks, asyncInfo); + if (matchedStack !== null) { + // Append this custom hook to the stack trace since it must have been called inside of it. + const source = node.hookSource; + if ( + source != null && + source.functionName !== null && + source.fileName !== null && + source.lineNumber !== null && + source.columnNumber !== null + ) { + // Unfortunately this is in a slightly different format. TODO: Unify HookNode with ReactCallSite. + const callSite: ReactCallSite = [ + source.functionName, + source.fileName, + source.lineNumber, + source.columnNumber, + 0, + 0, + false, + ]; + matchedStack.push(callSite); + } + return matchedStack; + } + } + return null; + } + function serializeAsyncInfo( asyncInfo: ReactAsyncInfo, - index: number, parentInstance: DevToolsInstance, + hooks: null | HooksTree, ): SerializedAsyncInfo { const ioInfo = asyncInfo.awaited; const ioOwnerInstance = findNearestOwnerInstance( @@ -5268,6 +5385,11 @@ export function attach( // 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. + if (hooks !== null) { + // If this component used Hooks we might be able to instead infer the stack from the + // use() callsite if this async info came from a hook. Let's search the tree to find it. + awaitStack = getAwaitStackFromHooks(hooks, asyncInfo); + } break; default: // If we awaited by passing a Promise to a built-in element, then the JSX callsite is a @@ -5538,31 +5660,9 @@ export function attach( const owners: null | Array = getOwnersListFromInstance(fiberInstance); - let hooks = null; + let hooks: null | HooksTree = null; if (usesHooks) { - const originalConsoleMethods: {[string]: $FlowFixMe} = {}; - - // Temporarily disable all console logging before re-running the hook. - for (const method in console) { - try { - // $FlowFixMe[invalid-computed-prop] - originalConsoleMethods[method] = console[method]; - // $FlowFixMe[prop-missing] - console[method] = () => {}; - } catch (error) {} - } - - try { - hooks = inspectHooksOfFiber(fiber, getDispatcherRef(renderer)); - } finally { - // Restore original console functionality. - for (const method in originalConsoleMethods) { - try { - // $FlowFixMe[prop-missing] - console[method] = originalConsoleMethods[method]; - } catch (error) {} - } - } + hooks = inspectHooks(fiber); } let rootType = null; @@ -5641,8 +5741,8 @@ export function attach( // TODO: Prepend other suspense sources like css, images and use(). fiberInstance.suspendedBy === null ? [] - : fiberInstance.suspendedBy.map((info, index) => - serializeAsyncInfo(info, index, fiberInstance), + : fiberInstance.suspendedBy.map(info => + serializeAsyncInfo(info, fiberInstance, hooks), ); return { id: fiberInstance.id, @@ -5813,8 +5913,8 @@ export function attach( suspendedBy: suspendedBy === null ? [] - : suspendedBy.map((info, index) => - serializeAsyncInfo(info, index, virtualInstance), + : suspendedBy.map(info => + serializeAsyncInfo(info, virtualInstance, null), ), // List of owners 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 3608cff85c..da74bc579e 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSuspendedBy.js +++ b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSuspendedBy.js @@ -81,7 +81,21 @@ function SuspendedByRow({ }: RowProps) { const [isOpen, setIsOpen] = useState(false); const ioInfo = asyncInfo.awaited; - const name = ioInfo.name; + let name = ioInfo.name; + if (name === '' || name === 'Promise') { + // If all we have is a generic name, we can try to infer a better name from + // the stack. We only do this if the stack has more than one frame since + // otherwise it's likely to just be the name of the component which isn't better. + const bestStack = ioInfo.stack || asyncInfo.stack; + if (bestStack !== null && bestStack.length > 1) { + // TODO: Ideally we'd get the name from the last ignore listed frame before the + // first visible frame since this is the same algorithm as the Flight server uses. + // Ideally, we'd also get the name from the source mapped entry instead of the + // original entry. However, that would require suspending the immediate display + // of these rows to first do source mapping before we can show the name. + name = bestStack[0][0]; + } + } const description = ioInfo.description; const longName = description === '' ? name : name + ' (' + description + ')'; const shortDescription = getShortDescription(name, description);