Repository: facebook/react
Pull request: [DevTools] Group consecutive suspended by rows by the same name

Stacked on #34829.

This lets you get an overview more easily when there's lots of things like scripts downloading. Pluralized the name. E.g. `script` -> `scripts` or `fetch` -> `fetches`.

This only groups them consecutively when they'd have the same place in the list anyway because otherwise it might cover up some kind of waterfall effects.

<img width="404" height="225" alt="Screenshot 2025-10-13 at 12 06 51 AM" src="https://github.com/user-attachments/assets/da204a8e-d5f7-4eb0-8c51-4cc5bfd184c4" />

Expanded:

<img width="407" height="360" alt="Screenshot 2025-10-13 at 12 07 00 AM" src="https://github.com/user-attachments/assets/de3c3de9-f314-4c87-b606-31bc49eb4aba" />


Changed files:
filename: packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSuspendedBy.js
patch: @@ -13,7 +13,7 @@ import {useState, useTransition} from 'react';
 import Button from '../Button';
 import ButtonIcon from '../ButtonIcon';
 import KeyValue from './KeyValue';
-import {serializeDataForCopy} from '../utils';
+import {serializeDataForCopy, pluralize} from '../utils';
 import Store from '../../store';
 import styles from './InspectedElementSharedStyles.css';
 import {withPermissionsCheck} from 'react-devtools-shared/src/frontend/utils/withPermissionsCheck';
