@samitouri / QOS-React-2 / commits / b467c6e949

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