[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.
This commit is contained in:
Sebastian Markbåge 2025-10-29 21:49:35 -04:00 committed by GitHub
parent 0a5fb67ddf
commit 3a0ab8a7ee
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 199 additions and 9 deletions

View File

@ -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.

View File

@ -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;

View File

@ -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<ComponentFilter>],
@ -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;
}

View File

@ -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<Rect>((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 (
<div
className={

View File

@ -35,7 +35,7 @@ import {
SuspenseTreeDispatcherContext,
SuspenseTreeStateContext,
} from './SuspenseTreeContext';
import {StoreContext, OptionsContext} from '../context';
import {BridgeContext, StoreContext, OptionsContext} from '../context';
import Button from '../Button';
import Toggle from '../Toggle';
import typeof {SyntheticPointerEvent} from 'react-dom-bindings/src/events/SyntheticEvent';
@ -157,6 +157,119 @@ function ToggleInspectedElement({
);
}
function SynchronizedScrollContainer({
className,
children,
scaleRef,
}: {
className?: string,
children?: React.Node,
scaleRef: {current: number},
}) {
const bridge = useContext(BridgeContext);
const ref = useRef(null);
const applyingScrollRef = useRef(false);
// TODO: useEffectEvent
function scrollContainerTo({
left,
top,
right,
bottom,
}: {
left: number,
top: number,
right: number,
bottom: number,
}): void {
const element = ref.current;
if (element === null) {
return;
}
const scale = scaleRef.current / element.clientWidth;
const targetLeft = Math.round(left / scale);
const targetTop = Math.round(top / scale);
if (
targetLeft !== Math.round(element.scrollLeft) ||
targetTop !== Math.round(element.scrollTop)
) {
// Disable scroll events until we've applied the new scroll position.
applyingScrollRef.current = true;
element.scrollTo({
left: targetLeft,
top: targetTop,
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 | TimeoutID>(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 (
<div className={className} ref={ref}>
{children}
</div>
);
}
function SuspenseTab(_: {}) {
const store = useContext(StoreContext);
const {hideSettings} = useContext(OptionsContext);
@ -341,6 +454,8 @@ function SuspenseTab(_: {}) {
}
};
const scaleRef = useRef(0);
return (
<SettingsModalContextController>
<div className={styles.SuspenseTab} ref={wrapperTreeRef}>
@ -388,9 +503,11 @@ function SuspenseTab(_: {}) {
orientation="horizontal"
/>
</header>
<div className={styles.Rects}>
<SuspenseRects />
</div>
<SynchronizedScrollContainer
className={styles.Rects}
scaleRef={scaleRef}>
<SuspenseRects scaleRef={scaleRef} />
</SynchronizedScrollContainer>
<footer className={styles.SuspenseTreeViewFooter}>
<SuspenseTimeline />
<div className={styles.SuspenseTreeViewFooterButtons}>