Skip to content

feat: support hover highlighting inside portals and modals#509

Open
Akshay090 wants to merge 6 commits intozh-lx:mainfrom
Akshay090:feat/portal-hover-support
Open

feat: support hover highlighting inside portals and modals#509
Akshay090 wants to merge 6 commits intozh-lx:mainfrom
Akshay090:feat/portal-hover-support

Conversation

@Akshay090
Copy link
Copy Markdown

@Akshay090 Akshay090 commented Mar 31, 2026

Problem

When elements are rendered inside React portals (e.g. modals, drawers, popovers), the hover highlight (Option+Shift hover) doesn't work. The right-click component tree works fine because it uses the fiber-based approach from #506, but hover highlighting fails for two reasons:

  1. No data-insp-path on portal content: Portal content is often rendered by library components (from node_modules) which are excluded from the JSX transform. The hover handler (handleMouseMove) relies entirely on data-insp-path to find elements, so it finds nothing inside portals.

  2. composedPath() returns backdrop element: When a modal has a backdrop overlay, composedPath() returns the backdrop div as the event target rather than the actual modal content the user sees.

Solution

1. Fiber-based fallback for hover (handleMouseMove)

When getValidNodeList returns empty (no data-insp-path found in the DOM path), fall back to getLayersFromFiber() — the same fiber tree infrastructure used by the right-click context menu — to find the nearest user component with source info and a highlightable DOM node.

2. elementFromPoint for effective target detection

Added getEffectiveNodePath() which uses document.elementFromPoint(e.clientX, e.clientY) to find the visually topmost element at the mouse coordinates, bypassing transparent backdrop overlays. Falls back to composedPath() when both return the same element.

3. renderCover accepts optional SourceInfo

Extended renderCover to accept an optional SourceInfo parameter. When the fiber fallback is used, source info (name, path, line, column) is passed directly — avoiding the crash that occurred when trying to parse data-insp-path from a DOM element that doesn't have it.

4. Relative path inference for fiber paths

React's _debugSource.fileName stores absolute paths, but data-insp-path uses relative paths (when pathType: 'relative'). Added inferProjectRoot() which detects the project root by comparing a relative data-insp-path with the absolute fiber path from the same element, then toRelativePath() strips the prefix. Result is cached after first lookup.

5. Component name cleanup

Cleaned up undefined fragments in component displayName values. For example, when a HOC wraps an anonymous component: WithFormField(Form.undefined)WithFormField(Form).

Testing

Tested on a large React 19 + Vite application with design system modals:

  • ✅ Hover highlighting works on elements inside modals/portals
  • ✅ Right-click component tree continues to work
  • ✅ Regular (non-portal) hover highlighting unaffected
  • ✅ Tooltip shows relative paths and cleaned-up component names
  • ✅ Click-to-source works correctly from portal elements

Dependencies

Note: This PR depends on #506 (fiber-based component tree & React 19 support). Please merge #506 first, then this PR will have a clean diff showing only the portal hover changes.

…ression

## What changed

1. **JSXMemberExpression support** (`transform-jsx.ts`)
   - `<Typography.H2>`, `<Page.Content>`, `<Icons.Close />` etc. now correctly
     appear in `data-insp-path`. Previously only simple identifiers like `<div>`
     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. `<Dashboard>`,
     `<UserProfile>`) instead of only DOM elements (`<div>`, `<span>`).
   - 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 `<div>` 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
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
…nsp-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
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
@qodo-free-for-open-source-projects
Copy link
Copy Markdown

Review Summary by Qodo

Support hover highlighting inside portals and modals with fiber-based fallback

✨ Enhancement

Grey Divider

Walkthroughs

