Skip to content

Commit 76ff6fa

Browse files
lxsmnsycclaude
andcommitted
feat: show where a component is declared
The ownership panel shows a component's source location under its name, and clicking it asks the dev server to open the file. - The location comes from the hot reload transform, which records `file:line:column` on the wrapper it creates. `@solidjs/vite-plugin` runs that transform in development, so no extra setup is needed. Components compiled without it simply have no location. - Clicking calls Vite's `/__open-in-editor` endpoint. - Names now drop the `[solid-refresh]` tag everywhere, not just on components, so the memo the wrapper creates reads as the component it wraps. - Long names in the signal and scope lists are clipped instead of overlapping their value. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 9cb9194 commit 76ff6fa

7 files changed

Lines changed: 100 additions & 10 deletions

File tree

‎.changeset/ownership-tree.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,5 @@ Add an ownership tree panel to the dev toolbar.
66

77
The panel shows the app as a tree of owners. Component mode lists components only and folds the scopes between them into the component above, so a component shows the signals, memos and effects created inside it. Owner mode shows every owner.
88
Selecting a row lists its prop names, the signals it holds with their values, the scopes folded into it and its children.
9+
Components show where they are declared, and clicking the location opens the file in your editor.
910
Rows flash when an owner is created, and the tree can be searched by component, scope or signal.

‎README.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,10 @@ Selecting a row lists its prop names, the signals it holds with their values, th
4949
folded into it and its children. Prop values are getters, so the panel lists their names
5050
and never reads them.
5151

52+
A component also shows where it is declared. The location comes from the hot reload
53+
transform, which `@solidjs/vite-plugin` runs in development, so it is there without any
54+
extra setup. Clicking it asks the dev server to open the file in your editor.
55+
5256
The panel reads the tree through the development hooks in `solid-js`, so it is empty in a
5357
production build of the runtime. It only watches while it is open.
5458

‎examples/explorer/README.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,5 +26,6 @@ and mounts the toolbar itself. `examples/explorer/src/App.tsx` is the whole app.
2626
1. Open the ownership panel and expand `routes` in the app. New rows appear and flash.
2727
2. Hide the preview. `<PreviewPane>` and the memo and effect it owns leave the tree.
2828
3. Select `<SelectionProvider>` and see the two signals every row depends on.
29-
4. Switch to owner mode to see the roots, memos and effects that component mode folds away.
30-
5. Search for `folder-stats` to find every folder memo at once.
29+
4. Click the file location under a component name to open it in your editor.
30+
5. Switch to owner mode to see the roots, memos and effects that component mode folds away.
31+
6. Search for `folder-stats` to find every folder memo at once.

‎src/dev-toolbar/ownership/index.tsx‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,14 @@ import {
1616
import { ancestorsOf, EMPTY_TREE, type OwnershipTree, type TreeNode } from './tree.js';
1717
import './styles.css';
1818

19+
/**
20+
* Asks the dev server to open the file. Vite serves this endpoint in
21+
* development. A failure is ignored, since the panel has nowhere to report it.
22+
*/
23+
function openInEditor(location: string): void {
24+
void fetch(`/__open-in-editor?file=${encodeURIComponent(location)}`).catch(() => {});
25+
}
26+
1927
/** How long a row stays marked as new after it first appears. */
2028
const FRESH_MS = 900;
2129

@@ -303,6 +311,21 @@ export default function OwnershipViewer(props: OwnershipViewerProps): JSX.Elemen
303311
<Badge type="info">{node().kind}</Badge>
304312
</div>
305313

314+
<Show when={node().location}>
315+
{(location) => (
316+
<button
317+
type="button"
318+
data-solid-ownership-location
319+
title="Open in editor"
320+
onClick={() => openInEditor(location())}
321+
>
322+
<Text options={{ size: 'xs', font: 'mono', wrap: 'nowrap' }}>
323+
{location()}
324+
</Text>
325+
</button>
326+
)}
327+
</Show>
328+
306329
<Show when={selectedPath().length > 0}>
307330
<Text data-solid-ownership-path options={{ size: 'xs', font: 'mono' }}>
308331
{selectedPath().join(' › ')}

‎src/dev-toolbar/ownership/styles.css‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,26 @@
287287
min-width: 0;
288288
}
289289

290+
[data-solid-ownership-location] {
291+
align-self: flex-start;
292+
293+
padding: 0.125rem 0.375rem;
294+
295+
border: var(--start-dt-border) 1px solid;
296+
border-radius: 0.375rem;
297+
background: var(--start-dt-surface);
298+
color: var(--start-dt-accent);
299+
300+
cursor: pointer;
301+
302+
max-width: 100%;
303+
overflow: hidden;
304+
}
305+
306+
[data-solid-ownership-location]:hover {
307+
background: var(--start-dt-surface-hover);
308+
}
309+
290310
[data-solid-ownership-path] {
291311
color: var(--start-dt-text-muted);
292312
}
@@ -336,6 +356,17 @@
336356
gap: 0.375rem;
337357

