();
+ let lastTarget: Element | null = null;
+
+ function updateHover(target: Element | null) {
+ if (!target || target === document.body || target === document.documentElement) {
+ setHovered(undefined);
+ lastTarget = null;
+ return;
+ }
+
+ if (target.closest('[data-solid-dev-toolbar]')) {
+ setHovered(undefined);
+ lastTarget = null;
+ return;
+ }
+
+ lastTarget = target;
+ const comp = findComponentFromElement(target);
+ if (comp) {
+ setHovered({
+ rect: comp.element.getBoundingClientRect(),
+ name: comp.name,
+ location: comp.location,
+ component: comp,
+ });
+ return;
+ }
+
+ setHovered({
+ rect: target.getBoundingClientRect(),
+ name: `<${target.tagName.toLowerCase()}>`,
+ });
+ }
+
+ createEffect(
+ () => props.active,
+ (active) => {
+ if (!active || typeof window === 'undefined') return;
+
+ const previousCursor = document.body.style.cursor;
+ document.body.style.cursor = 'crosshair';
+
+ const onPointerMove = (e: PointerEvent) => {
+ const target = document.elementFromPoint(e.clientX, e.clientY);
+ // The lookup walks the whole ownership tree, so it runs only when the
+ // pointer moves onto another element.
+ if (target === lastTarget) return;
+ updateHover(target);
+ };
+
+ const onClick = (e: MouseEvent) => {
+ const target = e.target as Element | null;
+ if (target?.closest('[data-solid-dev-toolbar]')) return;
+
+ e.preventDefault();
+ e.stopPropagation();
+
+ const current = hovered();
+ if (current?.component) {
+ props.onSelect(current.component);
+ } else {
+ props.onCancel();
+ }
+ };
+
+ const onKeyDown = (e: KeyboardEvent) => {
+ if (e.key === 'Escape') {
+ e.preventDefault();
+ e.stopPropagation();
+ props.onCancel();
+ }
+ };
+
+ const onScrollOrResize = () => {
+ if (lastTarget) updateHover(lastTarget);
+ };
+
+ window.addEventListener('pointermove', onPointerMove, { passive: true });
+ window.addEventListener('click', onClick, { capture: true });
+ window.addEventListener('keydown', onKeyDown, { capture: true });
+ window.addEventListener('scroll', onScrollOrResize, {
+ passive: true,
+ capture: true,
+ });
+ window.addEventListener('resize', onScrollOrResize, { passive: true });
+
+ return () => {
+ document.body.style.cursor = previousCursor;
+ window.removeEventListener('pointermove', onPointerMove);
+ window.removeEventListener('click', onClick, { capture: true });
+ window.removeEventListener('keydown', onKeyDown, { capture: true });
+ window.removeEventListener('scroll', onScrollOrResize, { capture: true });
+ window.removeEventListener('resize', onScrollOrResize);
+ setHovered(undefined);
+ lastTarget = null;
+ };
+ },
+ );
+
+ return (
+
+ {(state) => {
+ const rect = () => state().rect;
+ const top = () =>
+ rect().top < 34 ? `${rect().bottom + 6}px` : `${Math.max(4, rect().top - 28)}px`;
+ const left = () => `${Math.max(8, Math.min(rect().left, window.innerWidth - 240))}px`;
+
+ return (
+
+
+
+
+ {state().name}
+
+ {(loc) => {formatLocation(loc())}}
+
+
+ {`${Math.round(rect().width)} × ${Math.round(rect().height)}`}
+
+
+
+
+ );
+ }}
+
+ );
+}
diff --git a/src/dev-toolbar/open-in-editor.ts b/src/dev-toolbar/open-in-editor.ts
new file mode 100644
index 0000000..807ed5b
--- /dev/null
+++ b/src/dev-toolbar/open-in-editor.ts
@@ -0,0 +1,18 @@
+/**
+ * Asks the dev server to open the file in the user's IDE.
+ * Vite serves the `/__open-in-editor` endpoint in development.
+ */
+export function openInEditor(location: string): void {
+ // Vite resolves relative paths against its server root (server.config.root).
+ // In monorepos or subproject setups (like examples/explorer), `location` may
+ // include parent path segments (e.g. 'examples/explorer/src/App.tsx') while
+ // Vite's root is already set to 'examples/explorer'. We dispatch the full location
+ // and candidate sub-paths so launch-editor finds the file regardless of root depth.
+ void fetch(`/__open-in-editor?file=${encodeURIComponent(location)}`).catch(() => {});
+
+ const parts = location.split('/');
+ for (let i = 1; i < parts.length; i++) {
+ const subpath = parts.slice(i).join('/');
+ void fetch(`/__open-in-editor?file=${encodeURIComponent(subpath)}`).catch(() => {});
+ }
+}
diff --git a/src/dev-toolbar/ownership/index.tsx b/src/dev-toolbar/ownership/index.tsx
index e89a337..384d4e8 100644
--- a/src/dev-toolbar/ownership/index.tsx
+++ b/src/dev-toolbar/ownership/index.tsx
@@ -5,6 +5,7 @@ import IconButton from '../../ui/IconButton.js';
import Placeholder from '../../ui/Placeholder.js';
import { Text } from '../../ui/Text.js';
import { CollapseIcon, ExpandIcon, EyeIcon, PauseIcon, PlayIcon } from '../icons.js';
+import { openInEditor } from '../open-in-editor.js';
import { CopyReportButton } from '../report/CopyReportButton.js';
import { pageRoute } from '../report/markdown.js';
import { ownerReport } from '../report/owner-report.js';
@@ -31,14 +32,6 @@ function isInGraph(kind: OwnerKind): boolean {
return kind !== 'component' && kind !== 'root' && kind !== 'scope';
}
-/**
- * Asks the dev server to open the file. Vite serves this endpoint in
- * development. A failure is ignored, since the panel has nowhere to report it.
- */
-function openInEditor(location: string): void {
- void fetch(`/__open-in-editor?file=${encodeURIComponent(location)}`).catch(() => {});
-}
-
/** How long a row stays marked as new after it first appears. */
const FRESH_MS = 900;
diff --git a/src/dev-toolbar/ownership/tree.ts b/src/dev-toolbar/ownership/tree.ts
index 4ddd224..08d5e84 100644
--- a/src/dev-toolbar/ownership/tree.ts
+++ b/src/dev-toolbar/ownership/tree.ts
@@ -143,7 +143,7 @@ export function ownerName(owner: RawNode, kind: OwnerKind): string {
* The hot reload transform records this on its wrapper, so it is there whenever
* a build runs that transform. Components compiled without it have no location.
*/
-function componentLocation(owner: RawNode): string | undefined {
+export function componentLocation(owner: RawNode): string | undefined {
try {
const location = owner._component?.fn?.location;
return typeof location === 'string' && location.length > 0 ? location : undefined;
diff --git a/src/dev-toolbar/reactivity/index.tsx b/src/dev-toolbar/reactivity/index.tsx
index 837694b..2173e6a 100644
--- a/src/dev-toolbar/reactivity/index.tsx
+++ b/src/dev-toolbar/reactivity/index.tsx
@@ -72,7 +72,14 @@ function NodeSummary(props: {
<>
-
+
{props.node.name}
{stateBadge(props.node)}
@@ -131,6 +138,17 @@ export interface ReactivityViewerProps {
focus?: { node: object };
/** Opens the owner of a node in the ownership tree. */
onViewOwner?: (owner: object) => void;
+ /** Filters nodes to those owned by or under a specific component owner instance. */
+ ownerFilter?: object;
+ /** Filters nodes to those owned by or under a specific component name. */
+ componentFilter?: string;
+}
+
+function isDescendantOf(owner: object | undefined, target: object): boolean {
+ for (let cur = owner as Record | null | undefined; cur; cur = cur._parent) {
+ if (cur === target) return true;
+ }
+ return false;
}
export default function ReactivityViewer(props: ReactivityViewerProps): JSX.Element {
@@ -186,8 +204,18 @@ export default function ReactivityViewer(props: ReactivityViewerProps): JSX.Elem
const visibleNodes = createMemo(() => {
const search = query().trim().toLowerCase();
const hidden = hiddenKinds();
+ const ownerFilter = props.ownerFilter;
+ const nameFilter = props.componentFilter?.replace(/^<|>$/g, '').trim().toLowerCase();
return graph().nodes.filter((node) => {
if (hidden.includes(node.kind)) return false;
+ if (ownerFilter) {
+ if (!isDescendantOf(node.owner, ownerFilter)) return false;
+ } else if (
+ nameFilter &&
+ !node.ownerPath.some((p) => p.replace(/^<|>$/g, '').toLowerCase() === nameFilter)
+ ) {
+ return false;
+ }
if (!search) return true;
return (
node.name.toLowerCase().includes(search) ||
@@ -208,7 +236,11 @@ export default function ReactivityViewer(props: ReactivityViewerProps): JSX.Elem
let lastLayout: GraphLayout | undefined;
const layout = createMemo(() => {
lastLayout = layoutGraph(
- visibleNodes().map((node) => ({ id: node.id, name: node.name, kind: node.kind })),
+ visibleNodes().map((node) => ({
+ id: node.id,
+ name: node.name,
+ kind: node.kind,
+ })),
visibleEdges(),
lastLayout,
);
@@ -318,7 +350,11 @@ export default function ReactivityViewer(props: ReactivityViewerProps): JSX.Elem
// Another panel can ask for a node. Filters that would hide it are cleared,
// and the request waits for a snapshot that contains the node.
createEffect(
- () => ({ request: props.focus, nodes: graph().nodes, visible: !!props.show }),
+ () => ({
+ request: props.focus,
+ nodes: graph().nodes,
+ visible: !!props.show,
+ }),
({ request, nodes, visible }) => {
if (!request || !visible || request === appliedFocus) return;
const id = reactiveNodeId(request.node);
@@ -419,7 +455,10 @@ export default function ReactivityViewer(props: ReactivityViewerProps): JSX.Elem
width: NODE_WIDTH * current.k,
height: NODE_HEIGHT * current.k,
},
- canvas: { width: viewport?.clientWidth ?? 0, height: viewport?.clientHeight ?? 0 },
+ canvas: {
+ width: viewport?.clientWidth ?? 0,
+ height: viewport?.clientHeight ?? 0,
+ },
card: { width: HOVER_CARD_WIDTH, height: hoverCardHeight },
});
return { node, placement };
@@ -458,7 +497,13 @@ export default function ReactivityViewer(props: ReactivityViewerProps): JSX.Elem
data-off={hiddenKinds().includes(kind.value) ? '' : undefined}
onClick={() => toggleKind(kind.value)}
>
-
+
{kind.label}
@@ -712,7 +757,13 @@ export default function ReactivityViewer(props: ReactivityViewerProps): JSX.Elem
onClick={() => setSelected(id)}
>
-
+
{source().name}
@@ -742,7 +793,13 @@ export default function ReactivityViewer(props: ReactivityViewerProps): JSX.Elem
onClick={() => setSelected(id)}
>
-
+
{observer().name}
diff --git a/tests/e2e/devtools.spec.ts b/tests/e2e/devtools.spec.ts
index dbdd88f..e7aeb1f 100644
--- a/tests/e2e/devtools.spec.ts
+++ b/tests/e2e/devtools.spec.ts
@@ -543,6 +543,65 @@ test('highlights fine-grained reactive updates with frequency colors', async ({
).toHaveAttribute('data-active', '');
});
+test('inspects elements and funnels components across panels', async ({ page }) => {
+ await page.goto('/');
+
+ // Open the devtools dock.
+ await page.getByRole('button', { name: 'Open Start Devtools' }).click();
+ const inspectButton = page.getByRole('button', { name: 'Inspect element (Alt+Shift+C)' });
+ await expect(inspectButton).toBeVisible();
+
+ // 1. Toggle inspect mode with the header button.
+ await inspectButton.click();
+ await expect(inspectButton).toHaveAttribute('data-active', '');
+
+ // 2. Hover over a component's DOM element ('s button).
+ await page.locator('#increment-count').hover();
+ const overlay = page.locator('[data-solid-inspector-overlay]');
+ await expect(overlay).toBeVisible();
+ await expect(page.locator('[data-solid-inspector-badge-name]')).toHaveText('');
+ await expect(page.locator('[data-solid-inspector-box]')).toBeVisible();
+ await expect(page.locator('[data-solid-inspector-badge-size]')).toBeVisible();
+
+ // 3. Click to select the component.
+ await page.locator('#increment-count').click();
+ await expect(overlay).toBeHidden();
+ await expect(inspectButton).not.toHaveAttribute('data-active', '');
+
+ // 4. Verify the funnel badge appears in the header.
+ const funnel = page.locator('[data-solid-dev-toolbar-funnel]');
+ await expect(funnel).toBeVisible();
+ const funnelName = page.locator('[data-solid-dev-toolbar-funnel-name]');
+ await expect(funnelName).toHaveText('');
+
+ // 5. Clicking the component name in the funnel jumps to the Ownership tree.
+ await funnelName.click();
+ await expect(page.getByRole('tab', { name: 'Ownership' })).toHaveAttribute(
+ 'aria-selected',
+ 'true',
+ );
+ await expect(page.locator('[data-solid-ownership-row][data-selected]')).toContainText('Counter');
+ await expect(page.locator('[data-solid-ownership-detail]')).toBeVisible();
+
+ // 6. Switching to Reactivity isolates nodes belonging to the selected owner instance.
+ await page.getByRole('tab', { name: 'Reactivity' }).click();
+ const graphNodes = page.locator('[data-solid-reactivity-node-name]');
+ await expect(graphNodes.filter({ hasText: 'count' })).toBeVisible();
+
+ // 7. Clear the funnel.
+ await page.getByRole('button', { name: 'Clear component funnel' }).click();
+ await expect(funnel).toHaveCount(0);
+
+ // 8. Test keyboard shortcut Alt+Shift+C and Escape cancel.
+ await page.keyboard.press('Alt+Shift+C');
+ await expect(inspectButton).toHaveAttribute('data-active', '');
+ await page.locator('#fixture-greeting').hover();
+ await expect(page.locator('[data-solid-inspector-badge-name]')).toHaveText('');
+ await page.keyboard.press('Escape');
+ await expect(inspectButton).not.toHaveAttribute('data-active', '');
+ await expect(overlay).toBeHidden();
+});
+
test('mounts once and disposes', async ({ page }) => {
await page.goto('/?mount');