From 1efdd32ce5a235da2f4289623472827e00d3ab8b Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Mon, 29 Sep 2025 17:59:41 -0400 Subject: [PATCH 1/6] Add scrollToHostInstance bridge API It's like highlightHostInstance with the scrollIntoView option except without highlighting. --- .../src/backend/views/Highlighter/index.js | 39 +++++++++++++++++++ packages/react-devtools-shared/src/bridge.js | 5 +++ .../src/devtools/views/hooks.js | 27 +++++++++++-- 3 files changed, 68 insertions(+), 3 deletions(-) diff --git a/packages/react-devtools-shared/src/backend/views/Highlighter/index.js b/packages/react-devtools-shared/src/backend/views/Highlighter/index.js index 419fefc4ffcf8..a866e5a26ccd8 100644 --- a/packages/react-devtools-shared/src/backend/views/Highlighter/index.js +++ b/packages/react-devtools-shared/src/backend/views/Highlighter/index.js @@ -25,6 +25,7 @@ export default function setupHighlighter( ): void { bridge.addListener('clearHostInstanceHighlight', clearHostInstanceHighlight); bridge.addListener('highlightHostInstance', highlightHostInstance); + bridge.addListener('scrollToHostInstance', scrollToHostInstance); bridge.addListener('shutdown', stopInspectingHost); bridge.addListener('startInspectingHost', startInspectingHost); bridge.addListener('stopInspectingHost', stopInspectingHost); @@ -132,6 +133,44 @@ export default function setupHighlighter( } } + function scrollToHostInstance({ + id, + rendererID, + }: { + id: number, + rendererID: number, + }) { + // Always hide the existing overlay so it doesn't obscure the element. + // If you wanted to show the overlay, highlightHostInstance should be used instead + // with the scrollIntoView option. + hideOverlay(agent); + + const renderer = agent.rendererInterfaces[rendererID]; + if (renderer == null) { + console.warn(`Invalid renderer id "${rendererID}" for element "${id}"`); + return; + } + + // In some cases fiber may already be unmounted + if (!renderer.hasElementWithId(id)) { + return; + } + + const nodes = renderer.findHostInstancesForElementID(id); + + if (nodes != null && nodes[0] != null) { + const node = nodes[0]; + // $FlowFixMe[method-unbinding] + if (typeof node.scrollIntoView === 'function') { + node.scrollIntoView({ + block: 'nearest', + inline: 'nearest', + behavior: 'smooth', + }); + } + } + } + function onClick(event: MouseEvent) { event.preventDefault(); event.stopPropagation(); diff --git a/packages/react-devtools-shared/src/bridge.js b/packages/react-devtools-shared/src/bridge.js index 616f2d3d3ec23..aa9c867e1f1f2 100644 --- a/packages/react-devtools-shared/src/bridge.js +++ b/packages/react-devtools-shared/src/bridge.js @@ -93,6 +93,10 @@ type HighlightHostInstance = { scrollIntoView: boolean, }; +type ScrollToHostInstance = { + ...ElementAndRendererID, +}; + type OverrideValue = { ...ElementAndRendererID, path: Array, @@ -254,6 +258,7 @@ type FrontendEvents = { startInspectingHost: [], startProfiling: [StartProfilingParams], stopInspectingHost: [boolean], + scrollToHostInstance: [ScrollToHostInstance], stopProfiling: [], storeAsGlobal: [StoreAsGlobalParams], updateComponentFilters: [Array], diff --git a/packages/react-devtools-shared/src/devtools/views/hooks.js b/packages/react-devtools-shared/src/devtools/views/hooks.js index da69d6b493a19..a4ed2da526e16 100644 --- a/packages/react-devtools-shared/src/devtools/views/hooks.js +++ b/packages/react-devtools-shared/src/devtools/views/hooks.js @@ -345,13 +345,13 @@ export function useSubscription({ export function useHighlightHostInstance(): { clearHighlightHostInstance: () => void, - highlightHostInstance: (id: number) => void, + highlightHostInstance: (id: number, scrollIntoView?: boolean) => void, } { const bridge = useContext(BridgeContext); const store = useContext(StoreContext); const highlightHostInstance = useCallback( - (id: number) => { + (id: number, scrollIntoView?: boolean = false) => { const element = store.getElementByID(id); const rendererID = store.getRendererIDForElement(id); if (element !== null && rendererID !== null) { @@ -365,7 +365,7 @@ export function useHighlightHostInstance(): { id, openBuiltinElementsPanel: false, rendererID, - scrollIntoView: false, + scrollIntoView: scrollIntoView, }); } }, @@ -381,3 +381,24 @@ export function useHighlightHostInstance(): { clearHighlightHostInstance, }; } + +export function useScrollToHostInstance(): (id: number) => void { + const bridge = useContext(BridgeContext); + const store = useContext(StoreContext); + + const scrollToHostInstance = useCallback( + (id: number) => { + const element = store.getElementByID(id); + const rendererID = store.getRendererIDForElement(id); + if (element !== null && rendererID !== null) { + bridge.send('scrollToHostInstance', { + id, + rendererID, + }); + } + }, + [store, bridge], + ); + + return scrollToHostInstance; +} From 1374127ab8548efc6f3b9d634393ff5dce38afa6 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Mon, 29 Sep 2025 18:48:56 -0400 Subject: [PATCH 2/6] Scroll into view while changing timeline --- .../views/SuspenseTab/SuspenseTimeline.js | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTimeline.js b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTimeline.js index 18901b45b9ffb..b7340da915b9b 100644 --- a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTimeline.js +++ b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTimeline.js @@ -8,10 +8,10 @@ */ import * as React from 'react'; -import {useContext, useEffect} from 'react'; +import {useContext, useEffect, useRef} from 'react'; import {BridgeContext, StoreContext} from '../context'; import {TreeDispatcherContext} from '../Components/TreeContext'; -import {useHighlightHostInstance} from '../hooks'; +import {useHighlightHostInstance, useScrollToHostInstance} from '../hooks'; import { SuspenseTreeDispatcherContext, SuspenseTreeStateContext, @@ -28,6 +28,7 @@ function SuspenseTimelineInput() { const suspenseTreeDispatch = useContext(SuspenseTreeDispatcherContext); const {highlightHostInstance, clearHighlightHostInstance} = useHighlightHostInstance(); + const scrollToHostInstance = useScrollToHostInstance(); const { selectedRootID: rootID, @@ -77,7 +78,6 @@ function SuspenseTimelineInput() { function skipPrevious() { const nextSelectedSuspenseID = timeline[timelineIndex - 1]; - highlightHostInstance(nextSelectedSuspenseID); treeDispatch({ type: 'SELECT_ELEMENT_BY_ID', payload: nextSelectedSuspenseID, @@ -90,7 +90,6 @@ function SuspenseTimelineInput() { function skipForward() { const nextSelectedSuspenseID = timeline[timelineIndex + 1]; - highlightHostInstance(nextSelectedSuspenseID); treeDispatch({ type: 'SELECT_ELEMENT_BY_ID', payload: nextSelectedSuspenseID, @@ -108,6 +107,7 @@ function SuspenseTimelineInput() { }); } + const isInitialMount = useRef(true); // TODO: useEffectEvent here once it's supported in all versions DevTools supports. // For now we just exclude it from deps since we don't lint those anyway. function changeTimelineIndex(newIndex: number) { @@ -132,6 +132,16 @@ function SuspenseTimelineInput() { rootID, suspendedSet, }); + if (isInitialMount.current) { + // Skip scrolling on initial mount. Only when we're changing the timeline. + isInitialMount.current = false; + } else { + // When we're scrubbing through the timeline, scroll the current boundary + // into view as it was just revealed. This is after we override the milestone + // to reveal it. + const selectedSuspenseID = timeline[timelineIndex]; + scrollToHostInstance(selectedSuspenseID); + } } useEffect(() => { From 6b244814ce421a0a3532186a856924cc4d500a53 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Mon, 29 Sep 2025 19:02:24 -0400 Subject: [PATCH 3/6] If there are no nodes to scroll to, fallback to last known rect --- .../src/backend/fiber/renderer.js | 18 +++++++++++++ .../src/backend/flight/renderer.js | 3 +++ .../src/backend/legacy/renderer.js | 3 +++ .../src/backend/types.js | 11 ++++++++ .../src/backend/views/Highlighter/index.js | 27 +++++++++++++++++-- 5 files changed, 60 insertions(+), 2 deletions(-) diff --git a/packages/react-devtools-shared/src/backend/fiber/renderer.js b/packages/react-devtools-shared/src/backend/fiber/renderer.js index 0dd3084af5aa9..e37a47daf6b6b 100644 --- a/packages/react-devtools-shared/src/backend/fiber/renderer.js +++ b/packages/react-devtools-shared/src/backend/fiber/renderer.js @@ -5651,6 +5651,23 @@ export function attach( } } + function findLastKnownRectsForID(id: number): null | Array { + try { + const devtoolsInstance = idToDevToolsInstanceMap.get(id); + if (devtoolsInstance === undefined) { + console.warn(`Could not find DevToolsInstance with id "${id}"`); + return null; + } + if (devtoolsInstance.suspenseNode === null) { + return null; + } + return devtoolsInstance.suspenseNode.rects; + } catch (err) { + // The fiber might have unmounted by now. + return null; + } + } + function getDisplayNameForElementID(id: number): null | string { const devtoolsInstance = idToDevToolsInstanceMap.get(id); if (devtoolsInstance === undefined) { @@ -8387,6 +8404,7 @@ export function attach( getSerializedElementValueByPath, deletePath, findHostInstancesForElementID, + findLastKnownRectsForID, flushInitialOperations, getBestMatchForTrackedPath, getDisplayNameForElementID, diff --git a/packages/react-devtools-shared/src/backend/flight/renderer.js b/packages/react-devtools-shared/src/backend/flight/renderer.js index 75763b1f18499..d0dc9094334eb 100644 --- a/packages/react-devtools-shared/src/backend/flight/renderer.js +++ b/packages/react-devtools-shared/src/backend/flight/renderer.js @@ -152,6 +152,9 @@ export function attach( findHostInstancesForElementID() { return null; }, + findLastKnownRectsForID() { + return null; + }, flushInitialOperations() {}, getBestMatchForTrackedPath() { return null; diff --git a/packages/react-devtools-shared/src/backend/legacy/renderer.js b/packages/react-devtools-shared/src/backend/legacy/renderer.js index b59c0292942c6..8a245155ef2cc 100644 --- a/packages/react-devtools-shared/src/backend/legacy/renderer.js +++ b/packages/react-devtools-shared/src/backend/legacy/renderer.js @@ -1168,6 +1168,9 @@ export function attach( const hostInstance = findHostInstanceForInternalID(id); return hostInstance == null ? null : [hostInstance]; }, + findLastKnownRectsForID() { + return null; + }, getOwnersList, getPathForElement, getProfilingData, diff --git a/packages/react-devtools-shared/src/backend/types.js b/packages/react-devtools-shared/src/backend/types.js index 481bf65e210ed..e002740cb69f7 100644 --- a/packages/react-devtools-shared/src/backend/types.js +++ b/packages/react-devtools-shared/src/backend/types.js @@ -101,6 +101,16 @@ export type FindHostInstancesForElementID = ( id: number, ) => null | $ReadOnlyArray; +type Rect = { + x: number, + y: number, + width: number, + height: number, + ... +}; +export type FindLastKnownRectsForID = ( + id: number, +) => null | $ReadOnlyArray; export type ReactProviderType = { $$typeof: symbol | number, _context: ReactContext, @@ -411,6 +421,7 @@ export type RendererInterface = { path: Array, ) => void, findHostInstancesForElementID: FindHostInstancesForElementID, + findLastKnownRectsForID: FindLastKnownRectsForID, flushInitialOperations: () => void, getBestMatchForTrackedPath: () => PathMatch | null, getComponentStack?: GetComponentStack, diff --git a/packages/react-devtools-shared/src/backend/views/Highlighter/index.js b/packages/react-devtools-shared/src/backend/views/Highlighter/index.js index a866e5a26ccd8..4636e777ca210 100644 --- a/packages/react-devtools-shared/src/backend/views/Highlighter/index.js +++ b/packages/react-devtools-shared/src/backend/views/Highlighter/index.js @@ -157,17 +157,40 @@ export default function setupHighlighter( } const nodes = renderer.findHostInstancesForElementID(id); - if (nodes != null && nodes[0] != null) { const node = nodes[0]; // $FlowFixMe[method-unbinding] if (typeof node.scrollIntoView === 'function') { node.scrollIntoView({ - block: 'nearest', + block: 'start', inline: 'nearest', behavior: 'smooth', }); + return; + } + } + // It's possible that the current state of a Suspense boundary doesn't have a position + // in the tree. E.g. because it's not yet mounted in the state we're moving to. + // Such as if it's in a null tree or inside another boundary's hidden state. + // In this case we use the last known position and try to scroll to that. + const rects = renderer.findLastKnownRectsForID(id); + if (rects !== null && rects.length > 0) { + let x = Infinity; + let y = Infinity; + for (let i = 0; i < rects.length; i++) { + const rect = rects[i]; + if (rect.x < x) { + x = rect.x; + } + if (rect.y < y) { + y = rect.y; + } } + window.scrollTo({ + top: y, + left: x, + behavior: 'smooth', + }); } } From 00211790f54d983f21412bc29825063ca5282ef7 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Mon, 29 Sep 2025 19:08:53 -0400 Subject: [PATCH 4/6] Skip display: none nodes when highlighting or scrolling to --- .../src/backend/views/Highlighter/index.js | 92 +++++++++++++------ 1 file changed, 65 insertions(+), 27 deletions(-) diff --git a/packages/react-devtools-shared/src/backend/views/Highlighter/index.js b/packages/react-devtools-shared/src/backend/views/Highlighter/index.js index 4636e777ca210..d8f55375f5dad 100644 --- a/packages/react-devtools-shared/src/backend/views/Highlighter/index.js +++ b/packages/react-devtools-shared/src/backend/views/Highlighter/index.js @@ -112,25 +112,44 @@ export default function setupHighlighter( } const nodes = renderer.findHostInstancesForElementID(id); - - if (nodes != null && nodes[0] != null) { - const node = nodes[0]; - // $FlowFixMe[method-unbinding] - if (scrollIntoView && typeof node.scrollIntoView === 'function') { - // If the node isn't visible show it before highlighting it. - // We may want to reconsider this; it might be a little disruptive. - node.scrollIntoView({block: 'nearest', inline: 'nearest'}); - } - - showOverlay(nodes, displayName, agent, hideAfterTimeout); - - if (openBuiltinElementsPanel) { - window.__REACT_DEVTOOLS_GLOBAL_HOOK__.$0 = node; - bridge.send('syncSelectionToBuiltinElementsPanel'); + if (nodes != null) { + for (let i = 0; i < nodes.length; i++) { + const node = nodes[0]; + if (node === null) { + continue; + } + const nodeRects = + // $FlowFixMe[method-unbinding] + typeof node.getClientRects === 'function' + ? node.getClientRects() + : []; + // If this is currently display: none, then try another node. + // This can happen when one of the host instances is a hoistable. + if ( + nodeRects.length > 0 && + (nodeRects.length > 2 || + nodeRects[0].width > 0 || + nodeRects[0].height > 0) + ) { + // $FlowFixMe[method-unbinding] + if (scrollIntoView && typeof node.scrollIntoView === 'function') { + // If the node isn't visible show it before highlighting it. + // We may want to reconsider this; it might be a little disruptive. + node.scrollIntoView({block: 'nearest', inline: 'nearest'}); + } + + showOverlay(nodes, displayName, agent, hideAfterTimeout); + + if (openBuiltinElementsPanel) { + window.__REACT_DEVTOOLS_GLOBAL_HOOK__.$0 = node; + bridge.send('syncSelectionToBuiltinElementsPanel'); + } + return; + } } - } else { - hideOverlay(agent); } + + hideOverlay(agent); } function scrollToHostInstance({ @@ -157,16 +176,35 @@ export default function setupHighlighter( } const nodes = renderer.findHostInstancesForElementID(id); - if (nodes != null && nodes[0] != null) { - const node = nodes[0]; - // $FlowFixMe[method-unbinding] - if (typeof node.scrollIntoView === 'function') { - node.scrollIntoView({ - block: 'start', - inline: 'nearest', - behavior: 'smooth', - }); - return; + if (nodes != null) { + for (let i = 0; i < nodes.length; i++) { + const node = nodes[0]; + if (node === null) { + continue; + } + const nodeRects = + // $FlowFixMe[method-unbinding] + typeof node.getClientRects === 'function' + ? node.getClientRects() + : []; + // If this is currently display: none, then try another node. + // This can happen when one of the host instances is a hoistable. + if ( + nodeRects.length > 0 && + (nodeRects.length > 2 || + nodeRects[0].width > 0 || + nodeRects[0].height > 0) + ) { + // $FlowFixMe[method-unbinding] + if (typeof node.scrollIntoView === 'function') { + node.scrollIntoView({ + block: 'start', + inline: 'nearest', + behavior: 'smooth', + }); + return; + } + } } } // It's possible that the current state of a Suspense boundary doesn't have a position From 4edc7bb7dfbe0ae2291bfbf09cf81cc166e978d7 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Mon, 29 Sep 2025 19:34:34 -0400 Subject: [PATCH 5/6] Make a second attempt to scroll into view after mount --- .../src/backend/views/Highlighter/index.js | 74 +++++++++++++------ 1 file changed, 51 insertions(+), 23 deletions(-) diff --git a/packages/react-devtools-shared/src/backend/views/Highlighter/index.js b/packages/react-devtools-shared/src/backend/views/Highlighter/index.js index d8f55375f5dad..b47f6513bbc01 100644 --- a/packages/react-devtools-shared/src/backend/views/Highlighter/index.js +++ b/packages/react-devtools-shared/src/backend/views/Highlighter/index.js @@ -11,6 +11,7 @@ import Agent from 'react-devtools-shared/src/backend/agent'; import {hideOverlay, showOverlay} from './Highlighter'; import type {BackendBridge} from 'react-devtools-shared/src/bridge'; +import type {RendererInterface} from '../../types'; // This plug-in provides in-page highlighting of the selected element. // It is used by the browser extension and the standalone DevTools shell (when connected to a browser). @@ -133,6 +134,10 @@ export default function setupHighlighter( ) { // $FlowFixMe[method-unbinding] if (scrollIntoView && typeof node.scrollIntoView === 'function') { + if (scrollDelayTimer) { + clearTimeout(scrollDelayTimer); + scrollDelayTimer = null; + } // If the node isn't visible show it before highlighting it. // We may want to reconsider this; it might be a little disruptive. node.scrollIntoView({block: 'nearest', inline: 'nearest'}); @@ -152,29 +157,10 @@ export default function setupHighlighter( hideOverlay(agent); } - function scrollToHostInstance({ - id, - rendererID, - }: { + function attemptScrollToHostInstance( + renderer: RendererInterface, id: number, - rendererID: number, - }) { - // Always hide the existing overlay so it doesn't obscure the element. - // If you wanted to show the overlay, highlightHostInstance should be used instead - // with the scrollIntoView option. - hideOverlay(agent); - - const renderer = agent.rendererInterfaces[rendererID]; - if (renderer == null) { - console.warn(`Invalid renderer id "${rendererID}" for element "${id}"`); - return; - } - - // In some cases fiber may already be unmounted - if (!renderer.hasElementWithId(id)) { - return; - } - + ) { const nodes = renderer.findHostInstancesForElementID(id); if (nodes != null) { for (let i = 0; i < nodes.length; i++) { @@ -202,11 +188,47 @@ export default function setupHighlighter( inline: 'nearest', behavior: 'smooth', }); - return; + return true; } } } } + return false; + } + + let scrollDelayTimer = null; + function scrollToHostInstance({ + id, + rendererID, + }: { + id: number, + rendererID: number, + }) { + // Always hide the existing overlay so it doesn't obscure the element. + // If you wanted to show the overlay, highlightHostInstance should be used instead + // with the scrollIntoView option. + hideOverlay(agent); + + if (scrollDelayTimer) { + clearTimeout(scrollDelayTimer); + scrollDelayTimer = null; + } + + const renderer = agent.rendererInterfaces[rendererID]; + if (renderer == null) { + console.warn(`Invalid renderer id "${rendererID}" for element "${id}"`); + return; + } + + // In some cases fiber may already be unmounted + if (!renderer.hasElementWithId(id)) { + return; + } + + if (attemptScrollToHostInstance(renderer, id)) { + return; + } + // It's possible that the current state of a Suspense boundary doesn't have a position // in the tree. E.g. because it's not yet mounted in the state we're moving to. // Such as if it's in a null tree or inside another boundary's hidden state. @@ -229,6 +251,12 @@ export default function setupHighlighter( left: x, behavior: 'smooth', }); + // It's possible that after mount, we're able to scroll deeper once the new nodes + // have mounted. Let's try again after mount. Ideally we'd know which commit this + // is going to be but for now we just try after 100ms. + scrollDelayTimer = setTimeout(() => { + attemptScrollToHostInstance(renderer, id); + }, 100); } } From 0229bca279ab018b2278b03d6a89ed28a0490881 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Mon, 29 Sep 2025 19:44:39 -0400 Subject: [PATCH 6/6] Don't scroll if we're already in the viewport --- .../src/backend/views/Highlighter/index.js | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/packages/react-devtools-shared/src/backend/views/Highlighter/index.js b/packages/react-devtools-shared/src/backend/views/Highlighter/index.js index b47f6513bbc01..2b651cdc9cc45 100644 --- a/packages/react-devtools-shared/src/backend/views/Highlighter/index.js +++ b/packages/react-devtools-shared/src/backend/views/Highlighter/index.js @@ -184,7 +184,7 @@ export default function setupHighlighter( // $FlowFixMe[method-unbinding] if (typeof node.scrollIntoView === 'function') { node.scrollIntoView({ - block: 'start', + block: 'nearest', inline: 'nearest', behavior: 'smooth', }); @@ -246,11 +246,23 @@ export default function setupHighlighter( y = rect.y; } } - window.scrollTo({ - top: y, - left: x, - behavior: 'smooth', - }); + const element = document.documentElement; + if (!element) { + return; + } + // Check if the target corner is already in the viewport. + if ( + x < window.scrollX || + y < window.scrollY || + x > window.scrollX + element.clientWidth || + y > window.scrollY + element.clientHeight + ) { + window.scrollTo({ + top: y, + left: x, + behavior: 'smooth', + }); + } // It's possible that after mount, we're able to scroll deeper once the new nodes // have mounted. Let's try again after mount. Ideally we'd know which commit this // is going to be but for now we just try after 100ms.