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 419fefc4ffcf8..8b4a06515a651 100644 --- a/packages/react-devtools-shared/src/backend/views/Highlighter/index.js +++ b/packages/react-devtools-shared/src/backend/views/Highlighter/index.js @@ -28,6 +28,67 @@ 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({x, y}: {x: number, y: number}) { + const element = document.documentElement; + if (element === null) { + return; + } + const left = x * (element.scrollWidth - element.clientWidth); + const top = y * (element.scrollHeight - element.clientHeight); + if ( + left !== Math.round(window.scrollX) || + top !== Math.round(window.scrollY) + ) { + // Disable scroll events until we've applied the new scroll position. + applyingScroll = true; + window.scrollTo({ + top: top, + left: left, + behavior: 'smooth', + }); + } + } + + let scrollTimer = null; + function sendScroll() { + if (scrollTimer) { + clearTimeout(scrollTimer); + scrollTimer = null; + } + if (applyingScroll) { + return; + } + // We send in fraction of scrollable area. + const element = document.documentElement; + if (element === null) { + return; + } + const w = element.scrollWidth - element.clientWidth; + const x = w === 0 ? 0 : window.scrollX / w; + const h = element.scrollHeight - element.clientHeight; + const y = h === 0 ? 0 : window.scrollY / h; + bridge.send('scrollTo', {x, y}); + } + + 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() { registerListenersOnWindow(window); diff --git a/packages/react-devtools-shared/src/bridge.js b/packages/react-devtools-shared/src/bridge.js index 616f2d3d3ec23..a80b55ef5ac17 100644 --- a/packages/react-devtools-shared/src/bridge.js +++ b/packages/react-devtools-shared/src/bridge.js @@ -207,6 +207,7 @@ export type BackendEvents = { selectElement: [number], shutdown: [], stopInspectingHost: [boolean], + scrollTo: [{x: number, y: number}], syncSelectionFromBuiltinElementsPanel: [], syncSelectionToBuiltinElementsPanel: [], unsupportedRendererVersion: [], @@ -254,6 +255,8 @@ type FrontendEvents = { startInspectingHost: [], startProfiling: [StartProfilingParams], stopInspectingHost: [boolean], + scrollTo: [{x: number, y: number}], + requestScrollPosition: [], stopProfiling: [], storeAsGlobal: [StoreAsGlobalParams], updateComponentFilters: [Array], @@ -404,7 +407,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/SuspenseTab.js b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTab.js index b4ed7ec1f93c8..774798d7b8598 100644 --- a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTab.js +++ b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTab.js @@ -33,7 +33,7 @@ import { SuspenseTreeDispatcherContext, SuspenseTreeStateContext, } from './SuspenseTreeContext'; -import {StoreContext, OptionsContext} from '../context'; +import {BridgeContext, StoreContext, OptionsContext} from '../context'; import {TreeDispatcherContext} from '../Components/TreeContext'; import Button from '../Button'; import Toggle from '../Toggle'; @@ -212,6 +212,105 @@ function ToggleInspectedElement({ ); } +function SynchronizedScrollContainer({ + className, + children, +}: { + className?: string, + children?: React.Node, +}) { + const bridge = useContext(BridgeContext); + const ref = useRef(null); + const applyingScrollRef = useRef(false); + + // TODO: useEffectEvent + function scrollContainerTo({x, y}: {x: number, y: number}): void { + const element = ref.current; + if (element === null) { + return; + } + const left = Math.round(x * (element.scrollWidth - element.clientWidth)); + const top = Math.round(y * (element.scrollHeight - element.clientHeight)); + if ( + left !== Math.round(element.scrollLeft) || + top !== Math.round(element.scrollTop) + ) { + // Disable scroll events until we've applied the new scroll position. + applyingScrollRef.current = true; + element.scrollTo({ + left, + top, + behavior: 'smooth', + }); + } + } + + useEffect(() => { + 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 w = element.scrollWidth - element.clientWidth; + const x = w === 0 ? 0 : element.scrollLeft / w; + const h = element.scrollHeight - element.clientHeight; + const y = h === 0 ? 0 : element.scrollTop / h; + bridge.send('scrollTo', {x, y}); + } + + // 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 {hideSettings} = useContext(OptionsContext); const [state, dispatch] = useReducer( @@ -437,9 +536,9 @@ function SuspenseTab(_: {}) { orientation="horizontal" /> -
+ -
+