Repository: facebook/react
Pull request: [DevTools] Prefer I/O stack and show await stack after only if it's a different owner

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.

Changed files:
filename: packages/react-devtools-shared/src/backend/fiber/renderer.js
patch: @@ -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,

filename: packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSharedStyles.css
patch: @@ -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;
+}

filename: packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSuspendedBy.js
patch: @@ -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}

filename: packages/react-devtools-shared/src/devtools/views/Components/StackTraceView.js
patch: @@ -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}

filename: packages/react-devtools-shared/src/symbolicateSource.js
patch: @@ -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