/
githubmr
/
facebook-react
Обзор
Документация
Войти
/
githubmr
/
facebook-react
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
Аналитика
main
packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSuspendedBy.js
607 строк
18 KB
Sebastian Markbåge
[DevTools] Group consecutive suspended by rows by the same name (#34830)
13 окт 2025, 20:07
Не верифицирован
13 окт 2025, 20:07
83ea655
Код
Авторство
О чём код?
/** * Copyright (c) Meta Platforms, Inc. and affiliates. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * * @flow */ import {copy} from 'clipboard-js'; import * as React from 'react'; import {useState, useTransition} from 'react'; import Button from '../Button'; import ButtonIcon from '../ButtonIcon'; import KeyValue from './KeyValue'; import {serializeDataForCopy, pluralize} from '../utils'; import Store from '../../store'; import styles from './InspectedElementSharedStyles.css'; import {withPermissionsCheck} from 'react-devtools-shared/src/frontend/utils/withPermissionsCheck'; import StackTraceView from './StackTraceView'; import OwnerView from './OwnerView'; import {meta} from '../../../hydration'; import useInferredName from '../useInferredName'; import type { InspectedElement, SerializedAsyncInfo, } from 'react-devtools-shared/src/frontend/types'; import type {FrontendBridge} from 'react-devtools-shared/src/bridge'; import { UNKNOWN_SUSPENDERS_NONE, UNKNOWN_SUSPENDERS_REASON_PRODUCTION, UNKNOWN_SUSPENDERS_REASON_OLD_VERSION, UNKNOWN_SUSPENDERS_REASON_THROWN_PROMISE, } from '../../../constants'; type RowProps = { bridge: FrontendBridge, element: Element, inspectedElement: InspectedElement, store: Store, asyncInfo: SerializedAsyncInfo, index: number, minTime: number, maxTime: number, skipName?: boolean, }; function getShortDescription(name: string, description: string): string { const descMaxLength = 30 - name.length; if (descMaxLength > 1) { const l = description.length; if (l > 0 && l <= descMaxLength) { // We can fit the full description return description; } else if ( description.startsWith('http://') || description.startsWith('https://') || description.startsWith('/') ) { // Looks like a URL. Let's see if we can extract something shorter. // We don't have to do a full parse so let's try something cheaper. let queryIdx = description.indexOf('?'); if (queryIdx === -1) { queryIdx = description.length; } if (description.charCodeAt(queryIdx - 1) === 47 /* "/" */) { // Ends with slash. Look before that. queryIdx--; } const slashIdx = description.lastIndexOf('/', queryIdx - 1); // This may now be either the file name or the host. // Include the slash to make it more obvious what we trimmed. return '…' + description.slice(slashIdx, queryIdx); } } return ''; } function formatBytes(bytes: number) { if (bytes < 1_000) { return bytes + ' bytes'; } if (bytes < 1_000_000) { return (bytes / 1_000).toFixed(1) + ' kB'; } if (bytes < 1_000_000_000) { return (bytes / 1_000_000).toFixed(1) + ' mB'; } return (bytes / 1_000_000_000).toFixed(1) + ' gB'; } function SuspendedByRow({ bridge, element, inspectedElement, store, asyncInfo, index, minTime, maxTime, skipName, }: RowProps) { const [isOpen, setIsOpen] = useState(false); const [openIsPending, startOpenTransition] = useTransition(); const ioInfo = asyncInfo.awaited; const name = useInferredName(asyncInfo); const description = ioInfo.description; const longName = description === '' ? name : name + ' (' + description + ')'; const shortDescription = getShortDescription(name, description); const start = ioInfo.start; const end = ioInfo.end; 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 ioOwner = ioInfo.owner; const asyncOwner = asyncInfo.owner; const showIOStack = ioInfo.stack !== null && ioInfo.stack.length !== 0; // Only show the awaited stack if the I/O started in a different owner // than where it was awaited. If it's started by the same component it's // probably easy enough to infer and less noise in the common case. const canShowAwaitStack = (asyncInfo.stack !== null && asyncInfo.stack.length > 0) || (asyncOwner !== null && asyncOwner.id !== inspectedElement.id); const showAwaitStack = canShowAwaitStack && (!showIOStack || (ioOwner === null ? asyncOwner !== null : asyncOwner === null || ioOwner.id !== asyncOwner.id)); const value: any = ioInfo.value; const metaName = value !== null && typeof value === 'object' ? value[meta.name] : null; const isFulfilled = metaName === 'fulfilled Thenable'; const isRejected = metaName === 'rejected Thenable'; return ( <div className={styles.CollapsableRow}> <Button className={styles.CollapsableHeader} // TODO: May be better to leave to React's default Transition indicator. // Though no apps implement this option at the moment. data-pending={openIsPending} onClick={() => { startOpenTransition(() => { setIsOpen(prevIsOpen => !prevIsOpen); }); }} // Changing the title on pending transition will not be visible since // (Reach?) tooltips are dismissed on activation. title={ longName + ' — ' + (end - start).toFixed(2) + ' ms' + (ioInfo.byteSize != null ? ' — ' + formatBytes(ioInfo.byteSize) : '') }> <ButtonIcon className={styles.CollapsableHeaderIcon} type={isOpen ? 'expanded' : 'collapsed'} /> <span className={styles.CollapsableHeaderTitle}> {skipName ? shortDescription : name} </span> {skipName || shortDescription === '' ? null : ( <> <span className={styles.CollapsableHeaderSeparator}>{' ('}</span> <span className={styles.CollapsableHeaderTitle}> {shortDescription} </span> <span className={styles.CollapsableHeaderSeparator}>{') '}</span> </> )} <div className={styles.CollapsableHeaderFiller} /> <div className={styles.TimeBarContainer}> <div className={ !isRejected ? styles.TimeBarSpan : styles.TimeBarSpanErrored } style={{ left: left.toFixed(2) + '%', width: width.toFixed(2) + '%', }} /> </div> </Button> {isOpen && ( <div className={styles.CollapsableContent}> {showIOStack && ( <StackTraceView stack={ioInfo.stack} environmentName={ ioOwner !== null && ioOwner.env === ioInfo.env ? null : ioInfo.env } /> )} {ioOwner !== null && ioOwner.id !== inspectedElement.id && (showIOStack || !showAwaitStack || asyncOwner === null || ioOwner.id !== asyncOwner.id) ? ( <OwnerView key={ioOwner.id} displayName={ioOwner.displayName || 'Anonymous'} environmentName={ ioOwner.env === inspectedElement.env && ioOwner.env === ioInfo.env ? null : ioOwner.env } hocDisplayNames={ioOwner.hocDisplayNames} compiledWithForget={ioOwner.compiledWithForget} id={ioOwner.id} isInStore={store.containsElement(ioOwner.id)} type={ioOwner.type} /> ) : null} {showAwaitStack ? ( <> <div className={styles.SmallHeader}>awaited at:</div> {asyncInfo.stack !== null && asyncInfo.stack.length > 0 && ( <StackTraceView stack={asyncInfo.stack} environmentName={ asyncOwner !== null && asyncOwner.env === asyncInfo.env ? null : asyncInfo.env } /> )} {asyncOwner !== null && asyncOwner.id !== inspectedElement.id ? ( <OwnerView key={asyncOwner.id} displayName={asyncOwner.displayName || 'Anonymous'} environmentName={ asyncOwner.env === inspectedElement.env && asyncOwner.env === asyncInfo.env ? null : asyncOwner.env } hocDisplayNames={asyncOwner.hocDisplayNames} compiledWithForget={asyncOwner.compiledWithForget} id={asyncOwner.id} isInStore={store.containsElement(asyncOwner.id)} type={asyncOwner.type} /> ) : null} </> ) : null} <div className={styles.PreviewContainer}> <KeyValue alphaSort={true} bridge={bridge} canDeletePaths={false} canEditValues={false} canRenamePaths={false} depth={1} element={element} hidden={false} inspectedElement={inspectedElement} name={ isFulfilled ? 'awaited value' : isRejected ? 'rejected with' : 'pending value' } path={ isFulfilled ? [index, 'awaited', 'value', 'value'] : isRejected ? [index, 'awaited', 'value', 'reason'] : [index, 'awaited', 'value'] } pathRoot="suspendedBy" store={store} value={ isFulfilled ? value.value : isRejected ? value.reason : value } /> </div> </div> )} </div> ); } type Props = { bridge: FrontendBridge, element: Element, inspectedElement: InspectedElement, store: Store, }; function withIndex( value: SerializedAsyncInfo, index: number, ): { index: number, value: SerializedAsyncInfo, } { return { index, value, }; } function compareTime( a: { index: number, value: SerializedAsyncInfo, }, b: { index: number, value: SerializedAsyncInfo, }, ): number { const ioA = a.value.awaited; const ioB = b.value.awaited; if (ioA.start === ioB.start) { return ioA.end - ioB.end; } 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, inspectedElement, store, }: Props): React.Node { const {suspendedBy, suspendedByRange} = inspectedElement; // Skip the section if nothing suspended this component. if ( (suspendedBy == null || suspendedBy.length === 0) && inspectedElement.unknownSuspenders === UNKNOWN_SUSPENDERS_NONE ) { if (inspectedElement.isSuspended) { // If we're still suspended, show a place holder until the data loads. // We don't know what we're suspended by until it has loaded. return ( <div> <div className={styles.HeaderRow}> <div className={styles.Header}>suspended...</div> </div> </div> ); } return null; } const handleCopy = withPermissionsCheck( {permissions: ['clipboardWrite']}, () => copy(serializeDataForCopy(suspendedBy)), ); let minTime = Infinity; let maxTime = -Infinity; if (suspendedByRange !== null) { // The range of the whole suspense boundary. minTime = suspendedByRange[0]; maxTime = suspendedByRange[1]; } for (let i = 0; i < suspendedBy.length; i++) { const asyncInfo: SerializedAsyncInfo = suspendedBy[i]; if (asyncInfo.awaited.start < minTime) { minTime = asyncInfo.awaited.start; } if (asyncInfo.awaited.end > maxTime) { maxTime = asyncInfo.awaited.end; } } if (maxTime - minTime < 25) { // Stretch the time span a bit to ensure that we don't show // large bars that represent very small timespans. minTime = maxTime - 25; } const sortedSuspendedBy = 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: unknownSuspenders = ( <div className={styles.InfoRow}> Something suspended but we don't know the exact reason in production builds of React. Test this in development mode to see exactly what might suspend. </div> ); break; case UNKNOWN_SUSPENDERS_REASON_OLD_VERSION: unknownSuspenders = ( <div className={styles.InfoRow}> Something suspended but we don't track all the necessary information in older versions of React. Upgrade to the latest version of React to see exactly what might suspend. </div> ); break; case UNKNOWN_SUSPENDERS_REASON_THROWN_PROMISE: unknownSuspenders = ( <div className={styles.InfoRow}> Something threw a Promise to suspend this boundary. It's likely an outdated version of a library that doesn't yet fully take advantage of use(). Upgrade your data fetching library to see exactly what might suspend. </div> ); break; } return ( <div> <div className={styles.HeaderRow}> <div className={styles.Header}>suspended by</div> <Button onClick={handleCopy} title="Copy to clipboard"> <ButtonIcon type="copy" /> </Button> </div> {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> ); }