@samitouri / QOS-React-2 / commits / 2c9a42dfd7

[DevTools] If the await doesn't have a stack use the stack from use() if any (#34162)

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. <img width="438" height="401" alt="Screenshot 2025-08-10 at 10 07 55 PM" src="https://github.com/user-attachments/assets/2a68917d-c27b-4c00-84aa-0ceb51c4e541" />

Sebastian Markbåge committed Aug 11, 2025 at 12:28 UTC 2c9a42dfd7dc6f26be9694ffc6cb2ebf8ef8472b
2 files changed +146 -32
packages/react-devtools-shared/src/backend/fiber/renderer.js
+131 -31
@@ -13,8 +13,12 @@ import type {
13 ReactDebugInfo,
14 ReactAsyncInfo,
15 ReactIOInfo,
16 + ReactStackTrace,
17 + ReactCallSite,
18 } from 'shared/ReactTypes';
19
20 +import type {HooksTree} from 'react-debug-tools/src/ReactDebugHooks';
21 +
22 import {
23 ComponentFilterDisplayName,
24 ComponentFilterElementType,
@@ -5187,6 +5191,32 @@ export function attach(
5191 return null;
5192 }
5193
5194 + function inspectHooks(fiber: Fiber): HooksTree {
5195 + const originalConsoleMethods: {[string]: $FlowFixMe} = {};
5196 +
5197 + // Temporarily disable all console logging before re-running the hook.
5198 + for (const method in console) {
5199 + try {
5200 + // $FlowFixMe[invalid-computed-prop]
5201 + originalConsoleMethods[method] = console[method];
5202 + // $FlowFixMe[prop-missing]
5203 + console[method] = () => {};
5204 + } catch (error) {}
5205 + }
5206 +
5207 + try {
5208 + return inspectHooksOfFiber(fiber, getDispatcherRef(renderer));
5209 + } finally {
5210 + // Restore original console functionality.
5211 + for (const method in originalConsoleMethods) {
5212 + try {
5213 + // $FlowFixMe[prop-missing]
5214 + console[method] = originalConsoleMethods[method];
5215 + } catch (error) {}
5216 + }
5217 + }
5218 + }
5219 +
5220 function getSuspendedByOfSuspenseNode(
5221 suspenseNode: SuspenseNode,
5222 ): Array<SerializedAsyncInfo> {
@@ -5196,6 +5226,11 @@ export function attach(
5226 if (!suspenseNode.hasUniqueSuspenders) {
5227 return result;
5228 }
5229 + // Cache the inspection of Hooks in case we need it for multiple entries.
5230 + // We don't need a full map here since it's likely that every ioInfo that's unique
5231 + // to a specific instance will have those appear in order of when that instance was discovered.
5232 + let hooksCacheKey: null | DevToolsInstance = null;
5233 + let hooksCache: null | HooksTree = null;
5234 suspenseNode.suspendedBy.forEach((set, ioInfo) => {
5235 let parentNode = suspenseNode.parent;
5236 while (parentNode !== null) {
@@ -5217,18 +5252,100 @@ export function attach(
5252 ioInfo,
5253 );
5254 if (asyncInfo !== null) {
5220 - const index = result.length;
5221 - result.push(serializeAsyncInfo(asyncInfo, index, firstInstance));
5255 + let hooks: null | HooksTree = null;
5256 + if (asyncInfo.stack == null && asyncInfo.owner == null) {
5257 + if (hooksCacheKey === firstInstance) {
5258 + hooks = hooksCache;
5259 + } else if (firstInstance.kind !== VIRTUAL_INSTANCE) {
5260 + const fiber = firstInstance.data;
5261 + if (
5262 + fiber.dependencies &&
5263 + fiber.dependencies._debugThenableState
5264 + ) {
5265 + // This entry had no stack nor owner but this Fiber used Hooks so we might
5266 + // be able to get the stack from the Hook.
5267 + hooksCacheKey = firstInstance;
5268 + hooksCache = hooks = inspectHooks(fiber);
5269 + }
5270 + }
5271 + }
5272 + result.push(serializeAsyncInfo(asyncInfo, firstInstance, hooks));
5273 }
5274 }
5275 });
5276 return result;
5277 }
5278
5279 + function getAwaitStackFromHooks(
5280 + hooks: HooksTree,
5281 + asyncInfo: ReactAsyncInfo,
5282 + ): null | ReactStackTrace {
5283 + // TODO: We search through the hooks tree generated by inspectHooksOfFiber so that we can
5284 + // use the information already extracted but ideally this search would be faster since we
5285 + // could know which index to extract from the debug state.
5286 + for (let i = 0; i < hooks.length; i++) {
5287 + const node = hooks[i];
5288 + const debugInfo = node.debugInfo;
5289 + if (debugInfo != null && debugInfo.indexOf(asyncInfo) !== -1) {
5290 + // Found a matching Hook. We'll now use its source location to construct a stack.
5291 + const source = node.hookSource;
5292 + if (
5293 + source != null &&
5294 + source.functionName !== null &&
5295 + source.fileName !== null &&
5296 + source.lineNumber !== null &&
5297 + source.columnNumber !== null
5298 + ) {
5299 + // Unfortunately this is in a slightly different format. TODO: Unify HookNode with ReactCallSite.
5300 + const callSite: ReactCallSite = [
5301 + source.functionName,
5302 + source.fileName,
5303 + source.lineNumber,
5304 + source.columnNumber,
5305 + 0,
5306 + 0,
5307 + false,
5308 + ];
5309 + // As we return we'll add any custom hooks parent stacks to the array.
5310 + return [callSite];
5311 + } else {
5312 + return [];
5313 + }
5314 + }
5315 + // Otherwise, search the sub hooks of any custom hook.
5316 + const matchedStack = getAwaitStackFromHooks(node.subHooks, asyncInfo);
5317 + if (matchedStack !== null) {
5318 + // Append this custom hook to the stack trace since it must have been called inside of it.
5319 + const source = node.hookSource;
5320 + if (
5321 + source != null &&
5322 + source.functionName !== null &&
5323 + source.fileName !== null &&
5324 + source.lineNumber !== null &&
5325 + source.columnNumber !== null
5326 + ) {
5327 + // Unfortunately this is in a slightly different format. TODO: Unify HookNode with ReactCallSite.
5328 + const callSite: ReactCallSite = [
5329 + source.functionName,
5330 + source.fileName,
5331 + source.lineNumber,
5332 + source.columnNumber,
5333 + 0,
5334 + 0,
5335 + false,
5336 + ];
5337 + matchedStack.push(callSite);
5338 + }
5339 + return matchedStack;
5340 + }
5341 + }
5342 + return null;
5343 + }
5344 +
5345 function serializeAsyncInfo(
5346 asyncInfo: ReactAsyncInfo,
5230 - index: number,
5347 parentInstance: DevToolsInstance,
5348 + hooks: null | HooksTree,
5349 ): SerializedAsyncInfo {
5350 const ioInfo = asyncInfo.awaited;
5351 const ioOwnerInstance = findNearestOwnerInstance(
@@ -5268,6 +5385,11 @@ export function attach(
5385 // If we awaited in the child position of a component, then the best stack would be the
5386 // return callsite but we don't have that available so instead we skip. The callsite of
5387 // the JSX would be misleading in this case. The same thing happens with throw-a-Promise.
5388 + if (hooks !== null) {
5389 + // If this component used Hooks we might be able to instead infer the stack from the
5390 + // use() callsite if this async info came from a hook. Let's search the tree to find it.
5391 + awaitStack = getAwaitStackFromHooks(hooks, asyncInfo);
5392 + }
5393 break;
5394 default:
5395 // 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(
5660 const owners: null | Array<SerializedElement> =
5661 getOwnersListFromInstance(fiberInstance);
5662
5541 - let hooks = null;
5663 + let hooks: null | HooksTree = null;
5664 if (usesHooks) {
5543 - const originalConsoleMethods: {[string]: $FlowFixMe} = {};
5544 -
5545 - // Temporarily disable all console logging before re-running the hook.
5546 - for (const method in console) {
5547 - try {
5548 - // $FlowFixMe[invalid-computed-prop]
5549 - originalConsoleMethods[method] = console[method];
5550 - // $FlowFixMe[prop-missing]
5551 - console[method] = () => {};
5552 - } catch (error) {}
5553 - }
5554 -
5555 - try {
5556 - hooks = inspectHooksOfFiber(fiber, getDispatcherRef(renderer));
5557 - } finally {
5558 - // Restore original console functionality.
5559 - for (const method in originalConsoleMethods) {
5560 - try {
5561 - // $FlowFixMe[prop-missing]
5562 - console[method] = originalConsoleMethods[method];
5563 - } catch (error) {}
5564 - }
5565 - }
5665 + hooks = inspectHooks(fiber);
5666 }
5667
5668 let rootType = null;
@@ -5641,8 +5741,8 @@ export function attach(
5741 // TODO: Prepend other suspense sources like css, images and use().
5742 fiberInstance.suspendedBy === null
5743 ? []
5644 - : fiberInstance.suspendedBy.map((info, index) =>
5645 - serializeAsyncInfo(info, index, fiberInstance),
5744 + : fiberInstance.suspendedBy.map(info =>
5745 + serializeAsyncInfo(info, fiberInstance, hooks),
5746 );
5747 return {
5748 id: fiberInstance.id,
@@ -5813,8 +5913,8 @@ export function attach(
5913 suspendedBy:
5914 suspendedBy === null
5915 ? []
5816 - : suspendedBy.map((info, index) =>
5817 - serializeAsyncInfo(info, index, virtualInstance),
5916 + : suspendedBy.map(info =>
5917 + serializeAsyncInfo(info, virtualInstance, null),
5918 ),
5919
5920 // List of owners
packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSuspendedBy.js
+15 -1
@@ -81,7 +81,21 @@ function SuspendedByRow({
81 }: RowProps) {
82 const [isOpen, setIsOpen] = useState(false);
83 const ioInfo = asyncInfo.awaited;
84 - const name = ioInfo.name;
84 + let name = ioInfo.name;
85 + if (name === '' || name === 'Promise') {
86 + // If all we have is a generic name, we can try to infer a better name from
87 + // the stack. We only do this if the stack has more than one frame since
88 + // otherwise it's likely to just be the name of the component which isn't better.
89 + const bestStack = ioInfo.stack || asyncInfo.stack;
90 + if (bestStack !== null && bestStack.length > 1) {
91 + // TODO: Ideally we'd get the name from the last ignore listed frame before the
92 + // first visible frame since this is the same algorithm as the Flight server uses.
93 + // Ideally, we'd also get the name from the source mapped entry instead of the
94 + // original entry. However, that would require suspending the immediate display
95 + // of these rows to first do source mapping before we can show the name.
96 + name = bestStack[0][0];
97 + }
98 + }
99 const description = ioInfo.description;
100 const longName = description === '' ? name : name + ' (' + description + ')';
101 const shortDescription = getShortDescription(name, description);