Description
• Add fiber-based hover highlighting for React portals and modals
• Support JSXMemberExpression and JSXNamespacedName in JSX transform
• Patch React 19 jsx-dev-runtime to restore source info on fibers
• Implement effective target detection using elementFromPoint for overlays
• Add component name cleanup and relative path inference for fiber sources
Diagram
flowchart LR
  A["Mouse Event"] --> B["getEffectiveNodePath"]
  B --> C["elementFromPoint for topmost element"]
  C --> D["getValidNodeList with data-insp-path"]
  D --> E{Found valid node?}
  E -->|Yes| F["renderCover with DOM path"]
  E -->|No| G["getLayersFromFiber fallback"]
  G --> H["Extract React component info"]
  H --> I["renderCover with fiber SourceInfo"]
  J["React 19 jsx-dev-runtime"] --> K["Patch _debugInfo with source"]
  K --> L["Client reads source info"]
  L --> M["Component tree generation"]
Loading

Grey Divider

File Changes

1. packages/core/src/client/index.ts ✨ Enhancement +271/-10

Add fiber-based hover and tree generation for portals

• Add React fiber helper functions to extract component names and source info from fiber tree
• Implement getEffectiveNodePath() using elementFromPoint() to bypass modal backdrops
• Add fiber-based fallback in handleMouseMove() when no data-insp-path found
• Create generateFiberNodeTree() for fiber-based component tree in context menu
• Add project root inference and relative path conversion for fiber sources
• Support optional SourceInfo parameter in renderCover() for fiber-extracted data

packages/core/src/client/index.ts


2. packages/core/src/server/transform/transform-jsx.ts ✨ Enhancement +19/-1

Support JSXMemberExpression and JSXNamespacedName in transform

• Add getJSXElementName() helper to handle JSXIdentifier, JSXMemberExpression, and
 JSXNamespacedName
• Support member expressions like Typography.H2, Page.Content, Icons.Close
• Use dot separator for JSXNamespacedName instead of colon to avoid breaking data-insp-path parsing
• Update JSX element name extraction to use new helper function

packages/core/src/server/transform/transform-jsx.ts


3. packages/vite/src/index.ts ✨ Enhancement +63/-0

Patch React 19 jsx-dev-runtime to restore source info

• Add patchReact19JsxDevRuntime() function to inject source into _debugInfo for React 19
• Handle React 19.0-19.1 and 19.2+ parameter threading differences
• Add config() hook to exclude jsx-dev-runtime from Vite pre-bundling
• Implement content-based fallback detection for pre-bundled chunks
• Patch both direct jsx-dev-runtime files and pre-bundled chunk files

packages/vite/src/index.ts


View more (3)
4. packages/core/types/client/index.d.ts 📝 Documentation +4/-1

Update type declarations for new methods

• Update renderCover() signature to accept optional SourceInfo parameter
• Add type declarations for getNodePath() and getEffectiveNodePath() methods
• Add type declaration for generateFiberNodeTree() method

packages/core/types/client/index.d.ts


5. packages/vite/types/index.d.ts 📝 Documentation +5/-0

Add config method type declaration

• Add config() method declaration returning optimizeDeps configuration

packages/vite/types/index.d.ts


6. test/core/server/transform/transform-jsx.test.ts 🧪 Tests +47/-0

Add tests for JSXMemberExpression and JSXNamespacedName

• Add test suite for JSXMemberExpression support with various nesting levels
• Add test suite for JSXNamespacedName with dot separator validation
• Test escape tags functionality for member expressions
• Verify data-insp-path format correctness

test/core/server/transform/transform-jsx.test.ts


Grey Divider

Qodo Logo

@qodo-free-for-open-source-projects
Copy link
Copy Markdown

qodo-free-for-open-source-projects bot commented Mar 31, 2026

Code Review by Qodo

🐞 Bugs (3) 📘 Rule violations (0) 📎 Requirement gaps (0)

Grey Divider


Action required

1. Fiber walk stops early 🐞 Bug ≡ Correctness
Description
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.
Code

packages/core/src/client/index.ts[R226-244]

+  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: toRelativePath(source.fileName),
+        line: source.lineNumber ?? 1,
+        column: source.columnNumber ?? 1,
+        element: domNode || target,
+      });
+    }
+    current = current._debugOwner;
+  }
Evidence
The PR already acknowledges _debugOwner may be absent by falling back to cur.return in
inferProjectRoot(), but getLayersFromFiber() does not apply the same fallback and therefore can stop
before reaching fibers that actually carry source info.

