Repository: facebook/react
Pull request: [DevTools] Add breadcrumbs to Suspense tab 

Stacked on https://github.com/facebook/react/pull/34299

Helps orienting the currently selected Suspense in the tree. This is the same display as with the Owner hierarchy in the Components tab minus handling overflow. The overflow is just a scrollbar for now.

Probably better to keep the lineage locked while you click in it. At least while we don't have 1st class backwards/forwards navigation.

https://github.com/user-attachments/assets/c677052a-166a-4c81-a18a-688fe02b06ef




Changed files:
filename: packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseBreadcrumbs.css
patch: @@ -0,0 +1,33 @@
+.SuspenseBreadcrumbsList {
+  margin: 0;
+  padding: 0;
+  list-style: none;
+  display: flex;
+  flex-direction: row;
+  flex-wrap: nowrap;
+}
+
+.SuspenseBreadcrumbsListItem {
+  display: inline;
+}
+
+.SuspenseBreadcrumbsListItem[aria-current="true"] .SuspenseBreadcrumbsButton {
+  color: var(--color-button-active);
+}
+
+.SuspenseBreadcrumbsButton {
+  background: var(--color-button-background);
+  border: none;
+  border-radius: 0.25rem;
+  padding: 0.25rem;
+  white-space: nowrap;
+}
+
+.SuspenseBreadcrumbsButton:hover {
+  background-color: var(--color-button-background-hover);
+  color: var(--color-button-hover);
+}
+
+.SuspenseBreadcrumbsButton:focus-visible {
+  background: var(--color-button-background-focus);
+}

filename: packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseBreadcrumbs.js
patch: @@ -0,0 +1,79 @@
+/**
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ * @flow
+ */
+
+import type {SuspenseNode} from 'react-devtools-shared/src/frontend/types';
+
+import * as React from 'react';
+import {useContext} from 'react';
+import {
+  TreeDispatcherContext,
+  TreeStateContext,
+} from '../Components/TreeContext';
+import {StoreContext} from '../context';
+import {useHighlightHostInstance} from '../hooks';
+import styles from './SuspenseBreadcrumbs.css';
+import typeof {SyntheticMouseEvent} from 'react-dom-bindings/src/events/SyntheticEvent';
+
+export default function SuspenseBreadcrumbs(): React$Node {
+  const store = useContext(StoreContext);
+  const dispatch = useContext(TreeDispatcherContext);
+  const {inspectedElementID} = useContext(TreeStateContext);
+
+  const {highlightHostInstance, clearHighlightHostInstance} =
+    useHighlightHostInstance();
+
+  // TODO: Use the nearest Suspense boundary
+  const inspectedSuspenseID = inspectedElementID;
+  if (inspectedSuspenseID === null) {
+    return null;
+  }
+
+  const suspense = store.getSuspenseByID(inspectedSuspenseID);
+  if (suspense === null) {
+    return null;
+  }
+
+  const lineage: SuspenseNode[] = [];
+  let next: null | SuspenseNode = suspense;
+  while (next !== null) {
+    if (next.parentID === 0) {
+      next = null;
+    } else {
+      lineage.unshift(next);
+      next = store.getSuspenseByID(next.parentID);
+    }
+  }
+
+  function handleClick(node: SuspenseNode, event: SyntheticMouseEvent) {
+    event.preventDefault();
+    dispatch({type: 'SELECT_ELEMENT_BY_ID', payload: node.id});
+  }
+
+  return (
+    <ol className={styles.SuspenseBreadcrumbsList}>
+      {lineage.map((node, index) => {
+        return (
+          <li
+            key={node.id}
+            className={styles.SuspenseBreadcrumbsListItem}
+            aria-current={index === lineage.length - 1}
+            onPointerEnter={highlightHostInstance.bind(null, node.id)}
+            onPointerLeave={clearHighlightHostInstance}>
+            <button
+              className={styles.SuspenseBreadcrumbsButton}
+              onClick={handleClick.bind(null, node)}
+              type="button">
+              {node.name}
+            </button>
+          </li>
+        );
+      })}
+    </ol>
+  );
+}

filename: packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTab.css
patch: @@ -108,14 +108,22 @@
   overflow: auto;
 }
 
-.TimelineWrapper {
+.SuspenseTreeViewHeader {
   padding: 0.25rem;
-  display: flex;
-  flex-direction: row;
+  display: grid;
+  grid-template-columns: auto 1fr auto;
   align-items: flex-start;
 }
 
-.Timeline {
-  flex-grow: 1;
-  align-self: anchor-center;
+.SuspenseTreeViewHeaderMain {
+  display: grid;
+  grid-template-rows: auto auto;
+}
+
+.SuspenseBreadcrumbs {
+  /**
+   * TODO: Switch to single item view on overflow like OwnerStack does.
+   * OwnerStack has more constraints that make it easier so it won't be a 1:1 port.
+   */
+  overflow-x: auto;
 }

filename: packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTab.js
patch: @@ -19,6 +19,7 @@ import InspectedElementErrorBoundary from '../Components/InspectedElementErrorBo
 import InspectedElement from '../Components/InspectedElement';
 import portaledContent from '../portaledContent';
 import styles from './SuspenseTab.css';
+import SuspenseBreadcrumbs from './SuspenseBreadcrumbs';
 import SuspenseRects from './SuspenseRects';
 import SuspenseTimeline from './SuspenseTimeline';
 import SuspenseTreeList from './SuspenseTreeList';
@@ -304,10 +305,15 @@ function SuspenseTab(_: {}) {
           />
         </div>
         <div className={styles.TreeView}>
-          <div className={styles.TimelineWrapper}>
+          <div className={styles.SuspenseTreeViewHeader}>
             <ToggleTreeList dispatch={dispatch} state={state} />
-            <div className={styles.Timeline}>
-              <SuspenseTimeline />
+            <div className={styles.SuspenseTreeViewHeaderMain}>
+              <div className={styles.SuspenseTimeline}>
+                <SuspenseTimeline />
+              </div>
+              <div className={styles.SuspenseBreadcrumbs}>
+                <SuspenseBreadcrumbs />
+              </div>
             </div>
             <ToggleInspectedElement
               dispatch={dispatch}

filename: packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTimeline.css
patch: @@ -2,6 +2,7 @@
   width: 100%;
   display: flex;
   flex-direction: row;
+  padding: 0 0.25rem;
 }
 
 .SuspenseTimelineInput {