@samitouri / QOS-React-2 / commits / 83ea655a0b

[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 +}