[DevTools] Separate breadcrumbs with `»` (#35705)
Sebastian "Sebbie" Silbermann committed
Feb 6, 2026 at 00:40 UTC
1c66ac740c650083f008805ed605e8826832dd4c
4 files changed
+110
-78
packages/react-devtools-shared/src/devtools/views/Components/OwnersStack.css
+8
@@ -32,6 +32,14 @@
32
overflow-x: auto;
33
}
34
35
+.OwnerStackFlatListContainer {
36
+ display: inline-flex;
37
+}
38
+
39
+.OwnerStackFlatListSeparator {
40
+ user-select: none;
41
+}
42
+
43
.VRule {
44
flex: 0 0 auto;
45
height: 20px;
packages/react-devtools-shared/src/devtools/views/Components/OwnersStack.js
+56
-32
@@ -77,6 +77,54 @@ function dialogReducer(state: State, action: Action) {
77
}
78
}
79
80
+type OwnerStackFlatListProps = {
81
+ owners: Array<SerializedElement>,
82
+ selectedIndex: number,
83
+ selectOwner: SelectOwner,
84
+ setElementsTotalWidth: (width: number) => void,
85
+};
86
+
87
+function OwnerStackFlatList({
88
+ owners,
89
+ selectedIndex,
90
+ selectOwner,
91
+ setElementsTotalWidth,
92
+}: OwnerStackFlatListProps): React.Node {
93
+ const containerRef = useRef<HTMLDivElement | null>(null);
94
+ useLayoutEffect(() => {
95
+ const container = containerRef.current;
96
+ if (container === null) {
97
+ return;
98
+ }
99
+
100
+ const ResizeObserver = container.ownerDocument.defaultView.ResizeObserver;
101
+ const observer = new ResizeObserver(entries => {
102
+ const entry = entries[0];
103
+ setElementsTotalWidth(entry.contentRect.width);
104
+ });
105
+
106
+ observer.observe(container);
107
+ return observer.disconnect.bind(observer);
108
+ }, []);
109
+
110
+ return (
111
+ <div className={styles.OwnerStackFlatListContainer} ref={containerRef}>
112
+ {owners.map((owner, index) => (
113
+ <Fragment key={index}>
114
+ <ElementView
115
+ owner={owner}
116
+ isSelected={index === selectedIndex}
117
+ selectOwner={selectOwner}
118
+ />
119
+ {index < owners.length - 1 && (
120
+ <span className={styles.OwnerStackFlatListSeparator}>»</span>
121
+ )}
122
+ </Fragment>
123
+ ))}
124
+ </div>
125
+ );
126
+}
127
+
128
export default function OwnerStack(): React.Node {
129
const read = useContext(OwnersListContext);
130
const {ownerID} = useContext(TreeStateContext);
@@ -135,32 +183,10 @@ export default function OwnerStack(): React.Node {
183
184
const selectedOwner = owners[selectedIndex];
185
138
- useLayoutEffect(() => {
139
- // If we're already overflowing, then we don't need to re-measure items.
140
- // That's because once the owners stack is open, it can only get larger (by drilling in).
141
- // A totally new stack can only be reached by exiting this mode and re-entering it.
142
- if (elementsBarRef.current === null || isOverflowing) {
143
- return () => {};
144
- }
145
-
146
- let totalWidth = 0;
147
- for (let i = 0; i < owners.length; i++) {
148
- const element = elementsBarRef.current.children[i];
149
- const computedStyle = getComputedStyle(element);
150
-
151
- totalWidth +=
152
- element.offsetWidth +
153
- parseInt(computedStyle.marginLeft, 10) +
154
- parseInt(computedStyle.marginRight, 10);
155
- }
156
-
157
- setElementsTotalWidth(totalWidth);
158
- }, [elementsBarRef, isOverflowing, owners.length]);
159
-
186
return (
187
<div className={styles.OwnerStack}>
188
<div className={styles.Bar} ref={elementsBarRef}>
163
- {isOverflowing && (
189
+ {isOverflowing ? (
190
<Fragment>
191
<ElementsDropdown
192
owners={owners}
@@ -180,16 +206,14 @@ export default function OwnerStack(): React.Node {
206
/>
207
)}
208
</Fragment>
209
+ ) : (
210
+ <OwnerStackFlatList
211
+ owners={owners}
212
+ selectedIndex={selectedIndex}
213
+ selectOwner={selectOwner}
214
+ setElementsTotalWidth={setElementsTotalWidth}
215
+ />
216
)}
184
- {!isOverflowing &&
185
- owners.map((owner, index) => (
186
- <ElementView
187
- key={index}
188
- owner={owner}
189
- isSelected={index === selectedIndex}
190
- selectOwner={selectOwner}
191
- />
192
- ))}
217
</div>
218
<div className={styles.VRule} />
219
<Button onClick={() => selectOwner(null)} title="Back to tree view">
packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseBreadcrumbs.css
+4
@@ -16,6 +16,10 @@
16
display: inline;
17
}
18
19
+.SuspenseBreadcrumbsListItemSeparator {
20
+ user-select: none;
21
+}
22
+
23
.SuspenseBreadcrumbsListItem[aria-current="true"] .SuspenseBreadcrumbsButton {
24
color: var(--color-button-active);
25
}
packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseBreadcrumbs.js
+42
-46
@@ -11,7 +11,7 @@ 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, useLayoutEffect, useRef, useState} from 'react';
14
+import {Fragment, useContext, useLayoutEffect, useRef, useState} from 'react';
15
import Button from '../Button';
16
import ButtonIcon from '../ButtonIcon';
17
import Tooltip from '../Components/reach-ui/tooltip';
@@ -40,20 +40,40 @@ type SuspenseBreadcrumbsFlatListProps = {
40
scrollIntoView?: boolean,
41
) => void,
42
onItemPointerLeave: (event: SyntheticMouseEvent) => void,
43
+ setElementsTotalWidth: (width: number) => void,
44
};
45
46
function SuspenseBreadcrumbsFlatList({
47
onItemClick,
48
onItemPointerEnter,
49
onItemPointerLeave,
50
+ setElementsTotalWidth,
51
}: SuspenseBreadcrumbsFlatListProps): React$Node {
52
const store = useContext(StoreContext);
53
const {activityID} = useContext(TreeStateContext);
54
const {selectedSuspenseID, lineage, roots} = useContext(
55
SuspenseTreeStateContext,
56
);
57
+
58
+ const containerRef = useRef<HTMLDivElement | null>(null);
59
+ useLayoutEffect(() => {
60
+ const container = containerRef.current;
61
+ if (container === null) {
62
+ return;
63
+ }
64
+
65
+ const ResizeObserver = container.ownerDocument.defaultView.ResizeObserver;
66
+ const observer = new ResizeObserver(entries => {
67
+ const entry = entries[0];
68
+ setElementsTotalWidth(entry.contentRect.width);
69
+ });
70
+
71
+ observer.observe(container);
72
+ return observer.disconnect.bind(observer);
73
+ }, []);
74
+
75
return (
56
- <ol className={styles.SuspenseBreadcrumbsList}>
76
+ <ol className={styles.SuspenseBreadcrumbsList} ref={containerRef}>
77
{lineage === null ? null : lineage.length === 0 ? (
78
// We selected the root. This means that we're currently viewing the Transition
79
// that rendered the whole screen. In laymans terms this is really "Initial Paint" .
@@ -79,19 +99,25 @@ function SuspenseBreadcrumbsFlatList({
99
const node = store.getSuspenseByID(id);
100
101
return (
82
- <li
83
- key={id}
84
- className={styles.SuspenseBreadcrumbsListItem}
85
- aria-current={selectedSuspenseID === id}
86
- onPointerEnter={onItemPointerEnter.bind(null, id, false)}
87
- onPointerLeave={onItemPointerLeave}>
88
- <button
89
- className={styles.SuspenseBreadcrumbsButton}
90
- onClick={onItemClick.bind(null, id)}
91
- type="button">
92
- {node === null ? 'Unknown' : node.name || 'Unknown'}
93
- </button>
94
- </li>
102
+ <Fragment key={id}>
103
+ <li
104
+ className={styles.SuspenseBreadcrumbsListItem}
105
+ aria-current={selectedSuspenseID === id}
106
+ onPointerEnter={onItemPointerEnter.bind(null, id, false)}
107
+ onPointerLeave={onItemPointerLeave}>
108
+ <button
109
+ className={styles.SuspenseBreadcrumbsButton}
110
+ onClick={onItemClick.bind(null, id)}
111
+ type="button">
112
+ {node === null ? 'Unknown' : node.name || 'Unknown'}
113
+ </button>
114
+ </li>
115
+ {index < lineage.length - 1 && (
116
+ <span className={styles.SuspenseBreadcrumbsListItemSeparator}>
117
+ »
118
+ </span>
119
+ )}
120
+ </Fragment>
121
);
122
})
123
)}
@@ -271,37 +297,6 @@ export default function SuspenseBreadcrumbs(): React$Node {
297
const containerRef = useRef<HTMLDivElement | null>(null);
298
const isOverflowing = useIsOverflowing(containerRef, elementsTotalWidth);
299
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
-
300
return (
301
<div className={styles.SuspenseBreadcrumbsContainer} ref={containerRef}>
302
{isOverflowing ? (
@@ -315,6 +310,7 @@ export default function SuspenseBreadcrumbs(): React$Node {
310
onItemClick={handleClick}
311
onItemPointerEnter={highlightHostInstance}
312
onItemPointerLeave={clearHighlightHostInstance}
313
+ setElementsTotalWidth={setElementsTotalWidth}
314
/>
315
)}
316
</div>