From cbc4d4066333cb1a6736b93df4e0b5ff5936c849 Mon Sep 17 00:00:00 2001 From: "Sebastian \"Sebbie\" Silbermann" Date: Fri, 16 Jan 2026 13:08:28 +0100 Subject: [PATCH 1/2] Typecheck React DevTools extension main script (#35519) --- .../src/main/index.js | 90 ++++++++++++------- packages/react-devtools-shared/src/Logger.js | 6 +- packages/react-devtools-shared/src/bridge.js | 4 +- 3 files changed, 64 insertions(+), 36 deletions(-) diff --git a/packages/react-devtools-extensions/src/main/index.js b/packages/react-devtools-extensions/src/main/index.js index 5d7bd37ee42..bce648a5dba 100644 --- a/packages/react-devtools-extensions/src/main/index.js +++ b/packages/react-devtools-extensions/src/main/index.js @@ -1,6 +1,14 @@ /* global chrome */ - +/** @flow */ + +import type {RootType} from 'react-dom/src/client/ReactDOMRoot'; +import type {FrontendBridge, Message} from 'react-devtools-shared/src/bridge'; +import type { + TabID, + ViewElementSource, +} from 'react-devtools-shared/src/devtools/views/DevTools'; import type {SourceSelection} from 'react-devtools-shared/src/devtools/views/Editor/EditorPane'; +import type {Element} from 'react-devtools-shared/src/frontend/types'; import {createElement} from 'react'; import {flushSync} from 'react-dom'; @@ -51,9 +59,9 @@ const hookNamesModuleLoaderFunction = () => resolvedParseHookNames; function createBridge() { bridge = new Bridge({ listen(fn) { - const bridgeListener = message => fn(message); + const bridgeListener = (message: Message) => fn(message); // Store the reference so that we unsubscribe from the same object. - const portOnMessage = port.onMessage; + const portOnMessage = ((port: any): ExtensionPort).onMessage; portOnMessage.addListener(bridgeListener); lastSubscribedBridgeListener = bridgeListener; @@ -71,7 +79,7 @@ function createBridge() { bridge.addListener('reloadAppForProfiling', () => { localStorageSetItem(LOCAL_STORAGE_SUPPORTS_PROFILING_KEY, 'true'); - evalInInspectedWindow('reload', []); + evalInInspectedWindow('reload', [], () => {}); }); bridge.addListener( @@ -176,14 +184,20 @@ function createBridgeAndStore() { // Otherwise, the Store may miss important initial tree op codes. injectBackendManager(chrome.devtools.inspectedWindow.tabId); - const viewAttributeSourceFunction = (id, path) => { + const viewAttributeSourceFunction = ( + id: Element['id'], + path: Array, + ) => { const rendererID = store.getRendererIDForElement(id); if (rendererID != null) { viewAttributeSource(rendererID, id, path); } }; - const viewElementSourceFunction = (source, symbolicatedSource) => { + const viewElementSourceFunction: ViewElementSource = ( + source, + symbolicatedSource, + ) => { const [, sourceURL, line, column] = symbolicatedSource ? symbolicatedSource : source; @@ -198,7 +212,7 @@ function createBridgeAndStore() { root = createRoot(document.createElement('div')); - render = (overrideTab = mostRecentOverrideTab) => { + render = (overrideTab: TabID | null = mostRecentOverrideTab) => { mostRecentOverrideTab = overrideTab; root.render( @@ -227,7 +241,9 @@ function createBridgeAndStore() { }; } -function ensureInitialHTMLIsCleared(container) { +function ensureInitialHTMLIsCleared( + container: HTMLElement & {_hasInitialHTMLBeenCleared?: boolean}, +) { if (container._hasInitialHTMLBeenCleared) { return; } @@ -397,13 +413,6 @@ function createSourcesEditorPanel() { logEvent({event_name: 'selected-editor-pane'}); } }); - - createdPane.onShown.addListener(() => { - bridge.emit('extensionEditorPaneShown'); - }); - createdPane.onHidden.addListener(() => { - bridge.emit('extensionEditorPaneHidden'); - }); }); } @@ -479,10 +488,10 @@ function performInTabNavigationCleanup() { // Do not clean mostRecentOverrideTab on purpose, so we remember last opened // React DevTools tab, when user does in-tab navigation - store = null; - bridge = null; - render = null; - root = null; + store = (null: $FlowFixMe); + bridge = (null: $FlowFixMe); + render = (null: $FlowFixMe); + root = (null: $FlowFixMe); } function performFullCleanup() { @@ -504,18 +513,18 @@ function performFullCleanup() { componentsPortalContainer = null; profilerPortalContainer = null; suspensePortalContainer = null; - root = null; + root = (null: $FlowFixMe); mostRecentOverrideTab = null; - store = null; - bridge = null; - render = null; + store = (null: $FlowFixMe); + bridge = (null: $FlowFixMe); + render = (null: $FlowFixMe); port?.disconnect(); - port = null; + port = (null: $FlowFixMe); } -function connectExtensionPort() { +function connectExtensionPort(): void { if (port) { throw new Error('DevTools port was already connected'); } @@ -539,7 +548,7 @@ function connectExtensionPort() { // so, when we call `port.disconnect()` from this script, // this should not trigger this callback and port reconnection port.onDisconnect.addListener(() => { - port = null; + port = (null: $FlowFixMe); connectExtensionPort(); }); } @@ -593,9 +602,9 @@ function mountReactDevToolsWhenReactHasLoaded() { ); } -let bridge = null; +let bridge: FrontendBridge = (null: $FlowFixMe); let lastSubscribedBridgeListener = null; -let store = null; +let store: Store = (null: $FlowFixMe); let profilingData = null; @@ -610,13 +619,28 @@ let suspensePortalContainer = null; let editorPortalContainer = null; let inspectedElementPortalContainer = null; -let mostRecentOverrideTab = null; -let render = null; -let root = null; +let mostRecentOverrideTab: null | TabID = null; +let render: (overrideTab?: TabID) => void = (null: $FlowFixMe); +let root: RootType = (null: $FlowFixMe); let currentSelectedSource: null | SourceSelection = null; -let port = null; +type ExtensionEvent = { + addListener(callback: (message: Message, port: ExtensionPort) => void): void, + removeListener( + callback: (message: Message, port: ExtensionPort) => void, + ): void, +}; + +/** https://developer.chrome.com/docs/extensions/reference/api/runtime#type-Port */ +type ExtensionPort = { + onDisconnect: ExtensionEvent, + onMessage: ExtensionEvent, + postMessage(message: mixed, transferable?: Array): void, + disconnect(): void, +}; + +let port: ExtensionPort = (null: $FlowFixMe); // In case when multiple navigation events emitted in a short period of time // This debounced callback primarily used to avoid mounting React DevTools multiple times, which results @@ -649,7 +673,7 @@ connectExtensionPort(); mountReactDevToolsWhenReactHasLoaded(); -function onThemeChanged(themeName) { +function onThemeChanged() { // Rerender with the new theme render(); } diff --git a/packages/react-devtools-shared/src/Logger.js b/packages/react-devtools-shared/src/Logger.js index dd9dfb62025..0d1324d0d70 100644 --- a/packages/react-devtools-shared/src/Logger.js +++ b/packages/react-devtools-shared/src/Logger.js @@ -63,7 +63,11 @@ export type LoggerEvent = +value: any, ... }, - }; + } + | { + +event_name: 'selected-editor-pane', + } + | {+event_name: 'selected-inspected-element-pane'}; export type LogFunction = LoggerEvent => void | Promise; diff --git a/packages/react-devtools-shared/src/bridge.js b/packages/react-devtools-shared/src/bridge.js index b00867cc0cb..af98cb82989 100644 --- a/packages/react-devtools-shared/src/bridge.js +++ b/packages/react-devtools-shared/src/bridge.js @@ -74,7 +74,7 @@ export const currentBridgeProtocol: BridgeProtocol = type ElementAndRendererID = {id: number, rendererID: RendererID}; -type Message = { +export type Message = { event: string, payload: any, }; @@ -239,7 +239,7 @@ export type BackendEvents = { type StartProfilingParams = ProfilingSettings; type ReloadAndProfilingParams = ProfilingSettings; -type FrontendEvents = { +export type FrontendEvents = { clearErrorsAndWarnings: [{rendererID: RendererID}], clearErrorsForElementID: [ElementAndRendererID], clearHostInstanceHighlight: [], From 01c4d03d841b2695bf889ddeddc722b65f82031a Mon Sep 17 00:00:00 2001 From: "Sebastian \"Sebbie\" Silbermann" Date: Fri, 16 Jan 2026 13:20:44 +0100 Subject: [PATCH 2/2] [DevTools] Clear element inspection if host element not owned by any renderer is selected (#35504) --- .../src/main/index.js | 1 + .../src/backend/agent.js | 23 +++++++++++-------- packages/react-devtools-shared/src/bridge.js | 2 +- .../src/devtools/store.js | 13 ++++++++--- .../devtools/views/Components/TreeContext.js | 3 ++- 5 files changed, 28 insertions(+), 14 deletions(-) diff --git a/packages/react-devtools-extensions/src/main/index.js b/packages/react-devtools-extensions/src/main/index.js index bce648a5dba..76b320f1a66 100644 --- a/packages/react-devtools-extensions/src/main/index.js +++ b/packages/react-devtools-extensions/src/main/index.js @@ -330,6 +330,7 @@ function createElementsInspectPanel() { inspectedElementPortalContainer = portal.container; if (inspectedElementPortalContainer != null && render) { ensureInitialHTMLIsCleared(inspectedElementPortalContainer); + bridge.send('syncSelectionFromBuiltinElementsPanel'); render(); portal.injectStyles(cloneStyleTags); diff --git a/packages/react-devtools-shared/src/backend/agent.js b/packages/react-devtools-shared/src/backend/agent.js index 42fbbc9648a..a7c237be721 100644 --- a/packages/react-devtools-shared/src/backend/agent.js +++ b/packages/react-devtools-shared/src/backend/agent.js @@ -938,11 +938,19 @@ export default class Agent extends EventEmitter<{ } }; - selectNode(target: HostInstance): void { - const match = this.getIDForHostInstance(target); - if (match !== null) { - this._bridge.send('selectElement', match.id); - } + selectNode(target: HostInstance | null): void { + const match = target !== null ? this.getIDForHostInstance(target) : null; + this._bridge.send( + 'selectElement', + match !== null + ? match.id + : // If you click outside a React root in the Elements panel, we want to give + // feedback that no selection is possible so we clear the selection. + // Otherwise clicking outside a React root is indistinguishable from clicking + // a different host node that leads to the same selected React element + // due to Component filters + null, + ); } registerRendererInterface( @@ -988,10 +996,7 @@ export default class Agent extends EventEmitter<{ syncSelectionFromBuiltinElementsPanel: () => void = () => { const target = window.__REACT_DEVTOOLS_GLOBAL_HOOK__.$0; - if (target == null) { - return; - } - this.selectNode(target); + this.selectNode(target == null ? null : target); }; shutdown: () => void = () => { diff --git a/packages/react-devtools-shared/src/bridge.js b/packages/react-devtools-shared/src/bridge.js index af98cb82989..bc2669fda51 100644 --- a/packages/react-devtools-shared/src/bridge.js +++ b/packages/react-devtools-shared/src/bridge.js @@ -214,7 +214,7 @@ export type BackendEvents = { profilingStatus: [boolean], reloadAppForProfiling: [], saveToClipboard: [string], - selectElement: [number], + selectElement: [number | null], shutdown: [], stopInspectingHost: [boolean], scrollTo: [{left: number, top: number, right: number, bottom: number}], diff --git a/packages/react-devtools-shared/src/devtools/store.js b/packages/react-devtools-shared/src/devtools/store.js index 6ddaedb7981..c48d5b42fb6 100644 --- a/packages/react-devtools-shared/src/devtools/store.js +++ b/packages/react-devtools-shared/src/devtools/store.js @@ -147,7 +147,7 @@ export default class Store extends EventEmitter<{ enableSuspenseTab: [], error: [Error], hookSettings: [$ReadOnly], - hostInstanceSelected: [Element['id']], + hostInstanceSelected: [Element['id'] | null], settingsUpdated: [$ReadOnly], mutated: [ [ @@ -2381,8 +2381,15 @@ export default class Store extends EventEmitter<{ this._bridge.send('getHookSettings'); // Warm up cached hook settings }; - onHostInstanceSelected: (elementId: number) => void = elementId => { - if (this._lastSelectedHostInstanceElementId === elementId) { + onHostInstanceSelected: (elementId: number | null) => void = elementId => { + if ( + this._lastSelectedHostInstanceElementId === elementId && + // Force clear selection e.g. when we inspect an element in the Components panel + // and then switch to the browser's Elements panel. + // We wouldn't want to stay on the inspected element if we're inspecting + // an element not owned by React when switching to the browser's Elements panel. + elementId !== null + ) { return; } diff --git a/packages/react-devtools-shared/src/devtools/views/Components/TreeContext.js b/packages/react-devtools-shared/src/devtools/views/Components/TreeContext.js index 2a1477ddb57..80b1e9dce3e 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/TreeContext.js +++ b/packages/react-devtools-shared/src/devtools/views/Components/TreeContext.js @@ -967,8 +967,9 @@ function TreeContextController({ // Listen for host element selections. useEffect(() => { - const handler = (id: Element['id']) => + const handler = (id: Element['id'] | null) => { transitionDispatch({type: 'SELECT_ELEMENT_BY_ID', payload: id}); + }; store.addListener('hostInstanceSelected', handler); return () => store.removeListener('hostInstanceSelected', handler);