Skip to content

Commit 0825d01

Browse files
authored
[DevTools] Prefer I/O stack and show await stack after only if it's a different owner (#34101)
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. <img width="517" height="478" alt="Screenshot 2025-08-04 at 11 57 28 AM" src="https://github.com/user-attachments/assets/2dbfbed4-4671-4a5f-8e6e-ebec6fe8a1b7" /> 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.
1 parent c97ec75 commit 0825d01

File tree

5 files changed

+160
-46
lines changed

5 files changed

+160
-46
lines changed

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

Lines changed: 76 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ import {
5959
import {
6060
extractLocationFromComponentStack,
6161
extractLocationFromOwnerStack,
62+
parseStackTrace,
6263
} from 'react-devtools-shared/src/backend/utils/parseStackTrace';
6364
import {
6465
cleanForBridge,
@@ -4746,10 +4747,10 @@ export function attach(
47464747
47474748
function getSuspendedByOfSuspenseNode(
47484749
suspenseNode: SuspenseNode,
4749-
): Array<ReactAsyncInfo> {
4750+
): Array<SerializedAsyncInfo> {
47504751
// Collect all ReactAsyncInfo that was suspending this SuspenseNode but
47514752
// isn't also in any parent set.
4752-
const result: Array<ReactAsyncInfo> = [];
4753+
const result: Array<SerializedAsyncInfo> = [];
47534754
if (!suspenseNode.hasUniqueSuspenders) {
47544755
return result;
47554756
}
@@ -4774,7 +4775,8 @@ export function attach(
47744775
ioInfo,
47754776
);
47764777
if (asyncInfo !== null) {
4777-
result.push(asyncInfo);
4778+
const index = result.length;
4779+
result.push(serializeAsyncInfo(asyncInfo, index, firstInstance));
47784780
}
47794781
}
47804782
});
@@ -4791,10 +4793,63 @@ export function attach(
47914793
parentInstance,
47924794
ioInfo.owner,
47934795
);
4794-
const awaitOwnerInstance = findNearestOwnerInstance(
4795-
parentInstance,
4796-
asyncInfo.owner,
4797-
);
4796+
let awaitStack =
4797+
asyncInfo.debugStack == null
4798+
? null
4799+
: // While we have a ReactStackTrace on ioInfo.stack, that will point to the location on
4800+
// the server. We need a location that points to the virtual source on the client which
4801+
// we can then use to source map to the original location.
4802+
parseStackTrace(asyncInfo.debugStack, 1);
4803+
let awaitOwnerInstance: null | FiberInstance | VirtualInstance;
4804+
if (
4805+
asyncInfo.owner == null &&
4806+
(awaitStack === null || awaitStack.length === 0)
4807+
) {
4808+
// We had no owner nor stack for the await. This can happen if you render it as a child
4809+
// or throw a Promise. Replace it with the parent as the await.
4810+
awaitStack = null;
4811+
awaitOwnerInstance =
4812+
parentInstance.kind === FILTERED_FIBER_INSTANCE ? null : parentInstance;
4813+
if (
4814+
parentInstance.kind === FIBER_INSTANCE ||
4815+
parentInstance.kind === FILTERED_FIBER_INSTANCE
4816+
) {
4817+
const fiber = parentInstance.data;
4818+
switch (fiber.tag) {
4819+
case ClassComponent:
4820+
case FunctionComponent:
4821+
case IncompleteClassComponent:
4822+
case IncompleteFunctionComponent:
4823+
case IndeterminateComponent:
4824+
case MemoComponent:
4825+
case SimpleMemoComponent:
4826+
// If we awaited in the child position of a component, then the best stack would be the
4827+
// return callsite but we don't have that available so instead we skip. The callsite of
4828+
// the JSX would be misleading in this case. The same thing happens with throw-a-Promise.
4829+
break;
4830+
default:
4831+
// If we awaited by passing a Promise to a built-in element, then the JSX callsite is a
4832+
// good stack trace to use for the await.
4833+
if (
4834+
fiber._debugOwner != null &&
4835+
fiber._debugStack != null &&
4836+
typeof fiber._debugStack !== 'string'
4837+
) {
4838+
awaitStack = parseStackTrace(fiber._debugStack, 1);
4839+
awaitOwnerInstance = findNearestOwnerInstance(
4840+
parentInstance,
4841+
fiber._debugOwner,
4842+
);
4843+
}
4844+
}
4845+
}
4846+
} else {
4847+
awaitOwnerInstance = findNearestOwnerInstance(
4848+
parentInstance,
4849+
asyncInfo.owner,
4850+
);
4851+
}
4852+
47984853
const value: any = ioInfo.value;
47994854
let resolvedValue = undefined;
48004855
if (
@@ -4823,14 +4878,20 @@ export function attach(
48234878
ioOwnerInstance === null
48244879
? null
48254880
: instanceToSerializedElement(ioOwnerInstance),
4826-
stack: ioInfo.stack == null ? null : ioInfo.stack,
4881+
stack:
4882+
ioInfo.debugStack == null
4883+
? null
4884+
: // While we have a ReactStackTrace on ioInfo.stack, that will point to the location on
4885+
// the server. We need a location that points to the virtual source on the client which
4886+
// we can then use to source map to the original location.
4887+
parseStackTrace(ioInfo.debugStack, 1),
48274888
},
48284889
env: asyncInfo.env == null ? null : asyncInfo.env,
48294890
owner:
48304891
awaitOwnerInstance === null
48314892
? null
48324893
: instanceToSerializedElement(awaitOwnerInstance),
4833-
stack: asyncInfo.stack == null ? null : asyncInfo.stack,
4894+
stack: awaitStack,
48344895
};
48354896
}
48364897
@@ -5136,8 +5197,11 @@ export function attach(
51365197
// In this case, this becomes associated with the Client/Host Component where as normally
51375198
// you'd expect these to be associated with the Server Component that awaited the data.
51385199
// TODO: Prepend other suspense sources like css, images and use().
5139-
fiberInstance.suspendedBy;
5140-
5200+
fiberInstance.suspendedBy === null
5201+
? []
5202+
: fiberInstance.suspendedBy.map((info, index) =>
5203+
serializeAsyncInfo(info, index, fiberInstance),
5204+
);
51415205
return {
51425206
id: fiberInstance.id,
51435207
@@ -5194,12 +5258,7 @@ export function attach(
51945258
? []
51955259
: Array.from(componentLogsEntry.warnings.entries()),
51965260
5197-
suspendedBy:
5198-
suspendedBy === null
5199-
? []
5200-
: suspendedBy.map((info, index) =>
5201-
serializeAsyncInfo(info, index, fiberInstance),
5202-
),
5261+
suspendedBy: suspendedBy,
52035262
52045263
// List of owners
52055264
owners,

packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSharedStyles.css

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,3 +123,10 @@
123123
.TimeBarSpanErrored {
124124
background-color: var(--color-timespan-background-errored);
125125
}
126+
127+
.SmallHeader {
128+
font-family: var(--font-family-monospace);
129+
font-size: var(--font-size-monospace-normal);
130+
padding-left: 1.25rem;
131+
margin-top: 0.25rem;
132+
}

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

Lines changed: 48 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -80,21 +80,13 @@ function SuspendedByRow({
8080
maxTime,
8181
}: RowProps) {
8282
const [isOpen, setIsOpen] = useState(false);
83-
const name = asyncInfo.awaited.name;
84-
const description = asyncInfo.awaited.description;
83+
const ioInfo = asyncInfo.awaited;
84+
const name = ioInfo.name;
85+
const description = ioInfo.description;
8586
const longName = description === '' ? name : name + ' (' + description + ')';
8687
const shortDescription = getShortDescription(name, description);
87-
let stack;
88-
let owner;
89-
if (asyncInfo.stack === null || asyncInfo.stack.length === 0) {
90-
stack = asyncInfo.awaited.stack;
91-
owner = asyncInfo.awaited.owner;
92-
} else {
93-
stack = asyncInfo.stack;
94-
owner = asyncInfo.owner;
95-
}
96-
const start = asyncInfo.awaited.start;
97-
const end = asyncInfo.awaited.end;
88+
const start = ioInfo.start;
89+
const end = ioInfo.end;
9890
const timeScale = 100 / (maxTime - minTime);
9991
let left = (start - minTime) * timeScale;
10092
let width = (end - start) * timeScale;
@@ -106,7 +98,19 @@ function SuspendedByRow({
10698
}
10799
}
108100

109-
const value: any = asyncInfo.awaited.value;
101+
const ioOwner = ioInfo.owner;
102+
const asyncOwner = asyncInfo.owner;
103+
const showIOStack = ioInfo.stack !== null && ioInfo.stack.length !== 0;
104+
// Only show the awaited stack if the I/O started in a different owner
105+
// than where it was awaited. If it's started by the same component it's
106+
// probably easy enough to infer and less noise in the common case.
107+
const showAwaitStack =
108+
!showIOStack ||
109+
(ioOwner === null
110+
? asyncOwner !== null
111+
: asyncOwner === null || ioOwner.id !== asyncOwner.id);
112+
113+
const value: any = ioInfo.value;
110114
const metaName =
111115
value !== null && typeof value === 'object' ? value[meta.name] : null;
112116
const isFulfilled = metaName === 'fulfilled Thenable';
@@ -146,20 +150,39 @@ function SuspendedByRow({
146150
</Button>
147151
{isOpen && (
148152
<div className={styles.CollapsableContent}>
149-
{stack !== null && stack.length > 0 && (
150-
<StackTraceView stack={stack} />
151-
)}
152-
{owner !== null && owner.id !== inspectedElement.id ? (
153+
{showIOStack && <StackTraceView stack={ioInfo.stack} />}
154+
{(showIOStack || !showAwaitStack) &&
155+
ioOwner !== null &&
156+
ioOwner.id !== inspectedElement.id ? (
153157
<OwnerView
154-
key={owner.id}
155-
displayName={owner.displayName || 'Anonymous'}
156-
hocDisplayNames={owner.hocDisplayNames}
157-
compiledWithForget={owner.compiledWithForget}
158-
id={owner.id}
159-
isInStore={store.containsElement(owner.id)}
160-
type={owner.type}
158+
key={ioOwner.id}
159+
displayName={ioOwner.displayName || 'Anonymous'}
160+
hocDisplayNames={ioOwner.hocDisplayNames}
161+
compiledWithForget={ioOwner.compiledWithForget}
162+
id={ioOwner.id}
163+
isInStore={store.containsElement(ioOwner.id)}
164+
type={ioOwner.type}
161165
/>
162166
) : null}
167+
{showAwaitStack ? (
168+
<>
169+
<div className={styles.SmallHeader}>awaited at:</div>
170+
{asyncInfo.stack !== null && asyncInfo.stack.length > 0 && (
171+
<StackTraceView stack={asyncInfo.stack} />
172+
)}
173+
{asyncOwner !== null && asyncOwner.id !== inspectedElement.id ? (
174+
<OwnerView
175+
key={asyncOwner.id}
176+
displayName={asyncOwner.displayName || 'Anonymous'}
177+
hocDisplayNames={asyncOwner.hocDisplayNames}
178+
compiledWithForget={asyncOwner.compiledWithForget}
179+
id={asyncOwner.id}
180+
isInStore={store.containsElement(asyncOwner.id)}
181+
type={asyncOwner.type}
182+
/>
183+
) : null}
184+
</>
185+
) : null}
163186
<div className={styles.PreviewContainer}>
164187
<KeyValue
165188
alphaSort={true}

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

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,21 @@
88
*/
99

1010
import * as React from 'react';
11+
import {use, useContext} from 'react';
1112

1213
import useOpenResource from '../useOpenResource';
1314

1415
import styles from './StackTraceView.css';
1516

16-
import type {ReactStackTrace, ReactCallSite} from 'shared/ReactTypes';
17+
import type {
18+
ReactStackTrace,
19+
ReactCallSite,
20+
ReactFunctionLocation,
21+
} from 'shared/ReactTypes';
22+
23+
import FetchFileWithCachingContext from './FetchFileWithCachingContext';
24+
25+
import {symbolicateSourceWithCache} from 'react-devtools-shared/src/symbolicateSource';
1726

1827
import formatLocationForDisplay from './formatLocationForDisplay';
1928

@@ -22,7 +31,23 @@ type CallSiteViewProps = {
2231
};
2332

2433
export function CallSiteView({callSite}: CallSiteViewProps): React.Node {
25-
const symbolicatedCallSite: null | ReactCallSite = null; // TODO
34+
const fetchFileWithCaching = useContext(FetchFileWithCachingContext);
35+
36+
const [virtualFunctionName, virtualURL, virtualLine, virtualColumn] =
37+
callSite;
38+
39+
const symbolicatedCallSite: null | ReactFunctionLocation =
40+
fetchFileWithCaching !== null
41+
? use(
42+
symbolicateSourceWithCache(
43+
fetchFileWithCaching,
44+
virtualURL,
45+
virtualLine,
46+
virtualColumn,
47+
),
48+
)
49+
: null;
50+
2651
const [linkIsEnabled, viewSource] = useOpenResource(
2752
callSite,
2853
symbolicatedCallSite,
@@ -31,7 +56,7 @@ export function CallSiteView({callSite}: CallSiteViewProps): React.Node {
3156
symbolicatedCallSite !== null ? symbolicatedCallSite : callSite;
3257
return (
3358
<div className={styles.CallSite}>
34-
{functionName}
59+
{functionName || virtualFunctionName}
3560
{' @ '}
3661
<span
3762
className={linkIsEnabled ? styles.Link : null}

packages/react-devtools-shared/src/symbolicateSource.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ const symbolicationCache: Map<
1717
Promise<ReactFunctionLocation | null>,
1818
> = new Map();
1919

20-
export async function symbolicateSourceWithCache(
20+
export function symbolicateSourceWithCache(
2121
fetchFileWithCaching: FetchFileWithCaching,
2222
sourceURL: string,
2323
line: number, // 1-based

0 commit comments

Comments
 (0)