Skip to content

Commit 2c9a42d

Browse files
authored
[DevTools] If the await doesn't have a stack use the stack from use() if any (facebook#34162)
Stacked on facebook#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" />
1 parent f1e70b5 commit 2c9a42d

File tree

2 files changed

+146
-32
lines changed

2 files changed

+146
-32
lines changed

packages/react-devtools-shared/src/backend/fiber/renderer.js

Lines changed: 131 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,12 @@ import type {
1313
ReactDebugInfo,
1414
ReactAsyncInfo,
1515
ReactIOInfo,
16+
ReactStackTrace,
17+
ReactCallSite,
1618
} from 'shared/ReactTypes';
1719

20+
import type {HooksTree} from 'react-debug-tools/src/ReactDebugHooks';
21+
1822
import {
1923
ComponentFilterDisplayName,
2024
ComponentFilterElementType,
@@ -5187,6 +5191,32 @@ export function attach(
51875191
return null;
51885192
}
51895193
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+
51905220
function getSuspendedByOfSuspenseNode(
51915221
suspenseNode: SuspenseNode,
51925222
): Array<SerializedAsyncInfo> {
@@ -5196,6 +5226,11 @@ export function attach(
51965226
if (!suspenseNode.hasUniqueSuspenders) {
51975227
return result;
51985228
}
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;
51995234
suspenseNode.suspendedBy.forEach((set, ioInfo) => {
52005235
let parentNode = suspenseNode.parent;
52015236
while (parentNode !== null) {
@@ -5217,18 +5252,100 @@ export function attach(
52175252
ioInfo,
52185253
);
52195254
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));
52225273
}
52235274
}
52245275
});
52255276
return result;
52265277
}
52275278
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+
52285345
function serializeAsyncInfo(
52295346
asyncInfo: ReactAsyncInfo,
5230-
index: number,
52315347
parentInstance: DevToolsInstance,
5348+
hooks: null | HooksTree,
52325349
): SerializedAsyncInfo {
52335350
const ioInfo = asyncInfo.awaited;
52345351
const ioOwnerInstance = findNearestOwnerInstance(
@@ -5268,6 +5385,11 @@ export function attach(
52685385
// If we awaited in the child position of a component, then the best stack would be the
52695386
// return callsite but we don't have that available so instead we skip. The callsite of
52705387
// 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+
}
52715393
break;
52725394
default:
52735395
// 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(
55385660
const owners: null | Array<SerializedElement> =
55395661
getOwnersListFromInstance(fiberInstance);
55405662
5541-
let hooks = null;
5663+
let hooks: null | HooksTree = null;
55425664
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);
55665666
}
55675667
55685668
let rootType = null;
@@ -5641,8 +5741,8 @@ export function attach(
56415741
// TODO: Prepend other suspense sources like css, images and use().
56425742
fiberInstance.suspendedBy === null
56435743
? []
5644-
: fiberInstance.suspendedBy.map((info, index) =>
5645-
serializeAsyncInfo(info, index, fiberInstance),
5744+
: fiberInstance.suspendedBy.map(info =>
5745+
serializeAsyncInfo(info, fiberInstance, hooks),
56465746
);
56475747
return {
56485748
id: fiberInstance.id,
@@ -5813,8 +5913,8 @@ export function attach(
58135913
suspendedBy:
58145914
suspendedBy === null
58155915
? []
5816-
: suspendedBy.map((info, index) =>
5817-
serializeAsyncInfo(info, index, virtualInstance),
5916+
: suspendedBy.map(info =>
5917+
serializeAsyncInfo(info, virtualInstance, null),
58185918
),
58195919
58205920
// List of owners

packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSuspendedBy.js

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,21 @@ function SuspendedByRow({
8181
}: RowProps) {
8282
const [isOpen, setIsOpen] = useState(false);
8383
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+
}
8599
const description = ioInfo.description;
86100
const longName = description === '' ? name : name + ' (' + description + ')';
87101
const shortDescription = getShortDescription(name, description);

0 commit comments

Comments
 (0)