[DevTools] Add breadcrumbs to Suspense tab (#34312)
Sebastian "Sebbie" Silbermann committed
Aug 28, 2025 at 16:03 UTC
89a803fcec363df9108f2908735e5693280a78b5
5 files changed
+136
-9
packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseBreadcrumbs.css
new
+33
@@ -0,0 +1,33 @@
1
+.SuspenseBreadcrumbsList {
2
+ margin: 0;
3
+ padding: 0;
4
+ list-style: none;
5
+ display: flex;
6
+ flex-direction: row;
7
+ flex-wrap: nowrap;
8
+}
9
+
10
+.SuspenseBreadcrumbsListItem {
11
+ display: inline;
12
+}
13
+
14
+.SuspenseBreadcrumbsListItem[aria-current="true"] .SuspenseBreadcrumbsButton {
15
+ color: var(--color-button-active);
16
+}
17
+
18
+.SuspenseBreadcrumbsButton {
19
+ background: var(--color-button-background);
20
+ border: none;
21
+ border-radius: 0.25rem;
22
+ padding: 0.25rem;
23
+ white-space: nowrap;
24
+}
25
+
26
+.SuspenseBreadcrumbsButton:hover {
27
+ background-color: var(--color-button-background-hover);
28
+ color: var(--color-button-hover);
29
+}
30
+
31
+.SuspenseBreadcrumbsButton:focus-visible {
32
+ background: var(--color-button-background-focus);
33
+}
packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseBreadcrumbs.js
new
+79
@@ -0,0 +1,79 @@
1
+/**
2
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
3
+ *
4
+ * This source code is licensed under the MIT license found in the
5
+ * LICENSE file in the root directory of this source tree.
6
+ *
7
+ * @flow
8
+ */
9
+
10
+import type {SuspenseNode} from 'react-devtools-shared/src/frontend/types';
11
+
12
+import * as React from 'react';
13
+import {useContext} from 'react';
14
+import {
15
+ TreeDispatcherContext,
16
+ TreeStateContext,
17
+} from '../Components/TreeContext';
18
+import {StoreContext} from '../context';
19
+import {useHighlightHostInstance} from '../hooks';
20
+import styles from './SuspenseBreadcrumbs.css';
21
+import typeof {SyntheticMouseEvent} from 'react-dom-bindings/src/events/SyntheticEvent';
22
+
23
+export default function SuspenseBreadcrumbs(): React$Node {
24
+ const store = useContext(StoreContext);
25
+ const dispatch = useContext(TreeDispatcherContext);
26
+ const {inspectedElementID} = useContext(TreeStateContext);
27
+
28
+ const {highlightHostInstance, clearHighlightHostInstance} =
29
+ useHighlightHostInstance();
30
+
31
+ // TODO: Use the nearest Suspense boundary
32
+ const inspectedSuspenseID = inspectedElementID;
33
+ if (inspectedSuspenseID === null) {
34
+ return null;
35
+ }
36
+
37
+ const suspense = store.getSuspenseByID(inspectedSuspenseID);
38
+ if (suspense === null) {
39
+ return null;
40
+ }
41
+
42
+ const lineage: SuspenseNode[] = [];
43
+ let next: null | SuspenseNode = suspense;
44
+ while (next !== null) {
45
+ if (next.parentID === 0) {
46
+ next = null;
47
+ } else {
48
+ lineage.unshift(next);
49
+ next = store.getSuspenseByID(next.parentID);
50
+ }
51
+ }
52
+
53
+ function handleClick(node: SuspenseNode, event: SyntheticMouseEvent) {
54
+ event.preventDefault();
55
+ dispatch({type: 'SELECT_ELEMENT_BY_ID', payload: node.id});
56
+ }
57
+
58
+ return (
59
+ <ol className={styles.SuspenseBreadcrumbsList}>
60
+ {lineage.map((node, index) => {
61
+ return (
62
+ <li
63
+ key={node.id}
64
+ className={styles.SuspenseBreadcrumbsListItem}
65
+ aria-current={index === lineage.length - 1}
66
+ onPointerEnter={highlightHostInstance.bind(null, node.id)}
67
+ onPointerLeave={clearHighlightHostInstance}>
68
+ <button
69
+ className={styles.SuspenseBreadcrumbsButton}
70
+ onClick={handleClick.bind(null, node)}
71
+ type="button">
72
+ {node.name}
73
+ </button>
74
+ </li>
75
+ );
76
+ })}
77
+ </ol>
78
+ );
79
+}
packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTab.css
+14
-6
@@ -108,14 +108,22 @@
108
overflow: auto;
109
}
110
111
-.TimelineWrapper {
111
+.SuspenseTreeViewHeader {
112
padding: 0.25rem;
113
- display: flex;
114
- flex-direction: row;
113
+ display: grid;
114
+ grid-template-columns: auto 1fr auto;
115
align-items: flex-start;
116
}
117
118
-.Timeline {
119
- flex-grow: 1;
120
- align-self: anchor-center;
118
+.SuspenseTreeViewHeaderMain {
119
+ display: grid;
120
+ grid-template-rows: auto auto;
121
+}
122
+
123
+.SuspenseBreadcrumbs {
124
+ /**
125
+ * TODO: Switch to single item view on overflow like OwnerStack does.
126
+ * OwnerStack has more constraints that make it easier so it won't be a 1:1 port.
127
+ */
128
+ overflow-x: auto;
129
}
packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTab.js
+9
-3
@@ -19,6 +19,7 @@ import InspectedElementErrorBoundary from '../Components/InspectedElementErrorBo
19
import InspectedElement from '../Components/InspectedElement';
20
import portaledContent from '../portaledContent';
21
import styles from './SuspenseTab.css';
22
+import SuspenseBreadcrumbs from './SuspenseBreadcrumbs';
23
import SuspenseRects from './SuspenseRects';
24
import SuspenseTimeline from './SuspenseTimeline';
25
import SuspenseTreeList from './SuspenseTreeList';
@@ -304,10 +305,15 @@ function SuspenseTab(_: {}) {
305
/>
306
</div>
307
<div className={styles.TreeView}>
307
- <div className={styles.TimelineWrapper}>
308
+ <div className={styles.SuspenseTreeViewHeader}>
309
<ToggleTreeList dispatch={dispatch} state={state} />
309
- <div className={styles.Timeline}>
310
- <SuspenseTimeline />
310
+ <div className={styles.SuspenseTreeViewHeaderMain}>
311
+ <div className={styles.SuspenseTimeline}>
312
+ <SuspenseTimeline />
313
+ </div>
314
+ <div className={styles.SuspenseBreadcrumbs}>
315
+ <SuspenseBreadcrumbs />
316
+ </div>
317
</div>
318
<ToggleInspectedElement
319
dispatch={dispatch}
packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTimeline.css
+1
@@ -2,6 +2,7 @@
2
width: 100%;
3
display: flex;
4
flex-direction: row;
5
+ padding: 0 0.25rem;
6
}
7
8
.SuspenseTimelineInput {