Repository: facebook/react
Pull request: [DevTools] Clear element inspection if host element not owned by any renderer is selected

Stacked on https://github.com/facebook/react/pull/35503

If you click outside a React root in the Elements panel, we want to give eedback 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.

## Test plan

1. Open a document where not all DOM nodes are rendered by React e.g. https://react.new/
1. Select a DOM node owned by React
1. Select a DOM node not owned by React

https://github.com/user-attachments/assets/6840d768-bc04-4386-8c91-a86ef37feed2

1. Open a document where not all DOM nodes are rendered by React e.g. https://react.new/
1. Open Components panel
1. inspect element
1. Open Elements panel (should clear inspection since the selected host node is not owned by React)

https://github.com/user-attachments/assets/fcc83ca3-22b2-41fa-9330-76091cfc4608







Changed files:
filename: packages/react-devtools-extensions/src/main/index.js
patch: @@ -330,6 +330,7 @@ function createElementsInspectPanel() {
       inspectedElementPortalContainer = portal.container;
       if (inspectedElementPortalContainer != null && render) {
         ensureInitialHTMLIsCleared(inspectedElementPortalContainer);
+        bridge.send('syncSelectionFromBuiltinElementsPanel');
 
         render();
         portal.injectStyles(cloneStyleTags);

filename: packages/react-devtools-shared/src/backend/agent.js
patch: @@ -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 = () => {

filename: packages/react-devtools-shared/src/bridge.js
patch: @@ -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}],

filename: packages/react-devtools-shared/src/devtools/store.js
patch: @@ -147,7 +147,7 @@ export default class Store extends EventEmitter<{
   enableSuspenseTab: [],
   error: [Error],
   hookSettings: [$ReadOnly<DevToolsHookSettings>],
-  hostInstanceSelected: [Element['id']],
+  hostInstanceSelected: [Element['id'] | null],
   settingsUpdated: [$ReadOnly<DevToolsHookSettings>],
   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;
     }
 

filename: packages/react-devtools-shared/src/devtools/views/Components/TreeContext.js
patch: @@ -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);