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