From 8b276df415a29bee586473bfced9191b25166790 Mon Sep 17 00:00:00 2001 From: "Sebastian \"Sebbie\" Silbermann" Date: Thu, 5 Feb 2026 23:27:57 +0100 Subject: [PATCH 1/2] [DevTools] Avoid scrollbars in Suspense breadcrumbs (#35700) --- .../views/SuspenseTab/SuspenseBreadcrumbs.css | 63 +++- .../views/SuspenseTab/SuspenseBreadcrumbs.js | 270 ++++++++++++++++-- .../views/SuspenseTab/SuspenseTab.css | 9 - .../devtools/views/SuspenseTab/SuspenseTab.js | 4 +- 4 files changed, 314 insertions(+), 32 deletions(-) diff --git a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseBreadcrumbs.css b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseBreadcrumbs.css index 1e1544b477ca..3bd6a2519161 100644 --- a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseBreadcrumbs.css +++ b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseBreadcrumbs.css @@ -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); +} diff --git a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseBreadcrumbs.js b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseBreadcrumbs.js index 2ad235d57778..53a10ed0dc22 100644 --- a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseBreadcrumbs.js +++ b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseBreadcrumbs.js @@ -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 (
    {lineage === null ? null : lineage.length === 0 ? ( @@ -55,7 +65,7 @@ export default function SuspenseBreadcrumbs(): React$Node { aria-current="true"> @@ -88,3 +98,225 @@ export default function SuspenseBreadcrumbs(): React$Node {
); } + +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 ? ( + + ) : null + ) : ( + <> + + + {selectedSuspenseNode != null && ( + + )} + + )} + + ); +} + +type SuspenseBreadcrumbsDropdownProps = { + lineage: $ReadOnlyArray, + 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( + + {node === null ? 'Unknown' : node.name || 'Unknown'} + , + ); + } + + return ( + + + + + + + + + + {menuItems} + + + ); +} + +type SuspenseBreadcrumbsToParentButtonProps = { + lineage: $ReadOnlyArray, + 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 ( + + ); +} + +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(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 ( +
+ {isOverflowing ? ( + + ) : ( + + )} +
+ ); +} diff --git a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTab.css b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTab.css index a7915d0d9101..92935bd4a6b4 100644 --- a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTab.css +++ b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTab.css @@ -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; diff --git a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTab.js b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTab.js index 0b195a99c4db..3fdd9fe935a3 100644 --- a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTab.js +++ b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTab.js @@ -501,9 +501,7 @@ function SuspenseTab(_: {}) {
)} -
- -
+
{!hideSettings && } From 1c66ac740c650083f008805ed605e8826832dd4c Mon Sep 17 00:00:00 2001 From: "Sebastian \"Sebbie\" Silbermann" Date: Fri, 6 Feb 2026 00:40:31 +0100 Subject: [PATCH 2/2] =?UTF-8?q?[DevTools]=20Separate=20breadcrumbs=20with?= =?UTF-8?q?=20`=C2=BB`=20(#35705)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../devtools/views/Components/OwnersStack.css | 8 ++ .../devtools/views/Components/OwnersStack.js | 88 ++++++++++++------- .../views/SuspenseTab/SuspenseBreadcrumbs.css | 4 + .../views/SuspenseTab/SuspenseBreadcrumbs.js | 88 +++++++++---------- 4 files changed, 110 insertions(+), 78 deletions(-) diff --git a/packages/react-devtools-shared/src/devtools/views/Components/OwnersStack.css b/packages/react-devtools-shared/src/devtools/views/Components/OwnersStack.css index 293faa98376a..ca22f6774f09 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/OwnersStack.css +++ b/packages/react-devtools-shared/src/devtools/views/Components/OwnersStack.css @@ -32,6 +32,14 @@ overflow-x: auto; } +.OwnerStackFlatListContainer { + display: inline-flex; +} + +.OwnerStackFlatListSeparator { + user-select: none; +} + .VRule { flex: 0 0 auto; height: 20px; diff --git a/packages/react-devtools-shared/src/devtools/views/Components/OwnersStack.js b/packages/react-devtools-shared/src/devtools/views/Components/OwnersStack.js index 09bdb96af01c..596113bd8631 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/OwnersStack.js +++ b/packages/react-devtools-shared/src/devtools/views/Components/OwnersStack.js @@ -77,6 +77,54 @@ function dialogReducer(state: State, action: Action) { } } +type OwnerStackFlatListProps = { + owners: Array, + selectedIndex: number, + selectOwner: SelectOwner, + setElementsTotalWidth: (width: number) => void, +}; + +function OwnerStackFlatList({ + owners, + selectedIndex, + selectOwner, + setElementsTotalWidth, +}: OwnerStackFlatListProps): React.Node { + const containerRef = useRef(null); + useLayoutEffect(() => { + const container = containerRef.current; + if (container === null) { + return; + } + + const ResizeObserver = container.ownerDocument.defaultView.ResizeObserver; + const observer = new ResizeObserver(entries => { + const entry = entries[0]; + setElementsTotalWidth(entry.contentRect.width); + }); + + observer.observe(container); + return observer.disconnect.bind(observer); + }, []); + + return ( +
+ {owners.map((owner, index) => ( + + + {index < owners.length - 1 && ( + » + )} + + ))} +
+ ); +} + export default function OwnerStack(): React.Node { const read = useContext(OwnersListContext); const {ownerID} = useContext(TreeStateContext); @@ -135,32 +183,10 @@ export default function OwnerStack(): React.Node { const selectedOwner = owners[selectedIndex]; - useLayoutEffect(() => { - // If we're already overflowing, then we don't need to re-measure items. - // That's because once the owners stack is open, it can only get larger (by drilling in). - // A totally new stack can only be reached by exiting this mode and re-entering it. - if (elementsBarRef.current === null || isOverflowing) { - return () => {}; - } - - let totalWidth = 0; - for (let i = 0; i < owners.length; i++) { - const element = elementsBarRef.current.children[i]; - const computedStyle = getComputedStyle(element); - - totalWidth += - element.offsetWidth + - parseInt(computedStyle.marginLeft, 10) + - parseInt(computedStyle.marginRight, 10); - } - - setElementsTotalWidth(totalWidth); - }, [elementsBarRef, isOverflowing, owners.length]); - return (
- {isOverflowing && ( + {isOverflowing ? ( )} + ) : ( + )} - {!isOverflowing && - owners.map((owner, index) => ( - - ))}
- + +
  • + +
  • + {index < lineage.length - 1 && ( + + » + + )} +
    ); }) )} @@ -271,37 +297,6 @@ export default function SuspenseBreadcrumbs(): React$Node { const containerRef = useRef(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 (
    {isOverflowing ? ( @@ -315,6 +310,7 @@ export default function SuspenseBreadcrumbs(): React$Node { onItemClick={handleClick} onItemPointerEnter={highlightHostInstance} onItemPointerLeave={clearHighlightHostInstance} + setElementsTotalWidth={setElementsTotalWidth} /> )}