@samitouri / QOS-React / commits / 75dc0026d6

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