packages/core/src/client/index.ts[187-209]
packages/core/src/client/index.ts[219-246]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### 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



Remediation recommended

2. Root inference ignores hidden paths 🐞 Bug ≡ Correctness
Description
inferProjectRoot() searches only for elements with the data-insp-path *attribute*, but the
hideDomPathAttr feature removes that attribute and stores the value on node[PathName], causing
inferProjectRoot() to cache null and preventing fiber _debugSource.fileName paths from being
normalized to relative as intended.
Code

packages/core/src/client/index.ts[R187-196]

+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;
+  }
Evidence
inferProjectRoot() uses document.querySelector([data-insp-path]), but hideDomPathAttr actively
removes that attribute from all elements and only preserves the value on a JS property
(node[PathName]), which CSS selectors cannot match.

packages/core/src/client/index.ts[184-209]
packages/core/src/server/use-client.ts[220-236]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`inferProjectRoot()` relies on selecting an element by the `data-insp-path` attribute. When `hideDomPathAttr` is enabled, the runtime snippet removes that attribute and stores the value on `node[PathName]`, so `inferProjectRoot()` fails to find a sample element and permanently caches `null`.

### Issue Context
`toRelativePath()` depends on `inferProjectRoot()` to convert fiber absolute paths to the same relative format shown elsewhere.

### Fix Focus Areas
- packages/core/src/client/index.ts[184-217]
- packages/core/src/server/use-client.ts[220-236]

### What to change (choose one)
1) **Client-side fallback:** if `querySelector([PathName])` returns null, scan a small bounded subset of nodes (e.g. first N elements under `document.body`) and find one where `(node as any)[PathName]` is set.
2) **Server snippet support:** when hiding attributes, also stash one sample insp path somewhere queryable (e.g. `document.documentElement.setAttribute('data-insp-path-sample', value)` or `window.__code_inspector_insp_path_sample = value`) and read that from `inferProjectRoot()`.

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


3. Vite d.ts return mismatch 🐞 Bug ⚙ Maintainability
Description
The Vite plugin’s transform() can return undefined (e.g. when patchReact19JsxDevRuntime()
decides not to patch), but packages/vite/types/index.d.ts declares `transform(...):
Promise<string>`, which is incorrect for TypeScript consumers.
Code

packages/vite/types/index.d.ts[R10-17]

+    config(): {
+        optimizeDeps: {
+            exclude: string[];
+        };
+    };
    configResolved(config: any): void;
    transform(code: string, id: string): Promise<string>;
    transformIndexHtml(html: any): Promise<any>;
Evidence
patchReact19JsxDevRuntime() explicitly returns string | undefined, and transform() returns its
result directly for jsx-dev-runtime modules. The published declaration file still requires a
string Promise return.

packages/vite/src/index.ts[16-49]
packages/vite/src/index.ts[143-159]
packages/vite/types/index.d.ts[6-21]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
The Vite plugin implementation can return `undefined` from `transform()`, but the `.d.ts` contract says it always returns `Promise<string>`. This creates TS type errors for consumers.

### Issue Context
Vite plugin hooks allow returning `undefined`/`null` to signal "no transform".

### Fix Focus Areas
- packages/vite/types/index.d.ts[6-21]
- packages/vite/src/index.ts[143-159]

### What to change
- Update the declaration to `transform(code: string, id: string): Promise<string | undefined>` (or `Promise<string | void>`).
- Optionally align the whole return type with Vite’s `Plugin` type instead of a handwritten object shape.

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


Grey Divider

ⓘ The new review experience is currently in Beta. Learn more

Grey Divider

Qodo Logo

Comment on lines +226 to +244
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: toRelativePath(source.fileName),
line: source.lineNumber ?? 1,
column: source.columnNumber ?? 1,
element: domNode || target,
});
}
current = current._debugOwner;
}
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

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
When building fiber layers, _debugSource points to the call site (e.g.
withSpinner.func.tsx where <Component> 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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant