diff --git a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSuspendedBy.js b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSuspendedBy.js index 19cc47f982c..fa2b6a95bed 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSuspendedBy.js +++ b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSuspendedBy.js @@ -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'} /> - {name} - {shortDescription === '' ? null : ( + + {skipName ? shortDescription : name} + + {skipName || shortDescription === '' ? null : ( <> {' ('} @@ -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 ( +
+ + {isOpen && + suspendedBy.map(({value, index}) => ( + + ))} +
+ ); +} + 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({ - {sortedSuspendedBy.map(({value, index}) => ( - - ))} + {groups.length === 1 + ? // If it's only one type of suspender we can flatten it. + groups[0].map(entry => ( + + )) + : groups.map((entries, index) => + entries.length === 1 ? ( + + ) : ( + + ), + )} {unknownSuspenders} ); diff --git a/packages/react-devtools-shared/src/devtools/views/utils.js b/packages/react-devtools-shared/src/devtools/views/utils.js index ed14b2c236b..3b0de4118a2 100644 --- a/packages/react-devtools-shared/src/devtools/views/utils.js +++ b/packages/react-devtools-shared/src/devtools/views/utils.js @@ -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'; +}