Repository: facebook/react
Pull request: [DevTools] Add a little bar indicating time span of an async entry relative to others

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" />


Changed files:
filename: packages/react-devtools-shared/src/devtools/constants.js
patch: @@ -135,6 +135,9 @@ export const THEME_STYLES: {[style: Theme | DisplayDensity]: any, ...} = {
     '--color-timeline-text-color': '#000000',
     '--color-timeline-text-dim-color': '#ccc',
     '--color-timeline-react-work-border': '#eeeeee',
+    '--color-timebar-background': '#f6f6f6',
+    '--color-timespan-background': '#62bc6a',
+    '--color-timespan-background-errored': '#d57066',
     '--color-search-match': 'yellow',
     '--color-search-match-current': '#f7923b',
     '--color-selected-tree-highlight-active': 'rgba(0, 136, 250, 0.1)',
@@ -283,6 +286,9 @@ export const THEME_STYLES: {[style: Theme | DisplayDensity]: any, ...} = {
     '--color-timeline-text-color': '#282c34',
     '--color-timeline-text-dim-color': '#555b66',
     '--color-timeline-react-work-border': '#3d424a',
+    '--color-timebar-background': '#1d2129',
+    '--color-timespan-background': '#62bc6a',
+    '--color-timespan-background-errored': '#d57066',
     '--color-search-match': 'yellow',
     '--color-search-match-current': '#f7923b',
     '--color-selected-tree-highlight-active': 'rgba(23, 143, 185, 0.15)',

filename: packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSharedStyles.css
patch: @@ -89,3 +89,23 @@
 .PreviewContainer {
   padding: 0 0.25rem 0.25rem 0.25rem;
 }
+
+.TimeBarContainer {
+  position: relative;
+  flex: 0 0 20%;
+  height: 0.25rem;
+  border-radius: 0.125rem;
+  background-color: var(--color-timebar-background);
+}
+
+.TimeBarSpan, .TimeBarSpanErrored {
+  position: absolute;
+  border-radius: 0.125rem;
+  background-color: var(--color-timespan-background);
+  width: 100%;
+  height: 100%;
+}
+
+.TimeBarSpanErrored {
+  background-color: var(--color-timespan-background-errored);
+}
\ No newline at end of file

filename: packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSuspendedBy.js
patch: @@ -19,10 +19,13 @@ import styles from './InspectedElementSharedStyles.css';
 import {withPermissionsCheck} from 'react-devtools-shared/src/frontend/utils/withPermissionsCheck';
 import StackTraceView from './StackTraceView';
 import OwnerView from './OwnerView';
+import {meta} from '../../../hydration';
 
-import type {InspectedElement} from 'react-devtools-shared/src/frontend/types';
+import type {
+  InspectedElement,
+  SerializedAsyncInfo,
+} from 'react-devtools-shared/src/frontend/types';
 import type {FrontendBridge} from 'react-devtools-shared/src/bridge';
-import type {SerializedAsyncInfo} from 'react-devtools-shared/src/frontend/types';
 
 type RowProps = {
   bridge: FrontendBridge,
@@ -31,6 +34,8 @@ type RowProps = {
   store: Store,
   asyncInfo: SerializedAsyncInfo,
   index: number,
+  minTime: number,
+  maxTime: number,
 };
 
 function SuspendedByRow({
@@ -40,6 +45,8 @@ function SuspendedByRow({
   store,
   asyncInfo,
   index,
+  minTime,
+  maxTime,
 }: RowProps) {
   const [isOpen, setIsOpen] = useState(false);
   const name = asyncInfo.awaited.name;
@@ -52,17 +59,47 @@ function SuspendedByRow({
     stack = asyncInfo.stack;
     owner = asyncInfo.owner;
   }
+  const start = asyncInfo.awaited.start;
+  const end = asyncInfo.awaited.end;
+  const timeScale = 100 / (maxTime - minTime);
+  let left = (start - minTime) * timeScale;
+  let width = (end - start) * timeScale;
+  if (width < 5) {
+    // Use at least a 5% width to avoid showing too small indicators.
+    width = 5;
+    if (left > 95) {
+      left = 95;
+    }
+  }
+
+  const value: any = asyncInfo.awaited.value;
+  const isErrored =
+    value !== null &&
+    typeof value === 'object' &&
+    value[meta.name] === 'rejected Thenable';
+
   return (
     <div className={styles.CollapsableRow}>
       <Button
         className={styles.CollapsableHeader}
         onClick={() => setIsOpen(prevIsOpen => !prevIsOpen)}
-        title={`${isOpen ? 'Collapse' : 'Expand'}`}>
+        title={name + ' — ' + (end - start).toFixed(2) + ' ms'}>
         <ButtonIcon
           className={styles.CollapsableHeaderIcon}
           type={isOpen ? 'expanded' : 'collapsed'}
         />
         <span className={styles.CollapsableHeaderTitle}>{name}</span>
+        <div className={styles.TimeBarContainer}>
+          <div
+            className={
+              !isErrored ? styles.TimeBarSpan : styles.TimeBarSpanErrored
+            }
+            style={{
+              left: left.toFixed(2) + '%',
+              width: width.toFixed(2) + '%',
+            }}
+          />
+        </div>
       </Button>
       {isOpen && (
         <div className={styles.CollapsableContent}>
@@ -129,6 +166,24 @@ export default function InspectedElementSuspendedBy({
     () => copy(serializeDataForCopy(suspendedBy)),
   );
 
+  let minTime = Infinity;
+  let maxTime = -Infinity;
+  for (let i = 0; i < suspendedBy.length; i++) {
+    const asyncInfo: SerializedAsyncInfo = suspendedBy[i];
+    if (asyncInfo.awaited.start < minTime) {
+      minTime = asyncInfo.awaited.start;
+    }
+    if (asyncInfo.awaited.end > maxTime) {
+      maxTime = asyncInfo.awaited.end;
+    }
+  }
+
+  if (maxTime - minTime < 25) {
+    // Stretch the time span a bit to ensure that we don't show
+    // large bars that represent very small timespans.
+    minTime = maxTime - 25;
+  }
+
   return (
     <div>
       <div className={styles.HeaderRow}>
@@ -146,6 +201,8 @@ export default function InspectedElementSuspendedBy({
           element={element}
           inspectedElement={inspectedElement}
           store={store}
+          minTime={minTime}
+          maxTime={maxTime}
         />
       ))}
     </div>