Skip to content

Commit 83ea655

Browse files
authored
[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" />
1 parent 026abea commit 83ea655

File tree

2 files changed

+210
-16
lines changed

2 files changed

+210
-16
lines changed

packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSuspendedBy.js

Lines changed: 174 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import {useState, useTransition} from 'react';
1313
import Button from '../Button';
1414
import ButtonIcon from '../ButtonIcon';
1515
import KeyValue from './KeyValue';
16-
import {serializeDataForCopy} from '../utils';
16+
import {serializeDataForCopy, pluralize} from '../utils';
1717
import Store from '../../store';
1818
import styles from './InspectedElementSharedStyles.css';
1919
import {withPermissionsCheck} from 'react-devtools-shared/src/frontend/utils/withPermissionsCheck';
@@ -44,6 +44,7 @@ type RowProps = {
4444
index: number,
4545
minTime: number,
4646
maxTime: number,
47+
skipName?: boolean,
4748
};
4849

4950
function getShortDescription(name: string, description: string): string {
@@ -99,6 +100,7 @@ function SuspendedByRow({
99100
index,
100101
minTime,
101102
maxTime,
103+
skipName,
102104
}: RowProps) {
103105
const [isOpen, setIsOpen] = useState(false);
104106
const [openIsPending, startOpenTransition] = useTransition();
@@ -166,8 +168,10 @@ function SuspendedByRow({
166168
className={styles.CollapsableHeaderIcon}
167169
type={isOpen ? 'expanded' : 'collapsed'}
168170
/>
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 : (
171175
<>
172176
<span className={styles.CollapsableHeaderSeparator}>{' ('}</span>
173177
<span className={styles.CollapsableHeaderTitle}>
@@ -331,6 +335,110 @@ function compareTime(
331335
return ioA.start - ioB.start;
332336
}
333337

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+
334442
export default function InspectedElementSuspendedBy({
335443
bridge,
336444
element,
@@ -390,6 +498,27 @@ export default function InspectedElementSuspendedBy({
390498
suspendedBy === null ? [] : suspendedBy.map(withIndex);
391499
sortedSuspendedBy.sort(compareTime);
392500

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+
393522
let unknownSuspenders = null;
394523
switch (inspectedElement.unknownSuspenders) {
395524
case UNKNOWN_SUSPENDERS_REASON_PRODUCTION:
@@ -430,19 +559,48 @@ export default function InspectedElementSuspendedBy({
430559
<ButtonIcon type="copy" />
431560
</Button>
432561
</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+
)}
446604
{unknownSuspenders}
447605
</div>
448606
);

packages/react-devtools-shared/src/devtools/views/utils.js

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,3 +198,39 @@ export function truncateText(text: string, maxLength: number): string {
198198
return text;
199199
}
200200
}
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+
}

0 commit comments

Comments
 (0)