From 8dd373595f20cea882c0332b0a08869fe6274304 Mon Sep 17 00:00:00 2001 From: Chris Lorenzo Date: Mon, 18 May 2026 12:53:04 -0400 Subject: [PATCH] feat(inspector): improve DOM debug layer for Chrome devtools - Fix unparenting bug in DOM renderer: setting `parent = null` now removes the div from its previous parent (mirrors lightning-js/renderer#795), and `destroy()` is safe if the node was unparented first. - Sync `node.states` to `div.dataset.states` so focus/active states are visible in devtools and styleable via attribute selectors. - Set `componentName` for `` so runtime-resolved intrinsics show up in the inspector with the same field the jsx-locator babel plugin uses. - Add Alt+click-to-log inspector (gated by `isDev` + `Config.debug`) that walks the ElementNode tree by hit-position to find the deepest visible child, logs its key state, and pins it to `window.$el`. Lives in a new `core/clickInspector.ts` with a small `initClickInspector()` entry point. Co-Authored-By: Claude Opus 4.7 --- src/core/clickInspector.ts | 76 ++++++++++++++++++++++++++++ src/core/dom-renderer/domRenderer.ts | 4 +- src/core/elementNode.ts | 21 +++++++- src/render.ts | 1 + 4 files changed, 100 insertions(+), 2 deletions(-) create mode 100644 src/core/clickInspector.ts diff --git a/src/core/clickInspector.ts b/src/core/clickInspector.ts new file mode 100644 index 0000000..9c8cc73 --- /dev/null +++ b/src/core/clickInspector.ts @@ -0,0 +1,76 @@ +import { Config, isDev } from './config.js'; +import { isElementNode } from './utils.js'; +import type { ElementNode } from './elementNode.js'; + +let installed = false; + +function findDeepestAtPosition( + root: ElementNode, + x: number, + y: number, +): ElementNode { + const precision = Config.rendererOptions?.deviceLogicalPixelRatio || 1; + const px = x / precision; + const py = y / precision; + + let current = root; + while (true) { + let best: ElementNode | undefined; + let bestZ = -Infinity; + for (const child of current.children) { + if (!isElementNode(child) || child.alpha === 0) continue; + const cx = (child.lng.absX as number) || 0; + const cy = (child.lng.absY as number) || 0; + const cw = child.width || 0; + const ch = child.height || 0; + if (px < cx || px > cx + cw || py < cy || py > cy + ch) continue; + const z = child.zIndex ?? -1; + if (z >= bestZ) { + bestZ = z; + best = child; + } + } + if (!best) return current; + current = best; + } +} + +function handleClick(event: MouseEvent) { + if (!event.altKey) return; + let target = event.target as HTMLElement | null; + while (target && !target.element) { + target = target.parentElement; + } + const hit = target?.element; + if (!hit) return; + let root = hit; + while (root.parent) root = root.parent; + const el = findDeepestAtPosition(root, event.clientX, event.clientY); + event.preventDefault(); + event.stopPropagation(); + const lng = el.lng as any; + const label = el.componentName || el._type; + const loc = el.componentLocation ? ` @ ${el.componentLocation}` : ''; + console.log( + `%c[SolidTV Inspector] %c${label}${loc}`, + 'color: magenta; font-weight: bold;', + 'color: inherit; font-weight: normal;', + { + element: el, + div: lng.div, + lng, + states: el._states ? Array.from(el._states) : [], + position: { x: lng?.x, y: lng?.y, w: lng?.w, h: lng?.h }, + parent: el.parent, + children: el.children, + }, + ); + (globalThis as any).$el = el; + console.log('Pinned to $el — try $el.parent, $el.setFocus()'); +} + +export function initClickInspector(): void { + if (installed || !isDev || typeof document === 'undefined') return; + installed = true; + document.addEventListener('click', handleClick, true); +} diff --git a/src/core/dom-renderer/domRenderer.ts b/src/core/dom-renderer/domRenderer.ts index 5fb1be0..bcb3d56 100644 --- a/src/core/dom-renderer/domRenderer.ts +++ b/src/core/dom-renderer/domRenderer.ts @@ -230,6 +230,8 @@ function updateNodeParent(node: DOMNode | DOMText) { const parent = node.props.parent; if (parent instanceof DOMNode) { elMap.get(parent)!.appendChild(node.div); + } else { + node.div.parentNode?.removeChild(node.div); } } @@ -1174,7 +1176,7 @@ export class DOMNode extends EventEmitter implements IRendererNode { if (parent instanceof DOMNode) { parent.children.delete(this); } - this.div.parentNode!.removeChild(this.div); + this.div.parentNode?.removeChild(this.div); } get parent() { diff --git a/src/core/elementNode.ts b/src/core/elementNode.ts index 4d4523c..b1e4557 100644 --- a/src/core/elementNode.ts +++ b/src/core/elementNode.ts @@ -52,6 +52,7 @@ import { setActiveElement, FocusNode, } from './focusManager.js'; +import { initClickInspector } from './clickInspector.js'; import { IRendererNode, @@ -279,6 +280,8 @@ declare global { } } +initClickInspector(); + export type RendererNode = AddColorString< Partial< NewOmit< @@ -1338,6 +1341,17 @@ export class ElementNode { _stateChanged() { isDev && log('State Changed: ', this, this.states); + if (isDev) { + const div = (this.lng as any)?.div as HTMLElement | undefined; + if (div) { + if (this.states.length > 0) { + div.dataset.states = this.states.join(' '); + } else { + delete div.dataset.states; + } + } + } + if (this.forwardStates) { // apply states to children first const states = this.states.slice() as States; @@ -1622,7 +1636,12 @@ export class ElementNode { // L3 Inspector adds div to the lng object const div: HTMLElement | undefined = (node.lng as any)?.div; - if (div) div.element = node; + if (isDev && div) { + div.element = node; + if (node._states && node._states.length > 0) { + div.dataset.states = node._states.join(' '); + } + } if (node._type === NodeType.Element) { // only element nodes will have children that need rendering diff --git a/src/render.ts b/src/render.ts index d9bdebc..6cafad3 100644 --- a/src/render.ts +++ b/src/render.ts @@ -134,6 +134,7 @@ export function Dynamic>( case 'string': { const el = createElement(component); + (el as { componentName?: string }).componentName = component; spread(el, others); return el; }