[DevTools] Group consecutive suspended by rows by the same name (#34830)
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" />
Sebastian Markbåge committed
Oct 13, 2025 at 13:07 UTC
83ea655a0ba1de44c933368cd7f56c8f0418f07f
2 files changed
+210
-16
packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSuspendedBy.js
+174
-16
@@ -13,7 +13,7 @@ import {useState, useTransition} from 'react';
13
import Button from '../Button';
14
import ButtonIcon from '../ButtonIcon';
15
import KeyValue from './KeyValue';
16
-import {serializeDataForCopy} from '../utils';
16
+import {serializeDataForCopy, pluralize} from '../utils';
17
import Store from '../../store';
18
import styles from './InspectedElementSharedStyles.css';
19
import {withPermissionsCheck} from 'react-devtools-shared/src/frontend/utils/withPermissionsCheck';
@@ -44,6 +44,7 @@ type RowProps = {
44
index: number,
45
minTime: number,
46
maxTime: number,
47
+ skipName?: boolean,
48
};
49
50
function getShortDescription(name: string, description: string): string {
@@ -99,6 +100,7 @@ function SuspendedByRow({
100
index,
101
minTime,
102
maxTime,
103
+ skipName,
104
}: RowProps) {
105
const [isOpen, setIsOpen] = useState(false);
106
const [openIsPending, startOpenTransition] = useTransition();
@@ -166,8 +168,10 @@ function SuspendedByRow({
168
className={styles.CollapsableHeaderIcon}
169
type={isOpen ? 'expanded' : 'collapsed'}
170
/>
169
- <span className={styles.CollapsableHeaderTitle}>{name}</span>
170
- {shortDescription === '' ? null : (
171
+ <span className={styles.CollapsableHeaderTitle}>
172
+ {skipName ? shortDescription : name}
173
+ </span>
174
+ {skipName || shortDescription === '' ? null : (
175
<>
176
<span className={styles.CollapsableHeaderSeparator}>{' ('}</span>
177
<span className={styles.CollapsableHeaderTitle}>
@@ -331,6 +335,110 @@ function compareTime(
335
return ioA.start - ioB.start;
336
}
337
338
+type GroupProps = {
339
+ bridge: FrontendBridge,
340
+ element: Element,
341
+ inspectedElement: InspectedElement,
342
+ store: Store,
343
+ name: string,
344
+ suspendedBy: Array<{
345
+ index: number,
346
+ value: SerializedAsyncInfo,
347
+ }>,
348
+ minTime: number,
349
+ maxTime: number,
350
+};
351
+
352
+function SuspendedByGroup({
353
+ bridge,
354
+ element,
355
+ inspectedElement,
356
+ store,
357
+ name,
358
+ suspendedBy,
359
+ minTime,
360
+ maxTime,
361
+}: GroupProps) {
362
+ const [isOpen, setIsOpen] = useState(false);
363
+ let start = Infinity;
364
+ let end = -Infinity;
365
+ let isRejected = false;
366
+ for (let i = 0; i < suspendedBy.length; i++) {
367
+ const asyncInfo: SerializedAsyncInfo = suspendedBy[i].value;
368
+ const ioInfo = asyncInfo.awaited;
369
+ if (ioInfo.start < start) {
370
+ start = ioInfo.start;
371
+ }
372
+ if (ioInfo.end > end) {
373
+ end = ioInfo.end;
374
+ }
375
+ const value: any = ioInfo.value;
376
+ if (
377
+ value !== null &&
378
+ typeof value === 'object' &&
379
+ value[meta.name] === 'rejected Thenable'
380
+ ) {
381
+ isRejected = true;
382
+ }
383
+ }
384
+ const timeScale = 100 / (maxTime - minTime);
385
+ let left = (start - minTime) * timeScale;
386
+ let width = (end - start) * timeScale;
387
+ if (width < 5) {
388
+ // Use at least a 5% width to avoid showing too small indicators.
389
+ width = 5;
390
+ if (left > 95) {
391
+ left = 95;
392
+ }
393
+ }
394
+ const pluralizedName = pluralize(name);
395
+ return (
396
+ <div className={styles.CollapsableRow}>
397
+ <Button
398
+ className={styles.CollapsableHeader}
399
+ onClick={() => {
400
+ setIsOpen(prevIsOpen => !prevIsOpen);
401
+ }}
402
+ title={pluralizedName}>
403
+ <ButtonIcon
404
+ className={styles.CollapsableHeaderIcon}
405
+ type={isOpen ? 'expanded' : 'collapsed'}
406
+ />
407
+ <span className={styles.CollapsableHeaderTitle}>{pluralizedName}</span>
408
+ <div className={styles.CollapsableHeaderFiller} />
409
+ {isOpen ? null : (
410
+ <div className={styles.TimeBarContainer}>
411
+ <div
412
+ className={
413
+ !isRejected ? styles.TimeBarSpan : styles.TimeBarSpanErrored
414
+ }
415
+ style={{
416
+ left: left.toFixed(2) + '%',
417
+ width: width.toFixed(2) + '%',
418
+ }}
419
+ />
420
+ </div>
421
+ )}
422
+ </Button>
423
+ {isOpen &&
424
+ suspendedBy.map(({value, index}) => (
425
+ <SuspendedByRow
426
+ key={index}
427
+ index={index}
428
+ asyncInfo={value}
429
+ bridge={bridge}
430
+ element={element}
431
+ inspectedElement={inspectedElement}
432
+ store={store}
433
+ minTime={minTime}
434
+ maxTime={maxTime}
435
+ skipName={true}
436
+ />
437
+ ))}
438
+ </div>
439
+ );
440
+}
441
+
442
export default function InspectedElementSuspendedBy({
443
bridge,
444
element,
@@ -390,6 +498,27 @@ export default function InspectedElementSuspendedBy({
498
suspendedBy === null ? [] : suspendedBy.map(withIndex);
499
sortedSuspendedBy.sort(compareTime);
500
501
+ // Organize into groups of consecutive entries with the same name.
502
+ const groups = [];
503
+ let currentGroup = null;
504
+ let currentGroupName = null;
505
+ for (let i = 0; i < sortedSuspendedBy.length; i++) {
506
+ const entry = sortedSuspendedBy[i];
507
+ const name = entry.value.awaited.name;
508
+ if (
509
+ currentGroupName !== name ||
510
+ !name ||
511
+ name === 'Promise' ||
512
+ currentGroup === null
513
+ ) {
514
+ // Create a new group.
515
+ currentGroupName = name;
516
+ currentGroup = [];
517
+ groups.push(currentGroup);
518
+ }
519
+ currentGroup.push(entry);
520
+ }
521
+
522
let unknownSuspenders = null;
523
switch (inspectedElement.unknownSuspenders) {
524
case UNKNOWN_SUSPENDERS_REASON_PRODUCTION:
@@ -430,19 +559,48 @@ export default function InspectedElementSuspendedBy({
559
<ButtonIcon type="copy" />
560
</Button>
561
</div>
433
- {sortedSuspendedBy.map(({value, index}) => (
434
- <SuspendedByRow
435
- key={index}
436
- index={index}
437
- asyncInfo={value}
438
- bridge={bridge}
439
- element={element}
440
- inspectedElement={inspectedElement}
441
- store={store}
442
- minTime={minTime}
443
- maxTime={maxTime}
444
- />
445
- ))}
562
+ {groups.length === 1
563
+ ? // If it's only one type of suspender we can flatten it.
564
+ groups[0].map(entry => (
565
+ <SuspendedByRow
566
+ key={entry.index}
567
+ index={entry.index}
568
+ asyncInfo={entry.value}
569
+ bridge={bridge}
570
+ element={element}
571
+ inspectedElement={inspectedElement}
572
+ store={store}
573
+ minTime={minTime}
574
+ maxTime={maxTime}
575
+ />
576
+ ))
577
+ : groups.map((entries, index) =>
578
+ entries.length === 1 ? (
579
+ <SuspendedByRow
580
+ key={entries[0].index}
581
+ index={entries[0].index}
582
+ asyncInfo={entries[0].value}
583
+ bridge={bridge}
584
+ element={element}
585
+ inspectedElement={inspectedElement}
586
+ store={store}
587
+ minTime={minTime}
588
+ maxTime={maxTime}
589
+ />
590
+ ) : (
591
+ <SuspendedByGroup
592
+ key={entries[0].index}
593
+ name={entries[0].value.awaited.name}
594
+ suspendedBy={entries}
595
+ bridge={bridge}
596
+ element={element}
597
+ inspectedElement={inspectedElement}
598
+ store={store}
599
+ minTime={minTime}
600
+ maxTime={maxTime}
601
+ />
602
+ ),
603
+ )}
604
{unknownSuspenders}
605
</div>
606
);
packages/react-devtools-shared/src/devtools/views/utils.js
+36
@@ -198,3 +198,39 @@ export function truncateText(text: string, maxLength: number): string {
198
return text;
199
}
200
}
201
+
202
+export function pluralize(word: string): string {
203
+ if (!/^[a-z]+$/i.test(word)) {
204
+ // If it's not a single a-z word, give up.
205
+ return word;
206
+ }
207
+
208
+ switch (word) {
209
+ case 'man':
210
+ return 'men';
211
+ case 'woman':
212
+ return 'women';
213
+ case 'child':
214
+ return 'children';
215
+ case 'foot':
216
+ return 'feet';
217
+ case 'tooth':
218
+ return 'teeth';
219
+ case 'mouse':
220
+ return 'mice';
221
+ case 'person':
222
+ return 'people';
223
+ }
224
+
225
+ // Words ending in s, x, z, ch, sh → add "es"
226
+ if (/(s|x|z|ch|sh)$/i.test(word)) return word + 'es';
227
+
228
+ // Words ending in consonant + y → replace y with "ies"
229
+ if (/[bcdfghjklmnpqrstvwxz]y$/i.test(word)) return word.slice(0, -1) + 'ies';
230
+
231
+ // Words ending in f or fe → replace with "ves"
232
+ if (/(?:f|fe)$/i.test(word)) return word.replace(/(?:f|fe)$/i, 'ves');
233
+
234
+ // Default: just add "s"
235
+ return word + 's';
236
+}