[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.
This commit is contained in:
Sebastian Markbåge
2025-08-06 11:21:01 -04:00
committed by GitHub
parent c97ec75324
commit 0825d019be
5 changed files with 160 additions and 46 deletions

View File

@@ -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<ReactAsyncInfo> {
): Array<SerializedAsyncInfo> {
// Collect all ReactAsyncInfo that was suspending this SuspenseNode but
// isn't also in any parent set.
const result: Array<ReactAsyncInfo> = [];
const result: Array<SerializedAsyncInfo> = [];
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,

View File

@@ -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;
}

View File

@@ -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({
</Button>
{isOpen && (
<div className={styles.CollapsableContent}>
{stack !== null && stack.length > 0 && (
<StackTraceView stack={stack} />
)}
{owner !== null && owner.id !== inspectedElement.id ? (
{showIOStack && <StackTraceView stack={ioInfo.stack} />}
{(showIOStack || !showAwaitStack) &&
ioOwner !== null &&
ioOwner.id !== inspectedElement.id ? (
<OwnerView
key={owner.id}
displayName={owner.displayName || 'Anonymous'}
hocDisplayNames={owner.hocDisplayNames}
compiledWithForget={owner.compiledWithForget}
id={owner.id}
isInStore={store.containsElement(owner.id)}
type={owner.type}
key={ioOwner.id}
displayName={ioOwner.displayName || 'Anonymous'}
hocDisplayNames={ioOwner.hocDisplayNames}
compiledWithForget={ioOwner.compiledWithForget}
id={ioOwner.id}
isInStore={store.containsElement(ioOwner.id)}
type={ioOwner.type}
/>
) : null}
{showAwaitStack ? (
<>
<div className={styles.SmallHeader}>awaited at:</div>
{asyncInfo.stack !== null && asyncInfo.stack.length > 0 && (
<StackTraceView stack={asyncInfo.stack} />
)}
{asyncOwner !== null && asyncOwner.id !== inspectedElement.id ? (
<OwnerView
key={asyncOwner.id}
displayName={asyncOwner.displayName || 'Anonymous'}
hocDisplayNames={asyncOwner.hocDisplayNames}
compiledWithForget={asyncOwner.compiledWithForget}
id={asyncOwner.id}
isInStore={store.containsElement(asyncOwner.id)}
type={asyncOwner.type}
/>
) : null}
</>
) : null}
<div className={styles.PreviewContainer}>
<KeyValue
alphaSort={true}

View File

@@ -8,12 +8,21 @@
*/
import * as React from 'react';
import {use, useContext} from 'react';
import useOpenResource from '../useOpenResource';
import styles from './StackTraceView.css';
import type {ReactStackTrace, ReactCallSite} from 'shared/ReactTypes';
import type {
ReactStackTrace,
ReactCallSite,
ReactFunctionLocation,
} from 'shared/ReactTypes';
import FetchFileWithCachingContext from './FetchFileWithCachingContext';
import {symbolicateSourceWithCache} from 'react-devtools-shared/src/symbolicateSource';
import formatLocationForDisplay from './formatLocationForDisplay';
@@ -22,7 +31,23 @@ type CallSiteViewProps = {
};
export function CallSiteView({callSite}: CallSiteViewProps): React.Node {
const symbolicatedCallSite: null | ReactCallSite = null; // TODO
const fetchFileWithCaching = useContext(FetchFileWithCachingContext);
const [virtualFunctionName, virtualURL, virtualLine, virtualColumn] =
callSite;
const symbolicatedCallSite: null | ReactFunctionLocation =
fetchFileWithCaching !== null
? use(
symbolicateSourceWithCache(
fetchFileWithCaching,
virtualURL,
virtualLine,
virtualColumn,
),
)
: null;
const [linkIsEnabled, viewSource] = useOpenResource(
callSite,
symbolicatedCallSite,
@@ -31,7 +56,7 @@ export function CallSiteView({callSite}: CallSiteViewProps): React.Node {
symbolicatedCallSite !== null ? symbolicatedCallSite : callSite;
return (
<div className={styles.CallSite}>
{functionName}
{functionName || virtualFunctionName}
{' @ '}
<span
className={linkIsEnabled ? styles.Link : null}

View File

@@ -17,7 +17,7 @@ const symbolicationCache: Map<
Promise<ReactFunctionLocation | null>,
> = new Map();
export async function symbolicateSourceWithCache(
export function symbolicateSourceWithCache(
fetchFileWithCaching: FetchFileWithCaching,
sourceURL: string,
line: number, // 1-based