[DevTools] Avoid scrollbars in Suspense breadcrumbs (#35700)

This commit is contained in:
Sebastian "Sebbie" Silbermann
2026-02-05 23:27:57 +01:00
committed by GitHub
parent 95ffd6cd9c
commit 8b276df415
4 changed files with 314 additions and 32 deletions

View File

@@ -1,8 +1,13 @@
.SuspenseBreadcrumbsContainer {
flex: 1;
display: flex;
}
.SuspenseBreadcrumbsList {
margin: 0;
padding: 0;
list-style: none;
display: flex;
display: inline-flex;
flex-direction: row;
flex-wrap: nowrap;
}
@@ -34,3 +39,59 @@
.SuspenseBreadcrumbsButton:focus-visible {
background: var(--color-button-background-focus);
}
.SuspenseBreadcrumbsMenuButton {
border-radius: 0.25rem;
display: inline-flex;
align-items: center;
padding: 0;
flex: 0 0 auto;
border: none;
background: var(--color-button-background);
color: var(--color-button);
}
.SuspenseBreadcrumbsMenuButtonContent {
display: inline-flex;
align-items: center;
border-radius: 0.25rem;
padding: 0.25rem;
}
.SuspenseBreadcrumbsMenuButton:hover {
color: var(--color-button-hover);
}
.SuspenseBreadcrumbsMenuButton[aria-expanded="true"],
.SuspenseBreadcrumbsMenuButton[aria-expanded="true"]:active {
color: var(--color-button-active);
outline: none;
}
.SuspenseBreadcrumbsMenuButton:focus,
.SuspenseBreadcrumbsMenuButtonContent:focus {
outline: none;
}
.SuspenseBreadcrumbsMenuButton:focus > .SuspenseBreadcrumbsMenuButtonContent {
background: var(--color-button-background-focus);
}
.SuspenseBreadcrumbsModal[data-reach-menu-list] {
display: inline-flex;
flex-direction: column;
background-color: var(--color-background);
color: var(--color-button);
padding: 0.25rem 0;
padding-right: 0;
border: 1px solid var(--color-border);
border-radius: 0.25rem;
max-height: 10rem;
overflow: auto;
/* Make sure this is above the DevTools, which are above the Overlay */
z-index: 10000002;
position: relative;
/* Reach UI tries to set its own :( */
font-family: var(--font-family-monospace);
font-size: var(--font-size-monospace-normal);
}

View File

@@ -11,37 +11,47 @@ import type {SuspenseNode} from 'react-devtools-shared/src/frontend/types';
import typeof {SyntheticMouseEvent} from 'react-dom-bindings/src/events/SyntheticEvent';
import * as React from 'react';
import {useContext} from 'react';
import {useContext, useLayoutEffect, useRef, useState} from 'react';
import Button from '../Button';
import ButtonIcon from '../ButtonIcon';
import Tooltip from '../Components/reach-ui/tooltip';
import {
Menu,
MenuList,
MenuButton,
MenuItem,
} from '../Components/reach-ui/menu-button';
import {
TreeDispatcherContext,
TreeStateContext,
} from '../Components/TreeContext';
import {StoreContext} from '../context';
import {useHighlightHostInstance} from '../hooks';
import {useHighlightHostInstance, useIsOverflowing} from '../hooks';
import styles from './SuspenseBreadcrumbs.css';
import {
SuspenseTreeStateContext,
SuspenseTreeDispatcherContext,
} from './SuspenseTreeContext';
export default function SuspenseBreadcrumbs(): React$Node {
type SuspenseBreadcrumbsFlatListProps = {
onItemClick: (id: SuspenseNode['id'], event: SyntheticMouseEvent) => void,
onItemPointerEnter: (
id: SuspenseNode['id'],
scrollIntoView?: boolean,
) => void,
onItemPointerLeave: (event: SyntheticMouseEvent) => void,
};
function SuspenseBreadcrumbsFlatList({
onItemClick,
onItemPointerEnter,
onItemPointerLeave,
}: SuspenseBreadcrumbsFlatListProps): React$Node {
const store = useContext(StoreContext);
const {activityID} = useContext(TreeStateContext);
const treeDispatch = useContext(TreeDispatcherContext);
const suspenseTreeDispatch = useContext(SuspenseTreeDispatcherContext);
const {selectedSuspenseID, lineage, roots} = useContext(
SuspenseTreeStateContext,
);
const {highlightHostInstance, clearHighlightHostInstance} =
useHighlightHostInstance();
function handleClick(id: SuspenseNode['id'], event: SyntheticMouseEvent) {
event.preventDefault();
treeDispatch({type: 'SELECT_ELEMENT_BY_ID', payload: id});
suspenseTreeDispatch({type: 'SELECT_SUSPENSE_BY_ID', payload: id});
}
return (
<ol className={styles.SuspenseBreadcrumbsList}>
{lineage === null ? null : lineage.length === 0 ? (
@@ -55,7 +65,7 @@ export default function SuspenseBreadcrumbs(): React$Node {
aria-current="true">
<button
className={styles.SuspenseBreadcrumbsButton}
onClick={handleClick.bind(
onClick={onItemClick.bind(
null,
activityID === null ? roots[0] : activityID,
)}
@@ -73,11 +83,11 @@ export default function SuspenseBreadcrumbs(): React$Node {
key={id}
className={styles.SuspenseBreadcrumbsListItem}
aria-current={selectedSuspenseID === id}
onPointerEnter={highlightHostInstance.bind(null, id, false)}
onPointerLeave={clearHighlightHostInstance}>
onPointerEnter={onItemPointerEnter.bind(null, id, false)}
onPointerLeave={onItemPointerLeave}>
<button
className={styles.SuspenseBreadcrumbsButton}
onClick={handleClick.bind(null, id)}
onClick={onItemClick.bind(null, id)}
type="button">
{node === null ? 'Unknown' : node.name || 'Unknown'}
</button>
@@ -88,3 +98,225 @@ export default function SuspenseBreadcrumbs(): React$Node {
</ol>
);
}
type SuspenseBreadcrumbsMenuProps = {
onItemClick: (id: SuspenseNode['id'], event: SyntheticMouseEvent) => void,
onItemPointerEnter: (
id: SuspenseNode['id'],
scrollIntoView?: boolean,
) => void,
onItemPointerLeave: (event: SyntheticMouseEvent) => void,
};
function SuspenseBreadcrumbsMenu({
onItemClick,
onItemPointerEnter,
onItemPointerLeave,
}: SuspenseBreadcrumbsMenuProps): React$Node {
const store = useContext(StoreContext);
const {activityID} = useContext(TreeStateContext);
const {selectedSuspenseID, lineage, roots} = useContext(
SuspenseTreeStateContext,
);
const selectedSuspenseNode =
selectedSuspenseID !== null
? store.getSuspenseByID(selectedSuspenseID)
: null;
return (
<>
{lineage === null ? null : lineage.length === 0 ? (
// We selected the root. This means that we're currently viewing the Transition
// that rendered the whole screen. In laymans terms this is really "Initial Paint" .
// When we're looking at a subtree selection, then the equivalent is a
// "Transition" since in that case it's really about a Transition within the page.
roots.length > 0 ? (
<button
className={styles.SuspenseBreadcrumbsButton}
onClick={onItemClick.bind(
null,
activityID === null ? roots[0] : activityID,
)}
type="button">
{activityID === null ? 'Initial Paint' : 'Transition'}
</button>
) : null
) : (
<>
<SuspenseBreadcrumbsDropdown
lineage={lineage}
selectElement={onItemClick}
/>
<SuspenseBreadcrumbsToParentButton
lineage={lineage}
selectedSuspenseID={selectedSuspenseID}
selectElement={onItemClick}
/>
{selectedSuspenseNode != null && (
<button
className={styles.SuspenseBreadcrumbsButton}
onClick={onItemClick.bind(null, selectedSuspenseNode.id)}
onPointerEnter={onItemPointerEnter.bind(
null,
selectedSuspenseNode.id,
false,
)}
onPointerLeave={onItemPointerLeave}
type="button">
{selectedSuspenseNode === null
? 'Unknown'
: selectedSuspenseNode.name || 'Unknown'}
</button>
)}
</>
)}
</>
);
}
type SuspenseBreadcrumbsDropdownProps = {
lineage: $ReadOnlyArray<SuspenseNode['id']>,
selectedIndex: number,
selectElement: (id: SuspenseNode['id']) => void,
};
function SuspenseBreadcrumbsDropdown({
lineage,
selectElement,
}: SuspenseBreadcrumbsDropdownProps) {
const store = useContext(StoreContext);
const menuItems = [];
for (let index = lineage.length - 1; index >= 0; index--) {
const suspenseNodeID = lineage[index];
const node = store.getSuspenseByID(suspenseNodeID);
menuItems.push(
<MenuItem
key={suspenseNodeID}
className={`${styles.Component}`}
onSelect={selectElement.bind(null, suspenseNodeID)}>
{node === null ? 'Unknown' : node.name || 'Unknown'}
</MenuItem>,
);
}
return (
<Menu>
<MenuButton className={styles.SuspenseBreadcrumbsMenuButton}>
<Tooltip label="Open elements dropdown">
<span
className={styles.SuspenseBreadcrumbsMenuButtonContent}
tabIndex={-1}>
<ButtonIcon type="more" />
</span>
</Tooltip>
</MenuButton>
<MenuList className={styles.SuspenseBreadcrumbsModal}>
{menuItems}
</MenuList>
</Menu>
);
}
type SuspenseBreadcrumbsToParentButtonProps = {
lineage: $ReadOnlyArray<SuspenseNode['id']>,
selectedSuspenseID: SuspenseNode['id'] | null,
selectElement: (id: SuspenseNode['id'], event: SyntheticMouseEvent) => void,
};
function SuspenseBreadcrumbsToParentButton({
lineage,
selectedSuspenseID,
selectElement,
}: SuspenseBreadcrumbsToParentButtonProps) {
const store = useContext(StoreContext);
const selectedIndex =
selectedSuspenseID === null
? lineage.length - 1
: lineage.indexOf(selectedSuspenseID);
if (selectedIndex <= 0) {
return null;
}
const parentID = lineage[selectedIndex - 1];
const parent = store.getSuspenseByID(parentID);
return (
<Button
className={parent !== null ? undefined : styles.NotInStore}
onClick={parent !== null ? selectElement.bind(null, parentID) : null}
title={`Up to ${parent === null ? 'Unknown' : parent.name || 'Unknown'}`}>
<ButtonIcon type="previous" />
</Button>
);
}
export default function SuspenseBreadcrumbs(): React$Node {
const treeDispatch = useContext(TreeDispatcherContext);
const suspenseTreeDispatch = useContext(SuspenseTreeDispatcherContext);
const {highlightHostInstance, clearHighlightHostInstance} =
useHighlightHostInstance();
function handleClick(id: SuspenseNode['id'], event?: SyntheticMouseEvent) {
if (event !== undefined) {
// E.g. 3rd party component libraries might omit the event and already prevent default
// like Reach's MenuItem does.
event.preventDefault();
}
treeDispatch({type: 'SELECT_ELEMENT_BY_ID', payload: id});
suspenseTreeDispatch({type: 'SELECT_SUSPENSE_BY_ID', payload: id});
}
const [elementsTotalWidth, setElementsTotalWidth] = useState(0);
const containerRef = useRef<HTMLDivElement | null>(null);
const isOverflowing = useIsOverflowing(containerRef, elementsTotalWidth);
useLayoutEffect(() => {
const container = containerRef.current;
if (
container === null ||
// We want to measure the size of the flat list only when it's being used.
isOverflowing
) {
return;
}
const ResizeObserver = container.ownerDocument.defaultView.ResizeObserver;
const observer = new ResizeObserver(() => {
let totalWidth = 0;
for (let i = 0; i < container.children.length; i++) {
const element = container.children[i];
const computedStyle = getComputedStyle(element);
totalWidth +=
element.offsetWidth +
parseInt(computedStyle.marginLeft, 10) +
parseInt(computedStyle.marginRight, 10);
}
setElementsTotalWidth(totalWidth);
});
observer.observe(container);
return observer.disconnect.bind(observer);
}, [containerRef, isOverflowing]);
return (
<div className={styles.SuspenseBreadcrumbsContainer} ref={containerRef}>
{isOverflowing ? (
<SuspenseBreadcrumbsMenu
onItemClick={handleClick}
onItemPointerEnter={highlightHostInstance}
onItemPointerLeave={clearHighlightHostInstance}
/>
) : (
<SuspenseBreadcrumbsFlatList
onItemClick={handleClick}
onItemPointerEnter={highlightHostInstance}
onItemPointerLeave={clearHighlightHostInstance}
/>
)}
</div>
);
}

View File

@@ -121,15 +121,6 @@
border-bottom: 1px solid var(--color-border);
}
.SuspenseBreadcrumbs {
flex: 1;
/**
* TODO: Switch to single item view on overflow like OwnerStack does.
* OwnerStack has more constraints that make it easier so it won't be a 1:1 port.
*/
overflow-x: auto;
}
.SuspenseTreeViewFooter {
flex: 0 0 42px;
display: flex;

View File

@@ -501,9 +501,7 @@ function SuspenseTab(_: {}) {
<div className={styles.VRule} />
</Fragment>
)}
<div className={styles.SuspenseBreadcrumbs}>
<SuspenseBreadcrumbs />
</div>
<SuspenseBreadcrumbs />
<div className={styles.VRule} />
<ToggleUniqueSuspenders />
{!hideSettings && <SettingsModalContextToggle />}