[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"
/>
This commit is contained in:
Sebastian Markbåge 2025-10-13 13:07:39 -04:00 committed by GitHub
parent 026abeaa5f
commit 83ea655a0b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 210 additions and 16 deletions

View File

@ -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>
);

View File

@ -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';
}