338358
min-width: 0;
359+
overflow: hidden;
360+
}
361+
362+
[data-solid-ownership-scope-name] > [data-solid-text-size] {
363+
overflow: hidden;
364+
text-overflow: ellipsis;
365+
}
366+
367+
[data-solid-ownership-signal] > [data-solid-text-size]:first-child {
368+
overflow: hidden;
369+
text-overflow: ellipsis;
339370
}
340371

341372
[data-solid-ownership-signal-value] {

‎src/dev-toolbar/ownership/tree.test.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ describe('ownerName', () => {
9393

9494
it('falls back to the kind when an owner has no name', () => {
9595
expect(ownerName(owner({ name: 'count' }), 'memo')).toBe('count');
96+
expect(ownerName(owner({ name: '[solid-refresh]Counter' }), 'memo')).toBe('Counter');
9697
expect(ownerName(owner(), 'scope')).toBe('scope');
9798
});
9899
});
@@ -121,6 +122,17 @@ describe('buildOwnershipTree in component mode', () => {
121122
expect(tree.nodes[0]!.signals.map((entry) => entry.name)).toEqual(['outer', 'inner']);
122123
});
123124

125+
it('reads the source location the hot reload transform records', () => {
126+
const root = owner({ component: 'App' });
127+
root._component.fn.location = 'src/App.tsx:12:0';
128+
129+
expect(build([root]).nodes[0]!.location).toBe('src/App.tsx:12:0');
130+
});
131+
132+
it('has no location for a component compiled without the transform', () => {
133+
expect(build([owner({ component: 'App' })]).nodes[0]!.location).toBeUndefined();
134+
});
135+
124136
it('lists prop names of a component', () => {
125137
const root = owner({ component: 'Greeting' });
126138
root._component.props = { name: 'ada', greeting: 'hi' };

‎src/dev-toolbar/ownership/tree.ts‎

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@ export interface TreeNode {
6161
disposed: boolean;
6262
/** Owners this node stands in for, when scopes are folded away. */
6363
scopes: FoldedScope[];
64+
/** Where the component is declared, as `file:line:column`. */
65+
location: string | undefined;
6466
}
6567

6668
export interface OwnershipTree {
@@ -101,17 +103,32 @@ export function ownerKind(owner: RawNode): OwnerKind {
101103
/** The hot reload transform wraps components, and its wrapper carries the tag. */
102104
const REFRESH_PREFIX = '[solid-refresh]';
103105

106+
function withoutRefreshTag(name: unknown): string | undefined {
107+
if (typeof name !== 'string' || name.length === 0) return undefined;
108+
return name.startsWith(REFRESH_PREFIX) ? name.slice(REFRESH_PREFIX.length) : name;
109+
}
110+
104111
export function ownerName(owner: RawNode, kind: OwnerKind): string {
105112
if (kind === 'component') {
106-
let name = owner._component?.name;
107-
if (typeof name === 'string' && name.startsWith(REFRESH_PREFIX)) {
108-
name = name.slice(REFRESH_PREFIX.length);
109-
}
110-
return `<${typeof name === 'string' && name.length > 0 ? name : 'Anonymous'}>`;
113+
return `<${withoutRefreshTag(owner._component?.name) ?? 'Anonymous'}>`;
114+
}
115+
// The memo the hot reload wrapper creates carries the same tag.
116+
return withoutRefreshTag(owner._name) ?? KIND_LABELS[kind];
117+
}
118+
119+
/**
120+
* Where a component is declared.
121+
*
122+
* The hot reload transform records this on its wrapper, so it is there whenever
123+
* a build runs that transform. Components compiled without it have no location.
124+
*/
125+
function componentLocation(owner: RawNode): string | undefined {
126+
try {
127+
const location = owner._component?.fn?.location;
128+
return typeof location === 'string' && location.length > 0 ? location : undefined;
129+
} catch {
130+
return undefined;
111131
}
112-
const name = owner._name;
113-
if (typeof name === 'string' && name.length > 0) return name;
114-
return KIND_LABELS[kind];
115132
}
116133

117134
function propNames(owner: RawNode): string[] | undefined {
@@ -166,6 +183,7 @@ export function buildOwnershipTree(roots: RawNode[], options: BuildOptions): Own
166183
children: [],
167184
signals: [],
168185
props: kind === 'component' ? propNames(owner) : undefined,
186+
location: kind === 'component' ? componentLocation(owner) : undefined,
169187
value: '_value' in owner ? owner._value : undefined,
170188
hasValue: '_value' in owner,
171189
disposed: isDisposed(owner),

0 commit comments

Comments
 (0)