Skip to content

Commit 3a0ab8a

Browse files
authored
[DevTools] Synchronize Scroll Position Between Suspense Tab and Main Document (facebook#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.
1 parent 0a5fb67 commit 3a0ab8a

File tree

5 files changed

+199
-9
lines changed

5 files changed

+199
-9
lines changed

packages/react-devtools-shared/src/__tests__/bridge-test.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ describe('Bridge', () => {
2727
// Check that we're wired up correctly.
2828
bridge.send('reloadAppForProfiling');
2929
jest.runAllTimers();
30-
expect(wall.send).toHaveBeenCalledWith('reloadAppForProfiling');
30+
expect(wall.send).toHaveBeenCalledWith('reloadAppForProfiling', undefined);
3131

3232
// Should flush pending messages and then shut down.
3333
wall.send.mockClear();
@@ -37,7 +37,7 @@ describe('Bridge', () => {
3737
jest.runAllTimers();
3838
expect(wall.send).toHaveBeenCalledWith('update', '1');
3939
expect(wall.send).toHaveBeenCalledWith('update', '2');
40-
expect(wall.send).toHaveBeenCalledWith('shutdown');
40+
expect(wall.send).toHaveBeenCalledWith('shutdown', undefined);
4141
expect(shutdownCallback).toHaveBeenCalledTimes(1);
4242

4343
// Verify that the Bridge doesn't send messages after shutdown.

packages/react-devtools-shared/src/backend/views/Highlighter/index.js

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,66 @@ export default function setupHighlighter(
3333
bridge.addListener('shutdown', stopInspectingHost);
3434
bridge.addListener('startInspectingHost', startInspectingHost);
3535
bridge.addListener('stopInspectingHost', stopInspectingHost);
36+
bridge.addListener('scrollTo', scrollDocumentTo);
37+
bridge.addListener('requestScrollPosition', sendScroll);
38+
39+
let applyingScroll = false;
40+
41+
function scrollDocumentTo({
42+
left,
43+
top,
44+
right,
45+
bottom,
46+
}: {
47+
left: number,
48+
top: number,
49+
right: number,
50+
bottom: number,
51+
}) {
52+
if (
53+
left === Math.round(window.scrollX) &&
54+
top === Math.round(window.scrollY)
55+
) {
56+
return;
57+
}
58+
applyingScroll = true;
59+
window.scrollTo({
60+
top: top,
61+
left: left,
62+
behavior: 'smooth',
63+
});
64+
}
65+
66+
let scrollTimer = null;
67+
function sendScroll() {
68+
if (scrollTimer) {
69+
clearTimeout(scrollTimer);
70+
scrollTimer = null;
71+
}
72+
if (applyingScroll) {
73+
return;
74+
}
75+
const left = window.scrollX;
76+
const top = window.scrollY;
77+
const right = left + window.innerWidth;
78+
const bottom = top + window.innerHeight;
79+
bridge.send('scrollTo', {left, top, right, bottom});
80+
}
81+
82+
function scrollEnd() {
83+
// Upon scrollend send it immediately.
84+
sendScroll();
85+
applyingScroll = false;
86+
}
87+
88+
document.addEventListener('scroll', () => {
89+
if (!scrollTimer) {
90+
// Periodically synchronize the scroll while scrolling.
91+
scrollTimer = setTimeout(sendScroll, 400);
92+
}
93+
});
94+
95+
document.addEventListener('scrollend', scrollEnd);
3696

3797
function startInspectingHost(onlySuspenseNodes: boolean) {
3898
inspectOnlySuspenseNodes = onlySuspenseNodes;

packages/react-devtools-shared/src/bridge.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,7 @@ export type BackendEvents = {
217217
selectElement: [number],
218218
shutdown: [],
219219
stopInspectingHost: [boolean],
220+
scrollTo: [{left: number, top: number, right: number, bottom: number}],
220221
syncSelectionToBuiltinElementsPanel: [],
221222
unsupportedRendererVersion: [],
222223

@@ -270,6 +271,8 @@ type FrontendEvents = {
270271
startProfiling: [StartProfilingParams],
271272
stopInspectingHost: [],
272273
scrollToHostInstance: [ScrollToHostInstance],
274+
scrollTo: [{left: number, top: number, right: number, bottom: number}],
275+
requestScrollPosition: [],
273276
stopProfiling: [],
274277
storeAsGlobal: [StoreAsGlobalParams],
275278
updateComponentFilters: [Array<ComponentFilter>],
@@ -416,7 +419,8 @@ class Bridge<
416419
try {
417420
if (this._messageQueue.length) {
418421
for (let i = 0; i < this._messageQueue.length; i += 2) {
419-
this._wall.send(this._messageQueue[i], ...this._messageQueue[i + 1]);
422+
// This only supports one argument in practice but the types suggests it should support multiple.
423+
this._wall.send(this._messageQueue[i], this._messageQueue[i + 1][0]);
420424
}
421425
this._messageQueue.length = 0;
422426
}

packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseRects.js

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import typeof {
1818
} from 'react-dom-bindings/src/events/SyntheticEvent';
1919

2020
import * as React from 'react';
21-
import {createContext, useContext} from 'react';
21+
import {createContext, useContext, useLayoutEffect} from 'react';
2222
import {
2323
TreeDispatcherContext,
2424
TreeStateContext,
@@ -435,7 +435,11 @@ function SuspenseRectsRoot({rootID}: {rootID: SuspenseNode['id']}): React$Node {
435435

436436
const ViewBox = createContext<Rect>((null: any));
437437

438-
function SuspenseRectsContainer(): React$Node {
438+
function SuspenseRectsContainer({
439+
scaleRef,
440+
}: {
441+
scaleRef: {current: number},
442+
}): React$Node {
439443
const store = useContext(StoreContext);
440444
const {inspectedElementID} = useContext(TreeStateContext);
441445
const treeDispatch = useContext(TreeDispatcherContext);
@@ -505,6 +509,11 @@ function SuspenseRectsContainer(): React$Node {
505509
const rootEnvironment =
506510
timeline.length === 0 ? null : timeline[0].environment;
507511

512+
useLayoutEffect(() => {
513+
// 100% of the width represents this many pixels in the real document.
514+
scaleRef.current = boundingBoxWidth;
515+
}, [boundingBoxWidth]);
516+
508517
return (
509518
<div
510519
className={

packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTab.js

Lines changed: 121 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ import {
3535
SuspenseTreeDispatcherContext,
3636
SuspenseTreeStateContext,
3737
} from './SuspenseTreeContext';
38-
import {StoreContext, OptionsContext} from '../context';
38+
import {BridgeContext, StoreContext, OptionsContext} from '../context';
3939
import Button from '../Button';
4040
import Toggle from '../Toggle';
4141
import typeof {SyntheticPointerEvent} from 'react-dom-bindings/src/events/SyntheticEvent';
@@ -157,6 +157,119 @@ function ToggleInspectedElement({
157157
);
158158
}
159159

160+
function SynchronizedScrollContainer({
161+
className,
162+
children,
163+
scaleRef,
164+
}: {
165+
className?: string,
166+
children?: React.Node,
167+
scaleRef: {current: number},
168+
}) {
169+
const bridge = useContext(BridgeContext);
170+
const ref = useRef(null);
171+
const applyingScrollRef = useRef(false);
172+
173+
// TODO: useEffectEvent
174+
function scrollContainerTo({
175+
left,
176+
top,
177+
right,
178+
bottom,
179+
}: {
180+
left: number,
181+
top: number,
182+
right: number,
183+
bottom: number,
184+
}): void {
185+
const element = ref.current;
186+
if (element === null) {
187+
return;
188+
}
189+
const scale = scaleRef.current / element.clientWidth;
190+
const targetLeft = Math.round(left / scale);
191+
const targetTop = Math.round(top / scale);
192+
if (
193+
targetLeft !== Math.round(element.scrollLeft) ||
194+
targetTop !== Math.round(element.scrollTop)
195+
) {
196+
// Disable scroll events until we've applied the new scroll position.
197+
applyingScrollRef.current = true;
198+
element.scrollTo({
199+
left: targetLeft,
200+
top: targetTop,
201+
behavior: 'smooth',
202+
});
203+
}
204+
}
205+
206+
useEffect(() => {
207+
const callback = scrollContainerTo;
208+
bridge.addListener('scrollTo', callback);
209+
// Ask for the current scroll position when we mount so we can attach ourselves to it.
210+
bridge.send('requestScrollPosition');
211+
return () => bridge.removeListener('scrollTo', callback);
212+
}, [bridge]);
213+
214+
const scrollTimer = useRef<null | TimeoutID>(null);
215+
216+
// TODO: useEffectEvent
217+
function sendScroll() {
218+
if (scrollTimer.current) {
219+
clearTimeout(scrollTimer.current);
220+
scrollTimer.current = null;
221+
}
222+
if (applyingScrollRef.current) {
223+
return;
224+
}
225+
const element = ref.current;
226+
if (element === null) {
227+
return;
228+
}
229+
const scale = scaleRef.current / element.clientWidth;
230+
const left = element.scrollLeft * scale;
231+
const top = element.scrollTop * scale;
232+
const right = left + element.clientWidth * scale;
233+
const bottom = top + element.clientHeight * scale;
234+
bridge.send('scrollTo', {left, top, right, bottom});
235+
}
236+
237+
// TODO: useEffectEvent
238+
function throttleScroll() {
239+
if (!scrollTimer.current) {
240+
// Periodically synchronize the scroll while scrolling.
241+
scrollTimer.current = setTimeout(sendScroll, 400);
242+
}
243+
}
244+
245+
function scrollEnd() {
246+
// Upon scrollend send it immediately.
247+
sendScroll();
248+
applyingScrollRef.current = false;
249+
}
250+
251+
useEffect(() => {
252+
const element = ref.current;
253+
if (element === null) {
254+
return;
255+
}
256+
const scrollCallback = throttleScroll;
257+
const scrollEndCallback = scrollEnd;
258+
element.addEventListener('scroll', scrollCallback);
259+
element.addEventListener('scrollend', scrollEndCallback);
260+
return () => {
261+
element.removeEventListener('scroll', scrollCallback);
262+
element.removeEventListener('scrollend', scrollEndCallback);
263+
};
264+
}, [ref]);
265+
266+
return (
267+
<div className={className} ref={ref}>
268+
{children}
269+
</div>
270+
);
271+
}
272+
160273
function SuspenseTab(_: {}) {
161274
const store = useContext(StoreContext);
162275
const {hideSettings} = useContext(OptionsContext);
@@ -341,6 +454,8 @@ function SuspenseTab(_: {}) {
341454
}
342455
};
343456

457+
const scaleRef = useRef(0);
458+
344459
return (
345460
<SettingsModalContextController>
346461
<div className={styles.SuspenseTab} ref={wrapperTreeRef}>
@@ -388,9 +503,11 @@ function SuspenseTab(_: {}) {
388503
orientation="horizontal"
389504
/>
390505
</header>
391-
<div className={styles.Rects}>
392-
<SuspenseRects />
393-
</div>
506+
<SynchronizedScrollContainer
507+
className={styles.Rects}
508+
scaleRef={scaleRef}>
509+
<SuspenseRects scaleRef={scaleRef} />
510+
</SynchronizedScrollContainer>
394511
<footer className={styles.SuspenseTreeViewFooter}>
395512
<SuspenseTimeline />
396513
<div className={styles.SuspenseTreeViewFooterButtons}>

0 commit comments

Comments
 (0)