[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
+}