Skip to content

Commit 7385d1f

Browse files
authored
[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" />
1 parent 85f415e commit 7385d1f

File tree

10 files changed

+140
-78
lines changed

10 files changed

+140
-78
lines changed

packages/react-devtools-shared/src/__tests__/store-test.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1546,7 +1546,7 @@ describe('Store', () => {
15461546
▸ <Wrapper>
15471547
`);
15481548

1549-
const deepestedNodeID = agent.getIDForHostInstance(ref.current);
1549+
const deepestedNodeID = agent.getIDForHostInstance(ref.current).id;
15501550

15511551
await act(() => store.toggleIsCollapsed(deepestedNodeID, false));
15521552
expect(store).toMatchInlineSnapshot(`

packages/react-devtools-shared/src/backend/agent.js

Lines changed: 34 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -455,17 +455,25 @@ export default class Agent extends EventEmitter<{
455455
return renderer.getInstanceAndStyle(id);
456456
}
457457

458-
getIDForHostInstance(target: HostInstance): number | null {
458+
getIDForHostInstance(
459+
target: HostInstance,
460+
onlySuspenseNodes?: boolean,
461+
): null | {id: number, rendererID: number} {
459462
if (isReactNativeEnvironment() || typeof target.nodeType !== 'number') {
460463
// In React Native or non-DOM we simply pick any renderer that has a match.
461464
for (const rendererID in this._rendererInterfaces) {
462465
const renderer = ((this._rendererInterfaces[
463466
(rendererID: any)
464467
]: any): RendererInterface);
465468
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+
};
469477
}
470478
} catch (error) {
471479
// Some old React versions might throw if they can't find a match.
@@ -478,6 +486,7 @@ export default class Agent extends EventEmitter<{
478486
// that is registered if there isn't an exact match.
479487
let bestMatch: null | Element = null;
480488
let bestRenderer: null | RendererInterface = null;
489+
let bestRendererID: number = 0;
481490
// Find the nearest ancestor which is mounted by a React.
482491
for (const rendererID in this._rendererInterfaces) {
483492
const renderer = ((this._rendererInterfaces[
@@ -491,19 +500,29 @@ export default class Agent extends EventEmitter<{
491500
// Exact match we can exit early.
492501
bestMatch = nearestNode;
493502
bestRenderer = renderer;
503+
bestRendererID = +rendererID;
494504
break;
495505
}
496506
if (bestMatch === null || bestMatch.contains(nearestNode)) {
497507
// If this is the first match or the previous match contains the new match,
498508
// so the new match is a deeper and therefore better match.
499509
bestMatch = nearestNode;
500510
bestRenderer = renderer;
511+
bestRendererID = +rendererID;
501512
}
502513
}
503514
}
504515
if (bestRenderer != null && bestMatch != null) {
505516
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+
}
507526
} catch (error) {
508527
// Some old React versions might throw if they can't find a match.
509528
// If so we should ignore it...
@@ -514,65 +533,14 @@ export default class Agent extends EventEmitter<{
514533
}
515534

516535
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);
575542
}
543+
return null;
576544
}
577545

578546
getBackendVersion: () => void = () => {
@@ -971,9 +939,9 @@ export default class Agent extends EventEmitter<{
971939
};
972940

973941
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);
977945
}
978946
}
979947

packages/react-devtools-shared/src/backend/fiber/renderer.js

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5793,7 +5793,28 @@ export function attach(
57935793
return null;
57945794
}
57955795
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);
57975818
} else {
57985819
return devtoolsInstance.data.name || '';
57995820
}
@@ -5834,6 +5855,28 @@ export function attach(
58345855
return null;
58355856
}
58365857
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+
58375880
function getElementAttributeByPath(
58385881
id: number,
58395882
path: Array<string | number>,
@@ -8630,6 +8673,7 @@ export function attach(
86308673
getDisplayNameForElementID,
86318674
getNearestMountedDOMNode,
86328675
getElementIDForHostInstance,
8676+
getSuspenseNodeIDForHostInstance,
86338677
getInstanceAndStyle,
86348678
getOwnersList,
86358679
getPathForElement,

packages/react-devtools-shared/src/backend/flight/renderer.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,9 @@ export function attach(
169169
getElementIDForHostInstance() {
170170
return null;
171171
},
172+
getSuspenseNodeIDForHostInstance() {
173+
return null;
174+
},
172175
getInstanceAndStyle() {
173176
return {
174177
instance: null,

packages/react-devtools-shared/src/backend/legacy/renderer.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1269,6 +1269,9 @@ export function attach(
12691269
getDisplayNameForElementID,
12701270
getNearestMountedDOMNode,
12711271
getElementIDForHostInstance,
1272+
getSuspenseNodeIDForHostInstance(id: number): null {
1273+
return null;
1274+
},
12721275
getInstanceAndStyle,
12731276
findHostInstancesForElementID: (id: number) => {
12741277
const hostInstance = findHostInstanceForInternalID(id);

packages/react-devtools-shared/src/backend/types.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -427,6 +427,7 @@ export type RendererInterface = {
427427
getComponentStack?: GetComponentStack,
428428
getNearestMountedDOMNode: (component: Element) => Element | null,
429429
getElementIDForHostInstance: GetElementIDForHostInstance,
430+
getSuspenseNodeIDForHostInstance: GetElementIDForHostInstance,
430431
getDisplayNameForElementID: GetDisplayNameForElementID,
431432
getInstanceAndStyle(id: number): InstanceAndStyle,
432433
getProfilingData(): ProfilingDataBackend,

packages/react-devtools-shared/src/backend/views/Highlighter/index.js

Lines changed: 37 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import type {RendererInterface} from '../../types';
2020
// That is done by the React Native Inspector component.
2121

2222
let iframesListeningTo: Set<HTMLIFrameElement> = new Set();
23+
let inspectOnlySuspenseNodes = false;
2324

2425
export default function setupHighlighter(
2526
bridge: BackendBridge,
@@ -33,7 +34,8 @@ export default function setupHighlighter(
3334
bridge.addListener('startInspectingHost', startInspectingHost);
3435
bridge.addListener('stopInspectingHost', stopInspectingHost);
3536

36-
function startInspectingHost() {
37+
function startInspectingHost(onlySuspenseNodes: boolean) {
38+
inspectOnlySuspenseNodes = onlySuspenseNodes;
3739
registerListenersOnWindow(window);
3840
}
3941

@@ -363,9 +365,37 @@ export default function setupHighlighter(
363365
}
364366
}
365367

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+
}
369399
}
370400

371401
function onPointerUp(event: MouseEvent) {
@@ -374,9 +404,9 @@ export default function setupHighlighter(
374404
}
375405

376406
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);
380410
}
381411
};
382412

packages/react-devtools-shared/src/bridge.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -266,7 +266,7 @@ type FrontendEvents = {
266266
savedPreferences: [SavedPreferencesParams],
267267
setTraceUpdatesEnabled: [boolean],
268268
shutdown: [],
269-
startInspectingHost: [],
269+
startInspectingHost: [boolean],
270270
startProfiling: [StartProfilingParams],
271271
stopInspectingHost: [],
272272
scrollToHostInstance: [ScrollToHostInstance],

packages/react-devtools-shared/src/devtools/views/Components/InspectHostNodesToggle.js

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,11 @@ import Toggle from '../Toggle';
1414
import ButtonIcon from '../ButtonIcon';
1515
import {logEvent} from 'react-devtools-shared/src/Logger';
1616

17-
export default function InspectHostNodesToggle(): React.Node {
17+
export default function InspectHostNodesToggle({
18+
onlySuspenseNodes,
19+
}: {
20+
onlySuspenseNodes?: boolean,
21+
}): React.Node {
1822
const [isInspecting, setIsInspecting] = useState(false);
1923
const bridge = useContext(BridgeContext);
2024

@@ -24,7 +28,7 @@ export default function InspectHostNodesToggle(): React.Node {
2428

2529
if (isChecked) {
2630
logEvent({event_name: 'inspect-element-button-clicked'});
27-
bridge.send('startInspectingHost');
31+
bridge.send('startInspectingHost', !!onlySuspenseNodes);
2832
} else {
2933
bridge.send('stopInspectingHost');
3034
}

packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTab.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,15 @@ import {
1414
useLayoutEffect,
1515
useReducer,
1616
useRef,
17+
Fragment,
1718
} from 'react';
1819

1920
import {
2021
localStorageGetItem,
2122
localStorageSetItem,
2223
} from 'react-devtools-shared/src/storage';
2324
import ButtonIcon, {type IconType} from '../ButtonIcon';
25+
import InspectHostNodesToggle from '../Components/InspectHostNodesToggle';
2426
import InspectedElementErrorBoundary from '../Components/InspectedElementErrorBoundary';
2527
import InspectedElement from '../Components/InspectedElement';
2628
import portaledContent from '../portaledContent';
@@ -156,6 +158,7 @@ function ToggleInspectedElement({
156158
}
157159

158160
function SuspenseTab(_: {}) {
161+
const store = useContext(StoreContext);
159162
const {hideSettings} = useContext(OptionsContext);
160163
const [state, dispatch] = useReducer<LayoutState, null, LayoutAction>(
161164
layoutReducer,
@@ -367,6 +370,12 @@ function SuspenseTab(_: {}) {
367370
) : (
368371
<ToggleTreeList dispatch={dispatch} state={state} />
369372
)}
373+
{store.supportsClickToInspect && (
374+
<Fragment>
375+
<InspectHostNodesToggle onlySuspenseNodes={true} />
376+
<div className={styles.VRule} />
377+
</Fragment>
378+
)}
370379
<div className={styles.SuspenseBreadcrumbs}>
371380
<SuspenseBreadcrumbs />
372381
</div>

0 commit comments

Comments
 (0)