diff --git a/packages/core/src/client/index.ts b/packages/core/src/client/index.ts index 294ebe63..a69ccd50 100644 --- a/packages/core/src/client/index.ts +++ b/packages/core/src/client/index.ts @@ -76,6 +76,204 @@ 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') && !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') && !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 && hasUnresolvedName(name)) { + const inner = resolveInnerComponentName(t); + 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 + ?? 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; +} + +// 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 []; + + 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 ?? current.return; + continue; + } + const domNode = (current.stateNode instanceof HTMLElement) + ? 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: layerPath, + line: layerLine, + column: layerColumn, + element: domNode || target, + }); + } + current = current._debugOwner ?? current.return; + } + + return layers; +} + export class CodeInspectorComponent extends LitElement { @property() hotKeys: string = 'shiftKey,altKey'; @@ -378,7 +576,7 @@ export class CodeInspectorComponent extends LitElement { }; // 渲染遮罩层 - renderCover = async (target: HTMLElement) => { + renderCover = async (target: HTMLElement, sourceInfo?: SourceInfo) => { if (target === this.targetNode) { return; } @@ -424,7 +622,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 } = @@ -710,6 +908,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) { @@ -739,7 +966,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) { @@ -754,8 +981,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(); } @@ -777,7 +1023,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) { @@ -828,7 +1074,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 }); @@ -836,8 +1082,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 +1116,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; @@ -1020,7 +1304,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(); @@ -1216,11 +1505,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/src/server/transform/transform-jsx.ts b/packages/core/src/server/transform/transform-jsx.ts index 5489a8da..bccddead 100644 --- a/packages/core/src/server/transform/transform-jsx.ts +++ b/packages/core/src/server/transform/transform-jsx.ts @@ -10,6 +10,24 @@ 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') { + // 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 ''; +} + export function transformJsx(content: string, filePath: string, escapeTags: EscapeTags) { const s = new MagicString(content); @@ -27,7 +45,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..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; @@ -163,6 +165,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..d128928a 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', @@ -90,11 +125,39 @@ 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; }, 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); + } + + // 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; diff --git a/test/core/server/transform/transform-jsx.test.ts b/test/core/server/transform/transform-jsx.test.ts index 67c86fa7..fc851f72 100644 --- a/test/core/server/transform/transform-jsx.test.ts +++ b/test/core/server/transform/transform-jsx.test.ts @@ -265,6 +265,53 @@ 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('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