@samitouri / QOS-React / commits / 5dd163b49e

[DevTools] Auto-scroll when stepping through the timeline (#34653)

This brings the Suspense boundary that's switching into view so that when you play the loading sequence you can see how it plays out. Otherwise it's really hard to find where things are changing. This assumes we'll also scroll synchronize the suspense tab which will bring it into view there too.

Sebastian Markbåge committed Sep 30, 2025 at 14:37 UTC 5dd163b49e1c2ba94a23ce3b8c2c1874d3fc1d98
8 files changed +231 -20
packages/react-devtools-shared/src/backend/fiber/renderer.js
+18
@@ -5651,6 +5651,23 @@ export function attach(
5651 }
5652 }
5653
5654 + function findLastKnownRectsForID(id: number): null | Array<Rect> {
5655 + try {
5656 + const devtoolsInstance = idToDevToolsInstanceMap.get(id);
5657 + if (devtoolsInstance === undefined) {
5658 + console.warn(`Could not find DevToolsInstance with id "${id}"`);
5659 + return null;
5660 + }
5661 + if (devtoolsInstance.suspenseNode === null) {
5662 + return null;
5663 + }
5664 + return devtoolsInstance.suspenseNode.rects;
5665 + } catch (err) {
5666 + // The fiber might have unmounted by now.
5667 + return null;
5668 + }
5669 + }
5670 +
5671 function getDisplayNameForElementID(id: number): null | string {
5672 const devtoolsInstance = idToDevToolsInstanceMap.get(id);
5673 if (devtoolsInstance === undefined) {
@@ -8387,6 +8404,7 @@ export function attach(
8404 getSerializedElementValueByPath,
8405 deletePath,
8406 findHostInstancesForElementID,
8407 + findLastKnownRectsForID,
8408 flushInitialOperations,
8409 getBestMatchForTrackedPath,
8410 getDisplayNameForElementID,
packages/react-devtools-shared/src/backend/flight/renderer.js
+3
@@ -152,6 +152,9 @@ export function attach(
152 findHostInstancesForElementID() {
153 return null;
154 },
155 + findLastKnownRectsForID() {
156 + return null;
157 + },
158 flushInitialOperations() {},
159 getBestMatchForTrackedPath() {
160 return null;
packages/react-devtools-shared/src/backend/legacy/renderer.js
+3
@@ -1168,6 +1168,9 @@ export function attach(
1168 const hostInstance = findHostInstanceForInternalID(id);
1169 return hostInstance == null ? null : [hostInstance];
1170 },
1171 + findLastKnownRectsForID() {
1172 + return null;
1173 + },
1174 getOwnersList,
1175 getPathForElement,
1176 getProfilingData,
packages/react-devtools-shared/src/backend/types.js
+11
@@ -101,6 +101,16 @@ export type FindHostInstancesForElementID = (
101 id: number,
102 ) => null | $ReadOnlyArray<HostInstance>;
103
104 +type Rect = {
105 + x: number,
106 + y: number,
107 + width: number,
108 + height: number,
109 + ...
110 +};
111 +export type FindLastKnownRectsForID = (
112 + id: number,
113 +) => null | $ReadOnlyArray<Rect>;
114 export type ReactProviderType<T> = {
115 $$typeof: symbol | number,
116 _context: ReactContext<T>,
@@ -411,6 +421,7 @@ export type RendererInterface = {
421 path: Array<string | number>,
422 ) => void,
423 findHostInstancesForElementID: FindHostInstancesForElementID,
424 + findLastKnownRectsForID: FindLastKnownRectsForID,
425 flushInitialOperations: () => void,
426 getBestMatchForTrackedPath: () => PathMatch | null,
427 getComponentStack?: GetComponentStack,
packages/react-devtools-shared/src/backend/views/Highlighter/index.js
+153 -13
@@ -11,6 +11,7 @@ import Agent from 'react-devtools-shared/src/backend/agent';
11 import {hideOverlay, showOverlay} from './Highlighter';
12
13 import type {BackendBridge} from 'react-devtools-shared/src/bridge';
14 +import type {RendererInterface} from '../../types';
15
16 // This plug-in provides in-page highlighting of the selected element.
17 // It is used by the browser extension and the standalone DevTools shell (when connected to a browser).
@@ -25,6 +26,7 @@ export default function setupHighlighter(
26 ): void {
27 bridge.addListener('clearHostInstanceHighlight', clearHostInstanceHighlight);
28 bridge.addListener('highlightHostInstance', highlightHostInstance);
29 + bridge.addListener('scrollToHostInstance', scrollToHostInstance);
30 bridge.addListener('shutdown', stopInspectingHost);
31 bridge.addListener('startInspectingHost', startInspectingHost);
32 bridge.addListener('stopInspectingHost', stopInspectingHost);
@@ -111,24 +113,162 @@ export default function setupHighlighter(
113 }
114
115 const nodes = renderer.findHostInstancesForElementID(id);
116 + if (nodes != null) {
117 + for (let i = 0; i < nodes.length; i++) {
118 + const node = nodes[0];
119 + if (node === null) {
120 + continue;
121 + }
122 + const nodeRects =
123 + // $FlowFixMe[method-unbinding]
124 + typeof node.getClientRects === 'function'
125 + ? node.getClientRects()
126 + : [];
127 + // If this is currently display: none, then try another node.
128 + // This can happen when one of the host instances is a hoistable.
129 + if (
130 + nodeRects.length > 0 &&
131 + (nodeRects.length > 2 ||
132 + nodeRects[0].width > 0 ||
133 + nodeRects[0].height > 0)
134 + ) {
135 + // $FlowFixMe[method-unbinding]
136 + if (scrollIntoView && typeof node.scrollIntoView === 'function') {
137 + if (scrollDelayTimer) {
138 + clearTimeout(scrollDelayTimer);
139 + scrollDelayTimer = null;
140 + }
141 + // If the node isn't visible show it before highlighting it.
142 + // We may want to reconsider this; it might be a little disruptive.
143 + node.scrollIntoView({block: 'nearest', inline: 'nearest'});
144 + }
145 +
146 + showOverlay(nodes, displayName, agent, hideAfterTimeout);
147 +
148 + if (openBuiltinElementsPanel) {
149 + window.__REACT_DEVTOOLS_GLOBAL_HOOK__.$0 = node;
150 + bridge.send('syncSelectionToBuiltinElementsPanel');
151 + }
152 + return;
153 + }
154 + }
155 + }
156
115 - if (nodes != null && nodes[0] != null) {
116 - const node = nodes[0];
117 - // $FlowFixMe[method-unbinding]
118 - if (scrollIntoView && typeof node.scrollIntoView === 'function') {
119 - // If the node isn't visible show it before highlighting it.
120 - // We may want to reconsider this; it might be a little disruptive.
121 - node.scrollIntoView({block: 'nearest', inline: 'nearest'});
157 + hideOverlay(agent);
158 + }
159 +
160 + function attemptScrollToHostInstance(
161 + renderer: RendererInterface,
162 + id: number,
163 + ) {
164 + const nodes = renderer.findHostInstancesForElementID(id);
165 + if (nodes != null) {
166 + for (let i = 0; i < nodes.length; i++) {
167 + const node = nodes[0];
168 + if (node === null) {
169 + continue;
170 + }
171 + const nodeRects =
172 + // $FlowFixMe[method-unbinding]
173 + typeof node.getClientRects === 'function'
174 + ? node.getClientRects()
175 + : [];
176 + // If this is currently display: none, then try another node.
177 + // This can happen when one of the host instances is a hoistable.
178 + if (
179 + nodeRects.length > 0 &&
180 + (nodeRects.length > 2 ||
181 + nodeRects[0].width > 0 ||
182 + nodeRects[0].height > 0)
183 + ) {
184 + // $FlowFixMe[method-unbinding]
185 + if (typeof node.scrollIntoView === 'function') {
186 + node.scrollIntoView({
187 + block: 'nearest',
188 + inline: 'nearest',
189 + behavior: 'smooth',
190 + });
191 + return true;
192 + }
193 + }
194 }
195 + }
196 + return false;
197 + }
198 +
199 + let scrollDelayTimer = null;
200 + function scrollToHostInstance({
201 + id,
202 + rendererID,
203 + }: {
204 + id: number,
205 + rendererID: number,
206 + }) {
207 + // Always hide the existing overlay so it doesn't obscure the element.
208 + // If you wanted to show the overlay, highlightHostInstance should be used instead
209 + // with the scrollIntoView option.
210 + hideOverlay(agent);
211
124 - showOverlay(nodes, displayName, agent, hideAfterTimeout);
212 + if (scrollDelayTimer) {
213 + clearTimeout(scrollDelayTimer);
214 + scrollDelayTimer = null;
215 + }
216
126 - if (openBuiltinElementsPanel) {
127 - window.__REACT_DEVTOOLS_GLOBAL_HOOK__.$0 = node;
128 - bridge.send('syncSelectionToBuiltinElementsPanel');
217 + const renderer = agent.rendererInterfaces[rendererID];
218 + if (renderer == null) {
219 + console.warn(`Invalid renderer id "${rendererID}" for element "${id}"`);
220 + return;
221 + }
222 +
223 + // In some cases fiber may already be unmounted
224 + if (!renderer.hasElementWithId(id)) {
225 + return;
226 + }
227 +
228 + if (attemptScrollToHostInstance(renderer, id)) {
229 + return;
230 + }
231 +
232 + // It's possible that the current state of a Suspense boundary doesn't have a position
233 + // in the tree. E.g. because it's not yet mounted in the state we're moving to.
234 + // Such as if it's in a null tree or inside another boundary's hidden state.
235 + // In this case we use the last known position and try to scroll to that.
236 + const rects = renderer.findLastKnownRectsForID(id);
237 + if (rects !== null && rects.length > 0) {
238 + let x = Infinity;
239 + let y = Infinity;
240 + for (let i = 0; i < rects.length; i++) {
241 + const rect = rects[i];
242 + if (rect.x < x) {
243 + x = rect.x;
244 + }
245 + if (rect.y < y) {
246 + y = rect.y;
247 + }
248 }
130 - } else {
131 - hideOverlay(agent);
249 + const element = document.documentElement;
250 + if (!element) {
251 + return;
252 + }
253 + // Check if the target corner is already in the viewport.
254 + if (
255 + x < window.scrollX ||
256 + y < window.scrollY ||
257 + x > window.scrollX + element.clientWidth ||
258 + y > window.scrollY + element.clientHeight
259 + ) {
260 + window.scrollTo({
261 + top: y,
262 + left: x,
263 + behavior: 'smooth',
264 + });
265 + }
266 + // It's possible that after mount, we're able to scroll deeper once the new nodes
267 + // have mounted. Let's try again after mount. Ideally we'd know which commit this
268 + // is going to be but for now we just try after 100ms.
269 + scrollDelayTimer = setTimeout(() => {
270 + attemptScrollToHostInstance(renderer, id);
271 + }, 100);
272 }
273 }
274
packages/react-devtools-shared/src/bridge.js
+5
@@ -93,6 +93,10 @@ type HighlightHostInstance = {
93 scrollIntoView: boolean,
94 };
95
96 +type ScrollToHostInstance = {
97 + ...ElementAndRendererID,
98 +};
99 +
100 type OverrideValue = {
101 ...ElementAndRendererID,
102 path: Array<string | number>,
@@ -254,6 +258,7 @@ type FrontendEvents = {
258 startInspectingHost: [],
259 startProfiling: [StartProfilingParams],
260 stopInspectingHost: [boolean],
261 + scrollToHostInstance: [ScrollToHostInstance],
262 stopProfiling: [],
263 storeAsGlobal: [StoreAsGlobalParams],
264 updateComponentFilters: [Array<ComponentFilter>],
packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTimeline.js
+14 -4
@@ -8,10 +8,10 @@
8 */
9
10 import * as React from 'react';
11 -import {useContext, useEffect} from 'react';
11 +import {useContext, useEffect, useRef} from 'react';
12 import {BridgeContext, StoreContext} from '../context';
13 import {TreeDispatcherContext} from '../Components/TreeContext';
14 -import {useHighlightHostInstance} from '../hooks';
14 +import {useHighlightHostInstance, useScrollToHostInstance} from '../hooks';
15 import {
16 SuspenseTreeDispatcherContext,
17 SuspenseTreeStateContext,
@@ -28,6 +28,7 @@ function SuspenseTimelineInput() {
28 const suspenseTreeDispatch = useContext(SuspenseTreeDispatcherContext);
29 const {highlightHostInstance, clearHighlightHostInstance} =
30 useHighlightHostInstance();
31 + const scrollToHostInstance = useScrollToHostInstance();
32
33 const {
34 selectedRootID: rootID,
@@ -77,7 +78,6 @@ function SuspenseTimelineInput() {
78
79 function skipPrevious() {
80 const nextSelectedSuspenseID = timeline[timelineIndex - 1];
80 - highlightHostInstance(nextSelectedSuspenseID);
81 treeDispatch({
82 type: 'SELECT_ELEMENT_BY_ID',
83 payload: nextSelectedSuspenseID,
@@ -90,7 +90,6 @@ function SuspenseTimelineInput() {
90
91 function skipForward() {
92 const nextSelectedSuspenseID = timeline[timelineIndex + 1];
93 - highlightHostInstance(nextSelectedSuspenseID);
93 treeDispatch({
94 type: 'SELECT_ELEMENT_BY_ID',
95 payload: nextSelectedSuspenseID,
@@ -108,6 +107,7 @@ function SuspenseTimelineInput() {
107 });
108 }
109
110 + const isInitialMount = useRef(true);
111 // TODO: useEffectEvent here once it's supported in all versions DevTools supports.
112 // For now we just exclude it from deps since we don't lint those anyway.
113 function changeTimelineIndex(newIndex: number) {
@@ -132,6 +132,16 @@ function SuspenseTimelineInput() {
132 rootID,
133 suspendedSet,
134 });
135 + if (isInitialMount.current) {
136 + // Skip scrolling on initial mount. Only when we're changing the timeline.
137 + isInitialMount.current = false;
138 + } else {
139 + // When we're scrubbing through the timeline, scroll the current boundary
140 + // into view as it was just revealed. This is after we override the milestone
141 + // to reveal it.
142 + const selectedSuspenseID = timeline[timelineIndex];
143 + scrollToHostInstance(selectedSuspenseID);
144 + }
145 }
146
147 useEffect(() => {
packages/react-devtools-shared/src/devtools/views/hooks.js
+24 -3
@@ -345,13 +345,13 @@ export function useSubscription<Value>({
345
346 export function useHighlightHostInstance(): {
347 clearHighlightHostInstance: () => void,
348 - highlightHostInstance: (id: number) => void,
348 + highlightHostInstance: (id: number, scrollIntoView?: boolean) => void,
349 } {
350 const bridge = useContext(BridgeContext);
351 const store = useContext(StoreContext);
352
353 const highlightHostInstance = useCallback(
354 - (id: number) => {
354 + (id: number, scrollIntoView?: boolean = false) => {
355 const element = store.getElementByID(id);
356 const rendererID = store.getRendererIDForElement(id);
357 if (element !== null && rendererID !== null) {
@@ -365,7 +365,7 @@ export function useHighlightHostInstance(): {
365 id,
366 openBuiltinElementsPanel: false,
367 rendererID,
368 - scrollIntoView: false,
368 + scrollIntoView: scrollIntoView,
369 });
370 }
371 },
@@ -381,3 +381,24 @@ export function useHighlightHostInstance(): {
381 clearHighlightHostInstance,
382 };
383 }
384 +
385 +export function useScrollToHostInstance(): (id: number) => void {
386 + const bridge = useContext(BridgeContext);
387 + const store = useContext(StoreContext);
388 +
389 + const scrollToHostInstance = useCallback(
390 + (id: number) => {
391 + const element = store.getElementByID(id);
392 + const rendererID = store.getRendererIDForElement(id);
393 + if (element !== null && rendererID !== null) {
394 + bridge.send('scrollToHostInstance', {
395 + id,
396 + rendererID,
397 + });
398 + }
399 + },
400 + [store, bridge],
401 + );
402 +
403 + return scrollToHostInstance;
404 +}