@samitouri / QOS-React / commits / 89a803fcec

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