diff --git a/.changeset/element-inspector.md b/.changeset/element-inspector.md new file mode 100644 index 0000000..585bcd7 --- /dev/null +++ b/.changeset/element-inspector.md @@ -0,0 +1,12 @@ +--- +'@solidjs/start-devtools': minor +--- + +Add interactive Element Inspector and cross-panel component funneling: + +- Press `Alt+Shift+C` or click the crosshair icon in the dock header to inspect DOM elements. +- Hovering elements shows an overlay with the component name, file location, and dimensions. +- Clicking an element selects the component and funnels relevant data across devtool panels without interrupting your active view. +- Reactivity graph filters to reactive signals, memos, and computations owned by the selected component instance. +- Ownership tree automatically expands, scrolls to, and highlights the selected component. +- The funnel badge in the dock header provides quick navigation to the Ownership tree, direct "See in editor" launch, and 1-click filter clearing. diff --git a/src/dev-toolbar/icons.tsx b/src/dev-toolbar/icons.tsx index 5974b02..9e8dbe0 100644 --- a/src/dev-toolbar/icons.tsx +++ b/src/dev-toolbar/icons.tsx @@ -743,3 +743,20 @@ export function CheckIcon(props: JSX.IntrinsicElements['svg'] & { title: string ); } + +export function InspectIcon(props: JSX.IntrinsicElements['svg'] & { title: string }): JSX.Element { + return ( + + {props.title} + + + + + ); +} diff --git a/src/dev-toolbar/index.css b/src/dev-toolbar/index.css index b3d1c2f..becfddc 100644 --- a/src/dev-toolbar/index.css +++ b/src/dev-toolbar/index.css @@ -254,6 +254,88 @@ font-size: 0.75rem; } +[data-solid-dev-toolbar-funnel] { + display: flex; + align-items: center; + gap: 0.375rem; + padding: 0.125rem 0.5rem; + border-radius: 9999px; + background: var(--start-dt-accent-soft); + border: 1px solid var(--start-dt-border-soft); + font-size: 0.75rem; + line-height: 1.25rem; + white-space: nowrap; +} + +[data-solid-dev-toolbar-funnel-label] { + color: var(--start-dt-text-muted); + font-size: 0.6875rem; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +[data-solid-dev-toolbar-funnel-name] { + background: none; + border: none; + padding: 0; + font: inherit; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-weight: 600; + color: var(--start-dt-accent); + cursor: pointer; + text-decoration: underline; + text-decoration-style: dotted; + text-underline-offset: 2px; +} + +[data-solid-dev-toolbar-funnel-name]:hover { + text-decoration-style: solid; +} + +[data-solid-dev-toolbar-funnel-action] { + background: none; + border: none; + padding: 0 0.25rem; + font: inherit; + font-size: 0.6875rem; + color: var(--start-dt-text); + cursor: pointer; + text-decoration: underline; + opacity: 0.85; +} + +[data-solid-dev-toolbar-funnel-action]:hover { + opacity: 1; + color: var(--start-dt-accent); +} + +[data-solid-dev-toolbar-funnel-clear] { + display: flex; + align-items: center; + justify-content: center; + width: 1rem; + height: 1rem; + border-radius: 9999px; + background: none; + border: none; + padding: 0; + font: inherit; + font-size: 0.8125rem; + line-height: 1; + color: var(--start-dt-text-muted); + cursor: pointer; +} + +[data-solid-dev-toolbar-funnel-clear]:hover { + color: var(--start-dt-text); + background: var(--start-dt-surface-hover); +} + +[data-solid-icon-button][data-active] { + color: var(--start-dt-accent); + background-color: var(--start-dt-accent-soft); +} + [data-solid-dev-toolbar-spacer] { flex: 1; } diff --git a/src/dev-toolbar/index.tsx b/src/dev-toolbar/index.tsx index cee4d03..310e3ff 100644 --- a/src/dev-toolbar/index.tsx +++ b/src/dev-toolbar/index.tsx @@ -21,11 +21,15 @@ import { FunctionIcon, GraphIcon, HighlightUpdatesIcon, + InspectIcon, MinimizeIcon, SolidIcon, TreeIcon, } from './icons.js'; import { HighlightUpdatesOverlay } from './highlight-updates/overlay.js'; +import { installElementTracking, type InspectedComponent } from './inspector/lookup.js'; +import { InspectorOverlay } from './inspector/overlay.js'; +import { openInEditor } from './open-in-editor.js'; import { excludeOwner, includeOwner } from './ownership/registry.js'; import { excludeReactiveOwner, includeReactiveOwner } from './reactivity/registry.js'; import './index.css'; @@ -129,6 +133,27 @@ export function DevToolbar(props: DevToolbarProps) { const [height, setHeight] = createSignal(DEFAULT_HEIGHT); const [highlightUpdates, setHighlightUpdates] = createSignal(false); const [route, setRoute] = createSignal(''); + const [inspecting, setInspecting] = createSignal(false); + const [selectedComponent, setSelectedComponent] = createSignal(); + + function toggleInspect() { + setInspecting((current) => !current); + } + + function selectComponent(comp: InspectedComponent) { + setInspecting(false); + setSelectedComponent(comp); + setOwnershipFocus({ owner: comp.owner }); + if (!open()) { + setOpen(true); + persist({ open: true }); + } + } + + function clearComponentFunnel() { + setSelectedComponent(undefined); + } + // The dock keeps the height the user chose, and shows at most 90% of the // window, so a smaller window shrinks it without losing that choice. const [maxHeight, setMaxHeight] = createSignal(Infinity); @@ -251,6 +276,10 @@ export function DevToolbar(props: DevToolbarProps) { window.addEventListener('pointercancel', end); } + onSettled(() => { + installElementTracking(); + }); + onSettled(() => { const onKeyDown = (event: KeyboardEvent) => { // `code`, because Alt changes the character `key` reports on macOS. @@ -268,6 +297,9 @@ export function DevToolbar(props: DevToolbarProps) { persist({ highlightUpdates: next }); return next; }); + } else if (event.code === 'KeyC') { + event.preventDefault(); + toggleInspect(); } }; window.addEventListener('keydown', onKeyDown); @@ -416,7 +448,54 @@ export function DevToolbar(props: DevToolbarProps) { {route()} + + {(comp) => ( +
+ Inspecting + + + {(loc) => ( + + )} + + +
+ )} +
+ + + { setOwnershipFocus({ owner }); show('own'); @@ -522,6 +603,11 @@ export function DevToolbar(props: DevToolbarProps) {
+ setInspecting(false)} + /> { diff --git a/src/dev-toolbar/inspector/lookup.test.ts b/src/dev-toolbar/inspector/lookup.test.ts new file mode 100644 index 0000000..5336a48 --- /dev/null +++ b/src/dev-toolbar/inspector/lookup.test.ts @@ -0,0 +1,123 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { + findComponentFromElement, + findComponentOwner, + installElementTracking, + tagElementComponent, + uninstallElementTracking, +} from './lookup.js'; +import type { RawNode } from '../ownership/tree.js'; + +interface MockElement { + tagName: string; + parentElement: MockElement | null; + isToolbar?: boolean; + closest(selector: string): MockElement | null; +} + +function createMockElement(tag: string, parent: MockElement | null = null): MockElement { + const el: MockElement = { + tagName: tag.toUpperCase(), + parentElement: parent, + closest(selector: string) { + if (selector === '[data-solid-dev-toolbar]' && this.isToolbar) return this; + return this.parentElement ? this.parentElement.closest(selector) : null; + }, + }; + return el; +} + +describe('findComponentOwner', () => { + it('returns undefined if owner is nullish', () => { + expect(findComponentOwner(undefined)).toBeUndefined(); + expect(findComponentOwner(null)).toBeUndefined(); + }); + + it('returns owner directly if it is a component', () => { + const compOwner: RawNode = { + _component: { name: 'App', props: {}, fn() {} }, + }; + expect(findComponentOwner(compOwner)).toBe(compOwner); + }); + + it('climbs parent chain to find component owner', () => { + const compOwner: RawNode = { + _component: { name: 'Header', props: {}, fn() {} }, + }; + const memoOwner: RawNode = { + _parent: compOwner, + _deps: null, + _fn() {}, + }; + const effectOwner: RawNode = { + _parent: memoOwner, + _deps: null, + _fn() {}, + }; + + expect(findComponentOwner(effectOwner)).toBe(compOwner); + }); +}); + +describe('findComponentFromElement with tagged nodes', () => { + it('ignores elements inside the dev toolbar', () => { + const toolbar = createMockElement('div'); + toolbar.isToolbar = true; + const button = createMockElement('button', toolbar); + + const compOwner: RawNode = { + _component: { name: 'Ignored', props: {}, fn() {} }, + }; + tagElementComponent(button as unknown as Element, compOwner); + + expect(findComponentFromElement(button as unknown as Element)).toBeUndefined(); + }); + + it('finds component owner on tagged element', () => { + const button = createMockElement('button'); + + const compOwner: RawNode = { + _component: { name: 'Card', props: {}, fn() {} }, + }; + tagElementComponent(button as unknown as Element, compOwner); + + const result = findComponentFromElement(button as unknown as Element); + expect(result).toBeDefined(); + expect(result?.owner).toBe(compOwner); + expect(result?.name).toBe(''); + expect(result?.element).toBe(button as unknown as Element); + }); + + it('finds component owner when nested child is hovered', () => { + const section = createMockElement('section'); + const span = createMockElement('span', section); + + const compOwner: RawNode = { + _component: { + name: 'Greeting', + props: {}, + fn: Object.assign(() => {}, { location: 'src/Greeting.tsx:10:2' }), + }, + }; + tagElementComponent(section as unknown as Element, compOwner); + + const result = findComponentFromElement(span as unknown as Element); + expect(result).toBeDefined(); + expect(result?.owner).toBe(compOwner); + expect(result?.name).toBe(''); + expect(result?.location).toBe('src/Greeting.tsx:10:2'); + expect(result?.element).toBe(section as unknown as Element); + }); +}); + +describe('installElementTracking', () => { + afterEach(() => { + uninstallElementTracking(); + }); + + it('installs and uninstalls cleanly in node environment', () => { + const cleanup = installElementTracking(); + expect(typeof cleanup).toBe('function'); + cleanup(); + }); +}); diff --git a/src/dev-toolbar/inspector/lookup.ts b/src/dev-toolbar/inspector/lookup.ts new file mode 100644 index 0000000..d76a155 --- /dev/null +++ b/src/dev-toolbar/inspector/lookup.ts @@ -0,0 +1,174 @@ +import { getOwner } from 'solid-js'; +import { snapshotOwnershipTree } from '../ownership/registry.js'; +import { + componentLocation, + isComponent, + ownerName, + type RawNode, + type TreeNode, +} from '../ownership/tree.js'; + +export interface InspectedComponent { + element: Element; + owner: RawNode; + name: string; + location?: string; + treeNode?: TreeNode; +} + +const nodeToComponent = new WeakMap(); +let elementTrackingInstalled = false; +let originalClone: typeof Node.prototype.cloneNode | undefined; +let originalCreate: typeof Document.prototype.createElement | undefined; +let originalCreateNS: typeof Document.prototype.createElementNS | undefined; + +/** + * Climbs the reactive owner hierarchy to find the nearest enclosing component owner. + */ +export function findComponentOwner(owner: RawNode | null | undefined): RawNode | undefined { + let cur = owner; + while (cur) { + if (isComponent(cur)) return cur; + cur = cur._parent; + } + return undefined; +} + +/** + * Associates a DOM element directly with a component owner. + */ +export function tagElementComponent(node: Node, compOwner: RawNode): void { + nodeToComponent.set(node, compOwner); +} + +/** + * Patches DOM creation primitives so elements created while a component + * is executing can be mapped directly back to that component owner. + */ +export function installElementTracking(): () => void { + if (elementTrackingInstalled || typeof window === 'undefined') { + return uninstallElementTracking; + } + elementTrackingInstalled = true; + + originalClone = Node.prototype.cloneNode; + Node.prototype.cloneNode = function (this: Node, deep?: boolean): Node { + const clone = originalClone!.call(this, deep); + const owner = getOwner(); + const comp = findComponentOwner(owner as RawNode | null | undefined); + if (comp && clone) { + nodeToComponent.set(clone, comp); + } + return clone; + }; + + originalCreate = Document.prototype.createElement; + Document.prototype.createElement = function ( + this: Document, + ...args: Parameters + ): HTMLElement { + const el = originalCreate!.apply(this, args); + const owner = getOwner(); + const comp = findComponentOwner(owner as RawNode | null | undefined); + if (comp && el) { + nodeToComponent.set(el, comp); + } + return el; + }; + + originalCreateNS = Document.prototype.createElementNS; + Document.prototype.createElementNS = function (this: Document, ...args: [any, any, any?]): any { + const el = originalCreateNS!.apply( + this, + args as Parameters, + ); + const owner = getOwner(); + const comp = findComponentOwner(owner as RawNode | null | undefined); + if (comp && el) { + nodeToComponent.set(el, comp); + } + return el; + }; + + return uninstallElementTracking; +} + +/** + * Restores original DOM creation primitives. + */ +export function uninstallElementTracking(): void { + if (!elementTrackingInstalled || typeof window === 'undefined') return; + elementTrackingInstalled = false; + + if (originalClone) { + Node.prototype.cloneNode = originalClone; + originalClone = undefined; + } + if (originalCreate) { + Document.prototype.createElement = originalCreate; + originalCreate = undefined; + } + if (originalCreateNS) { + Document.prototype.createElementNS = originalCreateNS; + originalCreateNS = undefined; + } +} + +/** + * Finds the component owner associated with a given DOM element. + * Walks up the DOM tree and checks the weak node map as well as + * any binding effects recorded in the ownership hierarchy. + */ +export function findComponentFromElement(target: Element): InspectedComponent | undefined { + if (typeof target.closest === 'function' && target.closest('[data-solid-dev-toolbar]')) { + return undefined; + } + + const compTree = snapshotOwnershipTree({ componentsOnly: true }); + const compNodesByOwner = new Map(); + for (const node of compTree.nodes) { + compNodesByOwner.set(node.owner, node); + } + + // Also snapshot full tree to discover any _devElement references on render effects + const fullTree = snapshotOwnershipTree({ componentsOnly: false }); + const devElementToComp = new Map(); + for (const node of fullTree.nodes) { + const devEl = node.owner?._devElement; + if (devEl && typeof devEl === 'object') { + const comp = findComponentOwner(node.owner); + if (comp) { + devElementToComp.set(devEl, comp); + } + } + } + + let current: Element | null = target; + while (current) { + if ( + typeof document !== 'undefined' && + (current === document.body || current === document.documentElement) + ) { + break; + } + + let comp = nodeToComponent.get(current) ?? devElementToComp.get(current); + + if (comp) { + const treeNode = compNodesByOwner.get(comp); + const name = treeNode?.name ?? ownerName(comp, 'component'); + const location = treeNode?.location ?? componentLocation(comp); + return { + element: current, + owner: comp, + name, + location, + treeNode, + }; + } + + current = current.parentElement; + } + + return undefined; +} diff --git a/src/dev-toolbar/inspector/overlay.css b/src/dev-toolbar/inspector/overlay.css new file mode 100644 index 0000000..72d07e4 --- /dev/null +++ b/src/dev-toolbar/inspector/overlay.css @@ -0,0 +1,50 @@ +[data-solid-inspector-overlay] { + position: fixed; + inset: 0; + pointer-events: none; + z-index: 2147483646; +} + +[data-solid-inspector-box] { + position: fixed; + box-sizing: border-box; + pointer-events: none; + border-radius: 4px; + background: oklch(0.68 0.13 245 / 0.16); + border: 1.5px solid oklch(0.68 0.13 245); + box-shadow: 0 0 0 1px oklch(0.68 0.13 245 / 0.35); + transition: all 60ms cubic-bezier(0.4, 0, 0.2, 1); +} + +[data-solid-inspector-badge] { + position: fixed; + pointer-events: none; + display: flex; + align-items: center; + gap: 0.375rem; + padding: 0.25rem 0.5rem; + border-radius: 0.375rem; + background: var(--start-dt-surface, oklch(0.22 0.02 265)); + border: 1px solid var(--start-dt-border, oklch(0.34 0.02 265)); + box-shadow: 0 6px 16px oklch(0 0 0 / 0.4); + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.75rem; + line-height: 1rem; + color: var(--start-dt-text, oklch(0.93 0.005 265)); + white-space: nowrap; + z-index: 2147483647; +} + +[data-solid-inspector-badge-name] { + font-weight: 600; + color: var(--start-dt-accent, oklch(0.68 0.13 245)); +} + +[data-solid-inspector-badge-loc] { + color: var(--start-dt-text-muted, oklch(0.68 0.015 265)); +} + +[data-solid-inspector-badge-size] { + color: var(--start-dt-text-muted, oklch(0.68 0.015 265)); + opacity: 0.75; +} diff --git a/src/dev-toolbar/inspector/overlay.tsx b/src/dev-toolbar/inspector/overlay.tsx new file mode 100644 index 0000000..4cd9264 --- /dev/null +++ b/src/dev-toolbar/inspector/overlay.tsx @@ -0,0 +1,166 @@ +import { JSX, Portal } from '@solidjs/web'; +import { createEffect, createSignal, Show } from 'solid-js'; +import { findComponentFromElement, type InspectedComponent } from './lookup.js'; +import './overlay.css'; + +export interface InspectorOverlayProps { + active: boolean; + onSelect: (component: InspectedComponent) => void; + onCancel: () => void; +} + +interface HoverState { + rect: DOMRect; + name: string; + location?: string; + component?: InspectedComponent; +} + +function formatLocation(loc?: string): string | undefined { + if (!loc) return undefined; + const parts = loc.split('/'); + return parts.length > 1 ? `${parts[parts.length - 2]}/${parts[parts.length - 1]}` : loc; +} + +export function InspectorOverlay(props: InspectorOverlayProps): JSX.Element { + const [hovered, setHovered] = createSignal(); + let lastTarget: Element | null = null; + + function updateHover(target: Element | null) { + if (!target || target === document.body || target === document.documentElement) { + setHovered(undefined); + lastTarget = null; + return; + } + + if (target.closest('[data-solid-dev-toolbar]')) { + setHovered(undefined); + lastTarget = null; + return; + } + + lastTarget = target; + const comp = findComponentFromElement(target); + if (comp) { + setHovered({ + rect: comp.element.getBoundingClientRect(), + name: comp.name, + location: comp.location, + component: comp, + }); + return; + } + + setHovered({ + rect: target.getBoundingClientRect(), + name: `<${target.tagName.toLowerCase()}>`, + }); + } + + createEffect( + () => props.active, + (active) => { + if (!active || typeof window === 'undefined') return; + + const previousCursor = document.body.style.cursor; + document.body.style.cursor = 'crosshair'; + + const onPointerMove = (e: PointerEvent) => { + const target = document.elementFromPoint(e.clientX, e.clientY); + // The lookup walks the whole ownership tree, so it runs only when the + // pointer moves onto another element. + if (target === lastTarget) return; + updateHover(target); + }; + + const onClick = (e: MouseEvent) => { + const target = e.target as Element | null; + if (target?.closest('[data-solid-dev-toolbar]')) return; + + e.preventDefault(); + e.stopPropagation(); + + const current = hovered(); + if (current?.component) { + props.onSelect(current.component); + } else { + props.onCancel(); + } + }; + + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + props.onCancel(); + } + }; + + const onScrollOrResize = () => { + if (lastTarget) updateHover(lastTarget); + }; + + window.addEventListener('pointermove', onPointerMove, { passive: true }); + window.addEventListener('click', onClick, { capture: true }); + window.addEventListener('keydown', onKeyDown, { capture: true }); + window.addEventListener('scroll', onScrollOrResize, { + passive: true, + capture: true, + }); + window.addEventListener('resize', onScrollOrResize, { passive: true }); + + return () => { + document.body.style.cursor = previousCursor; + window.removeEventListener('pointermove', onPointerMove); + window.removeEventListener('click', onClick, { capture: true }); + window.removeEventListener('keydown', onKeyDown, { capture: true }); + window.removeEventListener('scroll', onScrollOrResize, { capture: true }); + window.removeEventListener('resize', onScrollOrResize); + setHovered(undefined); + lastTarget = null; + }; + }, + ); + + return ( + + {(state) => { + const rect = () => state().rect; + const top = () => + rect().top < 34 ? `${rect().bottom + 6}px` : `${Math.max(4, rect().top - 28)}px`; + const left = () => `${Math.max(8, Math.min(rect().left, window.innerWidth - 240))}px`; + + return ( + +