[DevTools] Initial version of Suspense timeline (#34233)
Sebastian "Sebbie" Silbermann committed
Aug 25, 2025 at 17:47 UTC
75dc0026d665bd3c92e677c91252e6bf18303e45
6 files changed
+245
-82
packages/react-devtools-shared/src/backend/fiber/renderer.js
+7
@@ -7455,6 +7455,13 @@ export function attach(
7455
}
7456
7457
function overrideSuspense(id: number, forceFallback: boolean) {
7458
+ if (!supportsTogglingSuspense) {
7459
+ // TODO:: Add getter to decide if overrideSuspense is available.
7460
+ // Currently only available on inspectElement.
7461
+ // Probably need a different affordance to batch since the timeline
7462
+ // fallback is not the same as resuspending.
7463
+ return;
7464
+ }
7465
if (
7466
typeof setSuspenseHandler !== 'function' ||
7467
typeof scheduleUpdate !== 'function'
packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTab.css
+1
@@ -110,6 +110,7 @@
110
padding: 0.25rem;
111
display: flex;
112
flex-direction: row;
113
+ align-items: flex-start;
114
}
115
116
.Timeline {
packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTab.js
+4
-5
@@ -20,6 +20,7 @@ import InspectedElement from '../Components/InspectedElement';
20
import portaledContent from '../portaledContent';
21
import styles from './SuspenseTab.css';
22
import SuspenseRects from './SuspenseRects';
23
+import SuspenseTimeline from './SuspenseTimeline';
24
import SuspenseTreeList from './SuspenseTreeList';
25
import Button from '../Button';
26
import typeof {SyntheticPointerEvent} from 'react-dom-bindings/src/events/SyntheticEvent';
@@ -46,10 +47,6 @@ type LayoutState = {
47
};
48
type LayoutDispatch = (action: LayoutAction) => void;
49
49
-function SuspenseTimeline() {
50
- return <div className={styles.Timeline}>timeline</div>;
51
-}
52
-
50
function ToggleTreeList({
51
dispatch,
52
state,
@@ -309,7 +306,9 @@ function SuspenseTab(_: {}) {
306
<div className={styles.TreeView}>
307
<div className={styles.TimelineWrapper}>
308
<ToggleTreeList dispatch={dispatch} state={state} />
312
- <SuspenseTimeline />
309
+ <div className={styles.Timeline}>
310
+ <SuspenseTimeline />
311
+ </div>
312
<ToggleInspectedElement
313
dispatch={dispatch}
314
state={state}
packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTimeline.css
new
+20
@@ -0,0 +1,20 @@
1
+.SuspenseTimelineSlider {
2
+ width: 100%;
3
+}
4
+
5
+.SuspenseTimelineMarkers {
6
+ display: flex;
7
+ flex-direction: row;
8
+ justify-content: space-between;
9
+}
10
+
11
+.SuspenseTimelineMarkers > * {
12
+ flex: 1 1 0;
13
+ overflow: visible;
14
+ visibility: hidden;
15
+ width: 0
16
+}
17
+
18
+.SuspenseTimelineActiveMarker {
19
+ visibility: visible;
20
+}
packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTimeline.js
new
+212
@@ -0,0 +1,212 @@
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 {Element, SuspenseNode} from '../../../frontend/types';
11
+import type Store from '../../store';
12
+
13
+import * as React from 'react';
14
+import {
15
+ useContext,
16
+ useId,
17
+ useLayoutEffect,
18
+ useMemo,
19
+ useRef,
20
+ useState,
21
+} from 'react';
22
+import {BridgeContext, StoreContext} from '../context';
23
+import {TreeDispatcherContext} from '../Components/TreeContext';
24
+import {useHighlightHostInstance} from '../hooks';
25
+import {SuspenseTreeStateContext} from './SuspenseTreeContext';
26
+import styles from './SuspenseTimeline.css';
27
+
28
+// TODO: This returns the roots which would mean we attempt to suspend the shell.
29
+// Suspending the shell is currently not supported and we don't have a good view
30
+// for inspecting the root. But we probably should?
31
+function getDocumentOrderSuspense(
32
+ store: Store,
33
+ roots: $ReadOnlyArray<Element['id']>,
34
+): Array<SuspenseNode> {
35
+ const suspenseTreeList: SuspenseNode[] = [];
36
+ for (let i = 0; i < roots.length; i++) {
37
+ const root = store.getElementByID(roots[i]);
38
+ if (root === null) {
39
+ continue;
40
+ }
41
+ const suspense = store.getSuspenseByID(root.id);
42
+ if (suspense !== null) {
43
+ const stack = [suspense];
44
+ while (stack.length > 0) {
45
+ const current = stack.pop();
46
+ if (current === undefined) {
47
+ continue;
48
+ }
49
+ suspenseTreeList.push(current);
50
+ // Add children in reverse order to maintain document order
51
+ for (let j = current.children.length - 1; j >= 0; j--) {
52
+ const childSuspense = store.getSuspenseByID(current.children[j]);
53
+ if (childSuspense !== null) {
54
+ stack.push(childSuspense);
55
+ }
56
+ }
57
+ }
58
+ }
59
+ }
60
+
61
+ return suspenseTreeList;
62
+}
63
+
64
+export default function SuspenseTimeline(): React$Node {
65
+ const bridge = useContext(BridgeContext);
66
+ const store = useContext(StoreContext);
67
+ const dispatch = useContext(TreeDispatcherContext);
68
+ const {shells} = useContext(SuspenseTreeStateContext);
69
+
70
+ const timeline = useMemo(() => {
71
+ return getDocumentOrderSuspense(store, shells);
72
+ }, [store, shells]);
73
+
74
+ const {highlightHostInstance, clearHighlightHostInstance} =
75
+ useHighlightHostInstance();
76
+
77
+ const inputRef = useRef<HTMLElement | null>(null);
78
+ const inputBBox = useRef<ClientRect | null>(null);
79
+ useLayoutEffect(() => {
80
+ const input = inputRef.current;
81
+ if (input === null) {
82
+ throw new Error('Expected an input HTML element to be present.');
83
+ }
84
+
85
+ inputBBox.current = input.getBoundingClientRect();
86
+ const observer = new ResizeObserver(entries => {
87
+ inputBBox.current = input.getBoundingClientRect();
88
+ });
89
+ observer.observe(input);
90
+ return () => {
91
+ inputBBox.current = null;
92
+ observer.disconnect();
93
+ };
94
+ }, []);
95
+
96
+ const min = 0;
97
+ const max = timeline.length > 0 ? timeline.length - 1 : 0;
98
+
99
+ const [value, setValue] = useState(max);
100
+ if (value > max) {
101
+ // TODO: Handle timeline changes
102
+ setValue(max);
103
+ }
104
+
105
+ const markersID = useId();
106
+ const markers: React.Node[] = useMemo(() => {
107
+ return timeline.map((suspense, index) => {
108
+ const takesUpSpace =
109
+ suspense.rects !== null &&
110
+ suspense.rects.some(rect => {
111
+ return rect.width > 0 && rect.height > 0;
112
+ });
113
+
114
+ return takesUpSpace ? (
115
+ <option
116
+ key={suspense.id}
117
+ className={
118
+ index === value ? styles.SuspenseTimelineActiveMarker : undefined
119
+ }
120
+ value={index}>
121
+ #{index + 1}
122
+ </option>
123
+ ) : (
124
+ <option key={suspense.id} />
125
+ );
126
+ });
127
+ }, [timeline, value]);
128
+
129
+ function handleChange(event: SyntheticEvent<HTMLInputElement>) {
130
+ const pendingValue = +event.currentTarget.value;
131
+ for (let i = 0; i < timeline.length; i++) {
132
+ const forceFallback = i > pendingValue;
133
+ const suspense = timeline[i];
134
+ const elementID = suspense.id;
135
+ const rendererID = store.getRendererIDForElement(elementID);
136
+ if (rendererID === null) {
137
+ // TODO: Handle disconnected elements.
138
+ console.warn(
139
+ `No renderer ID found for element ${elementID} in suspense timeline.`,
140
+ );
141
+ } else {
142
+ bridge.send('overrideSuspense', {
143
+ id: elementID,
144
+ rendererID,
145
+ forceFallback,
146
+ });
147
+ }
148
+ }
149
+
150
+ const suspense = timeline[pendingValue];
151
+ const elementID = suspense.id;
152
+ highlightHostInstance(elementID);
153
+ dispatch({type: 'SELECT_ELEMENT_BY_ID', payload: elementID});
154
+ setValue(pendingValue);
155
+ }
156
+
157
+ function handleBlur() {
158
+ clearHighlightHostInstance();
159
+ }
160
+
161
+ function handleFocus() {
162
+ const suspense = timeline[value];
163
+
164
+ highlightHostInstance(suspense.id);
165
+ }
166
+
167
+ function handlePointerMove(event: SyntheticPointerEvent<HTMLInputElement>) {
168
+ const bbox = inputBBox.current;
169
+ if (bbox === null) {
170
+ throw new Error('Bounding box of slider is unknown.');
171
+ }
172
+
173
+ const hoveredValue = Math.max(
174
+ min,
175
+ Math.min(
176
+ Math.round(
177
+ min + ((event.clientX - bbox.left) / bbox.width) * (max - min),
178
+ ),
179
+ max,
180
+ ),
181
+ );
182
+ const suspense = timeline[hoveredValue];
183
+ if (suspense === undefined) {
184
+ throw new Error(
185
+ `Suspense node not found for value ${hoveredValue} in timeline when on ${event.clientX} in bounding box ${JSON.stringify(bbox)}.`,
186
+ );
187
+ }
188
+ highlightHostInstance(suspense.id);
189
+ }
190
+
191
+ return (
192
+ <div>
193
+ <input
194
+ className={styles.SuspenseTimelineSlider}
195
+ type="range"
196
+ min={min}
197
+ max={max}
198
+ list={markersID}
199
+ value={value}
200
+ onBlur={handleBlur}
201
+ onChange={handleChange}
202
+ onFocus={handleFocus}
203
+ onPointerMove={handlePointerMove}
204
+ onPointerUp={clearHighlightHostInstance}
205
+ ref={inputRef}
206
+ />
207
+ <datalist id={markersID} className={styles.SuspenseTimelineMarkers}>
208
+ {markers}
209
+ </datalist>
210
+ </div>
211
+ );
212
+}
packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTreeList.js
+1
-77
@@ -6,85 +6,9 @@
6
*
7
* @flow
8
*/
9
-import type {SuspenseNode} from '../../../frontend/types';
10
-import type Store from '../../store';
9
10
import * as React from 'react';
13
-import {useContext} from 'react';
14
-import {StoreContext} from '../context';
15
-import {SuspenseTreeStateContext} from './SuspenseTreeContext';
16
-import {TreeDispatcherContext} from '../Components/TreeContext';
17
-
18
-function getDocumentOrderSuspenseTreeList(store: Store): Array<SuspenseNode> {
19
- const suspenseTreeList: SuspenseNode[] = [];
20
- for (let i = 0; i < store.roots.length; i++) {
21
- const root = store.getElementByID(store.roots[i]);
22
- if (root === null) {
23
- continue;
24
- }
25
- const suspense = store.getSuspenseByID(root.id);
26
- if (suspense !== null) {
27
- const stack = [suspense];
28
- while (stack.length > 0) {
29
- const current = stack.pop();
30
- if (current === undefined) {
31
- continue;
32
- }
33
- suspenseTreeList.push(current);
34
- // Add children in reverse order to maintain document order
35
- for (let j = current.children.length - 1; j >= 0; j--) {
36
- const childSuspense = store.getSuspenseByID(current.children[j]);
37
- if (childSuspense !== null) {
38
- stack.push(childSuspense);
39
- }
40
- }
41
- }
42
- }
43
- }
44
-
45
- return suspenseTreeList;
46
-}
11
12
export default function SuspenseTreeList(_: {}): React$Node {
49
- const store = useContext(StoreContext);
50
- const treeDispatch = useContext(TreeDispatcherContext);
51
- useContext(SuspenseTreeStateContext);
52
-
53
- const suspenseTreeList = getDocumentOrderSuspenseTreeList(store);
54
-
55
- return (
56
- <div>
57
- <p>Suspense Tree List</p>
58
- <ul>
59
- {suspenseTreeList.map(suspense => {
60
- const {id, parentID, children, name} = suspense;
61
- return (
62
- <li key={id}>
63
- <div>
64
- <button
65
- onClick={() => {
66
- treeDispatch({
67
- type: 'SELECT_ELEMENT_BY_ID',
68
- payload: id,
69
- });
70
- }}>
71
- inspect {name || 'N/A'} ({id})
72
- </button>
73
- </div>
74
- <div>
75
- <strong>Suspense ID:</strong> {id}
76
- </div>
77
- <div>
78
- <strong>Parent ID:</strong> {parentID}
79
- </div>
80
- <div>
81
- <strong>Children:</strong>{' '}
82
- {children.length === 0 ? '∅' : children.join(', ')}
83
- </div>
84
- </li>
85
- );
86
- })}
87
- </ul>
88
- </div>
89
- );
13
+ return <div>Activity slices</div>;
14
}