diff --git a/packages/react-devtools-shared/src/__tests__/bridge-test.js b/packages/react-devtools-shared/src/__tests__/bridge-test.js index fe265e326c982..1cc47fbe5d8ac 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 b67b3964ed597..0adf3ff64ef68 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 683b341920244..b00867cc0cb1a 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 d5f44eeb32b88..556238f1ea927 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" /> -
- -
+ + +