[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 />}