@samitouri / QOS-React-2 / commits / 1c66ac740c

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