@samitouri / QOS-React-1 / commits / 101b20b663

[DevTools] Add a little bar indicating time span of an async entry relative to others (#34016)

Stacked on #34012. This shows a time track for when some I/O started and when it finished relative to other I/O in the same component (or later in the same suspense boundary). This is not meant to be a precise visualization since the data might be misleading if you're running this in dev which has other perf characteristics anyway. It's just meant to be a general way to orient yourself in the data. We can also highlight rejected promises here. The color scheme is the same as Chrome's current Performance Track colors to add continuity but those could change. <img width="478" height="480" alt="Screenshot 2025-07-27 at 11 48 03 PM" src="https://github.com/user-attachments/assets/545dd591-a91f-4c47-be96-41d80f09a94a" />

Sebastian Markbåge committed Jul 28, 2025 at 12:22 UTC 101b20b663cc866ad4c0102413bbf7cb111451b1
3 files changed +86 -3
packages/react-devtools-shared/src/devtools/constants.js
+6
@@ -135,6 +135,9 @@ export const THEME_STYLES: {[style: Theme | DisplayDensity]: any, ...} = {
135 '--color-timeline-text-color': '#000000',
136 '--color-timeline-text-dim-color': '#ccc',
137 '--color-timeline-react-work-border': '#eeeeee',
138 + '--color-timebar-background': '#f6f6f6',
139 + '--color-timespan-background': '#62bc6a',
140 + '--color-timespan-background-errored': '#d57066',
141 '--color-search-match': 'yellow',
142 '--color-search-match-current': '#f7923b',
143 '--color-selected-tree-highlight-active': 'rgba(0, 136, 250, 0.1)',
@@ -283,6 +286,9 @@ export const THEME_STYLES: {[style: Theme | DisplayDensity]: any, ...} = {
286 '--color-timeline-text-color': '#282c34',
287 '--color-timeline-text-dim-color': '#555b66',
288 '--color-timeline-react-work-border': '#3d424a',
289 + '--color-timebar-background': '#1d2129',
290 + '--color-timespan-background': '#62bc6a',
291 + '--color-timespan-background-errored': '#d57066',
292 '--color-search-match': 'yellow',
293 '--color-search-match-current': '#f7923b',
294 '--color-selected-tree-highlight-active': 'rgba(23, 143, 185, 0.15)',
packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSharedStyles.css
+20
@@ -89,3 +89,23 @@
89 .PreviewContainer {
90 padding: 0 0.25rem 0.25rem 0.25rem;
91 }
92 +
93 +.TimeBarContainer {
94 + position: relative;
95 + flex: 0 0 20%;
96 + height: 0.25rem;
97 + border-radius: 0.125rem;
98 + background-color: var(--color-timebar-background);
99 +}
100 +
101 +.TimeBarSpan, .TimeBarSpanErrored {
102 + position: absolute;
103 + border-radius: 0.125rem;
104 + background-color: var(--color-timespan-background);
105 + width: 100%;
106 + height: 100%;
107 +}
108 +
109 +.TimeBarSpanErrored {
110 + background-color: var(--color-timespan-background-errored);
111 +}
\ No newline at end of file
packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSuspendedBy.js
+60 -3
@@ -19,10 +19,13 @@ import styles from './InspectedElementSharedStyles.css';
19 import {withPermissionsCheck} from 'react-devtools-shared/src/frontend/utils/withPermissionsCheck';
20 import StackTraceView from './StackTraceView';
21 import OwnerView from './OwnerView';
22 +import {meta} from '../../../hydration';
23
23 -import type {InspectedElement} from 'react-devtools-shared/src/frontend/types';
24 +import type {
25 + InspectedElement,
26 + SerializedAsyncInfo,
27 +} from 'react-devtools-shared/src/frontend/types';
28 import type {FrontendBridge} from 'react-devtools-shared/src/bridge';
25 -import type {SerializedAsyncInfo} from 'react-devtools-shared/src/frontend/types';
29
30 type RowProps = {
31 bridge: FrontendBridge,
@@ -31,6 +34,8 @@ type RowProps = {
34 store: Store,
35 asyncInfo: SerializedAsyncInfo,
36 index: number,
37 + minTime: number,
38 + maxTime: number,
39 };
40
41 function SuspendedByRow({
@@ -40,6 +45,8 @@ function SuspendedByRow({
45 store,
46 asyncInfo,
47 index,
48 + minTime,
49 + maxTime,
50 }: RowProps) {
51 const [isOpen, setIsOpen] = useState(false);
52 const name = asyncInfo.awaited.name;
@@ -52,17 +59,47 @@ function SuspendedByRow({
59 stack = asyncInfo.stack;
60 owner = asyncInfo.owner;
61 }
62 + const start = asyncInfo.awaited.start;
63 + const end = asyncInfo.awaited.end;
64 + const timeScale = 100 / (maxTime - minTime);
65 + let left = (start - minTime) * timeScale;
66 + let width = (end - start) * timeScale;
67 + if (width < 5) {
68 + // Use at least a 5% width to avoid showing too small indicators.
69 + width = 5;
70 + if (left > 95) {
71 + left = 95;
72 + }
73 + }
74 +
75 + const value: any = asyncInfo.awaited.value;
76 + const isErrored =
77 + value !== null &&
78 + typeof value === 'object' &&
79 + value[meta.name] === 'rejected Thenable';
80 +
81 return (
82 <div className={styles.CollapsableRow}>
83 <Button
84 className={styles.CollapsableHeader}
85 onClick={() => setIsOpen(prevIsOpen => !prevIsOpen)}
60 - title={`${isOpen ? 'Collapse' : 'Expand'}`}>
86 + title={name + ' — ' + (end - start).toFixed(2) + ' ms'}>
87 <ButtonIcon
88 className={styles.CollapsableHeaderIcon}
89 type={isOpen ? 'expanded' : 'collapsed'}
90 />
91 <span className={styles.CollapsableHeaderTitle}>{name}</span>
92 + <div className={styles.TimeBarContainer}>
93 + <div
94 + className={
95 + !isErrored ? styles.TimeBarSpan : styles.TimeBarSpanErrored
96 + }
97 + style={{
98 + left: left.toFixed(2) + '%',
99 + width: width.toFixed(2) + '%',
100 + }}
101 + />
102 + </div>
103 </Button>
104 {isOpen && (
105 <div className={styles.CollapsableContent}>
@@ -129,6 +166,24 @@ export default function InspectedElementSuspendedBy({
166 () => copy(serializeDataForCopy(suspendedBy)),
167 );
168
169 + let minTime = Infinity;
170 + let maxTime = -Infinity;
171 + for (let i = 0; i < suspendedBy.length; i++) {
172 + const asyncInfo: SerializedAsyncInfo = suspendedBy[i];
173 + if (asyncInfo.awaited.start < minTime) {
174 + minTime = asyncInfo.awaited.start;
175 + }
176 + if (asyncInfo.awaited.end > maxTime) {
177 + maxTime = asyncInfo.awaited.end;
178 + }
179 + }
180 +
181 + if (maxTime - minTime < 25) {
182 + // Stretch the time span a bit to ensure that we don't show
183 + // large bars that represent very small timespans.
184 + minTime = maxTime - 25;
185 + }
186 +
187 return (
188 <div>
189 <div className={styles.HeaderRow}>
@@ -146,6 +201,8 @@ export default function InspectedElementSuspendedBy({
201 element={element}
202 inspectedElement={inspectedElement}
203 store={store}
204 + minTime={minTime}
205 + maxTime={maxTime}
206 />
207 ))}
208 </div>