Repository: facebook/react
Pull request: [DevTools] Separate breadcrumbs with `»`

## Summary

Stacked on https://github.com/facebook/react/pull/35700

This avoids a common confusion with the Suspense breadcrumbs by making it clearer that this is a hierarchy. Applied the same styling to the Owner Stack in the Components tab.

<img width="450" height="40" alt="localhost_8080_ (5)" src="https://github.com/user-attachments/assets/be7c86f4-c6e0-4af8-8d31-9edad0d45bb1" />


Required a refactor how we measure the combined item size which isolates individual Components better and actually uses ResizeObserver instead of just recalculating when the number of owners changes.

## How did you test this change?

https://github.com/user-attachments/assets/c4bf4daf-a0d4-4f5b-89e1-15311ac0cc1b



Changed files:
filename: packages/react-devtools-shared/src/devtools/views/Components/OwnersStack.css
patch: @@ -32,6 +32,14 @@
   overflow-x: auto;
 }
 
+.OwnerStackFlatListContainer {
+  display: inline-flex;
+}
+
+.OwnerStackFlatListSeparator {
+  user-select: none;
+}
+
 .VRule {
   flex: 0 0 auto;
   height: 20px;

filename: packages/react-devtools-shared/src/devtools/views/Components/OwnersStack.js
patch: @@ -77,6 +77,54 @@ function dialogReducer(state: State, action: Action) {
   }
 }
 
+type OwnerStackFlatListProps = {
+  owners: Array<SerializedElement>,
+  selectedIndex: number,
+  selectOwner: SelectOwner,
+  setElementsTotalWidth: (width: number) => void,
+};
+
+function OwnerStackFlatList({
+  owners,
+  selectedIndex,
+  selectOwner,
+  setElementsTotalWidth,
+}: OwnerStackFlatListProps): React.Node {
+  const containerRef = useRef<HTMLDivElement | null>(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 (
+    <div className={styles.OwnerStackFlatListContainer} ref={containerRef}>
+      {owners.map((owner, index) => (
+        <Fragment key={index}>
+          <ElementView
+            owner={owner}
+            isSelected={index === selectedIndex}
+            selectOwner={selectOwner}
+          />
+          {index < owners.length - 1 && (
+            <span className={styles.OwnerStackFlatListSeparator}>»</span>
+          )}
+        </Fragment>
+      ))}
+    </div>
+  );
+}
+
 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 (
     <div className={styles.OwnerStack}>
       <div className={styles.Bar} ref={elementsBarRef}>
-        {isOverflowing && (
+        {isOverflowing ? (
           <Fragment>
             <ElementsDropdown
               owners={owners}
@@ -180,16 +206,14 @@ export default function OwnerStack(): React.Node {
               />
             )}
           </Fragment>
+        ) : (
+          <OwnerStackFlatList
+            owners={owners}
+            selectedIndex={selectedIndex}
+            selectOwner={selectOwner}
+            setElementsTotalWidth={setElementsTotalWidth}
+          />
         )}
-        {!isOverflowing &&
-          owners.map((owner, index) => (
-            <ElementView
-              key={index}
-              owner={owner}
-              isSelected={index === selectedIndex}
-              selectOwner={selectOwner}
-            />
-          ))}
       </div>
       <div className={styles.VRule} />
       <Button onClick={() => selectOwner(null)} title="Back to tree view">

filename: packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseBreadcrumbs.css
patch: @@ -16,6 +16,10 @@
   display: inline;
 }
 
+.SuspenseBreadcrumbsListItemSeparator {
+  user-select: none;
+}
+
 .SuspenseBreadcrumbsListItem[aria-current="true"] .SuspenseBreadcrumbsButton {
   color: var(--color-button-active);
 }

filename: packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseBreadcrumbs.js
patch: @@ -11,7 +11,7 @@ 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, useLayoutEffect, useRef, useState} from 'react';
+import {Fragment, useContext, useLayoutEffect, useRef, useState} from 'react';
 import Button from '../Button';
 import ButtonIcon from '../ButtonIcon';
 import Tooltip from '../Components/reach-ui/tooltip';
@@ -40,20 +40,40 @@ type SuspenseBreadcrumbsFlatListProps = {
     scrollIntoView?: boolean,
   ) => void,
   onItemPointerLeave: (event: SyntheticMouseEvent) => void,
+  setElementsTotalWidth: (width: number) => void,
 };
 
 function SuspenseBreadcrumbsFlatList({
   onItemClick,
   onItemPointerEnter,
   onItemPointerLeave,
+  setElementsTotalWidth,
 }: SuspenseBreadcrumbsFlatListProps): React$Node {
   const store = useContext(StoreContext);
   const {activityID} = useContext(TreeStateContext);
   const {selectedSuspenseID, lineage, roots} = useContext(
     SuspenseTreeStateContext,
   );
+
+  const containerRef = useRef<HTMLDivElement | null>(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 (
-    <ol className={styles.SuspenseBreadcrumbsList}>
+    <ol className={styles.SuspenseBreadcrumbsList} ref={containerRef}>
       {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" .
@@ -79,19 +99,25 @@ function SuspenseBreadcrumbsFlatList({
           const node = store.getSuspenseByID(id);
 
           return (
-            <li
-              key={id}
-              className={styles.SuspenseBreadcrumbsListItem}
-              aria-current={selectedSuspenseID === id}
-              onPointerEnter={onItemPointerEnter.bind(null, id, false)}
-              onPointerLeave={onItemPointerLeave}>
-              <button
-                className={styles.SuspenseBreadcrumbsButton}
-                onClick={onItemClick.bind(null, id)}
-                type="button">
-                {node === null ? 'Unknown' : node.name || 'Unknown'}
-              </button>
-            </li>
+            <Fragment key={id}>
+              <li
+                className={styles.SuspenseBreadcrumbsListItem}
+                aria-current={selectedSuspenseID === id}
+                onPointerEnter={onItemPointerEnter.bind(null, id, false)}
+                onPointerLeave={onItemPointerLeave}>
+                <button
+                  className={styles.SuspenseBreadcrumbsButton}
+                  onClick={onItemClick.bind(null, id)}
+                  type="button">
+                  {node === null ? 'Unknown' : node.name || 'Unknown'}
+                </button>
+              </li>
+              {index < lineage.length - 1 && (
+                <span className={styles.SuspenseBreadcrumbsListItemSeparator}>
+                  »
+                </span>
+              )}
+            </Fragment>
           );
         })
       )}
@@ -271,37 +297,6 @@ export default function SuspenseBreadcrumbs(): React$Node {
   const containerRef = useRef<HTMLDivElement | null>(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 (
     <div className={styles.SuspenseBreadcrumbsContainer} ref={containerRef}>
       {isOverflowing ? (
@@ -315,6 +310,7 @@ export default function SuspenseBreadcrumbs(): React$Node {
           onItemClick={handleClick}
           onItemPointerEnter={highlightHostInstance}
           onItemPointerLeave={clearHighlightHostInstance}
+          setElementsTotalWidth={setElementsTotalWidth}
         />
       )}
     </div>