@@ -44,6 +44,7 @@ type RowProps = {
   index: number,
   minTime: number,
   maxTime: number,
+  skipName?: boolean,
 };
 
 function getShortDescription(name: string, description: string): string {
@@ -99,6 +100,7 @@ function SuspendedByRow({
   index,
   minTime,
   maxTime,
+  skipName,
 }: RowProps) {
   const [isOpen, setIsOpen] = useState(false);
   const [openIsPending, startOpenTransition] = useTransition();
@@ -166,8 +168,10 @@ function SuspendedByRow({
           className={styles.CollapsableHeaderIcon}
           type={isOpen ? 'expanded' : 'collapsed'}
         />
-        <span className={styles.CollapsableHeaderTitle}>{name}</span>
-        {shortDescription === '' ? null : (
+        <span className={styles.CollapsableHeaderTitle}>
+          {skipName ? shortDescription : name}
+        </span>
+        {skipName || shortDescription === '' ? null : (
           <>
             <span className={styles.CollapsableHeaderSeparator}>{' ('}</span>
             <span className={styles.CollapsableHeaderTitle}>
@@ -331,6 +335,110 @@ function compareTime(
   return ioA.start - ioB.start;
 }
 
+type GroupProps = {
+  bridge: FrontendBridge,
+  element: Element,
+  inspectedElement: InspectedElement,
+  store: Store,
+  name: string,
+  suspendedBy: Array<{
+    index: number,
+    value: SerializedAsyncInfo,
+  }>,
+  minTime: number,
+  maxTime: number,
+};
+
+function SuspendedByGroup({
+  bridge,
+  element,
+  inspectedElement,
+  store,
+  name,
+  suspendedBy,
+  minTime,
+  maxTime,
+}: GroupProps) {
+  const [isOpen, setIsOpen] = useState(false);
+  let start = Infinity;
+  let end = -Infinity;
+  let isRejected = false;
+  for (let i = 0; i < suspendedBy.length; i++) {
+    const asyncInfo: SerializedAsyncInfo = suspendedBy[i].value;
+    const ioInfo = asyncInfo.awaited;
+    if (ioInfo.start < start) {
+      start = ioInfo.start;
+    }
+    if (ioInfo.end > end) {
+      end = ioInfo.end;
+    }
+    const value: any = ioInfo.value;
+    if (
+      value !== null &&
+      typeof value === 'object' &&
+      value[meta.name] === 'rejected Thenable'
+    ) {
+      isRejected = true;
+    }
+  }
+  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 pluralizedName = pluralize(name);
+  return (
+    <div className={styles.CollapsableRow}>
+      <Button
+        className={styles.CollapsableHeader}
+        onClick={() => {
+          setIsOpen(prevIsOpen => !prevIsOpen);
+        }}
+        title={pluralizedName}>
+        <ButtonIcon
+          className={styles.CollapsableHeaderIcon}
+          type={isOpen ? 'expanded' : 'collapsed'}
+        />
+        <span className={styles.CollapsableHeaderTitle}>{pluralizedName}</span>
+        <div className={styles.CollapsableHeaderFiller} />
+        {isOpen ? null : (
+          <div className={styles.TimeBarContainer}>
+            <div
+              className={
+                !isRejected ? styles.TimeBarSpan : styles.TimeBarSpanErrored
+              }
+              style={{
+                left: left.toFixed(2) + '%',
+                width: width.toFixed(2) + '%',
+              }}
+            />
+          </div>
+        )}
+      </Button>
+      {isOpen &&
+        suspendedBy.map(({value, index}) => (
+          <SuspendedByRow
+            key={index}
+            index={index}
+            asyncInfo={value}
+            bridge={bridge}
+            element={element}
+            inspectedElement={inspectedElement}
+            store={store}
+            minTime={minTime}
+            maxTime={maxTime}
+            skipName={true}
+          />
+        ))}
+    </div>
+  );
+}
+
 export default function InspectedElementSuspendedBy({
   bridge,
   element,
@@ -390,6 +498,27 @@ export default function InspectedElementSuspendedBy({
     suspendedBy === null ? [] : suspendedBy.map(withIndex);
   sortedSuspendedBy.sort(compareTime);
 
+  // Organize into groups of consecutive entries with the same name.
+  const groups = [];
+  let currentGroup = null;
+  let currentGroupName = null;
+  for (let i = 0; i < sortedSuspendedBy.length; i++) {
+    const entry = sortedSuspendedBy[i];
+    const name = entry.value.awaited.name;
+    if (
+      currentGroupName !== name ||
+      !name ||
+      name === 'Promise' ||
+      currentGroup === null
+    ) {
+      // Create a new group.
+      currentGroupName = name;
+      currentGroup = [];
+      groups.push(currentGroup);
+    }
+    currentGroup.push(entry);
+  }
+
   let unknownSuspenders = null;
   switch (inspectedElement.unknownSuspenders) {
     case UNKNOWN_SUSPENDERS_REASON_PRODUCTION:
@@ -430,19 +559,48 @@ export default function InspectedElementSuspendedBy({
           <ButtonIcon type="copy" />
         </Button>
       </div>
-      {sortedSuspendedBy.map(({value, index}) => (
-        <SuspendedByRow
-          key={index}
-          index={index}
-          asyncInfo={value}
-          bridge={bridge}
-          element={element}
-          inspectedElement={inspectedElement}
-          store={store}
-          minTime={minTime}
-          maxTime={maxTime}
-        />
-      ))}
+      {groups.length === 1
+        ? // If it's only one type of suspender we can flatten it.
+          groups[0].map(entry => (
+            <SuspendedByRow
+              key={entry.index}
+              index={entry.index}
+              asyncInfo={entry.value}
+              bridge={bridge}
+              element={element}
+              inspectedElement={inspectedElement}
+              store={store}
+              minTime={minTime}
+              maxTime={maxTime}
+            />
+          ))
+        : groups.map((entries, index) =>
+            entries.length === 1 ? (
+              <SuspendedByRow
+                key={entries[0].index}
+                index={entries[0].index}
+                asyncInfo={entries[0].value}
+                bridge={bridge}
+                element={element}
+                inspectedElement={inspectedElement}
+                store={store}
+                minTime={minTime}
+                maxTime={maxTime}
+              />
+            ) : (
+              <SuspendedByGroup
+                key={entries[0].index}
+                name={entries[0].value.awaited.name}
+                suspendedBy={entries}
+                bridge={bridge}
+                element={element}
+                inspectedElement={inspectedElement}
+                store={store}
+                minTime={minTime}
+                maxTime={maxTime}
+              />
+            ),
+          )}
       {unknownSuspenders}
     </div>
   );

filename: packages/react-devtools-shared/src/devtools/views/utils.js
patch: @@ -198,3 +198,39 @@ export function truncateText(text: string, maxLength: number): string {
     return text;
   }
 }
+
+export function pluralize(word: string): string {
+  if (!/^[a-z]+$/i.test(word)) {
+    // If it's not a single a-z word, give up.
+    return word;
+  }
+
+  switch (word) {
+    case 'man':
+      return 'men';
+    case 'woman':
+      return 'women';
+    case 'child':
+      return 'children';
+    case 'foot':
+      return 'feet';
+    case 'tooth':
+      return 'teeth';
+    case 'mouse':
+      return 'mice';
+    case 'person':
+      return 'people';
+  }
+
+  // Words ending in s, x, z, ch, sh → add "es"
+  if (/(s|x|z|ch|sh)$/i.test(word)) return word + 'es';
+
+  // Words ending in consonant + y → replace y with "ies"
+  if (/[bcdfghjklmnpqrstvwxz]y$/i.test(word)) return word.slice(0, -1) + 'ies';
+
+  // Words ending in f or fe → replace with "ves"
+  if (/(?:f|fe)$/i.test(word)) return word.replace(/(?:f|fe)$/i, 'ves');
+
+  // Default: just add "s"
+  return word + 's';
+}