From ef773201a6ef508fbe0049ba9e5f4953d88ff70c Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Sun, 12 Oct 2025 20:10:10 -0400 Subject: [PATCH] Explicitly say which id to scroll to and only once --- .../views/SuspenseTab/SuspenseTimeline.js | 27 +++++++++---------- .../views/SuspenseTab/SuspenseTreeContext.js | 7 +++++ 2 files changed, 19 insertions(+), 15 deletions(-) diff --git a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTimeline.js b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTimeline.js index 30ca21476f2b6..8ebb06899d62a 100644 --- a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTimeline.js +++ b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTimeline.js @@ -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; diff --git a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTreeContext.js b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTreeContext.js index 60235e09f3935..484a336c34959 100644 --- a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTreeContext.js +++ b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTreeContext.js @@ -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': {