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