@samitouri / QOS-React / commits / 3a0ab8a7ee

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

Sebastian Markbåge committed Oct 29, 2025 at 21:49 UTC 3a0ab8a7eea22394ba8948ff02af1c6d6e6e1921
5 files changed +199 -9
packages/react-devtools-shared/src/__tests__/bridge-test.js
+2 -2
@@ -27,7 +27,7 @@ describe('Bridge', () => {
27 // Check that we're wired up correctly.
28 bridge.send('reloadAppForProfiling');
29 jest.runAllTimers();
30 - expect(wall.send).toHaveBeenCalledWith('reloadAppForProfiling');
30 + expect(wall.send).toHaveBeenCalledWith('reloadAppForProfiling', undefined);
31
32 // Should flush pending messages and then shut down.
33 wall.send.mockClear();
@@ -37,7 +37,7 @@ describe('Bridge', () => {
37 jest.runAllTimers();
38 expect(wall.send).toHaveBeenCalledWith('update', '1');
39 expect(wall.send).toHaveBeenCalledWith('update', '2');
40 - expect(wall.send).toHaveBeenCalledWith('shutdown');
40 + expect(wall.send).toHaveBeenCalledWith('shutdown', undefined);
41 expect(shutdownCallback).toHaveBeenCalledTimes(1);
42
43 // Verify that the Bridge doesn't send messages after shutdown.
packages/react-devtools-shared/src/backend/views/Highlighter/index.js
+60
@@ -33,6 +33,66 @@ export default function setupHighlighter(
33 bridge.addListener('shutdown', stopInspectingHost);
34 bridge.addListener('startInspectingHost', startInspectingHost);
35 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);
96
97 function startInspectingHost(onlySuspenseNodes: boolean) {
98 inspectOnlySuspenseNodes = onlySuspenseNodes;
packages/react-devtools-shared/src/bridge.js
+5 -1
@@ -217,6 +217,7 @@ export type BackendEvents = {
217 selectElement: [number],
218 shutdown: [],
219 stopInspectingHost: [boolean],
220 + scrollTo: [{left: number, top: number, right: number, bottom: number}],
221 syncSelectionToBuiltinElementsPanel: [],
222 unsupportedRendererVersion: [],
223
@@ -270,6 +271,8 @@ type FrontendEvents = {
271 startProfiling: [StartProfilingParams],
272 stopInspectingHost: [],
273 scrollToHostInstance: [ScrollToHostInstance],
274 + scrollTo: [{left: number, top: number, right: number, bottom: number}],
275 + requestScrollPosition: [],
276 stopProfiling: [],
277 storeAsGlobal: [StoreAsGlobalParams],
278 updateComponentFilters: [Array<ComponentFilter>],
@@ -416,7 +419,8 @@ class Bridge<
419 try {
420 if (this._messageQueue.length) {
421 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]);
424 }
425 this._messageQueue.length = 0;
426 }
packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseRects.js
+11 -2
@@ -18,7 +18,7 @@ import typeof {
18 } from 'react-dom-bindings/src/events/SyntheticEvent';
19
20 import * as React from 'react';
21 -import {createContext, useContext} from 'react';
21 +import {createContext, useContext, useLayoutEffect} from 'react';
22 import {
23 TreeDispatcherContext,
24 TreeStateContext,
@@ -435,7 +435,11 @@ function SuspenseRectsRoot({rootID}: {rootID: SuspenseNode['id']}): React$Node {
435
436 const ViewBox = createContext<Rect>((null: any));
437
438 -function SuspenseRectsContainer(): React$Node {
438 +function SuspenseRectsContainer({
439 + scaleRef,
440 +}: {
441 + scaleRef: {current: number},
442 +}): React$Node {
443 const store = useContext(StoreContext);
444 const {inspectedElementID} = useContext(TreeStateContext);
445 const treeDispatch = useContext(TreeDispatcherContext);
@@ -505,6 +509,11 @@ function SuspenseRectsContainer(): React$Node {
509 const rootEnvironment =
510 timeline.length === 0 ? null : timeline[0].environment;
511
512 + useLayoutEffect(() => {
513 + // 100% of the width represents this many pixels in the real document.
514 + scaleRef.current = boundingBoxWidth;
515 + }, [boundingBoxWidth]);
516 +
517 return (
518 <div
519 className={
packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTab.js
+121 -4
@@ -35,7 +35,7 @@ import {
35 SuspenseTreeDispatcherContext,
36 SuspenseTreeStateContext,
37 } from './SuspenseTreeContext';
38 -import {StoreContext, OptionsContext} from '../context';
38 +import {BridgeContext, StoreContext, OptionsContext} from '../context';
39 import Button from '../Button';
40 import Toggle from '../Toggle';
41 import typeof {SyntheticPointerEvent} from 'react-dom-bindings/src/events/SyntheticEvent';
@@ -157,6 +157,119 @@ function ToggleInspectedElement({
157 );
158 }
159
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 +
273 function SuspenseTab(_: {}) {
274 const store = useContext(StoreContext);
275 const {hideSettings} = useContext(OptionsContext);
@@ -341,6 +454,8 @@ function SuspenseTab(_: {}) {
454 }
455 };
456
457 + const scaleRef = useRef(0);
458 +
459 return (
460 <SettingsModalContextController>
461 <div className={styles.SuspenseTab} ref={wrapperTreeRef}>
@@ -388,9 +503,11 @@ function SuspenseTab(_: {}) {
503 orientation="horizontal"
504 />
505 </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>
511 <footer className={styles.SuspenseTreeViewFooter}>
512 <SuspenseTimeline />
513 <div className={styles.SuspenseTreeViewFooterButtons}>