From 3a0ab8a7eea22394ba8948ff02af1c6d6e6e1921 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Wed, 29 Oct 2025 21:49:35 -0400 Subject: [PATCH] [DevTools] Synchronize Scroll Position Between Suspense Tab and Main Document (#34641) It's annoying to have to try to find where it lines up with no hints. This way when you hover over something it should be on screen. The strategy I went with is that it scrolls to a percentage along the scrollable axis but the two might not be exactly the same. Partially because they have different aspect ratios but also because suspended boundaries can shrink the document while the suspense tab needs to still be able to show the boundaries that are currently invisible. --- .../src/__tests__/bridge-test.js | 4 +- .../src/backend/views/Highlighter/index.js | 60 +++++++++ packages/react-devtools-shared/src/bridge.js | 6 +- .../views/SuspenseTab/SuspenseRects.js | 13 +- .../devtools/views/SuspenseTab/SuspenseTab.js | 125 +++++++++++++++++- 5 files changed, 199 insertions(+), 9 deletions(-) diff --git a/packages/react-devtools-shared/src/__tests__/bridge-test.js b/packages/react-devtools-shared/src/__tests__/bridge-test.js index fe265e326c9..1cc47fbe5d8 100644 --- a/packages/react-devtools-shared/src/__tests__/bridge-test.js +++ b/packages/react-devtools-shared/src/__tests__/bridge-test.js @@ -27,7 +27,7 @@ describe('Bridge', () => { // Check that we're wired up correctly. bridge.send('reloadAppForProfiling'); jest.runAllTimers(); - expect(wall.send).toHaveBeenCalledWith('reloadAppForProfiling'); + expect(wall.send).toHaveBeenCalledWith('reloadAppForProfiling', undefined); // Should flush pending messages and then shut down. wall.send.mockClear(); @@ -37,7 +37,7 @@ describe('Bridge', () => { jest.runAllTimers(); expect(wall.send).toHaveBeenCalledWith('update', '1'); expect(wall.send).toHaveBeenCalledWith('update', '2'); - expect(wall.send).toHaveBeenCalledWith('shutdown'); + expect(wall.send).toHaveBeenCalledWith('shutdown', undefined); expect(shutdownCallback).toHaveBeenCalledTimes(1); // Verify that the Bridge doesn't send messages after shutdown. diff --git a/packages/react-devtools-shared/src/backend/views/Highlighter/index.js b/packages/react-devtools-shared/src/backend/views/Highlighter/index.js index b67b3964ed5..0adf3ff64ef 100644 --- a/packages/react-devtools-shared/src/backend/views/Highlighter/index.js +++ b/packages/react-devtools-shared/src/backend/views/Highlighter/index.js @@ -33,6 +33,66 @@ export default function setupHighlighter( bridge.addListener('shutdown', stopInspectingHost); bridge.addListener('startInspectingHost', startInspectingHost); bridge.addListener('stopInspectingHost', stopInspectingHost); + bridge.addListener('scrollTo', scrollDocumentTo); + bridge.addListener('requestScrollPosition', sendScroll); + + let applyingScroll = false; + + function scrollDocumentTo({ + left, + top, + right, + bottom, + }: { + left: number, + top: number, + right: number, + bottom: number, + }) { + if ( + left === Math.round(window.scrollX) && + top === Math.round(window.scrollY) + ) { + return; + } + applyingScroll = true; + window.scrollTo({ + top: top, + left: left, + behavior: 'smooth', + }); + } + + let scrollTimer = null; + function sendScroll() { + if (scrollTimer) { + clearTimeout(scrollTimer); + scrollTimer = null; + } + if (applyingScroll) { + return; + } + const left = window.scrollX; + const top = window.scrollY; + const right = left + window.innerWidth; + const bottom = top + window.innerHeight; + bridge.send('scrollTo', {left, top, right, bottom}); + } + + function scrollEnd() { + // Upon scrollend send it immediately. + sendScroll(); + applyingScroll = false; + } + + document.addEventListener('scroll', () => { + if (!scrollTimer) { + // Periodically synchronize the scroll while scrolling. + scrollTimer = setTimeout(sendScroll, 400); + } + }); + + document.addEventListener('scrollend', scrollEnd); function startInspectingHost(onlySuspenseNodes: boolean) { inspectOnlySuspenseNodes = onlySuspenseNodes; diff --git a/packages/react-devtools-shared/src/bridge.js b/packages/react-devtools-shared/src/bridge.js index 683b3419202..b00867cc0cb 100644 --- a/packages/react-devtools-shared/src/bridge.js +++ b/packages/react-devtools-shared/src/bridge.js @@ -217,6 +217,7 @@ export type BackendEvents = { selectElement: [number], shutdown: [], stopInspectingHost: [boolean], + scrollTo: [{left: number, top: number, right: number, bottom: number}], syncSelectionToBuiltinElementsPanel: [], unsupportedRendererVersion: [], @@ -270,6 +271,8 @@ type FrontendEvents = { startProfiling: [StartProfilingParams], stopInspectingHost: [], scrollToHostInstance: [ScrollToHostInstance], + scrollTo: [{left: number, top: number, right: number, bottom: number}], + requestScrollPosition: [], stopProfiling: [], storeAsGlobal: [StoreAsGlobalParams], updateComponentFilters: [Array], @@ -416,7 +419,8 @@ class Bridge< try { if (this._messageQueue.length) { for (let i = 0; i < this._messageQueue.length; i += 2) { - this._wall.send(this._messageQueue[i], ...this._messageQueue[i + 1]); + // This only supports one argument in practice but the types suggests it should support multiple. + this._wall.send(this._messageQueue[i], this._messageQueue[i + 1][0]); } this._messageQueue.length = 0; } diff --git a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseRects.js b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseRects.js index d5f44eeb32b..556238f1ea9 100644 --- a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseRects.js +++ b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseRects.js @@ -18,7 +18,7 @@ import typeof { } from 'react-dom-bindings/src/events/SyntheticEvent'; import * as React from 'react'; -import {createContext, useContext} from 'react'; +import {createContext, useContext, useLayoutEffect} from 'react'; import { TreeDispatcherContext, TreeStateContext, @@ -435,7 +435,11 @@ function SuspenseRectsRoot({rootID}: {rootID: SuspenseNode['id']}): React$Node { const ViewBox = createContext((null: any)); -function SuspenseRectsContainer(): React$Node { +function SuspenseRectsContainer({ + scaleRef, +}: { + scaleRef: {current: number}, +}): React$Node { const store = useContext(StoreContext); const {inspectedElementID} = useContext(TreeStateContext); const treeDispatch = useContext(TreeDispatcherContext); @@ -505,6 +509,11 @@ function SuspenseRectsContainer(): React$Node { const rootEnvironment = timeline.length === 0 ? null : timeline[0].environment; + useLayoutEffect(() => { + // 100% of the width represents this many pixels in the real document. + scaleRef.current = boundingBoxWidth; + }, [boundingBoxWidth]); + return (
{ + const callback = scrollContainerTo; + bridge.addListener('scrollTo', callback); + // Ask for the current scroll position when we mount so we can attach ourselves to it. + bridge.send('requestScrollPosition'); + return () => bridge.removeListener('scrollTo', callback); + }, [bridge]); + + const scrollTimer = useRef(null); + + // TODO: useEffectEvent + function sendScroll() { + if (scrollTimer.current) { + clearTimeout(scrollTimer.current); + scrollTimer.current = null; + } + if (applyingScrollRef.current) { + return; + } + const element = ref.current; + if (element === null) { + return; + } + const scale = scaleRef.current / element.clientWidth; + const left = element.scrollLeft * scale; + const top = element.scrollTop * scale; + const right = left + element.clientWidth * scale; + const bottom = top + element.clientHeight * scale; + bridge.send('scrollTo', {left, top, right, bottom}); + } + + // TODO: useEffectEvent + function throttleScroll() { + if (!scrollTimer.current) { + // Periodically synchronize the scroll while scrolling. + scrollTimer.current = setTimeout(sendScroll, 400); + } + } + + function scrollEnd() { + // Upon scrollend send it immediately. + sendScroll(); + applyingScrollRef.current = false; + } + + useEffect(() => { + const element = ref.current; + if (element === null) { + return; + } + const scrollCallback = throttleScroll; + const scrollEndCallback = scrollEnd; + element.addEventListener('scroll', scrollCallback); + element.addEventListener('scrollend', scrollEndCallback); + return () => { + element.removeEventListener('scroll', scrollCallback); + element.removeEventListener('scrollend', scrollEndCallback); + }; + }, [ref]); + + return ( +
+ {children} +
+ ); +} + function SuspenseTab(_: {}) { const store = useContext(StoreContext); const {hideSettings} = useContext(OptionsContext); @@ -341,6 +454,8 @@ function SuspenseTab(_: {}) { } }; + const scaleRef = useRef(0); + return (
@@ -388,9 +503,11 @@ function SuspenseTab(_: {}) { orientation="horizontal" /> -
- -
+ + +