[DevTools] Explicitly say which id to scroll to and only once (#34823)
This ensures that we don't scroll on changes to the timeline such as when loading a new page or while the timeline is still loading. We only auto scroll to a boundary when we perform an explicit operation from the user.
Sebastian Markbåge committed
Oct 13, 2025 at 12:09 UTC
b467c6e9493a484d89b4c4c09d61add968a0b3c3
2 files changed
+19
-15
packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTimeline.js
+12
-15
@@ -8,7 +8,7 @@
8
*/
9
10
import * as React from 'react';
11
-import {useContext, useEffect, useRef} from 'react';
11
+import {useContext, useEffect} from 'react';
12
import {BridgeContext} from '../context';
13
import {TreeDispatcherContext} from '../Components/TreeContext';
14
import {useHighlightHostInstance, useScrollToHostInstance} from '../hooks';
@@ -29,9 +29,8 @@ function SuspenseTimelineInput() {
29
useHighlightHostInstance();
30
const scrollToHostInstance = useScrollToHostInstance();
31
32
- const {timeline, timelineIndex, hoveredTimelineIndex, playing} = useContext(
33
- SuspenseTreeStateContext,
34
- );
32
+ const {timeline, timelineIndex, hoveredTimelineIndex, playing, autoScroll} =
33
+ useContext(SuspenseTreeStateContext);
34
35
const min = 0;
36
const max = timeline.length > 0 ? timeline.length - 1 : 0;
@@ -102,7 +101,6 @@ function SuspenseTimelineInput() {
101
});
102
}
103
105
- const isInitialMount = useRef(true);
104
// TODO: useEffectEvent here once it's supported in all versions DevTools supports.
105
// For now we just exclude it from deps since we don't lint those anyway.
106
function changeTimelineIndex(newIndex: number) {
@@ -115,22 +113,21 @@ function SuspenseTimelineInput() {
113
bridge.send('overrideSuspenseMilestone', {
114
suspendedSet,
115
});
118
- if (isInitialMount.current) {
119
- // Skip scrolling on initial mount. Only when we're changing the timeline.
120
- isInitialMount.current = false;
121
- } else {
122
- // When we're scrubbing through the timeline, scroll the current boundary
123
- // into view as it was just revealed. This is after we override the milestone
124
- // to reveal it.
125
- const selectedSuspenseID = timeline[timelineIndex];
126
- scrollToHostInstance(selectedSuspenseID);
127
- }
116
}
117
118
useEffect(() => {
119
changeTimelineIndex(timelineIndex);
120
}, [timelineIndex]);
121
122
+ useEffect(() => {
123
+ if (autoScroll.id > 0) {
124
+ const scrollToId = autoScroll.id;
125
+ // Consume the scroll ref so that we only trigger this scroll once.
126
+ autoScroll.id = 0;
127
+ scrollToHostInstance(scrollToId);
128
+ }
129
+ }, [autoScroll]);
130
+
131
useEffect(() => {
132
if (!playing) {
133
return undefined;
packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTreeContext.js
+7
@@ -31,6 +31,7 @@ export type SuspenseTreeState = {
31
uniqueSuspendersOnly: boolean,
32
playing: boolean,
33
autoSelect: boolean,
34
+ autoScroll: {id: number}, // Ref that's set to 0 after scrolling once.
35
};
36
37
type ACTION_SUSPENSE_TREE_MUTATION = {
@@ -125,6 +126,7 @@ function getInitialState(store: Store): SuspenseTreeState {
126
uniqueSuspendersOnly,
127
playing: false,
128
autoSelect: true,
129
+ autoScroll: {id: 0}, // Don't auto-scroll initially
130
};
131
132
return initialState;
@@ -218,6 +220,7 @@ function SuspenseTreeContextController({children}: Props): React.Node {
220
selectedSuspenseID,
221
playing: false, // pause
222
autoSelect: false,
223
+ autoScroll: {id: selectedSuspenseID}, // scroll
224
};
225
}
226
case 'SET_SUSPENSE_LINEAGE': {
@@ -285,6 +288,7 @@ function SuspenseTreeContextController({children}: Props): React.Node {
288
timelineIndex: nextTimelineIndex,
289
playing: false, // pause
290
autoSelect: false,
291
+ autoScroll: {id: nextSelectedSuspenseID}, // scroll
292
};
293
}
294
case 'SUSPENSE_SKIP_TIMELINE_INDEX': {
@@ -308,6 +312,7 @@ function SuspenseTreeContextController({children}: Props): React.Node {
312
timelineIndex: nextTimelineIndex,
313
playing: false, // pause
314
autoSelect: false,
315
+ autoScroll: {id: nextSelectedSuspenseID}, // scroll
316
};
317
}
318
case 'SUSPENSE_PLAY_PAUSE': {
@@ -359,6 +364,7 @@ function SuspenseTreeContextController({children}: Props): React.Node {
364
selectedSuspenseID: nextSelectedSuspenseID,
365
timelineIndex: nextTimelineIndex,
366
playing: nextPlaying,
367
+ autoScroll: {id: nextSelectedSuspenseID}, // scroll
368
};
369
}
370
case 'TOGGLE_TIMELINE_FOR_ID': {
@@ -392,6 +398,7 @@ function SuspenseTreeContextController({children}: Props): React.Node {
398
timelineIndex: nextTimelineIndex,
399
playing: false, // pause
400
autoSelect: false,
401
+ autoScroll: {id: nextSelectedSuspenseID},
402
};
403
}
404
case 'HOVER_TIMELINE_FOR_ID': {