Repository: facebook/react
Pull request: [DevTools] Explicitly say which id to scroll to and only once

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.

Changed files:
filename: packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTimeline.js
patch: @@ -8,7 +8,7 @@
  */
 
 import * as React from 'react';
-import {useContext, useEffect, useRef} from 'react';
+import {useContext, useEffect} from 'react';
 import {BridgeContext} from '../context';
 import {TreeDispatcherContext} from '../Components/TreeContext';
 import {useHighlightHostInstance, useScrollToHostInstance} from '../hooks';
@@ -29,9 +29,8 @@ function SuspenseTimelineInput() {
     useHighlightHostInstance();
   const scrollToHostInstance = useScrollToHostInstance();
 
-  const {timeline, timelineIndex, hoveredTimelineIndex, playing} = useContext(
-    SuspenseTreeStateContext,
-  );
+  const {timeline, timelineIndex, hoveredTimelineIndex, playing, autoScroll} =
+    useContext(SuspenseTreeStateContext);
 
   const min = 0;
   const max = timeline.length > 0 ? timeline.length - 1 : 0;
@@ -102,7 +101,6 @@ function SuspenseTimelineInput() {
     });
   }
 
-  const isInitialMount = useRef(true);
   // TODO: useEffectEvent here once it's supported in all versions DevTools supports.
   // For now we just exclude it from deps since we don't lint those anyway.
   function changeTimelineIndex(newIndex: number) {
@@ -115,22 +113,21 @@ function SuspenseTimelineInput() {
     bridge.send('overrideSuspenseMilestone', {
       suspendedSet,
     });
-    if (isInitialMount.current) {
-      // Skip scrolling on initial mount. Only when we're changing the timeline.
-      isInitialMount.current = false;
-    } else {
-      // When we're scrubbing through the timeline, scroll the current boundary
-      // into view as it was just revealed. This is after we override the milestone
-      // to reveal it.
-      const selectedSuspenseID = timeline[timelineIndex];
-      scrollToHostInstance(selectedSuspenseID);
-    }
   }
 
   useEffect(() => {
     changeTimelineIndex(timelineIndex);
   }, [timelineIndex]);
 
+  useEffect(() => {
+    if (autoScroll.id > 0) {
+      const scrollToId = autoScroll.id;
+      // Consume the scroll ref so that we only trigger this scroll once.
+      autoScroll.id = 0;
+      scrollToHostInstance(scrollToId);
+    }
+  }, [autoScroll]);
+
   useEffect(() => {
     if (!playing) {
       return undefined;

filename: packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTreeContext.js
patch: @@ -31,6 +31,7 @@ export type SuspenseTreeState = {
   uniqueSuspendersOnly: boolean,
   playing: boolean,
   autoSelect: boolean,
+  autoScroll: {id: number}, // Ref that's set to 0 after scrolling once.
 };
 
 type ACTION_SUSPENSE_TREE_MUTATION = {
@@ -125,6 +126,7 @@ function getInitialState(store: Store): SuspenseTreeState {
     uniqueSuspendersOnly,
     playing: false,
     autoSelect: true,
+    autoScroll: {id: 0}, // Don't auto-scroll initially
   };
 
   return initialState;
@@ -218,6 +220,7 @@ function SuspenseTreeContextController({children}: Props): React.Node {
               selectedSuspenseID,
               playing: false, // pause
               autoSelect: false,
+              autoScroll: {id: selectedSuspenseID}, // scroll
             };
           }
           case 'SET_SUSPENSE_LINEAGE': {
@@ -285,6 +288,7 @@ function SuspenseTreeContextController({children}: Props): React.Node {
               timelineIndex: nextTimelineIndex,
               playing: false, // pause
               autoSelect: false,
+              autoScroll: {id: nextSelectedSuspenseID}, // scroll
             };
           }
           case 'SUSPENSE_SKIP_TIMELINE_INDEX': {
@@ -308,6 +312,7 @@ function SuspenseTreeContextController({children}: Props): React.Node {
               timelineIndex: nextTimelineIndex,
               playing: false, // pause
               autoSelect: false,
+              autoScroll: {id: nextSelectedSuspenseID}, // scroll
             };
           }
           case 'SUSPENSE_PLAY_PAUSE': {
@@ -359,6 +364,7 @@ function SuspenseTreeContextController({children}: Props): React.Node {
               selectedSuspenseID: nextSelectedSuspenseID,
               timelineIndex: nextTimelineIndex,
               playing: nextPlaying,
+              autoScroll: {id: nextSelectedSuspenseID}, // scroll
             };
           }
           case 'TOGGLE_TIMELINE_FOR_ID': {
@@ -392,6 +398,7 @@ function SuspenseTreeContextController({children}: Props): React.Node {
               timelineIndex: nextTimelineIndex,
               playing: false, // pause
               autoSelect: false,
+              autoScroll: {id: nextSelectedSuspenseID},
             };
           }
           case 'HOVER_TIMELINE_FOR_ID': {