diff --git a/packages/react-devtools-shared/src/__tests__/store-test.js b/packages/react-devtools-shared/src/__tests__/store-test.js index 9de9b564e93..d5280c091c9 100644 --- a/packages/react-devtools-shared/src/__tests__/store-test.js +++ b/packages/react-devtools-shared/src/__tests__/store-test.js @@ -974,12 +974,8 @@ describe('Store', () => { `); - const rendererID = getRendererID(); - const rootID = store.getRootIDForElement(store.getElementIDAtIndex(0)); await actAsync(() => { agent.overrideSuspenseMilestone({ - rendererID, - rootID, suspendedSet: [ store.getElementIDAtIndex(4), store.getElementIDAtIndex(8), @@ -1009,8 +1005,6 @@ describe('Store', () => { await actAsync(() => { agent.overrideSuspenseMilestone({ - rendererID, - rootID, suspendedSet: [], }); }); diff --git a/packages/react-devtools-shared/src/backend/agent.js b/packages/react-devtools-shared/src/backend/agent.js index 5a7d6eb836e..208923fe500 100644 --- a/packages/react-devtools-shared/src/backend/agent.js +++ b/packages/react-devtools-shared/src/backend/agent.js @@ -8,7 +8,11 @@ */ import EventEmitter from '../events'; -import {SESSION_STORAGE_LAST_SELECTION_KEY, __DEBUG__} from '../constants'; +import { + SESSION_STORAGE_LAST_SELECTION_KEY, + UNKNOWN_SUSPENDERS_NONE, + __DEBUG__, +} from '../constants'; import setupHighlighter from './views/Highlighter'; import { initialize as setupTraceUpdates, @@ -26,8 +30,13 @@ import type { RendererID, RendererInterface, DevToolsHookSettings, + InspectedElement, } from './types'; -import type {ComponentFilter} from 'react-devtools-shared/src/frontend/types'; +import type { + ComponentFilter, + DehydratedData, + ElementType, +} from 'react-devtools-shared/src/frontend/types'; import type {GroupItem} from './views/TraceUpdates/canvas'; import {gte, isReactNativeEnvironment} from './utils'; import { @@ -73,6 +82,13 @@ type InspectElementParams = { requestID: number, }; +type InspectScreenParams = { + forceFullData: boolean, + id: number, + path: Array | null, + requestID: number, +}; + type OverrideHookParams = { id: number, hookID: number, @@ -131,8 +147,6 @@ type OverrideSuspenseParams = { }; type OverrideSuspenseMilestoneParams = { - rendererID: number, - rootID: number, suspendedSet: Array, }; @@ -141,6 +155,111 @@ type PersistedSelection = { path: Array, }; +function createEmptyInspectedScreen( + arbitraryRootID: number, + type: ElementType, +): InspectedElement { + const suspendedBy: DehydratedData = { + cleaned: [], + data: [], + unserializable: [], + }; + return { + // invariants + id: arbitraryRootID, + type: type, + // Properties we merge + isErrored: false, + errors: [], + warnings: [], + suspendedBy, + suspendedByRange: null, + // TODO: How to merge these? + unknownSuspenders: UNKNOWN_SUSPENDERS_NONE, + // Properties where merging doesn't make sense so we ignore them entirely in the UI + rootType: null, + plugins: {stylex: null}, + nativeTag: null, + env: null, + source: null, + stack: null, + rendererPackageName: null, + rendererVersion: null, + // These don't make sense for a Root. They're just bottom values. + key: null, + canEditFunctionProps: false, + canEditHooks: false, + canEditFunctionPropsDeletePaths: false, + canEditFunctionPropsRenamePaths: false, + canEditHooksAndDeletePaths: false, + canEditHooksAndRenamePaths: false, + canToggleError: false, + canToggleSuspense: false, + isSuspended: false, + hasLegacyContext: false, + context: null, + hooks: null, + props: null, + state: null, + owners: null, + }; +} + +function mergeRoots( + left: InspectedElement, + right: InspectedElement, + suspendedByOffset: number, +): void { + const leftSuspendedByRange = left.suspendedByRange; + const rightSuspendedByRange = right.suspendedByRange; + + if (right.isErrored) { + left.isErrored = true; + } + for (let i = 0; i < right.errors.length; i++) { + left.errors.push(right.errors[i]); + } + for (let i = 0; i < right.warnings.length; i++) { + left.warnings.push(right.warnings[i]); + } + + const leftSuspendedBy: DehydratedData = left.suspendedBy; + const {data, cleaned, unserializable} = (right.suspendedBy: DehydratedData); + const leftSuspendedByData = ((leftSuspendedBy.data: any): Array); + const rightSuspendedByData = ((data: any): Array); + for (let i = 0; i < rightSuspendedByData.length; i++) { + leftSuspendedByData.push(rightSuspendedByData[i]); + } + for (let i = 0; i < cleaned.length; i++) { + leftSuspendedBy.cleaned.push( + [suspendedByOffset + cleaned[i][0]].concat(cleaned[i].slice(1)), + ); + } + for (let i = 0; i < unserializable.length; i++) { + leftSuspendedBy.unserializable.push( + [suspendedByOffset + unserializable[i][0]].concat( + unserializable[i].slice(1), + ), + ); + } + + if (rightSuspendedByRange !== null) { + if (leftSuspendedByRange === null) { + left.suspendedByRange = [ + rightSuspendedByRange[0], + rightSuspendedByRange[1], + ]; + } else { + if (rightSuspendedByRange[0] < leftSuspendedByRange[0]) { + leftSuspendedByRange[0] = rightSuspendedByRange[0]; + } + if (rightSuspendedByRange[1] > leftSuspendedByRange[1]) { + leftSuspendedByRange[1] = rightSuspendedByRange[1]; + } + } + } +} + export default class Agent extends EventEmitter<{ hideNativeHighlight: [], showNativeHighlight: [HostInstance], @@ -201,6 +320,7 @@ export default class Agent extends EventEmitter<{ bridge.addListener('getProfilingStatus', this.getProfilingStatus); bridge.addListener('getOwnersList', this.getOwnersList); bridge.addListener('inspectElement', this.inspectElement); + bridge.addListener('inspectScreen', this.inspectScreen); bridge.addListener('logElementToConsole', this.logElementToConsole); bridge.addListener('overrideError', this.overrideError); bridge.addListener('overrideSuspense', this.overrideSuspense); @@ -531,6 +651,138 @@ export default class Agent extends EventEmitter<{ } }; + inspectScreen: InspectScreenParams => void = ({ + requestID, + id, + forceFullData, + path: screenPath, + }) => { + let inspectedScreen: InspectedElement | null = null; + let found = false; + // the suspendedBy index will be from the previously merged roots. + // We need to keep track of how many suspendedBy we've already seen to know + // to which renderer the index belongs. + let suspendedByOffset = 0; + let suspendedByPathIndex: number | null = null; + // The path to hydrate for a specific renderer + let rendererPath: InspectElementParams['path'] = null; + if (screenPath !== null && screenPath.length > 1) { + const secondaryCategory = screenPath[0]; + if (secondaryCategory !== 'suspendedBy') { + throw new Error( + 'Only hydrating suspendedBy paths is supported. This is a bug.', + ); + } + if (typeof screenPath[1] !== 'number') { + throw new Error( + `Expected suspendedBy index to be a number. Received '${screenPath[1]}' instead. This is a bug.`, + ); + } + suspendedByPathIndex = screenPath[1]; + rendererPath = screenPath.slice(2); + } + + for (const rendererID in this._rendererInterfaces) { + const renderer = ((this._rendererInterfaces[ + (rendererID: any) + ]: any): RendererInterface); + let path: InspectElementParams['path'] = null; + if (suspendedByPathIndex !== null && rendererPath !== null) { + const suspendedByPathRendererIndex = + suspendedByPathIndex - suspendedByOffset; + const rendererHasRequestedSuspendedByPath = + renderer.getElementAttributeByPath(id, [ + 'suspendedBy', + suspendedByPathRendererIndex, + ]) !== undefined; + if (rendererHasRequestedSuspendedByPath) { + path = ['suspendedBy', suspendedByPathRendererIndex].concat( + rendererPath, + ); + } + } + + const inspectedRootsPayload = renderer.inspectElement( + requestID, + id, + path, + forceFullData, + ); + switch (inspectedRootsPayload.type) { + case 'hydrated-path': + // The path will be relative to the Roots of this renderer. We adjust it + // to be relative to all Roots of this implementation. + inspectedRootsPayload.path[1] += suspendedByOffset; + // TODO: Hydration logic is flawed since the Frontend path is not based + // on the original backend data but rather its own representation of it (e.g. due to reorder). + // So we can receive null here instead when hydration fails + if (inspectedRootsPayload.value !== null) { + for ( + let i = 0; + i < inspectedRootsPayload.value.cleaned.length; + i++ + ) { + inspectedRootsPayload.value.cleaned[i][1] += suspendedByOffset; + } + } + this._bridge.send('inspectedScreen', inspectedRootsPayload); + // If we hydrated a path, it must've been in a specific renderer so we can stop here. + return; + case 'full-data': + const inspectedRoots = inspectedRootsPayload.value; + if (inspectedScreen === null) { + inspectedScreen = createEmptyInspectedScreen( + inspectedRoots.id, + inspectedRoots.type, + ); + } + mergeRoots(inspectedScreen, inspectedRoots, suspendedByOffset); + const dehydratedSuspendedBy: DehydratedData = + inspectedRoots.suspendedBy; + const suspendedBy = ((dehydratedSuspendedBy.data: any): Array); + suspendedByOffset += suspendedBy.length; + found = true; + break; + case 'no-change': + found = true; + const rootsSuspendedBy: Array = + (renderer.getElementAttributeByPath(id, ['suspendedBy']): any); + suspendedByOffset += rootsSuspendedBy.length; + break; + case 'not-found': + break; + case 'error': + // bail out and show the error + // TODO: aggregate errors + this._bridge.send('inspectedScreen', inspectedRootsPayload); + return; + } + } + + if (inspectedScreen === null) { + if (found) { + this._bridge.send('inspectedScreen', { + type: 'no-change', + responseID: requestID, + id, + }); + } else { + this._bridge.send('inspectedScreen', { + type: 'not-found', + responseID: requestID, + id, + }); + } + } else { + this._bridge.send('inspectedScreen', { + type: 'full-data', + responseID: requestID, + id, + value: inspectedScreen, + }); + } + }; + logElementToConsole: ElementAndRendererID => void = ({id, rendererID}) => { const renderer = this._rendererInterfaces[rendererID]; if (renderer == null) { @@ -567,17 +819,15 @@ export default class Agent extends EventEmitter<{ }; overrideSuspenseMilestone: OverrideSuspenseMilestoneParams => void = ({ - rendererID, - rootID, suspendedSet, }) => { - const renderer = this._rendererInterfaces[rendererID]; - if (renderer == null) { - console.warn( - `Invalid renderer id "${rendererID}" to override suspense milestone`, - ); - } else { - renderer.overrideSuspenseMilestone(rootID, suspendedSet); + for (const rendererID in this._rendererInterfaces) { + const renderer = ((this._rendererInterfaces[ + (rendererID: any) + ]: any): RendererInterface); + if (renderer.supportsTogglingSuspense) { + renderer.overrideSuspenseMilestone(suspendedSet); + } } }; diff --git a/packages/react-devtools-shared/src/backend/fiber/renderer.js b/packages/react-devtools-shared/src/backend/fiber/renderer.js index 031a18b74d4..222baa0dae0 100644 --- a/packages/react-devtools-shared/src/backend/fiber/renderer.js +++ b/packages/react-devtools-shared/src/backend/fiber/renderer.js @@ -2420,7 +2420,6 @@ export function attach( !isProductionBuildOfRenderer && StrictModeBits !== 0 ? 1 : 0, ); pushOperation(hasOwnerMetadata ? 1 : 0); - pushOperation(supportsTogglingSuspense ? 1 : 0); if (isProfiling) { if (displayNamesByRootID !== null) { @@ -4902,7 +4901,11 @@ export function attach( fiberInstance.data = nextFiber; if ( mostRecentlyInspectedElement !== null && - mostRecentlyInspectedElement.id === fiberInstance.id && + (mostRecentlyInspectedElement.id === fiberInstance.id || + // If we're inspecting a Root, we inspect the Screen. + // Invalidating any Root invalidates the Screen too. + (mostRecentlyInspectedElement.type === ElementTypeRoot && + nextFiber.tag === HostRoot)) && didFiberRender(prevFiber, nextFiber) ) { // If this Fiber has updated, clear cached inspected data. @@ -6422,7 +6425,10 @@ export function attach( return inspectVirtualInstanceRaw(devtoolsInstance); } if (devtoolsInstance.kind === FIBER_INSTANCE) { - return inspectFiberInstanceRaw(devtoolsInstance); + const isRoot = devtoolsInstance.parent === null; + return isRoot + ? inspectRootsRaw(devtoolsInstance.id) + : inspectFiberInstanceRaw(devtoolsInstance); } (devtoolsInstance: FilteredFiberInstance); // assert exhaustive throw new Error('Unsupported instance kind'); @@ -6875,10 +6881,24 @@ export function attach( let currentlyInspectedPaths: Object = {}; function isMostRecentlyInspectedElement(id: number): boolean { - return ( - mostRecentlyInspectedElement !== null && - mostRecentlyInspectedElement.id === id - ); + if (mostRecentlyInspectedElement === null) { + return false; + } + if (mostRecentlyInspectedElement.id === id) { + return true; + } + + if (mostRecentlyInspectedElement.type === ElementTypeRoot) { + // we inspected the screen recently. If we're inspecting another root, we're + // still inspecting the screen. + const instance = idToDevToolsInstanceMap.get(id); + return ( + instance !== undefined && + instance.kind === FIBER_INSTANCE && + instance.parent === null + ); + } + return false; } function isMostRecentlyInspectedElementCurrent(id: number): boolean { @@ -7060,8 +7080,8 @@ export function attach( if (!hasElementUpdatedSinceLastInspected) { if (path !== null) { let secondaryCategory: 'suspendedBy' | 'hooks' | null = null; - if (path[0] === 'hooks') { - secondaryCategory = 'hooks'; + if (path[0] === 'hooks' || path[0] === 'suspendedBy') { + secondaryCategory = path[0]; } // If this element has not been updated since it was last inspected, @@ -7209,6 +7229,99 @@ export function attach( }; } + function inspectRootsRaw(arbitraryRootID: number): InspectedElement | null { + const roots = hook.getFiberRoots(rendererID); + if (roots.size === 0) { + return null; + } + + const inspectedRoots: InspectedElement = { + // invariants + id: arbitraryRootID, + type: ElementTypeRoot, + // Properties we merge + isErrored: false, + errors: [], + warnings: [], + suspendedBy: [], + suspendedByRange: null, + // TODO: How to merge these? + unknownSuspenders: UNKNOWN_SUSPENDERS_NONE, + // Properties where merging doesn't make sense so we ignore them entirely in the UI + rootType: null, + plugins: {stylex: null}, + nativeTag: null, + env: null, + source: null, + stack: null, + rendererPackageName: null, + rendererVersion: null, + // These don't make sense for a Root. They're just bottom values. + key: null, + canEditFunctionProps: false, + canEditHooks: false, + canEditFunctionPropsDeletePaths: false, + canEditFunctionPropsRenamePaths: false, + canEditHooksAndDeletePaths: false, + canEditHooksAndRenamePaths: false, + canToggleError: false, + canToggleSuspense: false, + isSuspended: false, + hasLegacyContext: false, + context: null, + hooks: null, + props: null, + state: null, + owners: null, + }; + + let minSuspendedByRange = Infinity; + let maxSuspendedByRange = -Infinity; + roots.forEach(root => { + const rootInstance = rootToFiberInstanceMap.get(root); + if (rootInstance === undefined) { + throw new Error( + 'Expected a root instance to exist for this Fiber root', + ); + } + const inspectedRoot = inspectFiberInstanceRaw(rootInstance); + if (inspectedRoot === null) { + return; + } + + if (inspectedRoot.isErrored) { + inspectedRoots.isErrored = true; + } + for (let i = 0; i < inspectedRoot.errors.length; i++) { + inspectedRoots.errors.push(inspectedRoot.errors[i]); + } + for (let i = 0; i < inspectedRoot.warnings.length; i++) { + inspectedRoots.warnings.push(inspectedRoot.warnings[i]); + } + for (let i = 0; i < inspectedRoot.suspendedBy.length; i++) { + inspectedRoots.suspendedBy.push(inspectedRoot.suspendedBy[i]); + } + const suspendedByRange = inspectedRoot.suspendedByRange; + if (suspendedByRange !== null) { + if (suspendedByRange[0] < minSuspendedByRange) { + minSuspendedByRange = suspendedByRange[0]; + } + if (suspendedByRange[1] > maxSuspendedByRange) { + maxSuspendedByRange = suspendedByRange[1]; + } + } + }); + + if (minSuspendedByRange !== Infinity || maxSuspendedByRange !== -Infinity) { + inspectedRoots.suspendedByRange = [ + minSuspendedByRange, + maxSuspendedByRange, + ]; + } + + return inspectedRoots; + } + function logElementToConsole(id: number) { const result = isMostRecentlyInspectedElementCurrent(id) ? mostRecentlyInspectedElement @@ -7867,13 +7980,9 @@ export function attach( /** * Resets the all other roots of this renderer. - * @param rootID The root that contains this milestone * @param suspendedSet List of IDs of SuspenseComponent Fibers */ - function overrideSuspenseMilestone( - rootID: FiberInstance['id'], - suspendedSet: Array, - ) { + function overrideSuspenseMilestone(suspendedSet: Array) { if ( typeof setSuspenseHandler !== 'function' || typeof scheduleUpdate !== 'function' @@ -7883,8 +7992,6 @@ export function attach( ); } - // TODO: Allow overriding the timeline for the specified root. - const unsuspendedSet: Set = new Set(forceFallbackForFibers); let resuspended = false; diff --git a/packages/react-devtools-shared/src/backend/legacy/renderer.js b/packages/react-devtools-shared/src/backend/legacy/renderer.js index 8a245155ef2..46709c9a804 100644 --- a/packages/react-devtools-shared/src/backend/legacy/renderer.js +++ b/packages/react-devtools-shared/src/backend/legacy/renderer.js @@ -412,7 +412,6 @@ export function attach( pushOperation(0); // Profiling flag pushOperation(0); // StrictMode supported? pushOperation(hasOwnerMetadata ? 1 : 0); - pushOperation(supportsTogglingSuspense ? 1 : 0); pushOperation(SUSPENSE_TREE_OPERATION_ADD); pushOperation(id); @@ -800,6 +799,20 @@ export function attach( return null; } + const rootID = internalInstanceToRootIDMap.get(internalInstance); + if (rootID === undefined) { + throw new Error('Expected to find root ID.'); + } + const isRoot = rootID === id; + return isRoot + ? inspectRootsRaw(rootID) + : inspectInternalInstanceRaw(id, internalInstance); + } + + function inspectInternalInstanceRaw( + id: number, + internalInstance: InternalInstance, + ): InspectedElement | null { const {key} = getData(internalInstance); const type = getElementType(internalInstance); @@ -903,6 +916,98 @@ export function attach( }; } + function inspectRootsRaw(arbitraryRootID: number): InspectedElement | null { + const roots = + renderer.Mount._instancesByReactRootID || + renderer.Mount._instancesByContainerID; + + const inspectedRoots: InspectedElement = { + // invariants + id: arbitraryRootID, + type: ElementTypeRoot, + // Properties we merge + isErrored: false, + errors: [], + warnings: [], + suspendedBy: [], + suspendedByRange: null, + // TODO: How to merge these? + unknownSuspenders: UNKNOWN_SUSPENDERS_NONE, + // Properties where merging doesn't make sense so we ignore them entirely in the UI + rootType: null, + plugins: {stylex: null}, + nativeTag: null, + env: null, + source: null, + stack: null, + // TODO: We could make the Frontend accept an array to display + // a list of unique renderers contributing to this Screen. + rendererPackageName: null, + rendererVersion: null, + // These don't make sense for a Root. They're just bottom values. + key: null, + canEditFunctionProps: false, + canEditHooks: false, + canEditFunctionPropsDeletePaths: false, + canEditFunctionPropsRenamePaths: false, + canEditHooksAndDeletePaths: false, + canEditHooksAndRenamePaths: false, + canToggleError: false, + canToggleSuspense: false, + isSuspended: false, + hasLegacyContext: false, + context: null, + hooks: null, + props: null, + state: null, + owners: null, + }; + + let minSuspendedByRange = Infinity; + let maxSuspendedByRange = -Infinity; + + for (const rootKey in roots) { + const internalInstance = roots[rootKey]; + const id = getID(internalInstance); + const inspectedRoot = inspectInternalInstanceRaw(id, internalInstance); + + if (inspectedRoot === null) { + return null; + } + + if (inspectedRoot.isErrored) { + inspectedRoots.isErrored = true; + } + for (let i = 0; i < inspectedRoot.errors.length; i++) { + inspectedRoots.errors.push(inspectedRoot.errors[i]); + } + for (let i = 0; i < inspectedRoot.warnings.length; i++) { + inspectedRoots.warnings.push(inspectedRoot.warnings[i]); + } + for (let i = 0; i < inspectedRoot.suspendedBy.length; i++) { + inspectedRoots.suspendedBy.push(inspectedRoot.suspendedBy[i]); + } + const suspendedByRange = inspectedRoot.suspendedByRange; + if (suspendedByRange !== null) { + if (suspendedByRange[0] < minSuspendedByRange) { + minSuspendedByRange = suspendedByRange[0]; + } + if (suspendedByRange[1] > maxSuspendedByRange) { + maxSuspendedByRange = suspendedByRange[1]; + } + } + } + + if (minSuspendedByRange !== Infinity || maxSuspendedByRange !== -Infinity) { + inspectedRoots.suspendedByRange = [ + minSuspendedByRange, + maxSuspendedByRange, + ]; + } + + return inspectedRoots; + } + function logElementToConsole(id: number): void { const result = inspectElementRaw(id); if (result === null) { diff --git a/packages/react-devtools-shared/src/backend/types.js b/packages/react-devtools-shared/src/backend/types.js index e002740cb69..1052dc9d75b 100644 --- a/packages/react-devtools-shared/src/backend/types.js +++ b/packages/react-devtools-shared/src/backend/types.js @@ -450,10 +450,7 @@ export type RendererInterface = { onErrorOrWarning?: OnErrorOrWarning, overrideError: (id: number, forceError: boolean) => void, overrideSuspense: (id: number, forceFallback: boolean) => void, - overrideSuspenseMilestone: ( - rootID: number, - suspendedSet: Array, - ) => void, + overrideSuspenseMilestone: (suspendedSet: Array) => void, overrideValueAtPath: ( type: Type, id: number, 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 155717e5d89..6fd93d3519c 100644 --- a/packages/react-devtools-shared/src/backend/views/Highlighter/index.js +++ b/packages/react-devtools-shared/src/backend/views/Highlighter/index.js @@ -10,6 +10,7 @@ import Agent from 'react-devtools-shared/src/backend/agent'; import {hideOverlay, showOverlay} from './Highlighter'; +import type {HostInstance} from 'react-devtools-shared/src/backend/types'; import type {BackendBridge} from 'react-devtools-shared/src/bridge'; import type {RendererInterface} from '../../types'; @@ -26,6 +27,7 @@ export default function setupHighlighter( ): void { bridge.addListener('clearHostInstanceHighlight', clearHostInstanceHighlight); bridge.addListener('highlightHostInstance', highlightHostInstance); + bridge.addListener('highlightHostInstances', highlightHostInstances); bridge.addListener('scrollToHostInstance', scrollToHostInstance); bridge.addListener('shutdown', stopInspectingHost); bridge.addListener('startInspectingHost', startInspectingHost); @@ -157,6 +159,52 @@ export default function setupHighlighter( hideOverlay(agent); } + function highlightHostInstances({ + displayName, + hideAfterTimeout, + elements, + scrollIntoView, + }: { + displayName: string | null, + hideAfterTimeout: boolean, + elements: Array<{rendererID: number, id: number}>, + scrollIntoView: boolean, + }) { + const nodes: Array = []; + for (let i = 0; i < elements.length; i++) { + const {id, rendererID} = elements[i]; + const renderer = agent.rendererInterfaces[rendererID]; + if (renderer == null) { + console.warn(`Invalid renderer id "${rendererID}" for element "${id}"`); + continue; + } + + // In some cases fiber may already be unmounted + if (!renderer.hasElementWithId(id)) { + continue; + } + + const hostInstances = renderer.findHostInstancesForElementID(id); + if (hostInstances !== null) { + for (let j = 0; j < hostInstances.length; j++) { + nodes.push(hostInstances[j]); + } + } + } + + if (nodes.length > 0) { + const node = nodes[0]; + // $FlowFixMe[method-unbinding] + if (scrollIntoView && typeof node.scrollIntoView === 'function') { + // If the node isn't visible show it before highlighting it. + // We may want to reconsider this; it might be a little disruptive. + node.scrollIntoView({block: 'nearest', inline: 'nearest'}); + } + } + + showOverlay(nodes, displayName, agent, hideAfterTimeout); + } + function attemptScrollToHostInstance( renderer: RendererInterface, id: number, diff --git a/packages/react-devtools-shared/src/backendAPI.js b/packages/react-devtools-shared/src/backendAPI.js index f6c11fb10d6..c332393d2aa 100644 --- a/packages/react-devtools-shared/src/backendAPI.js +++ b/packages/react-devtools-shared/src/backendAPI.js @@ -95,7 +95,7 @@ export function inspectElement( id: number, path: InspectedElementPath | null, rendererID: number, - shouldListenToPauseEvents: boolean = false, + shouldListenToPauseEvents: boolean, ): Promise { const requestID = requestCounter++; const promise = getPromiseForRequestID( @@ -117,6 +117,32 @@ export function inspectElement( return promise; } +export function inspectScreen( + bridge: FrontendBridge, + forceFullData: boolean, + arbitraryRootID: number, + path: InspectedElementPath | null, + shouldListenToPauseEvents: boolean, +): Promise { + const requestID = requestCounter++; + const promise = getPromiseForRequestID( + requestID, + 'inspectedScreen', + bridge, + `Timed out while inspecting screen.`, + shouldListenToPauseEvents, + ); + + bridge.send('inspectScreen', { + requestID, + id: arbitraryRootID, + path, + forceFullData, + }); + + return promise; +} + let storeAsGlobalCount = 0; export function storeAsGlobal({ diff --git a/packages/react-devtools-shared/src/bridge.js b/packages/react-devtools-shared/src/bridge.js index aa9c867e1f1..b6229192c23 100644 --- a/packages/react-devtools-shared/src/bridge.js +++ b/packages/react-devtools-shared/src/bridge.js @@ -65,12 +65,6 @@ export const BRIDGE_PROTOCOL: Array = [ { version: 2, minNpmVersion: '4.22.0', - maxNpmVersion: '6.2.0', - }, - // Version 3 adds supports-toggling-suspense bit to add-root - { - version: 3, - minNpmVersion: '6.2.0', maxNpmVersion: null, }, ]; @@ -92,6 +86,12 @@ type HighlightHostInstance = { openBuiltinElementsPanel: boolean, scrollIntoView: boolean, }; +type HighlightHostInstances = { + elements: Array, + displayName: string | null, + hideAfterTimeout: boolean, + scrollIntoView: boolean, +}; type ScrollToHostInstance = { ...ElementAndRendererID, @@ -145,8 +145,6 @@ type OverrideSuspense = { }; type OverrideSuspenseMilestone = { - rendererID: number, - rootID: number, suspendedSet: Array, }; @@ -167,6 +165,13 @@ type InspectElementParams = { requestID: number, }; +type InspectScreenParams = { + requestID: number, + id: number, + forceFullData: boolean, + path: Array | null, +}; + type StoreAsGlobalParams = { ...ElementAndRendererID, count: number, @@ -199,6 +204,7 @@ export type BackendEvents = { fastRefreshScheduled: [], getSavedPreferences: [], inspectedElement: [InspectedElementPayload], + inspectedScreen: [InspectedElementPayload], isReloadAndProfileSupportedByBackend: [boolean], operations: [Array], ownersList: [OwnersList], @@ -243,7 +249,9 @@ type FrontendEvents = { getProfilingData: [{rendererID: RendererID}], getProfilingStatus: [], highlightHostInstance: [HighlightHostInstance], + highlightHostInstances: [HighlightHostInstances], inspectElement: [InspectElementParams], + inspectScreen: [InspectScreenParams], logElementToConsole: [ElementAndRendererID], overrideError: [OverrideError], overrideSuspense: [OverrideSuspense], diff --git a/packages/react-devtools-shared/src/devtools/store.js b/packages/react-devtools-shared/src/devtools/store.js index 0e3373e15e2..99971f6a1a6 100644 --- a/packages/react-devtools-shared/src/devtools/store.js +++ b/packages/react-devtools-shared/src/devtools/store.js @@ -96,7 +96,6 @@ export type Capabilities = { supportsBasicProfiling: boolean, hasOwnerMetadata: boolean, supportsStrictMode: boolean, - supportsTogglingSuspense: boolean, supportsAdvancedProfiling: AdvancedProfiling, }; @@ -506,14 +505,6 @@ export default class Store extends EventEmitter<{ ); } - supportsTogglingSuspense(rootID: Element['id']): boolean { - const capabilities = this._rootIDToCapabilities.get(rootID); - if (capabilities === undefined) { - throw new Error(`No capabilities registered for root ${rootID}`); - } - return capabilities.supportsTogglingSuspense; - } - // This build of DevTools supports the Timeline profiler. // This is a static flag, controlled by the Store config. get supportsTimeline(): boolean { @@ -898,38 +889,48 @@ export default class Store extends EventEmitter<{ * @param uniqueSuspendersOnly Filters out boundaries without unique suspenders */ getSuspendableDocumentOrderSuspense( - rootID: Element['id'] | void, uniqueSuspendersOnly: boolean, ): $ReadOnlyArray { - if (rootID === undefined) { - return []; - } - const root = this.getElementByID(rootID); - if (root === null) { - return []; - } - if (!this.supportsTogglingSuspense(rootID)) { + const roots = this.roots; + if (roots.length === 0) { return []; } + const list: SuspenseNode['id'][] = []; - const suspense = this.getSuspenseByID(rootID); - if (suspense !== null) { - const stack = [suspense]; - while (stack.length > 0) { - const current = stack.pop(); - if (current === undefined) { - continue; - } - // Include the root even if we won't show it suspended (because that's just blank). - // You should be able to see what suspended the shell. - if (!uniqueSuspendersOnly || current.hasUniqueSuspenders) { - list.push(current.id); + for (let i = 0; i < roots.length; i++) { + const rootID = roots[i]; + const root = this.getElementByID(rootID); + if (root === null) { + continue; + } + // TODO: This includes boundaries that can't be suspended due to no support from the renderer. + + const suspense = this.getSuspenseByID(rootID); + if (suspense !== null) { + if (list.length === 0) { + // start with an arbitrary root that will allow inspection of the Screen + list.push(suspense.id); } - // Add children in reverse order to maintain document order - for (let j = current.children.length - 1; j >= 0; j--) { - const childSuspense = this.getSuspenseByID(current.children[j]); - if (childSuspense !== null) { - stack.push(childSuspense); + + const stack = [suspense]; + while (stack.length > 0) { + const current = stack.pop(); + if (current === undefined) { + continue; + } + if ( + (!uniqueSuspendersOnly || current.hasUniqueSuspenders) && + // Roots are already included as part of the Screen + current.id !== rootID + ) { + list.push(current.id); + } + // Add children in reverse order to maintain document order + for (let j = current.children.length - 1; j >= 0; j--) { + const childSuspense = this.getSuspenseByID(current.children[j]); + if (childSuspense !== null) { + stack.push(childSuspense); + } } } } @@ -1191,7 +1192,6 @@ export default class Store extends EventEmitter<{ let supportsStrictMode = false; let hasOwnerMetadata = false; - let supportsTogglingSuspense = false; // If we don't know the bridge protocol, guess that we're dealing with the latest. // If we do know it, we can take it into consideration when parsing operations. @@ -1204,9 +1204,6 @@ export default class Store extends EventEmitter<{ hasOwnerMetadata = operations[i] > 0; i++; - - supportsTogglingSuspense = operations[i] > 0; - i++; } this._roots = this._roots.concat(id); @@ -1215,7 +1212,6 @@ export default class Store extends EventEmitter<{ supportsBasicProfiling, hasOwnerMetadata, supportsStrictMode, - supportsTogglingSuspense, supportsAdvancedProfiling, }); @@ -1561,7 +1557,12 @@ export default class Store extends EventEmitter<{ if (name === null) { // The boundary isn't explicitly named. // Pick a sensible default. - name = this._guessSuspenseName(element); + if (parentID === 0) { + // For Roots we use their display name. + name = element.displayName; + } else { + name = this._guessSuspenseName(element); + } } } diff --git a/packages/react-devtools-shared/src/devtools/views/Profiler/CommitTreeBuilder.js b/packages/react-devtools-shared/src/devtools/views/Profiler/CommitTreeBuilder.js index 9e928b02319..28be5f9e1c7 100644 --- a/packages/react-devtools-shared/src/devtools/views/Profiler/CommitTreeBuilder.js +++ b/packages/react-devtools-shared/src/devtools/views/Profiler/CommitTreeBuilder.js @@ -209,7 +209,6 @@ function updateTree( i++; // Profiling flag i++; // supportsStrictMode flag i++; // hasOwnerMetadata flag - i++; // supportsTogglingSuspense flag if (__DEBUG__) { debug('Add', `new root fiber ${id}`); diff --git a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseBreadcrumbs.js b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseBreadcrumbs.js index 13dab6efbc7..d7112ec7d3a 100644 --- a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseBreadcrumbs.js +++ b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseBreadcrumbs.js @@ -25,7 +25,7 @@ export default function SuspenseBreadcrumbs(): React$Node { const store = useContext(StoreContext); const treeDispatch = useContext(TreeDispatcherContext); const suspenseTreeDispatch = useContext(SuspenseTreeDispatcherContext); - const {selectedSuspenseID, selectedRootID, lineage} = useContext( + const {selectedSuspenseID, lineage, roots} = useContext( SuspenseTreeStateContext, ); @@ -45,13 +45,13 @@ export default function SuspenseBreadcrumbs(): React$Node { // that rendered the whole screen. In laymans terms this is really "Initial Paint". // TODO: Once we add subtree selection, then the equivalent should be called // "Transition" since in that case it's really about a Transition within the page. - selectedRootID !== null ? ( + roots.length > 0 ? (
  • + aria-current="true"> diff --git a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseRects.js b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseRects.js index 00ca3e14594..cd77a7a62c5 100644 --- a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseRects.js +++ b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseRects.js @@ -278,11 +278,7 @@ function getDocumentBoundingRect( }; } -function SuspenseRectsShell({ - rootID, -}: { - rootID: SuspenseNode['id'], -}): React$Node { +function SuspenseRectsRoot({rootID}: {rootID: SuspenseNode['id']}): React$Node { const store = useContext(StoreContext); const root = store.getSuspenseByID(rootID); if (root === null) { @@ -299,6 +295,8 @@ const ViewBox = createContext((null: any)); function SuspenseRectsContainer(): React$Node { const store = useContext(StoreContext); + const treeDispatch = useContext(TreeDispatcherContext); + const suspenseTreeDispatch = useContext(SuspenseTreeDispatcherContext); // TODO: This relies on a full re-render of all children when the Suspense tree changes. const {roots} = useContext(SuspenseTreeStateContext); @@ -312,14 +310,33 @@ function SuspenseRectsContainer(): React$Node { const width = '100%'; const aspectRatio = `1 / ${heightScale}`; + function handleClick(event: SyntheticMouseEvent) { + if (event.defaultPrevented) { + // Already clicked on an inner rect + return; + } + if (roots.length === 0) { + // Nothing to select + return; + } + const arbitraryRootID = roots[0]; + + event.preventDefault(); + treeDispatch({type: 'SELECT_ELEMENT_BY_ID', payload: arbitraryRootID}); + suspenseTreeDispatch({ + type: 'SET_SUSPENSE_LINEAGE', + payload: arbitraryRootID, + }); + } + return ( -
    +
    {roots.map(rootID => { - return ; + return ; })}
    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 b4ed7ec1f93..f7b63fa2c62 100644 --- a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTab.js +++ b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTab.js @@ -34,13 +34,9 @@ import { SuspenseTreeStateContext, } from './SuspenseTreeContext'; import {StoreContext, OptionsContext} from '../context'; -import {TreeDispatcherContext} from '../Components/TreeContext'; import Button from '../Button'; import Toggle from '../Toggle'; -import typeof { - SyntheticEvent, - SyntheticPointerEvent, -} from 'react-dom-bindings/src/events/SyntheticEvent'; +import typeof {SyntheticPointerEvent} from 'react-dom-bindings/src/events/SyntheticEvent'; import SettingsModal from 'react-devtools-shared/src/devtools/views/Settings/SettingsModal'; import SettingsModalContextToggle from 'react-devtools-shared/src/devtools/views/Settings/SettingsModalContextToggle'; import {SettingsModalContextController} from 'react-devtools-shared/src/devtools/views/Settings/SettingsModalContext'; @@ -71,20 +67,14 @@ function ToggleUniqueSuspenders() { const store = useContext(StoreContext); const suspenseTreeDispatch = useContext(SuspenseTreeDispatcherContext); - const {selectedRootID: rootID, uniqueSuspendersOnly} = useContext( - SuspenseTreeStateContext, - ); + const {uniqueSuspendersOnly} = useContext(SuspenseTreeStateContext); function handleToggleUniqueSuspenders() { const nextUniqueSuspendersOnly = !uniqueSuspendersOnly; - const nextTimeline = - rootID === null - ? [] - : // TODO: Handle different timeline modes (e.g. random order) - store.getSuspendableDocumentOrderSuspense( - rootID, - nextUniqueSuspendersOnly, - ); + // TODO: Handle different timeline modes (e.g. random order) + const nextTimeline = store.getSuspendableDocumentOrderSuspense( + nextUniqueSuspendersOnly, + ); suspenseTreeDispatch({ type: 'SET_SUSPENSE_TIMELINE', payload: [nextTimeline, null, nextUniqueSuspendersOnly], @@ -101,55 +91,6 @@ function ToggleUniqueSuspenders() { ); } -function SelectRoot() { - const store = useContext(StoreContext); - const {roots, selectedRootID, uniqueSuspendersOnly} = useContext( - SuspenseTreeStateContext, - ); - const treeDispatch = useContext(TreeDispatcherContext); - const suspenseTreeDispatch = useContext(SuspenseTreeDispatcherContext); - - function handleChange(event: SyntheticEvent) { - const newRootID = +event.currentTarget.value; - // TODO: scrollIntoView both suspense rects and host instance. - const nextTimeline = store.getSuspendableDocumentOrderSuspense( - newRootID, - uniqueSuspendersOnly, - ); - suspenseTreeDispatch({ - type: 'SET_SUSPENSE_TIMELINE', - payload: [nextTimeline, newRootID, uniqueSuspendersOnly], - }); - if (nextTimeline.length > 0) { - const milestone = nextTimeline[nextTimeline.length - 1]; - treeDispatch({type: 'SELECT_ELEMENT_BY_ID', payload: milestone}); - } - } - return ( - roots.length > 0 && ( - - ) - ); -} - function ToggleTreeList({ dispatch, state, @@ -427,7 +368,6 @@ function SuspenseTab(_: {}) {
    -
    {!hideSettings && } diff --git a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTimeline.js b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTimeline.js index b7340da915b..30ca21476f2 100644 --- a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTimeline.js +++ b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTimeline.js @@ -9,7 +9,7 @@ import * as React from 'react'; import {useContext, useEffect, useRef} from 'react'; -import {BridgeContext, StoreContext} from '../context'; +import {BridgeContext} from '../context'; import {TreeDispatcherContext} from '../Components/TreeContext'; import {useHighlightHostInstance, useScrollToHostInstance} from '../hooks'; import { @@ -23,20 +23,15 @@ import ButtonIcon from '../ButtonIcon'; function SuspenseTimelineInput() { const bridge = useContext(BridgeContext); - const store = useContext(StoreContext); const treeDispatch = useContext(TreeDispatcherContext); const suspenseTreeDispatch = useContext(SuspenseTreeDispatcherContext); const {highlightHostInstance, clearHighlightHostInstance} = useHighlightHostInstance(); const scrollToHostInstance = useScrollToHostInstance(); - const { - selectedRootID: rootID, - timeline, - timelineIndex, - hoveredTimelineIndex, - playing, - } = useContext(SuspenseTreeStateContext); + const {timeline, timelineIndex, hoveredTimelineIndex, playing} = useContext( + SuspenseTreeStateContext, + ); const min = 0; const max = timeline.length > 0 ? timeline.length - 1 : 0; @@ -112,24 +107,12 @@ function SuspenseTimelineInput() { // For now we just exclude it from deps since we don't lint those anyway. function changeTimelineIndex(newIndex: number) { // Synchronize timeline index with what is resuspended. - if (rootID === null) { - return; - } - const rendererID = store.getRendererIDForElement(rootID); - if (rendererID === null) { - console.error( - `No renderer ID found for root element ${rootID} in suspense timeline.`, - ); - return; - } // We suspend everything after the current selection. The root isn't showing // anything suspended in the root. The step after that should have one less // thing suspended. I.e. the first suspense boundary should be unsuspended // when it's selected. This also lets you show everything in the last step. const suspendedSet = timeline.slice(timelineIndex + 1); bridge.send('overrideSuspenseMilestone', { - rendererID, - rootID, suspendedSet, }); if (isInitialMount.current) { @@ -164,20 +147,6 @@ function SuspenseTimelineInput() { }; }, [playing]); - if (rootID === null) { - return ( -
    No root selected.
    - ); - } - - if (!store.supportsTogglingSuspense(rootID)) { - return ( -
    - Can't step through Suspense in production apps. -
    - ); - } - if (timeline.length === 0) { return (
    @@ -226,10 +195,9 @@ function SuspenseTimelineInput() { } export default function SuspenseTimeline(): React$Node { - const {selectedRootID} = useContext(SuspenseTreeStateContext); return (
    - +
    ); } diff --git a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTreeContext.js b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTreeContext.js index 151f454eb5e..bfe3f42bda6 100644 --- a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTreeContext.js +++ b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTreeContext.js @@ -7,10 +7,7 @@ * @flow */ import type {ReactContext} from 'shared/ReactTypes'; -import type { - Element, - SuspenseNode, -} from 'react-devtools-shared/src/frontend/types'; +import type {SuspenseNode} from 'react-devtools-shared/src/frontend/types'; import type Store from '../../store'; import * as React from 'react'; @@ -27,7 +24,6 @@ import {StoreContext} from '../context'; export type SuspenseTreeState = { lineage: $ReadOnlyArray | null, roots: $ReadOnlyArray, - selectedRootID: SuspenseNode['id'] | null, selectedSuspenseID: SuspenseNode['id'] | null, timeline: $ReadOnlyArray, timelineIndex: number | -1, @@ -107,60 +103,27 @@ type Props = { children: React$Node, }; -function getDefaultRootID(store: Store): Element['id'] | null { - const designatedRootID = store.roots.find(rootID => { - const suspense = store.getSuspenseByID(rootID); - return ( - store.supportsTogglingSuspense(rootID) && - suspense !== null && - suspense.children.length > 1 - ); - }); - - return designatedRootID === undefined ? null : designatedRootID; -} - function getInitialState(store: Store): SuspenseTreeState { - let initialState: SuspenseTreeState; const uniqueSuspendersOnly = true; - const selectedRootID = getDefaultRootID(store); - // TODO: Default to nearest from inspected - if (selectedRootID === null) { - initialState = { - selectedSuspenseID: null, - lineage: null, - roots: store.roots, - selectedRootID, - timeline: [], - timelineIndex: -1, - hoveredTimelineIndex: -1, - uniqueSuspendersOnly, - playing: false, - }; - } else { - const timeline = store.getSuspendableDocumentOrderSuspense( - selectedRootID, - uniqueSuspendersOnly, - ); - const timelineIndex = timeline.length - 1; - const selectedSuspenseID = - timelineIndex === -1 ? null : timeline[timelineIndex]; - const lineage = - selectedSuspenseID !== null - ? store.getSuspenseLineage(selectedSuspenseID) - : []; - initialState = { - selectedSuspenseID, - lineage, - roots: store.roots, - selectedRootID, - timeline, - timelineIndex, - hoveredTimelineIndex: -1, - uniqueSuspendersOnly, - playing: false, - }; - } + const timeline = + store.getSuspendableDocumentOrderSuspense(uniqueSuspendersOnly); + const timelineIndex = timeline.length - 1; + const selectedSuspenseID = + timelineIndex === -1 ? null : timeline[timelineIndex]; + const lineage = + selectedSuspenseID !== null + ? store.getSuspenseLineage(selectedSuspenseID) + : []; + const initialState: SuspenseTreeState = { + selectedSuspenseID, + lineage, + roots: store.roots, + timeline, + timelineIndex, + hoveredTimelineIndex: -1, + uniqueSuspendersOnly, + playing: false, + }; return initialState; } @@ -209,23 +172,10 @@ function SuspenseTreeContextController({children}: Props): React.Node { selectedTimelineID = removedIDs.get(selectedTimelineID); } - let nextRootID = state.selectedRootID; - if (selectedTimelineID !== null && selectedTimelineID !== 0) { - nextRootID = - store.getSuspenseRootIDForSuspense(selectedTimelineID); - } - if (nextRootID === null) { - nextRootID = getDefaultRootID(store); - } - - const nextTimeline = - nextRootID === null - ? [] - : // TODO: Handle different timeline modes (e.g. random order) - store.getSuspendableDocumentOrderSuspense( - nextRootID, - state.uniqueSuspendersOnly, - ); + // TODO: Handle different timeline modes (e.g. random order) + const nextTimeline = store.getSuspendableDocumentOrderSuspense( + state.uniqueSuspendersOnly, + ); let nextTimelineIndex = selectedTimelineID === null || nextTimeline.length === 0 @@ -250,7 +200,6 @@ function SuspenseTreeContextController({children}: Props): React.Node { ...state, lineage: nextLineage, roots: store.roots, - selectedRootID: nextRootID, selectedSuspenseID, timeline: nextTimeline, timelineIndex: nextTimelineIndex, @@ -258,27 +207,21 @@ function SuspenseTreeContextController({children}: Props): React.Node { } case 'SELECT_SUSPENSE_BY_ID': { const selectedSuspenseID = action.payload; - const selectedRootID = - store.getSuspenseRootIDForSuspense(selectedSuspenseID); return { ...state, selectedSuspenseID, - selectedRootID, playing: false, // pause }; } case 'SET_SUSPENSE_LINEAGE': { const suspenseID = action.payload; const lineage = store.getSuspenseLineage(suspenseID); - const selectedRootID = - store.getSuspenseRootIDForSuspense(suspenseID); return { ...state, lineage, selectedSuspenseID: suspenseID, - selectedRootID, playing: false, // pause }; } @@ -316,8 +259,6 @@ function SuspenseTreeContextController({children}: Props): React.Node { ...state, selectedSuspenseID: nextSelectedSuspenseID, lineage: nextLineage, - selectedRootID: - nextRootID === null ? state.selectedRootID : nextRootID, timeline: nextTimeline, timelineIndex: nextMilestoneIndex, uniqueSuspendersOnly: nextUniqueSuspendersOnly, diff --git a/packages/react-devtools-shared/src/devtools/views/hooks.js b/packages/react-devtools-shared/src/devtools/views/hooks.js index a4ed2da526e..2984c2fbfcd 100644 --- a/packages/react-devtools-shared/src/devtools/views/hooks.js +++ b/packages/react-devtools-shared/src/devtools/views/hooks.js @@ -353,20 +353,44 @@ export function useHighlightHostInstance(): { const highlightHostInstance = useCallback( (id: number, scrollIntoView?: boolean = false) => { const element = store.getElementByID(id); - const rendererID = store.getRendererIDForElement(id); - if (element !== null && rendererID !== null) { + if (element !== null) { + const isRoot = element.parentID === 0; let displayName = element.displayName; if (displayName !== null && element.nameProp !== null) { displayName += ` name="${element.nameProp}"`; } - bridge.send('highlightHostInstance', { - displayName, - hideAfterTimeout: false, - id, - openBuiltinElementsPanel: false, - rendererID, - scrollIntoView: scrollIntoView, - }); + if (isRoot) { + // Inspect screen + const elements: Array<{rendererID: number, id: number}> = []; + + for (let i = 0; i < store.roots.length; i++) { + const rootID = store.roots[i]; + const rendererID = store.getRendererIDForElement(rootID); + if (rendererID === null) { + continue; + } + elements.push({rendererID, id: rootID}); + } + + bridge.send('highlightHostInstances', { + displayName, + hideAfterTimeout: false, + elements, + scrollIntoView: scrollIntoView, + }); + } else { + const rendererID = store.getRendererIDForElement(id); + if (rendererID !== null) { + bridge.send('highlightHostInstance', { + displayName, + hideAfterTimeout: false, + id, + openBuiltinElementsPanel: false, + rendererID, + scrollIntoView: scrollIntoView, + }); + } + } } }, [store, bridge], diff --git a/packages/react-devtools-shared/src/inspectedElementMutableSource.js b/packages/react-devtools-shared/src/inspectedElementMutableSource.js index ae90bd021bc..d967109163a 100644 --- a/packages/react-devtools-shared/src/inspectedElementMutableSource.js +++ b/packages/react-devtools-shared/src/inspectedElementMutableSource.js @@ -12,6 +12,7 @@ import { convertInspectedElementBackendToFrontend, hydrateHelper, inspectElement as inspectElementAPI, + inspectScreen as inspectScreenAPI, } from 'react-devtools-shared/src/backendAPI'; import {fillInPath} from 'react-devtools-shared/src/hydration'; @@ -57,21 +58,31 @@ export function inspectElement( rendererID: number, shouldListenToPauseEvents: boolean = false, ): Promise { - const {id} = element; + const {id, parentID} = element; // This could indicate that the DevTools UI has been closed and reopened. // The in-memory cache will be clear but the backend still thinks we have cached data. // In this case, we need to tell it to resend the full data. const forceFullData = !inspectedElementCache.has(id); - - return inspectElementAPI( - bridge, - forceFullData, - id, - path, - rendererID, - shouldListenToPauseEvents, - ).then((data: any) => { + const isRoot = parentID === 0; + const promisedElement = isRoot + ? inspectScreenAPI( + bridge, + forceFullData, + id, + path, + shouldListenToPauseEvents, + ) + : inspectElementAPI( + bridge, + forceFullData, + id, + path, + rendererID, + shouldListenToPauseEvents, + ); + + return promisedElement.then((data: any) => { const {type} = data; let inspectedElement; diff --git a/packages/react-devtools-shared/src/utils.js b/packages/react-devtools-shared/src/utils.js index bc8e7684505..007db77f220 100644 --- a/packages/react-devtools-shared/src/utils.js +++ b/packages/react-devtools-shared/src/utils.js @@ -262,7 +262,6 @@ export function printOperationsArray(operations: Array) { i++; // supportsProfiling i++; // supportsStrictMode i++; // hasOwnerMetadata - i++; // supportsTogglingSuspense } else { const parentID = ((operations[i]: any): number); i++;