@samitouri / QOS-React-2 / commits / 7385d1f61a

[DevTools] Add inspection button to Suspense tab (#34867)

Add inspection button to Suspense tab which lets you select only among Suspense nodes. It highlights all the DOM nodes in the root of the Suspense node instead of just the DOM element you hover. The name is inferred. <img width="1172" height="841" alt="Screenshot 2025-10-15 at 8 03 34 PM" src="https://github.com/user-attachments/assets/f04d965b-ef6e-4196-9ba0-51626148fa1a" />

Sebastian Markbåge committed Oct 16, 2025 at 10:49 UTC 7385d1f61ac6fedb1473ee5aafb2c39d1a620a4b
10 files changed +140 -78
packages/react-devtools-shared/src/__tests__/store-test.js
+1 -1
@@ -1546,7 +1546,7 @@ describe('Store', () => {
1546 ▸ <Wrapper>
1547 `);
1548
1549 - const deepestedNodeID = agent.getIDForHostInstance(ref.current);
1549 + const deepestedNodeID = agent.getIDForHostInstance(ref.current).id;
1550
1551 await act(() => store.toggleIsCollapsed(deepestedNodeID, false));
1552 expect(store).toMatchInlineSnapshot(`
packages/react-devtools-shared/src/backend/agent.js
+34 -66
@@ -455,7 +455,10 @@ export default class Agent extends EventEmitter<{
455 return renderer.getInstanceAndStyle(id);
456 }
457
458 - getIDForHostInstance(target: HostInstance): number | null {
458 + getIDForHostInstance(
459 + target: HostInstance,
460 + onlySuspenseNodes?: boolean,
461 + ): null | {id: number, rendererID: number} {
462 if (isReactNativeEnvironment() || typeof target.nodeType !== 'number') {
463 // In React Native or non-DOM we simply pick any renderer that has a match.
464 for (const rendererID in this._rendererInterfaces) {
@@ -463,9 +466,14 @@ export default class Agent extends EventEmitter<{
466 (rendererID: any)
467 ]: any): RendererInterface);
468 try {
466 - const match = renderer.getElementIDForHostInstance(target);
467 - if (match != null) {
468 - return match;
469 + const id = onlySuspenseNodes
470 + ? renderer.getSuspenseNodeIDForHostInstance(target)
471 + : renderer.getElementIDForHostInstance(target);
472 + if (id !== null) {
473 + return {
474 + id: id,
475 + rendererID: +rendererID,
476 + };
477 }
478 } catch (error) {
479 // Some old React versions might throw if they can't find a match.
@@ -478,6 +486,7 @@ export default class Agent extends EventEmitter<{
486 // that is registered if there isn't an exact match.
487 let bestMatch: null | Element = null;
488 let bestRenderer: null | RendererInterface = null;
489 + let bestRendererID: number = 0;
490 // Find the nearest ancestor which is mounted by a React.
491 for (const rendererID in this._rendererInterfaces) {
492 const renderer = ((this._rendererInterfaces[
@@ -491,6 +500,7 @@ export default class Agent extends EventEmitter<{
500 // Exact match we can exit early.
501 bestMatch = nearestNode;
502 bestRenderer = renderer;
503 + bestRendererID = +rendererID;
504 break;
505 }
506 if (bestMatch === null || bestMatch.contains(nearestNode)) {
@@ -498,12 +508,21 @@ export default class Agent extends EventEmitter<{
508 // so the new match is a deeper and therefore better match.
509 bestMatch = nearestNode;
510 bestRenderer = renderer;
511 + bestRendererID = +rendererID;
512 }
513 }
514 }
515 if (bestRenderer != null && bestMatch != null) {
516 try {
506 - return bestRenderer.getElementIDForHostInstance(bestMatch);
517 + const id = onlySuspenseNodes
518 + ? bestRenderer.getSuspenseNodeIDForHostInstance(bestMatch)
519 + : bestRenderer.getElementIDForHostInstance(bestMatch);
520 + if (id !== null) {
521 + return {
522 + id,
523 + rendererID: bestRendererID,
524 + };
525 + }
526 } catch (error) {
527 // Some old React versions might throw if they can't find a match.
528 // If so we should ignore it...
@@ -514,65 +533,14 @@ export default class Agent extends EventEmitter<{
533 }
534
535 getComponentNameForHostInstance(target: HostInstance): string | null {
517 - // We duplicate this code from getIDForHostInstance to avoid an object allocation.
518 - if (isReactNativeEnvironment() || typeof target.nodeType !== 'number') {
519 - // In React Native or non-DOM we simply pick any renderer that has a match.
520 - for (const rendererID in this._rendererInterfaces) {
521 - const renderer = ((this._rendererInterfaces[
522 - (rendererID: any)
523 - ]: any): RendererInterface);
524 - try {
525 - const id = renderer.getElementIDForHostInstance(target);
526 - if (id) {
527 - return renderer.getDisplayNameForElementID(id);
528 - }
529 - } catch (error) {
530 - // Some old React versions might throw if they can't find a match.
531 - // If so we should ignore it...
532 - }
533 - }
534 - return null;
535 - } else {
536 - // In the DOM we use a smarter mechanism to find the deepest a DOM node
537 - // that is registered if there isn't an exact match.
538 - let bestMatch: null | Element = null;
539 - let bestRenderer: null | RendererInterface = null;
540 - // Find the nearest ancestor which is mounted by a React.
541 - for (const rendererID in this._rendererInterfaces) {
542 - const renderer = ((this._rendererInterfaces[
543 - (rendererID: any)
544 - ]: any): RendererInterface);
545 - const nearestNode: null | Element = renderer.getNearestMountedDOMNode(
546 - (target: any),
547 - );
548 - if (nearestNode !== null) {
549 - if (nearestNode === target) {
550 - // Exact match we can exit early.
551 - bestMatch = nearestNode;
552 - bestRenderer = renderer;
553 - break;
554 - }
555 - if (bestMatch === null || bestMatch.contains(nearestNode)) {
556 - // If this is the first match or the previous match contains the new match,
557 - // so the new match is a deeper and therefore better match.
558 - bestMatch = nearestNode;
559 - bestRenderer = renderer;
560 - }
561 - }
562 - }
563 - if (bestRenderer != null && bestMatch != null) {
564 - try {
565 - const id = bestRenderer.getElementIDForHostInstance(bestMatch);
566 - if (id) {
567 - return bestRenderer.getDisplayNameForElementID(id);
568 - }
569 - } catch (error) {
570 - // Some old React versions might throw if they can't find a match.
571 - // If so we should ignore it...
572 - }
573 - }
574 - return null;
536 + const match = this.getIDForHostInstance(target);
537 + if (match !== null) {
538 + const renderer = ((this._rendererInterfaces[
539 + (match.rendererID: any)
540 + ]: any): RendererInterface);
541 + return renderer.getDisplayNameForElementID(match.id);
542 }
543 + return null;
544 }
545
546 getBackendVersion: () => void = () => {
@@ -971,9 +939,9 @@ export default class Agent extends EventEmitter<{
939 };
940
941 selectNode(target: HostInstance): void {
974 - const id = this.getIDForHostInstance(target);
975 - if (id !== null) {
976 - this._bridge.send('selectElement', id);
942 + const match = this.getIDForHostInstance(target);
943 + if (match !== null) {
944 + this._bridge.send('selectElement', match.id);
945 }
946 }
947
packages/react-devtools-shared/src/backend/fiber/renderer.js
+45 -1
@@ -5793,7 +5793,28 @@ export function attach(
5793 return null;
5794 }
5795 if (devtoolsInstance.kind === FIBER_INSTANCE) {
5796 - return getDisplayNameForFiber(devtoolsInstance.data);
5796 + const fiber = devtoolsInstance.data;
5797 + if (fiber.tag === HostRoot) {
5798 + // The only reason you'd inspect a HostRoot is to show it as a SuspenseNode.
5799 + return 'Initial Paint';
5800 + }
5801 + if (fiber.tag === SuspenseComponent || fiber.tag === ActivityComponent) {
5802 + // For Suspense and Activity components, we can show a better name
5803 + // by using the name prop or their owner.
5804 + const props = fiber.memoizedProps;
5805 + if (props.name != null) {
5806 + return props.name;
5807 + }
5808 + const owner = getUnfilteredOwner(fiber);
5809 + if (owner != null) {
5810 + if (typeof owner.tag === 'number') {
5811 + return getDisplayNameForFiber((owner: any));
5812 + } else {
5813 + return owner.name || '';
5814 + }
5815 + }
5816 + }
5817 + return getDisplayNameForFiber(fiber);
5818 } else {
5819 return devtoolsInstance.data.name || '';
5820 }
@@ -5834,6 +5855,28 @@ export function attach(
5855 return null;
5856 }
5857
5858 + function getSuspenseNodeIDForHostInstance(
5859 + publicInstance: HostInstance,
5860 + ): number | null {
5861 + const instance = publicInstanceToDevToolsInstanceMap.get(publicInstance);
5862 + if (instance !== undefined) {
5863 + // Pick nearest unfiltered SuspenseNode instance.
5864 + let suspenseInstance = instance;
5865 + while (
5866 + suspenseInstance.suspenseNode === null ||
5867 + suspenseInstance.kind === FILTERED_FIBER_INSTANCE
5868 + ) {
5869 + if (suspenseInstance.parent === null) {
5870 + // We shouldn't get here since we'll always have a suspenseNode at the root.
5871 + return null;
5872 + }
5873 + suspenseInstance = suspenseInstance.parent;
5874 + }
5875 + return suspenseInstance.id;
5876 + }
5877 + return null;
5878 + }
5879 +
5880 function getElementAttributeByPath(
5881 id: number,
5882 path: Array<string | number>,
@@ -8630,6 +8673,7 @@ export function attach(
8673 getDisplayNameForElementID,
8674 getNearestMountedDOMNode,
8675 getElementIDForHostInstance,
8676 + getSuspenseNodeIDForHostInstance,
8677 getInstanceAndStyle,
8678 getOwnersList,
8679 getPathForElement,
packages/react-devtools-shared/src/backend/flight/renderer.js
+3
@@ -169,6 +169,9 @@ export function attach(
169 getElementIDForHostInstance() {
170 return null;
171 },
172 + getSuspenseNodeIDForHostInstance() {
173 + return null;
174 + },
175 getInstanceAndStyle() {
176 return {
177 instance: null,
packages/react-devtools-shared/src/backend/legacy/renderer.js
+3
@@ -1269,6 +1269,9 @@ export function attach(
1269 getDisplayNameForElementID,
1270 getNearestMountedDOMNode,
1271 getElementIDForHostInstance,
1272 + getSuspenseNodeIDForHostInstance(id: number): null {
1273 + return null;
1274 + },
1275 getInstanceAndStyle,
1276 findHostInstancesForElementID: (id: number) => {
1277 const hostInstance = findHostInstanceForInternalID(id);
packages/react-devtools-shared/src/backend/types.js
+1
@@ -427,6 +427,7 @@ export type RendererInterface = {
427 getComponentStack?: GetComponentStack,
428 getNearestMountedDOMNode: (component: Element) => Element | null,
429 getElementIDForHostInstance: GetElementIDForHostInstance,
430 + getSuspenseNodeIDForHostInstance: GetElementIDForHostInstance,
431 getDisplayNameForElementID: GetDisplayNameForElementID,
432 getInstanceAndStyle(id: number): InstanceAndStyle,
433 getProfilingData(): ProfilingDataBackend,
packages/react-devtools-shared/src/backend/views/Highlighter/index.js
+37 -7
@@ -20,6 +20,7 @@ import type {RendererInterface} from '../../types';
20 // That is done by the React Native Inspector component.
21
22 let iframesListeningTo: Set<HTMLIFrameElement> = new Set();
23 +let inspectOnlySuspenseNodes = false;
24
25 export default function setupHighlighter(
26 bridge: BackendBridge,
@@ -33,7 +34,8 @@ export default function setupHighlighter(
34 bridge.addListener('startInspectingHost', startInspectingHost);
35 bridge.addListener('stopInspectingHost', stopInspectingHost);
36
36 - function startInspectingHost() {
37 + function startInspectingHost(onlySuspenseNodes: boolean) {
38 + inspectOnlySuspenseNodes = onlySuspenseNodes;
39 registerListenersOnWindow(window);
40 }
41
@@ -363,9 +365,37 @@ export default function setupHighlighter(
365 }
366 }
367
366 - // Don't pass the name explicitly.
367 - // It will be inferred from DOM tag and Fiber owner.
368 - showOverlay([target], null, agent, false);
368 + if (inspectOnlySuspenseNodes) {
369 + // For Suspense nodes we want to highlight not the actual target but the nodes
370 + // that are the root of the Suspense node.
371 + // TODO: Consider if we should just do the same for other elements because the
372 + // hovered node might just be one child of many in the Component.
373 + const match = agent.getIDForHostInstance(
374 + target,
375 + inspectOnlySuspenseNodes,
376 + );
377 + if (match !== null) {
378 + const renderer = agent.rendererInterfaces[match.rendererID];
379 + if (renderer == null) {
380 + console.warn(
381 + `Invalid renderer id "${match.rendererID}" for element "${match.id}"`,
382 + );
383 + return;
384 + }
385 + highlightHostInstance({
386 + displayName: renderer.getDisplayNameForElementID(match.id),
387 + hideAfterTimeout: false,
388 + id: match.id,
389 + openBuiltinElementsPanel: false,
390 + rendererID: match.rendererID,
391 + scrollIntoView: false,
392 + });
393 + }
394 + } else {
395 + // Don't pass the name explicitly.
396 + // It will be inferred from DOM tag and Fiber owner.
397 + showOverlay([target], null, agent, false);
398 + }
399 }
400
401 function onPointerUp(event: MouseEvent) {
@@ -374,9 +404,9 @@ export default function setupHighlighter(
404 }
405
406 const selectElementForNode = (node: HTMLElement) => {
377 - const id = agent.getIDForHostInstance(node);
378 - if (id !== null) {
379 - bridge.send('selectElement', id);
407 + const match = agent.getIDForHostInstance(node, inspectOnlySuspenseNodes);
408 + if (match !== null) {
409 + bridge.send('selectElement', match.id);
410 }
411 };
412
packages/react-devtools-shared/src/bridge.js
+1 -1
@@ -266,7 +266,7 @@ type FrontendEvents = {
266 savedPreferences: [SavedPreferencesParams],
267 setTraceUpdatesEnabled: [boolean],
268 shutdown: [],
269 - startInspectingHost: [],
269 + startInspectingHost: [boolean],
270 startProfiling: [StartProfilingParams],
271 stopInspectingHost: [],
272 scrollToHostInstance: [ScrollToHostInstance],
packages/react-devtools-shared/src/devtools/views/Components/InspectHostNodesToggle.js
+6 -2
@@ -14,7 +14,11 @@ import Toggle from '../Toggle';
14 import ButtonIcon from '../ButtonIcon';
15 import {logEvent} from 'react-devtools-shared/src/Logger';
16
17 -export default function InspectHostNodesToggle(): React.Node {
17 +export default function InspectHostNodesToggle({
18 + onlySuspenseNodes,
19 +}: {
20 + onlySuspenseNodes?: boolean,
21 +}): React.Node {
22 const [isInspecting, setIsInspecting] = useState(false);
23 const bridge = useContext(BridgeContext);
24
@@ -24,7 +28,7 @@ export default function InspectHostNodesToggle(): React.Node {
28
29 if (isChecked) {
30 logEvent({event_name: 'inspect-element-button-clicked'});
27 - bridge.send('startInspectingHost');
31 + bridge.send('startInspectingHost', !!onlySuspenseNodes);
32 } else {
33 bridge.send('stopInspectingHost');
34 }
packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTab.js
+9
@@ -14,6 +14,7 @@ import {
14 useLayoutEffect,
15 useReducer,
16 useRef,
17 + Fragment,
18 } from 'react';
19
20 import {
@@ -21,6 +22,7 @@ import {
22 localStorageSetItem,
23 } from 'react-devtools-shared/src/storage';
24 import ButtonIcon, {type IconType} from '../ButtonIcon';
25 +import InspectHostNodesToggle from '../Components/InspectHostNodesToggle';
26 import InspectedElementErrorBoundary from '../Components/InspectedElementErrorBoundary';
27 import InspectedElement from '../Components/InspectedElement';
28 import portaledContent from '../portaledContent';
@@ -156,6 +158,7 @@ function ToggleInspectedElement({
158 }
159
160 function SuspenseTab(_: {}) {
161 + const store = useContext(StoreContext);
162 const {hideSettings} = useContext(OptionsContext);
163 const [state, dispatch] = useReducer<LayoutState, null, LayoutAction>(
164 layoutReducer,
@@ -367,6 +370,12 @@ function SuspenseTab(_: {}) {
370 ) : (
371 <ToggleTreeList dispatch={dispatch} state={state} />
372 )}
373 + {store.supportsClickToInspect && (
374 + <Fragment>
375 + <InspectHostNodesToggle onlySuspenseNodes={true} />
376 + <div className={styles.VRule} />
377 + </Fragment>
378 + )}
379 <div className={styles.SuspenseBreadcrumbs}>
380 <SuspenseBreadcrumbs />
381 </div>