@samitouri / QOS-React / commits / 8b276df415

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

Sebastian "Sebbie" Silbermann committed Feb 5, 2026 at 23:27 UTC 8b276df415a29bee586473bfced9191b25166790
4 files changed +314 -32
packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseBreadcrumbs.css
+62 -1
@@ -1,8 +1,13 @@
1 +.SuspenseBreadcrumbsContainer {
2 + flex: 1;
3 + display: flex;
4 +}
5 +
6 .SuspenseBreadcrumbsList {
7 margin: 0;
8 padding: 0;
9 list-style: none;
5 - display: flex;
10 + display: inline-flex;
11 flex-direction: row;
12 flex-wrap: nowrap;
13 }
@@ -34,3 +39,59 @@
39 .SuspenseBreadcrumbsButton:focus-visible {
40 background: var(--color-button-background-focus);
41 }
42 +
43 +.SuspenseBreadcrumbsMenuButton {
44 + border-radius: 0.25rem;
45 + display: inline-flex;
46 + align-items: center;
47 + padding: 0;
48 + flex: 0 0 auto;
49 + border: none;
50 + background: var(--color-button-background);
51 + color: var(--color-button);
52 +}
53 +
54 +.SuspenseBreadcrumbsMenuButtonContent {
55 + display: inline-flex;
56 + align-items: center;
57 + border-radius: 0.25rem;
58 + padding: 0.25rem;
59 +}
60 +
61 +.SuspenseBreadcrumbsMenuButton:hover {
62 + color: var(--color-button-hover);
63 +}
64 +.SuspenseBreadcrumbsMenuButton[aria-expanded="true"],
65 +.SuspenseBreadcrumbsMenuButton[aria-expanded="true"]:active {
66 + color: var(--color-button-active);
67 + outline: none;
68 +}
69 +
70 +.SuspenseBreadcrumbsMenuButton:focus,
71 +.SuspenseBreadcrumbsMenuButtonContent:focus {
72 + outline: none;
73 +}
74 +.SuspenseBreadcrumbsMenuButton:focus > .SuspenseBreadcrumbsMenuButtonContent {
75 + background: var(--color-button-background-focus);
76 +}
77 +
78 +.SuspenseBreadcrumbsModal[data-reach-menu-list] {
79 + display: inline-flex;
80 + flex-direction: column;
81 + background-color: var(--color-background);
82 + color: var(--color-button);
83 + padding: 0.25rem 0;
84 + padding-right: 0;
85 + border: 1px solid var(--color-border);
86 + border-radius: 0.25rem;
87 + max-height: 10rem;
88 + overflow: auto;
89 +
90 + /* Make sure this is above the DevTools, which are above the Overlay */
91 + z-index: 10000002;
92 + position: relative;
93 +
94 + /* Reach UI tries to set its own :( */
95 + font-family: var(--font-family-monospace);
96 + font-size: var(--font-size-monospace-normal);
97 +}
packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseBreadcrumbs.js
+251 -19
@@ -11,37 +11,47 @@ import type {SuspenseNode} from 'react-devtools-shared/src/frontend/types';
11 import typeof {SyntheticMouseEvent} from 'react-dom-bindings/src/events/SyntheticEvent';
12
13 import * as React from 'react';
14 -import {useContext} from 'react';
14 +import {useContext, useLayoutEffect, useRef, useState} from 'react';
15 +import Button from '../Button';
16 +import ButtonIcon from '../ButtonIcon';
17 +import Tooltip from '../Components/reach-ui/tooltip';
18 +import {
19 + Menu,
20 + MenuList,
21 + MenuButton,
22 + MenuItem,
23 +} from '../Components/reach-ui/menu-button';
24 import {
25 TreeDispatcherContext,
26 TreeStateContext,
27 } from '../Components/TreeContext';
28 import {StoreContext} from '../context';
20 -import {useHighlightHostInstance} from '../hooks';
29 +import {useHighlightHostInstance, useIsOverflowing} from '../hooks';
30 import styles from './SuspenseBreadcrumbs.css';
31 import {
32 SuspenseTreeStateContext,
33 SuspenseTreeDispatcherContext,
34 } from './SuspenseTreeContext';
35
27 -export default function SuspenseBreadcrumbs(): React$Node {
36 +type SuspenseBreadcrumbsFlatListProps = {
37 + onItemClick: (id: SuspenseNode['id'], event: SyntheticMouseEvent) => void,
38 + onItemPointerEnter: (
39 + id: SuspenseNode['id'],
40 + scrollIntoView?: boolean,
41 + ) => void,
42 + onItemPointerLeave: (event: SyntheticMouseEvent) => void,
43 +};
44 +
45 +function SuspenseBreadcrumbsFlatList({
46 + onItemClick,
47 + onItemPointerEnter,
48 + onItemPointerLeave,
49 +}: SuspenseBreadcrumbsFlatListProps): React$Node {
50 const store = useContext(StoreContext);
51 const {activityID} = useContext(TreeStateContext);
30 - const treeDispatch = useContext(TreeDispatcherContext);
31 - const suspenseTreeDispatch = useContext(SuspenseTreeDispatcherContext);
52 const {selectedSuspenseID, lineage, roots} = useContext(
53 SuspenseTreeStateContext,
54 );
35 -
36 - const {highlightHostInstance, clearHighlightHostInstance} =
37 - useHighlightHostInstance();
38 -
39 - function handleClick(id: SuspenseNode['id'], event: SyntheticMouseEvent) {
40 - event.preventDefault();
41 - treeDispatch({type: 'SELECT_ELEMENT_BY_ID', payload: id});
42 - suspenseTreeDispatch({type: 'SELECT_SUSPENSE_BY_ID', payload: id});
43 - }
44 -
55 return (
56 <ol className={styles.SuspenseBreadcrumbsList}>
57 {lineage === null ? null : lineage.length === 0 ? (
@@ -55,7 +65,7 @@ export default function SuspenseBreadcrumbs(): React$Node {
65 aria-current="true">
66 <button
67 className={styles.SuspenseBreadcrumbsButton}
58 - onClick={handleClick.bind(
68 + onClick={onItemClick.bind(
69 null,
70 activityID === null ? roots[0] : activityID,
71 )}
@@ -73,11 +83,11 @@ export default function SuspenseBreadcrumbs(): React$Node {
83 key={id}
84 className={styles.SuspenseBreadcrumbsListItem}
85 aria-current={selectedSuspenseID === id}
76 - onPointerEnter={highlightHostInstance.bind(null, id, false)}
77 - onPointerLeave={clearHighlightHostInstance}>
86 + onPointerEnter={onItemPointerEnter.bind(null, id, false)}
87 + onPointerLeave={onItemPointerLeave}>
88 <button
89 className={styles.SuspenseBreadcrumbsButton}
80 - onClick={handleClick.bind(null, id)}
90 + onClick={onItemClick.bind(null, id)}
91 type="button">
92 {node === null ? 'Unknown' : node.name || 'Unknown'}
93 </button>
@@ -88,3 +98,225 @@ export default function SuspenseBreadcrumbs(): React$Node {
98 </ol>
99 );
100 }
101 +
102 +type SuspenseBreadcrumbsMenuProps = {
103 + onItemClick: (id: SuspenseNode['id'], event: SyntheticMouseEvent) => void,
104 + onItemPointerEnter: (
105 + id: SuspenseNode['id'],
106 + scrollIntoView?: boolean,
107 + ) => void,
108 + onItemPointerLeave: (event: SyntheticMouseEvent) => void,
109 +};
110 +
111 +function SuspenseBreadcrumbsMenu({
112 + onItemClick,
113 + onItemPointerEnter,
114 + onItemPointerLeave,
115 +}: SuspenseBreadcrumbsMenuProps): React$Node {
116 + const store = useContext(StoreContext);
117 + const {activityID} = useContext(TreeStateContext);
118 + const {selectedSuspenseID, lineage, roots} = useContext(
119 + SuspenseTreeStateContext,
120 + );
121 + const selectedSuspenseNode =
122 + selectedSuspenseID !== null
123 + ? store.getSuspenseByID(selectedSuspenseID)
124 + : null;
125 +
126 + return (
127 + <>
128 + {lineage === null ? null : lineage.length === 0 ? (
129 + // We selected the root. This means that we're currently viewing the Transition
130 + // that rendered the whole screen. In laymans terms this is really "Initial Paint" .
131 + // When we're looking at a subtree selection, then the equivalent is a
132 + // "Transition" since in that case it's really about a Transition within the page.
133 + roots.length > 0 ? (
134 + <button
135 + className={styles.SuspenseBreadcrumbsButton}
136 + onClick={onItemClick.bind(
137 + null,
138 + activityID === null ? roots[0] : activityID,
139 + )}
140 + type="button">
141 + {activityID === null ? 'Initial Paint' : 'Transition'}
142 + </button>
143 + ) : null
144 + ) : (
145 + <>
146 + <SuspenseBreadcrumbsDropdown
147 + lineage={lineage}
148 + selectElement={onItemClick}
149 + />
150 + <SuspenseBreadcrumbsToParentButton
151 + lineage={lineage}
152 + selectedSuspenseID={selectedSuspenseID}
153 + selectElement={onItemClick}
154 + />
155 + {selectedSuspenseNode != null && (
156 + <button
157 + className={styles.SuspenseBreadcrumbsButton}
158 + onClick={onItemClick.bind(null, selectedSuspenseNode.id)}
159 + onPointerEnter={onItemPointerEnter.bind(
160 + null,
161 + selectedSuspenseNode.id,
162 + false,
163 + )}
164 + onPointerLeave={onItemPointerLeave}
165 + type="button">
166 + {selectedSuspenseNode === null
167 + ? 'Unknown'
168 + : selectedSuspenseNode.name || 'Unknown'}
169 + </button>
170 + )}
171 + </>
172 + )}
173 + </>
174 + );
175 +}
176 +
177 +type SuspenseBreadcrumbsDropdownProps = {
178 + lineage: $ReadOnlyArray<SuspenseNode['id']>,
179 + selectedIndex: number,
180 + selectElement: (id: SuspenseNode['id']) => void,
181 +};
182 +function SuspenseBreadcrumbsDropdown({
183 + lineage,
184 + selectElement,
185 +}: SuspenseBreadcrumbsDropdownProps) {
186 + const store = useContext(StoreContext);
187 +
188 + const menuItems = [];
189 + for (let index = lineage.length - 1; index >= 0; index--) {
190 + const suspenseNodeID = lineage[index];
191 + const node = store.getSuspenseByID(suspenseNodeID);
192 + menuItems.push(
193 + <MenuItem
194 + key={suspenseNodeID}
195 + className={`${styles.Component}`}
196 + onSelect={selectElement.bind(null, suspenseNodeID)}>
197 + {node === null ? 'Unknown' : node.name || 'Unknown'}
198 + </MenuItem>,
199 + );
200 + }
201 +
202 + return (
203 + <Menu>
204 + <MenuButton className={styles.SuspenseBreadcrumbsMenuButton}>
205 + <Tooltip label="Open elements dropdown">
206 + <span
207 + className={styles.SuspenseBreadcrumbsMenuButtonContent}
208 + tabIndex={-1}>
209 + <ButtonIcon type="more" />
210 + </span>
211 + </Tooltip>
212 + </MenuButton>
213 + <MenuList className={styles.SuspenseBreadcrumbsModal}>
214 + {menuItems}
215 + </MenuList>
216 + </Menu>
217 + );
218 +}
219 +
220 +type SuspenseBreadcrumbsToParentButtonProps = {
221 + lineage: $ReadOnlyArray<SuspenseNode['id']>,
222 + selectedSuspenseID: SuspenseNode['id'] | null,
223 + selectElement: (id: SuspenseNode['id'], event: SyntheticMouseEvent) => void,
224 +};
225 +function SuspenseBreadcrumbsToParentButton({
226 + lineage,
227 + selectedSuspenseID,
228 + selectElement,
229 +}: SuspenseBreadcrumbsToParentButtonProps) {
230 + const store = useContext(StoreContext);
231 + const selectedIndex =
232 + selectedSuspenseID === null
233 + ? lineage.length - 1
234 + : lineage.indexOf(selectedSuspenseID);
235 +
236 + if (selectedIndex <= 0) {
237 + return null;
238 + }
239 +
240 + const parentID = lineage[selectedIndex - 1];
241 + const parent = store.getSuspenseByID(parentID);
242 +
243 + return (
244 + <Button
245 + className={parent !== null ? undefined : styles.NotInStore}
246 + onClick={parent !== null ? selectElement.bind(null, parentID) : null}
247 + title={`Up to ${parent === null ? 'Unknown' : parent.name || 'Unknown'}`}>
248 + <ButtonIcon type="previous" />
249 + </Button>
250 + );
251 +}
252 +
253 +export default function SuspenseBreadcrumbs(): React$Node {
254 + const treeDispatch = useContext(TreeDispatcherContext);
255 + const suspenseTreeDispatch = useContext(SuspenseTreeDispatcherContext);
256 +
257 + const {highlightHostInstance, clearHighlightHostInstance} =
258 + useHighlightHostInstance();
259 +
260 + function handleClick(id: SuspenseNode['id'], event?: SyntheticMouseEvent) {
261 + if (event !== undefined) {
262 + // E.g. 3rd party component libraries might omit the event and already prevent default
263 + // like Reach's MenuItem does.
264 + event.preventDefault();
265 + }
266 + treeDispatch({type: 'SELECT_ELEMENT_BY_ID', payload: id});
267 + suspenseTreeDispatch({type: 'SELECT_SUSPENSE_BY_ID', payload: id});
268 + }
269 +
270 + const [elementsTotalWidth, setElementsTotalWidth] = useState(0);
271 + const containerRef = useRef<HTMLDivElement | null>(null);
272 + const isOverflowing = useIsOverflowing(containerRef, elementsTotalWidth);
273 +
274 + useLayoutEffect(() => {
275 + const container = containerRef.current;
276 +
277 + if (
278 + container === null ||
279 + // We want to measure the size of the flat list only when it's being used.
280 + isOverflowing
281 + ) {
282 + return;
283 + }
284 +
285 + const ResizeObserver = container.ownerDocument.defaultView.ResizeObserver;
286 + const observer = new ResizeObserver(() => {
287 + let totalWidth = 0;
288 + for (let i = 0; i < container.children.length; i++) {
289 + const element = container.children[i];
290 + const computedStyle = getComputedStyle(element);
291 +
292 + totalWidth +=
293 + element.offsetWidth +
294 + parseInt(computedStyle.marginLeft, 10) +
295 + parseInt(computedStyle.marginRight, 10);
296 + }
297 + setElementsTotalWidth(totalWidth);
298 + });
299 +
300 + observer.observe(container);
301 +
302 + return observer.disconnect.bind(observer);
303 + }, [containerRef, isOverflowing]);
304 +
305 + return (
306 + <div className={styles.SuspenseBreadcrumbsContainer} ref={containerRef}>
307 + {isOverflowing ? (
308 + <SuspenseBreadcrumbsMenu
309 + onItemClick={handleClick}
310 + onItemPointerEnter={highlightHostInstance}
311 + onItemPointerLeave={clearHighlightHostInstance}
312 + />
313 + ) : (
314 + <SuspenseBreadcrumbsFlatList
315 + onItemClick={handleClick}
316 + onItemPointerEnter={highlightHostInstance}
317 + onItemPointerLeave={clearHighlightHostInstance}
318 + />
319 + )}
320 + </div>
321 + );
322 +}
packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTab.css
-9
@@ -121,15 +121,6 @@
121 border-bottom: 1px solid var(--color-border);
122 }
123
124 -.SuspenseBreadcrumbs {
125 - flex: 1;
126 - /**
127 - * TODO: Switch to single item view on overflow like OwnerStack does.
128 - * OwnerStack has more constraints that make it easier so it won't be a 1:1 port.
129 - */
130 - overflow-x: auto;
131 -}
132 -
124 .SuspenseTreeViewFooter {
125 flex: 0 0 42px;
126 display: flex;
packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTab.js
+1 -3
@@ -501,9 +501,7 @@ function SuspenseTab(_: {}) {
501 <div className={styles.VRule} />
502 </Fragment>
503 )}
504 - <div className={styles.SuspenseBreadcrumbs}>
505 - <SuspenseBreadcrumbs />
506 - </div>
504 + <SuspenseBreadcrumbs />
505 <div className={styles.VRule} />
506 <ToggleUniqueSuspenders />
507 {!hideSettings && <SettingsModalContextToggle />}