diff --git a/packages/react-devtools-shared/src/__tests__/store-test.js b/packages/react-devtools-shared/src/__tests__/store-test.js index 3638852c20b..c02d8130c30 100644 --- a/packages/react-devtools-shared/src/__tests__/store-test.js +++ b/packages/react-devtools-shared/src/__tests__/store-test.js @@ -1546,7 +1546,7 @@ describe('Store', () => { ▸ `); - const deepestedNodeID = agent.getIDForHostInstance(ref.current); + const deepestedNodeID = agent.getIDForHostInstance(ref.current).id; await act(() => store.toggleIsCollapsed(deepestedNodeID, false)); expect(store).toMatchInlineSnapshot(` diff --git a/packages/react-devtools-shared/src/backend/agent.js b/packages/react-devtools-shared/src/backend/agent.js index fce8fe626d4..42fbbc9648a 100644 --- a/packages/react-devtools-shared/src/backend/agent.js +++ b/packages/react-devtools-shared/src/backend/agent.js @@ -455,7 +455,10 @@ export default class Agent extends EventEmitter<{ return renderer.getInstanceAndStyle(id); } - getIDForHostInstance(target: HostInstance): number | null { + getIDForHostInstance( + target: HostInstance, + onlySuspenseNodes?: boolean, + ): null | {id: number, rendererID: number} { if (isReactNativeEnvironment() || typeof target.nodeType !== 'number') { // In React Native or non-DOM we simply pick any renderer that has a match. for (const rendererID in this._rendererInterfaces) { @@ -463,9 +466,14 @@ export default class Agent extends EventEmitter<{ (rendererID: any) ]: any): RendererInterface); try { - const match = renderer.getElementIDForHostInstance(target); - if (match != null) { - return match; + const id = onlySuspenseNodes + ? renderer.getSuspenseNodeIDForHostInstance(target) + : renderer.getElementIDForHostInstance(target); + if (id !== null) { + return { + id: id, + rendererID: +rendererID, + }; } } catch (error) { // Some old React versions might throw if they can't find a match. @@ -478,6 +486,7 @@ export default class Agent extends EventEmitter<{ // that is registered if there isn't an exact match. let bestMatch: null | Element = null; let bestRenderer: null | RendererInterface = null; + let bestRendererID: number = 0; // Find the nearest ancestor which is mounted by a React. for (const rendererID in this._rendererInterfaces) { const renderer = ((this._rendererInterfaces[ @@ -491,6 +500,7 @@ export default class Agent extends EventEmitter<{ // Exact match we can exit early. bestMatch = nearestNode; bestRenderer = renderer; + bestRendererID = +rendererID; break; } if (bestMatch === null || bestMatch.contains(nearestNode)) { @@ -498,12 +508,21 @@ export default class Agent extends EventEmitter<{ // so the new match is a deeper and therefore better match. bestMatch = nearestNode; bestRenderer = renderer; + bestRendererID = +rendererID; } } } if (bestRenderer != null && bestMatch != null) { try { - return bestRenderer.getElementIDForHostInstance(bestMatch); + const id = onlySuspenseNodes + ? bestRenderer.getSuspenseNodeIDForHostInstance(bestMatch) + : bestRenderer.getElementIDForHostInstance(bestMatch); + if (id !== null) { + return { + id, + rendererID: bestRendererID, + }; + } } catch (error) { // Some old React versions might throw if they can't find a match. // If so we should ignore it... @@ -514,65 +533,14 @@ export default class Agent extends EventEmitter<{ } getComponentNameForHostInstance(target: HostInstance): string | null { - // We duplicate this code from getIDForHostInstance to avoid an object allocation. - if (isReactNativeEnvironment() || typeof target.nodeType !== 'number') { - // In React Native or non-DOM we simply pick any renderer that has a match. - for (const rendererID in this._rendererInterfaces) { - const renderer = ((this._rendererInterfaces[ - (rendererID: any) - ]: any): RendererInterface); - try { - const id = renderer.getElementIDForHostInstance(target); - if (id) { - return renderer.getDisplayNameForElementID(id); - } - } catch (error) { - // Some old React versions might throw if they can't find a match. - // If so we should ignore it... - } - } - return null; - } else { - // In the DOM we use a smarter mechanism to find the deepest a DOM node - // that is registered if there isn't an exact match. - let bestMatch: null | Element = null; - let bestRenderer: null | RendererInterface = null; - // Find the nearest ancestor which is mounted by a React. - for (const rendererID in this._rendererInterfaces) { - const renderer = ((this._rendererInterfaces[ - (rendererID: any) - ]: any): RendererInterface); - const nearestNode: null | Element = renderer.getNearestMountedDOMNode( - (target: any), - ); - if (nearestNode !== null) { - if (nearestNode === target) { - // Exact match we can exit early. - bestMatch = nearestNode; - bestRenderer = renderer; - break; - } - if (bestMatch === null || bestMatch.contains(nearestNode)) { - // If this is the first match or the previous match contains the new match, - // so the new match is a deeper and therefore better match. - bestMatch = nearestNode; - bestRenderer = renderer; - } - } - } - if (bestRenderer != null && bestMatch != null) { - try { - const id = bestRenderer.getElementIDForHostInstance(bestMatch); - if (id) { - return bestRenderer.getDisplayNameForElementID(id); - } - } catch (error) { - // Some old React versions might throw if they can't find a match. - // If so we should ignore it... - } - } - return null; + const match = this.getIDForHostInstance(target); + if (match !== null) { + const renderer = ((this._rendererInterfaces[ + (match.rendererID: any) + ]: any): RendererInterface); + return renderer.getDisplayNameForElementID(match.id); } + return null; } getBackendVersion: () => void = () => { @@ -971,9 +939,9 @@ export default class Agent extends EventEmitter<{ }; selectNode(target: HostInstance): void { - const id = this.getIDForHostInstance(target); - if (id !== null) { - this._bridge.send('selectElement', id); + const match = this.getIDForHostInstance(target); + if (match !== null) { + this._bridge.send('selectElement', match.id); } } diff --git a/packages/react-devtools-shared/src/backend/fiber/renderer.js b/packages/react-devtools-shared/src/backend/fiber/renderer.js index b7fe41b96c5..47902a44382 100644 --- a/packages/react-devtools-shared/src/backend/fiber/renderer.js +++ b/packages/react-devtools-shared/src/backend/fiber/renderer.js @@ -5793,7 +5793,28 @@ export function attach( return null; } if (devtoolsInstance.kind === FIBER_INSTANCE) { - return getDisplayNameForFiber(devtoolsInstance.data); + const fiber = devtoolsInstance.data; + if (fiber.tag === HostRoot) { + // The only reason you'd inspect a HostRoot is to show it as a SuspenseNode. + return 'Initial Paint'; + } + if (fiber.tag === SuspenseComponent || fiber.tag === ActivityComponent) { + // For Suspense and Activity components, we can show a better name + // by using the name prop or their owner. + const props = fiber.memoizedProps; + if (props.name != null) { + return props.name; + } + const owner = getUnfilteredOwner(fiber); + if (owner != null) { + if (typeof owner.tag === 'number') { + return getDisplayNameForFiber((owner: any)); + } else { + return owner.name || ''; + } + } + } + return getDisplayNameForFiber(fiber); } else { return devtoolsInstance.data.name || ''; } @@ -5834,6 +5855,28 @@ export function attach( return null; } + function getSuspenseNodeIDForHostInstance( + publicInstance: HostInstance, + ): number | null { + const instance = publicInstanceToDevToolsInstanceMap.get(publicInstance); + if (instance !== undefined) { + // Pick nearest unfiltered SuspenseNode instance. + let suspenseInstance = instance; + while ( + suspenseInstance.suspenseNode === null || + suspenseInstance.kind === FILTERED_FIBER_INSTANCE + ) { + if (suspenseInstance.parent === null) { + // We shouldn't get here since we'll always have a suspenseNode at the root. + return null; + } + suspenseInstance = suspenseInstance.parent; + } + return suspenseInstance.id; + } + return null; + } + function getElementAttributeByPath( id: number, path: Array, @@ -8630,6 +8673,7 @@ export function attach( getDisplayNameForElementID, getNearestMountedDOMNode, getElementIDForHostInstance, + getSuspenseNodeIDForHostInstance, getInstanceAndStyle, getOwnersList, getPathForElement, diff --git a/packages/react-devtools-shared/src/backend/flight/renderer.js b/packages/react-devtools-shared/src/backend/flight/renderer.js index d0dc9094334..e26525a0d60 100644 --- a/packages/react-devtools-shared/src/backend/flight/renderer.js +++ b/packages/react-devtools-shared/src/backend/flight/renderer.js @@ -169,6 +169,9 @@ export function attach( getElementIDForHostInstance() { return null; }, + getSuspenseNodeIDForHostInstance() { + return null; + }, getInstanceAndStyle() { return { instance: null, diff --git a/packages/react-devtools-shared/src/backend/legacy/renderer.js b/packages/react-devtools-shared/src/backend/legacy/renderer.js index 1262a8d4647..ccd9cdac3e0 100644 --- a/packages/react-devtools-shared/src/backend/legacy/renderer.js +++ b/packages/react-devtools-shared/src/backend/legacy/renderer.js @@ -1269,6 +1269,9 @@ export function attach( getDisplayNameForElementID, getNearestMountedDOMNode, getElementIDForHostInstance, + getSuspenseNodeIDForHostInstance(id: number): null { + return null; + }, getInstanceAndStyle, findHostInstancesForElementID: (id: number) => { const hostInstance = findHostInstanceForInternalID(id); diff --git a/packages/react-devtools-shared/src/backend/types.js b/packages/react-devtools-shared/src/backend/types.js index 1052dc9d75b..67d6a5f834b 100644 --- a/packages/react-devtools-shared/src/backend/types.js +++ b/packages/react-devtools-shared/src/backend/types.js @@ -427,6 +427,7 @@ export type RendererInterface = { getComponentStack?: GetComponentStack, getNearestMountedDOMNode: (component: Element) => Element | null, getElementIDForHostInstance: GetElementIDForHostInstance, + getSuspenseNodeIDForHostInstance: GetElementIDForHostInstance, getDisplayNameForElementID: GetDisplayNameForElementID, getInstanceAndStyle(id: number): InstanceAndStyle, getProfilingData(): ProfilingDataBackend, diff --git a/packages/react-devtools-shared/src/backend/views/Highlighter/index.js b/packages/react-devtools-shared/src/backend/views/Highlighter/index.js index 894c4fba944..b67b3964ed5 100644 --- a/packages/react-devtools-shared/src/backend/views/Highlighter/index.js +++ b/packages/react-devtools-shared/src/backend/views/Highlighter/index.js @@ -20,6 +20,7 @@ import type {RendererInterface} from '../../types'; // That is done by the React Native Inspector component. let iframesListeningTo: Set = new Set(); +let inspectOnlySuspenseNodes = false; export default function setupHighlighter( bridge: BackendBridge, @@ -33,7 +34,8 @@ export default function setupHighlighter( bridge.addListener('startInspectingHost', startInspectingHost); bridge.addListener('stopInspectingHost', stopInspectingHost); - function startInspectingHost() { + function startInspectingHost(onlySuspenseNodes: boolean) { + inspectOnlySuspenseNodes = onlySuspenseNodes; registerListenersOnWindow(window); } @@ -363,9 +365,37 @@ export default function setupHighlighter( } } - // Don't pass the name explicitly. - // It will be inferred from DOM tag and Fiber owner. - showOverlay([target], null, agent, false); + if (inspectOnlySuspenseNodes) { + // For Suspense nodes we want to highlight not the actual target but the nodes + // that are the root of the Suspense node. + // TODO: Consider if we should just do the same for other elements because the + // hovered node might just be one child of many in the Component. + const match = agent.getIDForHostInstance( + target, + inspectOnlySuspenseNodes, + ); + if (match !== null) { + const renderer = agent.rendererInterfaces[match.rendererID]; + if (renderer == null) { + console.warn( + `Invalid renderer id "${match.rendererID}" for element "${match.id}"`, + ); + return; + } + highlightHostInstance({ + displayName: renderer.getDisplayNameForElementID(match.id), + hideAfterTimeout: false, + id: match.id, + openBuiltinElementsPanel: false, + rendererID: match.rendererID, + scrollIntoView: false, + }); + } + } else { + // Don't pass the name explicitly. + // It will be inferred from DOM tag and Fiber owner. + showOverlay([target], null, agent, false); + } } function onPointerUp(event: MouseEvent) { @@ -374,9 +404,9 @@ export default function setupHighlighter( } const selectElementForNode = (node: HTMLElement) => { - const id = agent.getIDForHostInstance(node); - if (id !== null) { - bridge.send('selectElement', id); + const match = agent.getIDForHostInstance(node, inspectOnlySuspenseNodes); + if (match !== null) { + bridge.send('selectElement', match.id); } }; diff --git a/packages/react-devtools-shared/src/bridge.js b/packages/react-devtools-shared/src/bridge.js index 3162dc215ff..683b3419202 100644 --- a/packages/react-devtools-shared/src/bridge.js +++ b/packages/react-devtools-shared/src/bridge.js @@ -266,7 +266,7 @@ type FrontendEvents = { savedPreferences: [SavedPreferencesParams], setTraceUpdatesEnabled: [boolean], shutdown: [], - startInspectingHost: [], + startInspectingHost: [boolean], startProfiling: [StartProfilingParams], stopInspectingHost: [], scrollToHostInstance: [ScrollToHostInstance], diff --git a/packages/react-devtools-shared/src/devtools/views/Components/InspectHostNodesToggle.js b/packages/react-devtools-shared/src/devtools/views/Components/InspectHostNodesToggle.js index 17a7b049cc9..1b8fd54dc5c 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/InspectHostNodesToggle.js +++ b/packages/react-devtools-shared/src/devtools/views/Components/InspectHostNodesToggle.js @@ -14,7 +14,11 @@ import Toggle from '../Toggle'; import ButtonIcon from '../ButtonIcon'; import {logEvent} from 'react-devtools-shared/src/Logger'; -export default function InspectHostNodesToggle(): React.Node { +export default function InspectHostNodesToggle({ + onlySuspenseNodes, +}: { + onlySuspenseNodes?: boolean, +}): React.Node { const [isInspecting, setIsInspecting] = useState(false); const bridge = useContext(BridgeContext); @@ -24,7 +28,7 @@ export default function InspectHostNodesToggle(): React.Node { if (isChecked) { logEvent({event_name: 'inspect-element-button-clicked'}); - bridge.send('startInspectingHost'); + bridge.send('startInspectingHost', !!onlySuspenseNodes); } else { bridge.send('stopInspectingHost'); } diff --git a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTab.js b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTab.js index 9ade19c3307..1344faf0a29 100644 --- a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTab.js +++ b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTab.js @@ -14,6 +14,7 @@ import { useLayoutEffect, useReducer, useRef, + Fragment, } from 'react'; import { @@ -21,6 +22,7 @@ import { localStorageSetItem, } from 'react-devtools-shared/src/storage'; import ButtonIcon, {type IconType} from '../ButtonIcon'; +import InspectHostNodesToggle from '../Components/InspectHostNodesToggle'; import InspectedElementErrorBoundary from '../Components/InspectedElementErrorBoundary'; import InspectedElement from '../Components/InspectedElement'; import portaledContent from '../portaledContent'; @@ -156,6 +158,7 @@ function ToggleInspectedElement({ } function SuspenseTab(_: {}) { + const store = useContext(StoreContext); const {hideSettings} = useContext(OptionsContext); const [state, dispatch] = useReducer( layoutReducer, @@ -367,6 +370,12 @@ function SuspenseTab(_: {}) { ) : ( )} + {store.supportsClickToInspect && ( + + +
+ + )}