Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
309 changes: 299 additions & 10 deletions packages/core/src/client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Fiber>();
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;
}
Comment on lines +227 to +272
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. Fiber walk stops early 🐞 Bug ≡ Correctness

getLayersFromFiber() only walks via _debugOwner, so if _debugOwner is missing on the
starting/host fiber the traversal terminates immediately and the new portal hover fallback (and
fiber-based node tree) can return no layers even when an owner chain exists.
Agent Prompt
### Issue description
`getLayersFromFiber()` climbs only via `current._debugOwner`. When `_debugOwner` is unset on the starting fiber, traversal ends and `layers` becomes empty, breaking the new hover fallback inside portals/modals and the fiber-based context menu tree.

### Issue Context
`inferProjectRoot()` already uses `_debugOwner ?? return`, indicating the codebase expects `_debugOwner` can be missing.

### Fix Focus Areas
- packages/core/src/client/index.ts[219-246]
- packages/core/src/client/index.ts[187-209]

### What to change
- In `getLayersFromFiber()`, advance `current` using `current._debugOwner ?? current.return` (similar to `inferProjectRoot`).
- Add a small guard against non-progressing loops (optional): if neither pointer changes, break.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


return layers;
}

export class CodeInspectorComponent extends LitElement {
@property()
hotKeys: string = 'shiftKey,altKey';
Expand Down Expand Up @@ -378,7 +576,7 @@ export class CodeInspectorComponent extends LitElement {
};

// 渲染遮罩层
renderCover = async (target: HTMLElement) => {
renderCover = async (target: HTMLElement, sourceInfo?: SourceInfo) => {
if (target === this.targetNode) {
return;
}
Expand Down Expand Up @@ -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 } =
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand All @@ -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();
}
Expand All @@ -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) {
Expand Down Expand Up @@ -828,16 +1074,23 @@ 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 });
}
};

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;

Expand All @@ -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;
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -1216,11 +1505,11 @@ export class CodeInspectorComponent extends LitElement {
<div class="element-info-content">
<div class="name-line">
<div class="element-name">
<span class="element-title">&lt;${this.element.name}&gt;</span>
<span class="element-title">&lt;${this.element?.name ?? 'unknown'}&gt;</span>
</div>
</div>
<div class="path-line">
${this.element.path}:${this.element.line}:${this.element.column}
${this.element?.path ?? ''}:${this.element?.line ?? ''}:${this.element?.column ?? ''}
</div>
</div>
</div>
Expand Down
Loading