@samitouri / QOS-React-2 / commits / 0825d019be

[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.

Sebastian Markbåge committed Aug 6, 2025 at 11:21 UTC 0825d019be044b24504be494620022d972eeb160
5 files changed +160 -46
packages/react-devtools-shared/src/backend/fiber/renderer.js
+76 -17
@@ -59,6 +59,7 @@ import {
59 import {
60 extractLocationFromComponentStack,
61 extractLocationFromOwnerStack,
62 + parseStackTrace,
63 } from 'react-devtools-shared/src/backend/utils/parseStackTrace';
64 import {
65 cleanForBridge,
@@ -4746,10 +4747,10 @@ export function attach(
4747
4748 function getSuspendedByOfSuspenseNode(
4749 suspenseNode: SuspenseNode,
4749 - ): Array<ReactAsyncInfo> {
4750 + ): Array<SerializedAsyncInfo> {
4751 // Collect all ReactAsyncInfo that was suspending this SuspenseNode but
4752 // isn't also in any parent set.
4752 - const result: Array<ReactAsyncInfo> = [];
4753 + const result: Array<SerializedAsyncInfo> = [];
4754 if (!suspenseNode.hasUniqueSuspenders) {
4755 return result;
4756 }
@@ -4774,7 +4775,8 @@ export function attach(
4775 ioInfo,
4776 );
4777 if (asyncInfo !== null) {
4777 - result.push(asyncInfo);
4778 + const index = result.length;
4779 + result.push(serializeAsyncInfo(asyncInfo, index, firstInstance));
4780 }
4781 }
4782 });
@@ -4791,10 +4793,63 @@ export function attach(
4793 parentInstance,
4794 ioInfo.owner,
4795 );
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 +
4853 const value: any = ioInfo.value;
4854 let resolvedValue = undefined;
4855 if (
@@ -4823,14 +4878,20 @@ export function attach(
4878 ioOwnerInstance === null
4879 ? null
4880 : 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),
4888 },
4889 env: asyncInfo.env == null ? null : asyncInfo.env,
4890 owner:
4891 awaitOwnerInstance === null
4892 ? null
4893 : instanceToSerializedElement(awaitOwnerInstance),
4833 - stack: asyncInfo.stack == null ? null : asyncInfo.stack,
4894 + stack: awaitStack,
4895 };
4896 }
4897
@@ -5136,8 +5197,11 @@ export function attach(
5197 // In this case, this becomes associated with the Client/Host Component where as normally
5198 // you'd expect these to be associated with the Server Component that awaited the data.
5199 // 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 + );
5205 return {
5206 id: fiberInstance.id,
5207
@@ -5194,12 +5258,7 @@ export function attach(
5258 ? []
5259 : Array.from(componentLogsEntry.warnings.entries()),
5260
5197 - suspendedBy:
5198 - suspendedBy === null
5199 - ? []
5200 - : suspendedBy.map((info, index) =>
5201 - serializeAsyncInfo(info, index, fiberInstance),
5202 - ),
5261 + suspendedBy: suspendedBy,
5262
5263 // List of owners
5264 owners,
packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSharedStyles.css
+7
@@ -123,3 +123,10 @@
123 .TimeBarSpanErrored {
124 background-color: var(--color-timespan-background-errored);
125 }
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
+48 -25
@@ -80,21 +80,13 @@ function SuspendedByRow({
80 maxTime,
81 }: RowProps) {
82 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;
86 const longName = description === '' ? name : name + ' (' + description + ')';
87 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;
90 const timeScale = 100 / (maxTime - minTime);
91 let left = (start - minTime) * timeScale;
92 let width = (end - start) * timeScale;
@@ -106,7 +98,19 @@ function SuspendedByRow({
98 }
99 }
100
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;
114 const metaName =
115 value !== null && typeof value === 'object' ? value[meta.name] : null;
116 const isFulfilled = metaName === 'fulfilled Thenable';
@@ -146,20 +150,39 @@ function SuspendedByRow({
150 </Button>
151 {isOpen && (
152 <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 ? (
157 <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}
165 />
166 ) : 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}
186 <div className={styles.PreviewContainer}>
187 <KeyValue
188 alphaSort={true}
packages/react-devtools-shared/src/devtools/views/Components/StackTraceView.js
+28 -3
@@ -8,12 +8,21 @@
8 */
9
10 import * as React from 'react';
11 +import {use, useContext} from 'react';
12
13 import useOpenResource from '../useOpenResource';
14
15 import styles from './StackTraceView.css';
16
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';
26
27 import formatLocationForDisplay from './formatLocationForDisplay';
28
@@ -22,7 +31,23 @@ type CallSiteViewProps = {
31 };
32
33 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 +
51 const [linkIsEnabled, viewSource] = useOpenResource(
52 callSite,
53 symbolicatedCallSite,
@@ -31,7 +56,7 @@ export function CallSiteView({callSite}: CallSiteViewProps): React.Node {
56 symbolicatedCallSite !== null ? symbolicatedCallSite : callSite;
57 return (
58 <div className={styles.CallSite}>
34 - {functionName}
59 + {functionName || virtualFunctionName}
60 {' @ '}
61 <span
62 className={linkIsEnabled ? styles.Link : null}
packages/react-devtools-shared/src/symbolicateSource.js
+1 -1
@@ -17,7 +17,7 @@ const symbolicationCache: Map<
17 Promise<ReactFunctionLocation | null>,
18 > = new Map();
19
20 -export async function symbolicateSourceWithCache(
20 +export function symbolicateSourceWithCache(
21 fetchFileWithCaching: FetchFileWithCaching,
22 sourceURL: string,
23 line: number, // 1-based