From 9745c0c4ec500c417fb8dbe88b910151e077855d Mon Sep 17 00:00:00 2001 From: Akshay Ashok Date: Thu, 26 Mar 2026 13:08:33 +0530 Subject: [PATCH 1/6] feat: show React component names in inspector tree & fix JSXMemberExpression MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What changed 1. **JSXMemberExpression support** (`transform-jsx.ts`) - ``, ``, `` etc. now correctly appear in `data-insp-path`. Previously only simple identifiers like `
` were captured; member expressions were silently dropped. 2. **React 19 jsx-dev-runtime patch** (`packages/vite`) - React 19 removed `_debugSource` from fibers (facebook/react#32574). The Vite plugin now patches `jsx-dev-runtime.js` at dev time to inject the `source` parameter into `_debugInfo`, restoring file/line/column info for the client to read. Supports React 19.0–19.2+. - Adapted from vite-plugin-react-click-to-component by ArnaudBarre. 3. **Fiber-based component tree in context menu** (`packages/core/client`) - The right-click "Click node to locate" panel now walks React's `_debugOwner` chain to show actual component names (e.g. ``, ``) instead of only DOM elements (`
`, ``). - Components from `node_modules` or with unresolvable names are filtered out. - Falls back to the original DOM-based tree for non-React apps. ## Before / After **Before:** tree shows mostly `
` tags with only a few component names that happened to have `data-insp-path`. **After:** tree shows the actual React component hierarchy with proper names, making it far easier to navigate to the right source file. Made-with: Cursor --- packages/core/src/client/index.ts | 167 +++++++++++++++++- .../src/server/transform/transform-jsx.ts | 18 +- packages/core/types/client/index.d.ts | 1 + packages/vite/src/index.ts | 40 +++++ .../server/transform/transform-jsx.test.ts | 37 ++++ 5 files changed, 261 insertions(+), 2 deletions(-) diff --git a/packages/core/src/client/index.ts b/packages/core/src/client/index.ts index 294ebe63..a15b5ade 100644 --- a/packages/core/src/client/index.ts +++ b/packages/core/src/client/index.ts @@ -76,6 +76,133 @@ function nextTick() { }); } +// --- React Fiber helpers (for component tree in context menu) --- + +type FiberSource = { + columnNumber?: number; + fileName: string; + lineNumber?: number; +}; + +type Fiber = { + _debugSource?: FiberSource; + _debugInfo?: FiberSource; // Injected by our jsx-dev-runtime patch for React 19 + _debugOwner?: Fiber; + type: string | { displayName?: string; name?: string; render?: { name?: string } }; + stateNode: any; + child?: Fiber; + sibling?: Fiber; + return?: Fiber; +}; + +function getReactFiber(element: Element): Fiber | undefined { + if ('__REACT_DEVTOOLS_GLOBAL_HOOK__' in window) { + const { renderers } = (window as any).__REACT_DEVTOOLS_GLOBAL_HOOK__; + for (const renderer of renderers.values()) { + try { + const fiber = renderer.findFiberByHostInstance(element); + if (fiber) return fiber; + } catch { + // React may be mid-render + } + } + } + for (const key in element) { + if (key.startsWith('__reactFiber')) return (element as any)[key]; + } + return undefined; +} + +function resolveInnerComponentName(type: any, depth = 0): string { + if (!type || depth > 5) return ''; + const name = type.displayName ?? type.name; + if (name && !name.includes('Unknown')) return name; + if (type.WrappedComponent) return resolveInnerComponentName(type.WrappedComponent, depth + 1); + if (type.type) return resolveInnerComponentName(type.type, depth + 1); + const renderName = type.render?.displayName ?? type.render?.name; + if (renderName && !renderName.includes('Unknown')) return renderName; + return ''; +} + +function getFiberComponentName(fiber: Fiber): string { + if (typeof fiber.type === 'string') return fiber.type; + const t = fiber.type as any; + let name = t?.displayName ?? t?.name; + + if (name?.includes('(Unknown)')) { + const inner = resolveInnerComponentName(t); + if (inner) name = name.replace(/Unknown/g, inner); + } + + return name + ?? t?.render?.displayName ?? t?.render?.name + ?? t?.type?.displayName ?? t?.type?.name + ?? 'Unknown'; +} + +function getFiberSource(fiber: Fiber): FiberSource | undefined { + return fiber._debugSource ?? fiber._debugInfo; +} + +function findFirstDomNode(fiber: Fiber): HTMLElement | null { + const root = fiber; + let current: Fiber | undefined = fiber.child; + while (current) { + if (current.stateNode instanceof HTMLElement) return current.stateNode; + if (current.child) { current = current.child; continue; } + while (current && current !== root && !current.sibling) { + current = current.return; + } + if (!current || current === root) break; + current = current.sibling; + } + return null; +} + +interface FiberLayer { + name: string; + path: string; + line: number; + column: number; + element: HTMLElement; +} + +function isValidSourcePath(fileName: string): boolean { + if (!fileName) return false; + if (fileName.includes('node_modules')) return false; + return true; +} + +function getLayersFromFiber(target: HTMLElement): FiberLayer[] { + const fiber = getReactFiber(target); + if (!fiber) return []; + + const layers: FiberLayer[] = []; + let current: Fiber | undefined = fiber; + + while (current) { + const source = getFiberSource(current); + if (source?.fileName && isValidSourcePath(source.fileName)) { + const name = getFiberComponentName(current); + if (name.includes('Unknown')) { current = current._debugOwner; continue; } + const domNode = (current.stateNode instanceof HTMLElement) + ? current.stateNode + : findFirstDomNode(current); + + layers.push({ + name, + path: source.fileName, + line: source.lineNumber ?? 1, + column: source.columnNumber ?? 1, + element: domNode || target, + }); + } + current = current._debugOwner; + } + + return layers; +} + export class CodeInspectorComponent extends LitElement { @property() hotKeys: string = 'shiftKey,altKey'; @@ -836,8 +963,15 @@ export class CodeInspectorComponent extends LitElement { }; generateNodeTree = (nodePath: HTMLElement[]): TreeNode => { - let root: TreeNode; + // Try fiber-based tree first (shows React component names) + const target = nodePath[0]; + if (target) { + const fiberTree = this.generateFiberNodeTree(target); + if (fiberTree) return fiberTree; + } + // Fallback: DOM-based tree using data-insp-path attributes + let root: TreeNode; let depth = 1; let preNode = null; @@ -863,6 +997,37 @@ export class CodeInspectorComponent extends LitElement { return root!; }; + generateFiberNodeTree = (target: HTMLElement): TreeNode | null => { + const layers = getLayersFromFiber(target); + if (layers.length === 0) return null; + + let root: TreeNode | null = null; + let preNode: TreeNode | null = null; + + // Layers are innermost-first, reverse for tree (outermost = root) + for (let i = layers.length - 1; i >= 0; i--) { + const layer = layers[i]; + const node: TreeNode = { + name: layer.name, + path: layer.path, + line: layer.line, + column: layer.column, + children: [], + element: layer.element, + depth: (layers.length - i), + }; + + if (preNode) { + preNode.children.push(node); + } else { + root = node; + } + preNode = node; + } + + return root; + }; + // disabled 的元素及其子元素无法触发 click 事件 handlePointerDown = (e: PointerEvent) => { let disabled = false; diff --git a/packages/core/src/server/transform/transform-jsx.ts b/packages/core/src/server/transform/transform-jsx.ts index 5489a8da..24fa23b8 100644 --- a/packages/core/src/server/transform/transform-jsx.ts +++ b/packages/core/src/server/transform/transform-jsx.ts @@ -10,6 +10,22 @@ import importMetaPlugin from '@babel/plugin-syntax-import-meta'; // @ts-expect-error - @babel/plugin-proposal-decorators doesn't provide TypeScript types import proposalDecorators from '@babel/plugin-proposal-decorators'; +function getJSXElementName(nameNode: any): string { + if (!nameNode) return ''; + if (nameNode.type === 'JSXIdentifier') { + return nameNode.name || ''; + } + if (nameNode.type === 'JSXMemberExpression') { + const objectName = getJSXElementName(nameNode.object); + const propertyName = nameNode.property?.name || ''; + return objectName ? `${objectName}.${propertyName}` : propertyName; + } + if (nameNode.type === 'JSXNamespacedName') { + return `${nameNode.namespace?.name || ''}:${nameNode.name?.name || ''}`; + } + return ''; +} + export function transformJsx(content: string, filePath: string, escapeTags: EscapeTags) { const s = new MagicString(content); @@ -27,7 +43,7 @@ export function transformJsx(content: string, filePath: string, escapeTags: Esca traverse(ast!, { enter({ node }: any) { - const nodeName = node?.openingElement?.name?.name || ''; + const nodeName = getJSXElementName(node?.openingElement?.name); const attributes = node?.openingElement?.attributes || []; if ( node.type === 'JSXElement' && diff --git a/packages/core/types/client/index.d.ts b/packages/core/types/client/index.d.ts index a0b5d986..9eef54bb 100644 --- a/packages/core/types/client/index.d.ts +++ b/packages/core/types/client/index.d.ts @@ -163,6 +163,7 @@ export declare class CodeInspectorComponent extends LitElement { handleMouseClick: (e: MouseEvent | TouchEvent) => void; handleContextMenu: (e: MouseEvent) => void; generateNodeTree: (nodePath: HTMLElement[]) => TreeNode; + generateFiberNodeTree: (target: HTMLElement) => TreeNode | null; handlePointerDown: (e: PointerEvent) => void; handleKeyUp: (e: KeyboardEvent) => void; printTip: () => void; diff --git a/packages/vite/src/index.ts b/packages/vite/src/index.ts index b3eae01a..a7cf44a8 100644 --- a/packages/vite/src/index.ts +++ b/packages/vite/src/index.ts @@ -13,6 +13,41 @@ import chalk from 'chalk'; const PluginName = '@code-inspector/vite'; +// Patch React 19's jsx-dev-runtime to restore source info on fibers. +// React 19 removed _debugSource (facebook/react#32574) but still receives `source` +// in jsxDEV(). This patch injects it into _debugInfo so the client can read it. +// Adapted from vite-plugin-react-click-to-component by ArnaudBarre. +function patchReact19JsxDevRuntime(code: string): string | undefined { + if (code.includes('_source')) return undefined; // React <19, already has source + + const defineIndex = code.indexOf('"_debugInfo"'); + if (defineIndex === -1) return undefined; + const valueIndex = code.indexOf('value: null', defineIndex); + if (valueIndex === -1) return undefined; + + let patched = + code.slice(0, valueIndex) + 'value: source' + code.slice(valueIndex + 11); + + // React 19.0-19.1: source is already a param of ReactElement + if (patched.includes('function ReactElement(type, key, self, source,')) { + return patched; + } + + // React 19.2+: source needs to be threaded through jsxDEV → jsxDEVImpl → ReactElement + patched = patched.replace( + /maybeKey,\s*isStaticChildren/g, + 'maybeKey, isStaticChildren, source', + ); + patched = patched.replace( + /(\w+)?,\s*debugStack,\s*debugTask/g, + (m: string, previousArg: string) => { + if (previousArg === 'source') return m; + return m.replace('debugTask', 'debugTask, source'); + }, + ); + return patched; +} + const OrderedPlugins = [ { name: 'vite:react-babel', @@ -95,6 +130,11 @@ export function ViteCodeInspectorPlugin(options: Options) { record.root = config.root; }, async transform(code: string, id: string) { + // Patch React 19 jsx-dev-runtime to re-inject source into _debugInfo + if (id.includes('jsx-dev-runtime') && id.includes('.js')) { + return patchReact19JsxDevRuntime(code); + } + if (isExcludedFile(id, options)) { return code; } diff --git a/test/core/server/transform/transform-jsx.test.ts b/test/core/server/transform/transform-jsx.test.ts index 67c86fa7..946f1248 100644 --- a/test/core/server/transform/transform-jsx.test.ts +++ b/test/core/server/transform/transform-jsx.test.ts @@ -265,6 +265,43 @@ function B() { return B; }`; }); }); + describe('JSXMemberExpression support', () => { + it('should handle JSXMemberExpression like Typography.H2', () => { + const content = 'function App() { return Hello; }'; + const result = transformJsx(content, filePath, defaultEscapeTags); + + expect(result).toContain(':Typography.H2"'); + }); + + it('should handle JSXMemberExpression like Page.Content', () => { + const content = 'function App() { return Hello; }'; + const result = transformJsx(content, filePath, defaultEscapeTags); + + expect(result).toContain(':Page.Content"'); + }); + + it('should handle self-closing JSXMemberExpression', () => { + const content = 'function App() { return ; }'; + const result = transformJsx(content, filePath, defaultEscapeTags); + + expect(result).toContain(':Icons.Close"'); + }); + + it('should handle deeply nested member expressions', () => { + const content = 'function App() { return Hello; }'; + const result = transformJsx(content, filePath, defaultEscapeTags); + + expect(result).toContain(':A.B.C"'); + }); + + it('should escape JSXMemberExpression tags via escapeTags', () => { + const content = 'function App() { return Hello; }'; + const result = transformJsx(content, filePath, ['Foo.Bar']); + + expect(result).not.toContain(':Foo.Bar"'); + }); + }); + describe('edge cases', () => { it('should handle elements without name (should not crash)', () => { // This tests that code handles nodeName being empty From 560f0305db6c51aa362d585c32c6244a9aed7adc Mon Sep 17 00:00:00 2001 From: Akshay Ashok Date: Thu, 26 Mar 2026 14:20:37 +0530 Subject: [PATCH 2/6] fix: handle Vite pre-bundled chunks for React 19 jsx-dev-runtime patch Vite pre-bundles dependencies into chunk files (e.g. chunk-XXXXX.js), so the jsx-dev-runtime code ends up in a chunk without "jsx-dev-runtime" in the filename. Add content-based fallback detection for these chunks by checking for "_debugInfo" and "value: null" in the code content. Made-with: Cursor --- packages/vite/src/index.ts | 23 +++++++++++++++++++++++ packages/vite/types/index.d.ts | 5 +++++ 2 files changed, 28 insertions(+) diff --git a/packages/vite/src/index.ts b/packages/vite/src/index.ts index a7cf44a8..d128928a 100644 --- a/packages/vite/src/index.ts +++ b/packages/vite/src/index.ts @@ -125,6 +125,17 @@ export function ViteCodeInspectorPlugin(options: Options) { apply(_, { command }) { return !options.close && isDev(options.dev, command === 'serve'); }, + config() { + // Exclude jsx-dev-runtime from Vite's dependency pre-bundling so the + // transform hook can intercept it by filename. Without this, Vite may + // bundle it into a chunk file (e.g. chunk-XXXXX.js) where the filename + // no longer contains "jsx-dev-runtime". + return { + optimizeDeps: { + exclude: ['react/jsx-dev-runtime'], + }, + }; + }, configResolved(config) { record.envDir = config.envDir || config.root; record.root = config.root; @@ -135,6 +146,18 @@ export function ViteCodeInspectorPlugin(options: Options) { return patchReact19JsxDevRuntime(code); } + // Fallback: Vite may still pre-bundle jsx-dev-runtime into chunk files + // (e.g. when another dep re-exports it). Detect via code content, + // but only for files in Vite's dep cache to avoid scanning user code. + if ( + id.includes('node_modules') && + code.includes('"_debugInfo"') && + code.includes('value: null') + ) { + const patched = patchReact19JsxDevRuntime(code); + if (patched) return patched; + } + if (isExcludedFile(id, options)) { return code; } diff --git a/packages/vite/types/index.d.ts b/packages/vite/types/index.d.ts index e6519e01..b17f268c 100644 --- a/packages/vite/types/index.d.ts +++ b/packages/vite/types/index.d.ts @@ -7,6 +7,11 @@ export declare function ViteCodeInspectorPlugin(options: Options): { apply(_: any, { command }: { command: any; }): boolean; + config(): { + optimizeDeps: { + exclude: string[]; + }; + }; configResolved(config: any): void; transform(code: string, id: string): Promise; transformIndexHtml(html: any): Promise; From 3d952bfa05f523ade8bac4421e7bd3caddde563a Mon Sep 17 00:00:00 2001 From: Akshay Ashok Date: Thu, 26 Mar 2026 14:31:58 +0530 Subject: [PATCH 3/6] fix: use dot separator for JSXNamespacedName to avoid breaking data-insp-path parsing data-insp-path uses colon as its delimiter (filePath:line:column:tagName). JSXNamespacedName produces names like `svg:xlink` which would break the client-side parser. Use dot separator instead (e.g. `svg.xlink`). Added a test to verify the separator is dot, not colon. Made-with: Cursor --- packages/core/src/server/transform/transform-jsx.ts | 4 +++- test/core/server/transform/transform-jsx.test.ts | 10 ++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/core/src/server/transform/transform-jsx.ts b/packages/core/src/server/transform/transform-jsx.ts index 24fa23b8..bccddead 100644 --- a/packages/core/src/server/transform/transform-jsx.ts +++ b/packages/core/src/server/transform/transform-jsx.ts @@ -21,7 +21,9 @@ function getJSXElementName(nameNode: any): string { return objectName ? `${objectName}.${propertyName}` : propertyName; } if (nameNode.type === 'JSXNamespacedName') { - return `${nameNode.namespace?.name || ''}:${nameNode.name?.name || ''}`; + // Use '.' instead of ':' to avoid breaking data-insp-path parsing + // which uses ':' as its delimiter (filePath:line:column:tagName) + return `${nameNode.namespace?.name || ''}.${nameNode.name?.name || ''}`; } return ''; } diff --git a/test/core/server/transform/transform-jsx.test.ts b/test/core/server/transform/transform-jsx.test.ts index 946f1248..fc851f72 100644 --- a/test/core/server/transform/transform-jsx.test.ts +++ b/test/core/server/transform/transform-jsx.test.ts @@ -302,6 +302,16 @@ function B() { return B; }`; }); }); + describe('JSXNamespacedName support', () => { + it('should use dot separator instead of colon to avoid breaking data-insp-path parsing', () => { + const content = 'function App() { return ; }'; + const result = transformJsx(content, filePath, defaultEscapeTags); + + expect(result).toContain(':svg.rect"'); + expect(result).not.toContain(':svg:rect"'); + }); + }); + describe('edge cases', () => { it('should handle elements without name (should not crash)', () => { // This tests that code handles nodeName being empty From 5948dae4d92fa1a872a59addc07cf1c94f9bfa7b Mon Sep 17 00:00:00 2001 From: Akshay Ashok Date: Tue, 31 Mar 2026 12:15:02 +0530 Subject: [PATCH 4/6] feat: support hover highlighting inside portals and modals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When elements are rendered inside React portals (e.g. modals, drawers), the hover highlight (Option+Shift) previously didn't work because: 1. Portal content is often rendered by library components (from node_modules) which don't have data-insp-path attributes 2. composedPath() may return the modal backdrop instead of the actual content when a backdrop overlay intercepts mouse events This commit fixes both issues: - Add fiber-based fallback in handleMouseMove: when no data-insp-path is found in the DOM path, fall back to getLayersFromFiber() to find the nearest user component with source info - Add elementFromPoint-based target detection: use document.elementFromPoint() to find the visually topmost element, bypassing backdrop overlays that intercept composedPath() - Extend renderCover to accept optional SourceInfo parameter so fiber- derived source info can be passed directly without requiring data-insp-path on the DOM element - Add project root inference to convert absolute fiber paths to relative paths matching data-insp-path format - Clean up "undefined" fragments in component displayNames (e.g. "WithFormField(Form.undefined)" → "WithFormField(Form)") - Add null safety for this.element in the render template Made-with: Cursor --- packages/core/src/client/index.ts | 124 +++++++++++++++++++++++--- packages/core/types/client/index.d.ts | 4 +- 2 files changed, 113 insertions(+), 15 deletions(-) diff --git a/packages/core/src/client/index.ts b/packages/core/src/client/index.ts index a15b5ade..817a6957 100644 --- a/packages/core/src/client/index.ts +++ b/packages/core/src/client/index.ts @@ -116,22 +116,30 @@ function getReactFiber(element: Element): Fiber | undefined { function resolveInnerComponentName(type: any, depth = 0): string { if (!type || depth > 5) return ''; const name = type.displayName ?? type.name; - if (name && !name.includes('Unknown')) return name; + if (name && !name.includes('Unknown') && !name.includes('undefined')) return name; if (type.WrappedComponent) return resolveInnerComponentName(type.WrappedComponent, depth + 1); if (type.type) return resolveInnerComponentName(type.type, depth + 1); const renderName = type.render?.displayName ?? type.render?.name; - if (renderName && !renderName.includes('Unknown')) return renderName; + if (renderName && !renderName.includes('Unknown') && !renderName.includes('undefined')) return renderName; return ''; } +function hasUnresolvedName(name: string): boolean { + return name.includes('(Unknown)') || name.includes('undefined'); +} + function getFiberComponentName(fiber: Fiber): string { if (typeof fiber.type === 'string') return fiber.type; const t = fiber.type as any; let name = t?.displayName ?? t?.name; - if (name?.includes('(Unknown)')) { + if (name && hasUnresolvedName(name)) { const inner = resolveInnerComponentName(t); - if (inner) name = name.replace(/Unknown/g, inner); + if (inner) { + name = name.replace(/Unknown/g, inner); + } + // Clean up remaining "undefined" fragments (e.g. "Form.undefined" → "Form") + name = name.replace(/\.undefined/g, ''); } return name @@ -173,6 +181,41 @@ function isValidSourcePath(fileName: string): boolean { return true; } +// Infer the project root by comparing an absolute fiber path against a +// relative data-insp-path from the same page. Cached after first lookup. +let _projectRoot: string | null | undefined; +function inferProjectRoot(): string | null { + if (_projectRoot !== undefined) return _projectRoot; + const el = document.querySelector(`[${PathName}]`); + if (!el) { _projectRoot = null; return null; } + const inspPath = el.getAttribute(PathName) || ''; + const relativePath = inspPath.split(':')[0]; + if (!relativePath || relativePath.startsWith('/')) { + _projectRoot = null; + return null; + } + const fiber = getReactFiber(el); + let cur: Fiber | undefined = fiber; + while (cur) { + const src = getFiberSource(cur); + if (src?.fileName && src.fileName.endsWith(relativePath)) { + _projectRoot = src.fileName.slice(0, -relativePath.length); + return _projectRoot; + } + cur = cur._debugOwner ?? cur.return; + } + _projectRoot = null; + return null; +} + +function toRelativePath(absolutePath: string): string { + const root = inferProjectRoot(); + if (root && absolutePath.startsWith(root)) { + return absolutePath.slice(root.length); + } + return absolutePath; +} + function getLayersFromFiber(target: HTMLElement): FiberLayer[] { const fiber = getReactFiber(target); if (!fiber) return []; @@ -191,7 +234,7 @@ function getLayersFromFiber(target: HTMLElement): FiberLayer[] { layers.push({ name, - path: source.fileName, + path: toRelativePath(source.fileName), line: source.lineNumber ?? 1, column: source.columnNumber ?? 1, element: domNode || target, @@ -505,7 +548,7 @@ export class CodeInspectorComponent extends LitElement { }; // 渲染遮罩层 - renderCover = async (target: HTMLElement) => { + renderCover = async (target: HTMLElement, sourceInfo?: SourceInfo) => { if (target === this.targetNode) { return; } @@ -551,7 +594,7 @@ export class CodeInspectorComponent extends LitElement { this.preUserSelect = getComputedStyle(document.body).userSelect; } document.body.style.userSelect = 'none'; - this.element = this.getSourceInfo(target)!; + this.element = sourceInfo || this.getSourceInfo(target)!; this.show = true; if (!this.showNodeTree) { const { vertical, horizon, additionStyle } = @@ -837,6 +880,35 @@ export class CodeInspectorComponent extends LitElement { } }; + // Get the DOM path from an element up to the document root. + // Used as a fallback when composedPath() doesn't reach the visually + // topmost element (e.g. inside portals/modals with backdrop overlays). + getNodePath = (element: HTMLElement | null): HTMLElement[] => { + const path: HTMLElement[] = []; + let current = element; + while (current) { + path.push(current); + current = current.parentElement; + } + return path; + }; + + // Get the effective target element at a mouse position. + // composedPath() returns elements from the event target up, but when a + // modal backdrop sits on top, the target is the backdrop — not the content. + // elementFromPoint returns the topmost visible element, which is the + // actual modal content the user sees and wants to inspect. + getEffectiveNodePath = (e: MouseEvent | TouchEvent): HTMLElement[] => { + const composedNodePath = e.composedPath() as HTMLElement[]; + if (e instanceof MouseEvent) { + const elementAtPoint = document.elementFromPoint(e.clientX, e.clientY) as HTMLElement | null; + if (elementAtPoint && elementAtPoint !== composedNodePath[0]) { + return this.getNodePath(elementAtPoint); + } + } + return composedNodePath; + }; + getValidNodeList = (nodePath: HTMLElement[]) => { const validNodeList: { node: HTMLElement; isAstro: boolean }[] = []; for (const node of nodePath) { @@ -866,7 +938,7 @@ export class CodeInspectorComponent extends LitElement { ((this.isTracking(e) && !this.dragging) || this.open) && !this.hoverSwitch ) { - const nodePath = e.composedPath() as HTMLElement[]; + const nodePath = this.getEffectiveNodePath(e); const validNodeList = this.getValidNodeList(nodePath); let targetNode; for (const { node, isAstro } of validNodeList) { @@ -881,8 +953,27 @@ export class CodeInspectorComponent extends LitElement { targetNode = node; } } + + // Fallback: when no data-insp-path is found (e.g. inside portals + // rendering library components), use the fiber tree to find the + // nearest user component with source info to highlight. + let fiberSourceInfo: SourceInfo | undefined; + if (!targetNode && nodePath[0]) { + const layers = getLayersFromFiber(nodePath[0]); + if (layers.length > 0) { + const layer = layers[0]; + targetNode = layer.element; + fiberSourceInfo = { + name: layer.name, + path: layer.path, + line: layer.line, + column: layer.column, + }; + } + } + if (targetNode) { - this.renderCover(targetNode); + this.renderCover(targetNode, fiberSourceInfo); } else { this.removeCover(); } @@ -904,7 +995,7 @@ export class CodeInspectorComponent extends LitElement { this.wheelThrottling = true; - const nodePath = e.composedPath() as HTMLElement[]; + const nodePath = this.getEffectiveNodePath(e); const validNodeList = this.getValidNodeList(nodePath); let targetNodeIndex = validNodeList.findIndex(({ node }) => node === this.targetNode); if (targetNodeIndex === -1) { @@ -955,7 +1046,7 @@ export class CodeInspectorComponent extends LitElement { !this.hoverSwitch ) { e.preventDefault(); - const nodePath = e.composedPath() as HTMLElement[]; + const nodePath = this.getEffectiveNodePath(e); const nodeTree = this.generateNodeTree(nodePath); this.renderLayerPanel(nodeTree, { x: e.clientX, y: e.clientY }); @@ -1185,7 +1276,12 @@ export class CodeInspectorComponent extends LitElement { class: 'tooltip-top', }; - this.renderCover(node.element); + this.renderCover(node.element, { + name: node.name, + path: node.path, + line: node.line, + column: node.column, + }); await nextTick(); const { y: tooltipY } = this.nodeTreeTooltipRef!.getBoundingClientRect(); @@ -1381,11 +1477,11 @@ export class CodeInspectorComponent extends LitElement {
- <${this.element.name}> + <${this.element?.name ?? 'unknown'}>
- ${this.element.path}:${this.element.line}:${this.element.column} + ${this.element?.path ?? ''}:${this.element?.line ?? ''}:${this.element?.column ?? ''}
diff --git a/packages/core/types/client/index.d.ts b/packages/core/types/client/index.d.ts index 9eef54bb..8dcdd58b 100644 --- a/packages/core/types/client/index.d.ts +++ b/packages/core/types/client/index.d.ts @@ -133,7 +133,7 @@ export declare class CodeInspectorComponent extends LitElement { transform: string; }; }>; - renderCover: (target: HTMLElement) => Promise; + renderCover: (target: HTMLElement, sourceInfo?: SourceInfo) => Promise; getAstroFilePath: (target: HTMLElement) => string; getSourceInfo: (target: HTMLElement) => SourceInfo | null; removeCover: (force?: boolean | MouseEvent) => void; @@ -153,6 +153,8 @@ export declare class CodeInspectorComponent extends LitElement { copyToClipboard(text: string): void; private fallbackCopy; handleDrag: (e: MouseEvent | TouchEvent) => void; + getNodePath: (element: HTMLElement | null) => HTMLElement[]; + getEffectiveNodePath: (e: MouseEvent | TouchEvent) => HTMLElement[]; getValidNodeList: (nodePath: HTMLElement[]) => { node: HTMLElement; isAstro: boolean; From 082ccbd6d2c625584fb0289e820a0bfa224b36ef Mon Sep 17 00:00:00 2001 From: Akshay Ashok Date: Wed, 1 Apr 2026 15:16:43 +0530 Subject: [PATCH 5/6] fix: use _debugOwner ?? return fallback in fiber tree walk getLayersFromFiber() previously only walked via _debugOwner, which can be unset on host fibers (e.g. plain div elements). This caused the traversal to stop immediately, returning no layers and breaking the portal hover fallback. Now falls back to fiber.return when _debugOwner is absent, matching the pattern already used in inferProjectRoot(). Added a visited set guard to prevent infinite loops. Made-with: Cursor --- packages/core/src/client/index.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/core/src/client/index.ts b/packages/core/src/client/index.ts index 817a6957..3f3290e5 100644 --- a/packages/core/src/client/index.ts +++ b/packages/core/src/client/index.ts @@ -223,11 +223,18 @@ function getLayersFromFiber(target: HTMLElement): FiberLayer[] { const layers: FiberLayer[] = []; let current: Fiber | undefined = fiber; + const visited = new Set(); while (current) { + if (visited.has(current)) break; + visited.add(current); + const source = getFiberSource(current); if (source?.fileName && isValidSourcePath(source.fileName)) { const name = getFiberComponentName(current); - if (name.includes('Unknown')) { current = current._debugOwner; continue; } + if (name.includes('Unknown')) { + current = current._debugOwner ?? current.return; + continue; + } const domNode = (current.stateNode instanceof HTMLElement) ? current.stateNode : findFirstDomNode(current); @@ -240,7 +247,7 @@ function getLayersFromFiber(target: HTMLElement): FiberLayer[] { element: domNode || target, }); } - current = current._debugOwner; + current = current._debugOwner ?? current.return; } return layers; From fc0e110583c8527442a890f1f0deac82f57b1d62 Mon Sep 17 00:00:00 2001 From: Akshay Ashok Date: Wed, 1 Apr 2026 16:54:56 +0530 Subject: [PATCH 6/6] feat: prefer data-insp-path over _debugSource for fiber layer navigation When building fiber layers, _debugSource points to the call site (e.g. withSpinner.func.tsx where is rendered), not the component's definition file. This is confusing when clicking a component in the tree. Now checks if the fiber's DOM node has a data-insp-path attribute and prefers its file path, which points to where the component's render output is actually defined. This fixes navigation for all HOC-wrapped components (withSpinner, connect, memo, forwardRef, etc.). Made-with: Cursor --- packages/core/src/client/index.ts | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/packages/core/src/client/index.ts b/packages/core/src/client/index.ts index 3f3290e5..a69ccd50 100644 --- a/packages/core/src/client/index.ts +++ b/packages/core/src/client/index.ts @@ -239,11 +239,32 @@ function getLayersFromFiber(target: HTMLElement): FiberLayer[] { ? current.stateNode : findFirstDomNode(current); + // Prefer data-insp-path from rendered DOM (points to definition file) + // over _debugSource (points to call site / HOC wrapper). + let layerPath = toRelativePath(source.fileName); + let layerLine = source.lineNumber ?? 1; + let layerColumn = source.columnNumber ?? 1; + + const inspAttr = domNode?.getAttribute?.(PathName); + if (inspAttr) { + const segments = inspAttr.split(':'); + if (segments.length >= 4) { + const inspFile = segments.slice(0, segments.length - 3).join(':'); + const inspLine = Number(segments[segments.length - 3]); + const inspColumn = Number(segments[segments.length - 2]); + if (inspFile && isValidSourcePath(inspFile)) { + layerPath = inspFile; + layerLine = inspLine || layerLine; + layerColumn = inspColumn || layerColumn; + } + } + } + layers.push({ name, - path: toRelativePath(source.fileName), - line: source.lineNumber ?? 1, - column: source.columnNumber ?? 1, + path: layerPath, + line: layerLine, + column: layerColumn, element: domNode || target, }); }