@samitouri / QOS-React / commits / 7c0fff6f2b

[DevTools] Add Play/Pause and Skip Controls to the Timeline (#34620)

Stacked on #34625. This is a nice way to step through the timeline and simulate the visuals on screen as you do it. It's also convenient to step through one at a time, especially with the forwards button. However, the secondary purpose of this is that it helps anchor the UI visually as something like a timeline like in a video so that the timeline itself becomes more identifiable. https://github.com/user-attachments/assets/cb367c8e-9efb-4a00-a58e-4579be20beb8

Sebastian Markbåge committed Sep 28, 2025 at 19:14 UTC 7c0fff6f2b0edb26b52418b3eef8819e51b96c61
6 files changed +249 -31
packages/react-devtools-shared/src/devtools/views/ButtonIcon.js
+40
@@ -42,6 +42,10 @@ export type IconType =
42 | 'panel-bottom-close'
43 | 'filter-on'
44 | 'filter-off'
45 + | 'play'
46 + | 'pause'
47 + | 'skip-previous'
48 + | 'skip-next'
49 | 'error'
50 | 'suspend'
51 | 'undo'
@@ -163,6 +167,22 @@ export default function ButtonIcon({className = '', type}: Props): React.Node {
167 pathData = PATH_MATERIAL_FILTER_ALT_OFF;
168 viewBox = panelIcons;
169 break;
170 + case 'play':
171 + pathData = PATH_MATERIAL_PLAY_ARROW;
172 + viewBox = panelIcons;
173 + break;
174 + case 'pause':
175 + pathData = PATH_MATERIAL_PAUSE;
176 + viewBox = panelIcons;
177 + break;
178 + case 'skip-previous':
179 + pathData = PATH_MATERIAL_SKIP_PREVIOUS_ARROW;
180 + viewBox = panelIcons;
181 + break;
182 + case 'skip-next':
183 + pathData = PATH_MATERIAL_SKIP_NEXT_ARROW;
184 + viewBox = panelIcons;
185 + break;
186 case 'suspend':
187 pathData = PATH_SUSPEND;
188 break;
@@ -358,3 +378,23 @@ const PATH_MATERIAL_FILTER_ALT = `
378 const PATH_MATERIAL_FILTER_ALT_OFF = `
379 m592-481-57-57 143-182H353l-80-80h487q25 0 36 22t-4 42L592-481ZM791-56 560-287v87q0 17-11.5 28.5T520-160h-80q-17 0-28.5-11.5T400-200v-247L56-791l56-57 736 736-57 56ZM535-538Z
380 `;
381 +
382 +// Source: Material Design Icons play_arrow
383 +const PATH_MATERIAL_PLAY_ARROW = `
384 + M320-200v-560l440 280-440 280Zm80-280Zm0 134 210-134-210-134v268Z
385 +`;
386 +
387 +// Source: Material Design Icons pause
388 +const PATH_MATERIAL_PAUSE = `
389 + M520-200v-560h240v560H520Zm-320 0v-560h240v560H200Zm400-80h80v-400h-80v400Zm-320 0h80v-400h-80v400Zm0-400v400-400Zm320 0v400-400Z
390 +`;
391 +
392 +// Source: Material Design Icons skip_previous
393 +const PATH_MATERIAL_SKIP_PREVIOUS_ARROW = `
394 + M220-240v-480h80v480h-80Zm520 0L380-480l360-240v480Zm-80-240Zm0 90v-180l-136 90 136 90Z
395 +`;
396 +
397 +// Source: Material Design Icons skip_next
398 +const PATH_MATERIAL_SKIP_NEXT_ARROW = `
399 + M660-240v-480h80v480h-80Zm-440 0v-480l360 240-360 240Zm80-240Zm0 90 136-90-136-90v180Z
400 +`;
packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTab.css
+4
@@ -139,3 +139,7 @@
139 grid-template-columns: 1fr auto;
140 align-items: center;
141 }
142 +
143 +.SuspenseTreeViewFooterButtons {
144 + padding: 0.25rem;
145 +}
\ No newline at end of file
packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTab.js
+7 -7
@@ -441,14 +441,14 @@ function SuspenseTab(_: {}) {
441 <SuspenseRects />
442 </div>
443 <footer className={styles.SuspenseTreeViewFooter}>
444 - <div className={styles.SuspenseTimeline}>
445 - <SuspenseTimeline />
444 + <SuspenseTimeline />
445 + <div className={styles.SuspenseTreeViewFooterButtons}>
446 + <ToggleInspectedElement
447 + dispatch={dispatch}
448 + state={state}
449 + orientation="vertical"
450 + />
451 </div>
447 - <ToggleInspectedElement
448 - dispatch={dispatch}
449 - state={state}
450 - orientation="vertical"
451 - />
452 </footer>
453 </div>
454 </div>
packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTimeline.css
-1
@@ -1,5 +1,4 @@
1 .SuspenseTimelineContainer {
2 - width: 100%;
2 display: flex;
3 flex-direction: row;
4 padding: 0.25rem;
packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTimeline.js
+104 -22
@@ -8,7 +8,7 @@
8 */
9
10 import * as React from 'react';
11 -import {useContext, useLayoutEffect, useRef} from 'react';
11 +import {useContext, useLayoutEffect, useEffect, useRef} from 'react';
12 import {BridgeContext, StoreContext} from '../context';
13 import {TreeDispatcherContext} from '../Components/TreeContext';
14 import {useHighlightHostInstance} from '../hooks';
@@ -21,6 +21,8 @@ import typeof {
21 SyntheticEvent,
22 SyntheticPointerEvent,
23 } from 'react-dom-bindings/src/events/SyntheticEvent';
24 +import Button from '../Button';
25 +import ButtonIcon from '../ButtonIcon';
26
27 function SuspenseTimelineInput() {
28 const bridge = useContext(BridgeContext);
@@ -34,6 +36,7 @@ function SuspenseTimelineInput() {
36 selectedRootID: rootID,
37 timeline,
38 timelineIndex,
39 + playing,
40 } = useContext(SuspenseTreeStateContext);
41
42 const inputRef = useRef<HTMLElement | null>(null);
@@ -98,26 +101,7 @@ function SuspenseTimelineInput() {
101 }
102
103 function handleChange(event: SyntheticEvent) {
101 - if (rootID === null) {
102 - return;
103 - }
104 - const rendererID = store.getRendererIDForElement(rootID);
105 - if (rendererID === null) {
106 - console.error(
107 - `No renderer ID found for root element ${rootID} in suspense timeline.`,
108 - );
109 - return;
110 - }
111 -
104 const pendingTimelineIndex = +event.currentTarget.value;
113 - const suspendedSet = timeline.slice(pendingTimelineIndex);
114 -
115 - bridge.send('overrideSuspenseMilestone', {
116 - rendererID,
117 - rootID,
118 - suspendedSet,
119 - });
120 -
105 switchSuspenseNode(pendingTimelineIndex);
106 }
107
@@ -153,10 +137,108 @@ function SuspenseTimelineInput() {
137 highlightHostInstance(suspenseID);
138 }
139
140 + function skipPrevious() {
141 + const nextSelectedSuspenseID = timeline[timelineIndex - 1];
142 + highlightHostInstance(nextSelectedSuspenseID);
143 + treeDispatch({
144 + type: 'SELECT_ELEMENT_BY_ID',
145 + payload: nextSelectedSuspenseID,
146 + });
147 + suspenseTreeDispatch({
148 + type: 'SUSPENSE_SKIP_TIMELINE_INDEX',
149 + payload: false,
150 + });
151 + }
152 +
153 + function skipForward() {
154 + const nextSelectedSuspenseID = timeline[timelineIndex + 1];
155 + highlightHostInstance(nextSelectedSuspenseID);
156 + treeDispatch({
157 + type: 'SELECT_ELEMENT_BY_ID',
158 + payload: nextSelectedSuspenseID,
159 + });
160 + suspenseTreeDispatch({
161 + type: 'SUSPENSE_SKIP_TIMELINE_INDEX',
162 + payload: true,
163 + });
164 + }
165 +
166 + function togglePlaying() {
167 + suspenseTreeDispatch({
168 + type: 'SUSPENSE_PLAY_PAUSE',
169 + payload: 'toggle',
170 + });
171 + }
172 +
173 + // TODO: useEffectEvent here once it's supported in all versions DevTools supports.
174 + // For now we just exclude it from deps since we don't lint those anyway.
175 + function changeTimelineIndex(newIndex: number) {
176 + // Synchronize timeline index with what is resuspended.
177 + if (rootID === null) {
178 + return;
179 + }
180 + const rendererID = store.getRendererIDForElement(rootID);
181 + if (rendererID === null) {
182 + console.error(
183 + `No renderer ID found for root element ${rootID} in suspense timeline.`,
184 + );
185 + return;
186 + }
187 + // We suspend everything after the current selection. The root isn't showing
188 + // anything suspended in the root. The step after that should have one less
189 + // thing suspended. I.e. the first suspense boundary should be unsuspended
190 + // when it's selected. This also lets you show everything in the last step.
191 + const suspendedSet = timeline.slice(timelineIndex + 1);
192 + bridge.send('overrideSuspenseMilestone', {
193 + rendererID,
194 + rootID,
195 + suspendedSet,
196 + });
197 + }
198 +
199 + useEffect(() => {
200 + changeTimelineIndex(timelineIndex);
201 + }, [timelineIndex]);
202 +
203 + useEffect(() => {
204 + if (!playing) {
205 + return undefined;
206 + }
207 + // While playing, advance one step every second.
208 + const PLAY_SPEED_INTERVAL = 1000;
209 + const timer = setInterval(() => {
210 + suspenseTreeDispatch({
211 + type: 'SUSPENSE_PLAY_TICK',
212 + });
213 + }, PLAY_SPEED_INTERVAL);
214 + return () => {
215 + clearInterval(timer);
216 + };
217 + }, [playing]);
218 +
219 return (
220 <>
158 - {timelineIndex}/{max}
159 - <div className={styles.SuspenseTimelineInput}>
221 + <Button
222 + disabled={timelineIndex === 0}
223 + title={'Previous'}
224 + onClick={skipPrevious}>
225 + <ButtonIcon type={'skip-previous'} />
226 + </Button>
227 + <Button
228 + disabled={max === 0 && !playing}
229 + title={playing ? 'Pause' : 'Play'}
230 + onClick={togglePlaying}>
231 + <ButtonIcon type={playing ? 'pause' : 'play'} />
232 + </Button>
233 + <Button
234 + disabled={timelineIndex === max}
235 + title={'Next'}
236 + onClick={skipForward}>
237 + <ButtonIcon type={'skip-next'} />
238 + </Button>
239 + <div
240 + className={styles.SuspenseTimelineInput}
241 + title={timelineIndex + '/' + max}>
242 <input
243 className={styles.SuspenseTimelineSlider}
244 type="range"
packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTreeContext.js
+94 -1
@@ -32,6 +32,7 @@ export type SuspenseTreeState = {
32 timeline: $ReadOnlyArray<SuspenseNode['id']>,
33 timelineIndex: number | -1,
34 uniqueSuspendersOnly: boolean,
35 + playing: boolean,
36 };
37
38 type ACTION_SUSPENSE_TREE_MUTATION = {
@@ -60,12 +61,27 @@ type ACTION_SUSPENSE_SET_TIMELINE_INDEX = {
61 type: 'SUSPENSE_SET_TIMELINE_INDEX',
62 payload: number,
63 };
64 +type ACTION_SUSPENSE_SKIP_TIMELINE_INDEX = {
65 + type: 'SUSPENSE_SKIP_TIMELINE_INDEX',
66 + payload: boolean,
67 +};
68 +type ACTION_SUSPENSE_PLAY_PAUSE = {
69 + type: 'SUSPENSE_PLAY_PAUSE',
70 + payload: 'toggle' | 'play' | 'pause',
71 +};
72 +type ACTION_SUSPENSE_PLAY_TICK = {
73 + type: 'SUSPENSE_PLAY_TICK',
74 +};
75 +
76 export type SuspenseTreeAction =
77 | ACTION_SUSPENSE_TREE_MUTATION
78 | ACTION_SET_SUSPENSE_LINEAGE
79 | ACTION_SELECT_SUSPENSE_BY_ID
80 | ACTION_SET_SUSPENSE_TIMELINE
68 - | ACTION_SUSPENSE_SET_TIMELINE_INDEX;
81 + | ACTION_SUSPENSE_SET_TIMELINE_INDEX
82 + | ACTION_SUSPENSE_SKIP_TIMELINE_INDEX
83 + | ACTION_SUSPENSE_PLAY_PAUSE
84 + | ACTION_SUSPENSE_PLAY_TICK;
85 export type SuspenseTreeDispatch = (action: SuspenseTreeAction) => void;
86
87 const SuspenseTreeStateContext: ReactContext<SuspenseTreeState> =
@@ -107,6 +123,7 @@ function getInitialState(store: Store): SuspenseTreeState {
123 timeline: [],
124 timelineIndex: -1,
125 uniqueSuspendersOnly,
126 + playing: false,
127 };
128 } else {
129 const timeline = store.getSuspendableDocumentOrderSuspense(
@@ -128,6 +145,7 @@ function getInitialState(store: Store): SuspenseTreeState {
145 timeline,
146 timelineIndex,
147 uniqueSuspendersOnly,
148 + playing: false,
149 };
150 }
151
@@ -234,6 +252,7 @@ function SuspenseTreeContextController({children}: Props): React.Node {
252 ...state,
253 selectedSuspenseID,
254 selectedRootID,
255 + playing: false, // pause
256 };
257 }
258 case 'SET_SUSPENSE_LINEAGE': {
@@ -247,6 +266,7 @@ function SuspenseTreeContextController({children}: Props): React.Node {
266 lineage,
267 selectedSuspenseID: suspenseID,
268 selectedRootID,
269 + playing: false, // pause
270 };
271 }
272 case 'SET_SUSPENSE_TIMELINE': {
@@ -302,6 +322,79 @@ function SuspenseTreeContextController({children}: Props): React.Node {
322 lineage: nextLineage,
323 selectedSuspenseID: nextSelectedSuspenseID,
324 timelineIndex: nextTimelineIndex,
325 + playing: false, // pause
326 + };
327 + }
328 + case 'SUSPENSE_SKIP_TIMELINE_INDEX': {
329 + const direction = action.payload;
330 + const nextTimelineIndex =
331 + state.timelineIndex + (direction ? 1 : -1);
332 + if (
333 + nextTimelineIndex < 0 ||
334 + nextTimelineIndex > state.timeline.length - 1
335 + ) {
336 + return state;
337 + }
338 + const nextSelectedSuspenseID = state.timeline[nextTimelineIndex];
339 + const nextLineage = store.getSuspenseLineage(
340 + nextSelectedSuspenseID,
341 + );
342 + return {
343 + ...state,
344 + lineage: nextLineage,
345 + selectedSuspenseID: nextSelectedSuspenseID,
346 + timelineIndex: nextTimelineIndex,
347 + playing: false, // pause
348 + };
349 + }
350 + case 'SUSPENSE_PLAY_PAUSE': {
351 + const mode = action.payload;
352 +
353 + let nextTimelineIndex = state.timelineIndex;
354 + let nextSelectedSuspenseID = state.selectedSuspenseID;
355 + let nextLineage = state.lineage;
356 +
357 + if (
358 + !state.playing &&
359 + mode !== 'pause' &&
360 + nextTimelineIndex === state.timeline.length - 1
361 + ) {
362 + // If we're restarting at the end. Then loop around and start again from the beginning.
363 + nextTimelineIndex = 0;
364 + nextSelectedSuspenseID = state.timeline[nextTimelineIndex];
365 + nextLineage = store.getSuspenseLineage(nextSelectedSuspenseID);
366 + }
367 +
368 + return {
369 + ...state,
370 + lineage: nextLineage,
371 + selectedSuspenseID: nextSelectedSuspenseID,
372 + timelineIndex: nextTimelineIndex,
373 + playing: mode === 'toggle' ? !state.playing : mode === 'play',
374 + };
375 + }
376 + case 'SUSPENSE_PLAY_TICK': {
377 + if (!state.playing) {
378 + // We stopped but haven't yet cleaned up the callback. Noop.
379 + return state;
380 + }
381 + // Advance time
382 + const nextTimelineIndex = state.timelineIndex + 1;
383 + if (nextTimelineIndex > state.timeline.length - 1) {
384 + return state;
385 + }
386 + const nextSelectedSuspenseID = state.timeline[nextTimelineIndex];
387 + const nextLineage = store.getSuspenseLineage(
388 + nextSelectedSuspenseID,
389 + );
390 + // Stop once we reach the end.
391 + const nextPlaying = nextTimelineIndex < state.timeline.length - 1;
392 + return {
393 + ...state,
394 + lineage: nextLineage,
395 + selectedSuspenseID: nextSelectedSuspenseID,
396 + timelineIndex: nextTimelineIndex,
397 + playing: nextPlaying,
398 };
399 }
400 default: