Skip to content
Merged
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
12 changes: 12 additions & 0 deletions .changeset/element-inspector.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@solidjs/start-devtools': minor
---

Add interactive Element Inspector and cross-panel component funneling:

- Press `Alt+Shift+C` or click the crosshair icon in the dock header to inspect DOM elements.
- Hovering elements shows an overlay with the component name, file location, and dimensions.
- Clicking an element selects the component and funnels relevant data across devtool panels without interrupting your active view.
- Reactivity graph filters to reactive signals, memos, and computations owned by the selected component instance.
- Ownership tree automatically expands, scrolls to, and highlights the selected component.
- The funnel badge in the dock header provides quick navigation to the Ownership tree, direct "See in editor" launch, and 1-click filter clearing.
17 changes: 17 additions & 0 deletions src/dev-toolbar/icons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -743,3 +743,20 @@ export function CheckIcon(props: JSX.IntrinsicElements['svg'] & { title: string
</svg>
);
}

export function InspectIcon(props: JSX.IntrinsicElements['svg'] & { title: string }): JSX.Element {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
{...props}
>
<title>{props.title}</title>
<circle cx="12" cy="12" r="1" />
<circle cx="12" cy="12" r="7" stroke-width="1.6" />
<path stroke-linecap="round" stroke-width="1.6" d="M12 2v3m0 14v3M2 12h3m14 0h3" />
</svg>
);
}
82 changes: 82 additions & 0 deletions src/dev-toolbar/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,88 @@
font-size: 0.75rem;
}

[data-solid-dev-toolbar-funnel] {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.125rem 0.5rem;
border-radius: 9999px;
background: var(--start-dt-accent-soft);
border: 1px solid var(--start-dt-border-soft);
font-size: 0.75rem;
line-height: 1.25rem;
white-space: nowrap;
}

[data-solid-dev-toolbar-funnel-label] {
color: var(--start-dt-text-muted);
font-size: 0.6875rem;
text-transform: uppercase;
letter-spacing: 0.04em;
}

[data-solid-dev-toolbar-funnel-name] {
background: none;
border: none;
padding: 0;
font: inherit;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-weight: 600;
color: var(--start-dt-accent);
cursor: pointer;
text-decoration: underline;
text-decoration-style: dotted;
text-underline-offset: 2px;
}

[data-solid-dev-toolbar-funnel-name]:hover {
text-decoration-style: solid;
}

[data-solid-dev-toolbar-funnel-action] {
background: none;
border: none;
padding: 0 0.25rem;
font: inherit;
font-size: 0.6875rem;
color: var(--start-dt-text);
cursor: pointer;
text-decoration: underline;
opacity: 0.85;
}

[data-solid-dev-toolbar-funnel-action]:hover {
opacity: 1;
color: var(--start-dt-accent);
}

[data-solid-dev-toolbar-funnel-clear] {
display: flex;
align-items: center;
justify-content: center;
width: 1rem;
height: 1rem;
border-radius: 9999px;
background: none;
border: none;
padding: 0;
font: inherit;
font-size: 0.8125rem;
line-height: 1;
color: var(--start-dt-text-muted);
cursor: pointer;
}

[data-solid-dev-toolbar-funnel-clear]:hover {
color: var(--start-dt-text);
background: var(--start-dt-surface-hover);
}

[data-solid-icon-button][data-active] {
color: var(--start-dt-accent);
background-color: var(--start-dt-accent-soft);
}

[data-solid-dev-toolbar-spacer] {
flex: 1;
}
Expand Down
86 changes: 86 additions & 0 deletions src/dev-toolbar/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,15 @@ import {
FunctionIcon,
GraphIcon,
HighlightUpdatesIcon,
InspectIcon,
MinimizeIcon,
SolidIcon,
TreeIcon,
} from './icons.js';
import { HighlightUpdatesOverlay } from './highlight-updates/overlay.js';
import { installElementTracking, type InspectedComponent } from './inspector/lookup.js';
import { InspectorOverlay } from './inspector/overlay.js';
import { openInEditor } from './open-in-editor.js';
import { excludeOwner, includeOwner } from './ownership/registry.js';
import { excludeReactiveOwner, includeReactiveOwner } from './reactivity/registry.js';
import './index.css';
Expand Down Expand Up @@ -129,6 +133,27 @@ export function DevToolbar(props: DevToolbarProps) {
const [height, setHeight] = createSignal(DEFAULT_HEIGHT);
const [highlightUpdates, setHighlightUpdates] = createSignal(false);
const [route, setRoute] = createSignal('');
const [inspecting, setInspecting] = createSignal(false);
const [selectedComponent, setSelectedComponent] = createSignal<InspectedComponent | undefined>();

function toggleInspect() {
setInspecting((current) => !current);
}

function selectComponent(comp: InspectedComponent) {
setInspecting(false);
setSelectedComponent(comp);
setOwnershipFocus({ owner: comp.owner });
if (!open()) {
setOpen(true);
persist({ open: true });
}
}

function clearComponentFunnel() {
setSelectedComponent(undefined);
}

// The dock keeps the height the user chose, and shows at most 90% of the
// window, so a smaller window shrinks it without losing that choice.
const [maxHeight, setMaxHeight] = createSignal(Infinity);
Expand Down Expand Up @@ -251,6 +276,10 @@ export function DevToolbar(props: DevToolbarProps) {
window.addEventListener('pointercancel', end);
}

onSettled(() => {
installElementTracking();
});

onSettled(() => {
const onKeyDown = (event: KeyboardEvent) => {
// `code`, because Alt changes the character `key` reports on macOS.
Expand All @@ -268,6 +297,9 @@ export function DevToolbar(props: DevToolbarProps) {
persist({ highlightUpdates: next });
return next;
});
} else if (event.code === 'KeyC') {
event.preventDefault();
toggleInspect();
}
};
window.addEventListener('keydown', onKeyDown);
Expand Down Expand Up @@ -416,7 +448,54 @@ export function DevToolbar(props: DevToolbarProps) {
<span data-solid-dev-toolbar-route title="Current route">
{route()}
</span>
<Show when={selectedComponent()}>
{(comp) => (
<div data-solid-dev-toolbar-funnel>
<span data-solid-dev-toolbar-funnel-label>Inspecting</span>
<button
type="button"
data-solid-dev-toolbar-funnel-name
title={`View ${comp().name} in ownership tree`}
onClick={() => {
setOwnershipFocus({ owner: comp().owner });
show('own');
}}
>
{comp().name}
</button>
<Show when={comp().location}>
{(loc) => (
<button
type="button"
data-solid-dev-toolbar-funnel-action
title={`Open ${loc()} in editor`}
onClick={() => openInEditor(loc())}
>
Open in editor
</button>
)}
</Show>
<button
type="button"
data-solid-dev-toolbar-funnel-clear
title="Clear component funnel"
aria-label="Clear component funnel"
onClick={clearComponentFunnel}
>
×
</button>
</div>
)}
</Show>
<div data-solid-dev-toolbar-spacer />
<IconButton
onClick={toggleInspect}
data-active={inspecting() ? '' : undefined}
aria-label="Inspect element (Alt+Shift+C)"
title="Inspect element (Alt+Shift+C)"
>
<InspectIcon title="Inspect element (Alt+Shift+C)" />
</IconButton>
<IconButton
title="Highlight updates (Alt+Shift+H)"
aria-label="Highlight updates (Alt+Shift+H)"
Expand Down Expand Up @@ -493,6 +572,8 @@ export function DevToolbar(props: DevToolbarProps) {
<ReactivityViewer
show={visible('rx')}
focus={graphFocus()}
ownerFilter={selectedComponent()?.owner}
componentFilter={selectedComponent()?.name}
onViewOwner={(owner) => {
setOwnershipFocus({ owner });
show('own');
Expand Down Expand Up @@ -522,6 +603,11 @@ export function DevToolbar(props: DevToolbarProps) {
</div>
</div>
<HighlightUpdatesOverlay active={highlightUpdates()} />
<InspectorOverlay
active={inspecting()}
onSelect={selectComponent}
onCancel={() => setInspecting(false)}
/>
</Portal>
<Errored
fallback={(error) => {
Expand Down
123 changes: 123 additions & 0 deletions src/dev-toolbar/inspector/lookup.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { afterEach, describe, expect, it } from 'vitest';
import {
findComponentFromElement,
findComponentOwner,
installElementTracking,
tagElementComponent,
uninstallElementTracking,
} from './lookup.js';
import type { RawNode } from '../ownership/tree.js';

interface MockElement {
tagName: string;
parentElement: MockElement | null;
isToolbar?: boolean;
closest(selector: string): MockElement | null;
}

function createMockElement(tag: string, parent: MockElement | null = null): MockElement {
const el: MockElement = {
tagName: tag.toUpperCase(),
parentElement: parent,
closest(selector: string) {
if (selector === '[data-solid-dev-toolbar]' && this.isToolbar) return this;
return this.parentElement ? this.parentElement.closest(selector) : null;
},
};
return el;
}

describe('findComponentOwner', () => {
it('returns undefined if owner is nullish', () => {
expect(findComponentOwner(undefined)).toBeUndefined();
expect(findComponentOwner(null)).toBeUndefined();
});

it('returns owner directly if it is a component', () => {
const compOwner: RawNode = {
_component: { name: 'App', props: {}, fn() {} },
};
expect(findComponentOwner(compOwner)).toBe(compOwner);
});

it('climbs parent chain to find component owner', () => {
const compOwner: RawNode = {
_component: { name: 'Header', props: {}, fn() {} },
};
const memoOwner: RawNode = {
_parent: compOwner,
_deps: null,
_fn() {},
};
const effectOwner: RawNode = {
_parent: memoOwner,
_deps: null,
_fn() {},
};

expect(findComponentOwner(effectOwner)).toBe(compOwner);
});
});

describe('findComponentFromElement with tagged nodes', () => {
it('ignores elements inside the dev toolbar', () => {
const toolbar = createMockElement('div');
toolbar.isToolbar = true;
const button = createMockElement('button', toolbar);

const compOwner: RawNode = {
_component: { name: 'Ignored', props: {}, fn() {} },
};
tagElementComponent(button as unknown as Element, compOwner);

expect(findComponentFromElement(button as unknown as Element)).toBeUndefined();
});

it('finds component owner on tagged element', () => {
const button = createMockElement('button');

const compOwner: RawNode = {
_component: { name: 'Card', props: {}, fn() {} },
};
tagElementComponent(button as unknown as Element, compOwner);

const result = findComponentFromElement(button as unknown as Element);
expect(result).toBeDefined();
expect(result?.owner).toBe(compOwner);
expect(result?.name).toBe('<Card>');
expect(result?.element).toBe(button as unknown as Element);
});

it('finds component owner when nested child is hovered', () => {
const section = createMockElement('section');
const span = createMockElement('span', section);

const compOwner: RawNode = {
_component: {
name: 'Greeting',
props: {},
fn: Object.assign(() => {}, { location: 'src/Greeting.tsx:10:2' }),
},
};
tagElementComponent(section as unknown as Element, compOwner);

const result = findComponentFromElement(span as unknown as Element);
expect(result).toBeDefined();
expect(result?.owner).toBe(compOwner);
expect(result?.name).toBe('<Greeting>');
expect(result?.location).toBe('src/Greeting.tsx:10:2');
expect(result?.element).toBe(section as unknown as Element);
});
});

describe('installElementTracking', () => {
afterEach(() => {
uninstallElementTracking();
});

it('installs and uninstalls cleanly in node environment', () => {
const cleanup = installElementTracking();
expect(typeof cleanup).toBe('function');
cleanup();
});
});
Loading
Loading