From f744157f003aa6f9afacf15dde36f8c8df20eaa2 Mon Sep 17 00:00:00 2001 From: "Alexis H. Munsayac" Date: Fri, 11 Sep 2026 21:45:16 +0800 Subject: [PATCH 1/4] feat: add an ownership tree panel The toolbar gains a panel that 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, including roots, memos and effects. - Selecting a row lists its prop names, the signals it holds with their values, the scopes folded into it and its children. - Rows flash when an owner is created, and the tree can be searched by component, scope or signal. Components are found through `_component`, which the dev build of solid-js writes on the root it creates for every component, so rows carry real component names and prop names. Prop values are getters, so the panel lists the names and never reads them. The panel reads the tree through the development hooks in solid-js, so it stays empty in a production build of the runtime, and it installs those hooks only while it is open. The toolbar marks its own scope so its components never show up, and marks the scope that owns the app so the wrapped app still does. Co-Authored-By: Claude Opus 5 --- .changeset/ownership-tree.md | 9 + README.md | 23 +- src/dev-toolbar/icons.tsx | 90 +++++ src/dev-toolbar/index.tsx | 28 +- src/dev-toolbar/ownership/format.ts | 83 +++++ src/dev-toolbar/ownership/index.tsx | 465 +++++++++++++++++++++++++ src/dev-toolbar/ownership/registry.ts | 193 ++++++++++ src/dev-toolbar/ownership/styles.css | 374 ++++++++++++++++++++ src/dev-toolbar/ownership/tree.test.ts | 205 +++++++++++ src/dev-toolbar/ownership/tree.ts | 253 ++++++++++++++ tests/e2e/devtools.spec.ts | 30 ++ tests/fixture/app.tsx | 19 +- 12 files changed, 1764 insertions(+), 8 deletions(-) create mode 100644 .changeset/ownership-tree.md create mode 100644 src/dev-toolbar/ownership/format.ts create mode 100644 src/dev-toolbar/ownership/index.tsx create mode 100644 src/dev-toolbar/ownership/registry.ts create mode 100644 src/dev-toolbar/ownership/styles.css create mode 100644 src/dev-toolbar/ownership/tree.test.ts create mode 100644 src/dev-toolbar/ownership/tree.ts diff --git a/.changeset/ownership-tree.md b/.changeset/ownership-tree.md new file mode 100644 index 0000000..a4765da --- /dev/null +++ b/.changeset/ownership-tree.md @@ -0,0 +1,9 @@ +--- +'@solidjs/start-devtools': patch +--- + +Add an ownership tree panel to the dev toolbar. + +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. +Selecting a row lists its prop names, the signals it holds with their values, the scopes folded into it and its children. +Rows flash when an owner is created, and the tree can be searched by component, scope or signal. diff --git a/README.md b/README.md index d6804b4..a669dcb 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,11 @@ Development error and server-function tooling for Solid Start mode. -`@solidjs/start-devtools` provides the toolbar used by the Solid Vite plugin in development. It includes runtime error inspection, source-mapped stack frames, and server-function request and response inspection. +`@solidjs/start-devtools` provides the toolbar used by the Solid Vite plugin in development. + +- Runtime error inspection with source-mapped stack frames. +- Server-function request and response inspection. +- An ownership tree of the components and scopes the app created. ```sh pnpm add @solidjs/start-devtools@next @@ -25,4 +29,19 @@ does not include the toolbar. The same import is safe in development and production entries. -For component and reactivity inspection, see [Solid Devtools](https://github.com/thetarnav/solid-devtools). +## Ownership tree + +The ownership panel shows the app as a tree of owners. + +Component mode lists components only. The scopes between them are folded into the component +above, so a component shows every signal, memo and effect created inside it. Owner mode +shows every owner instead, including roots, memos and effects. + +Selecting a row lists its prop names, the signals it holds with their values, the scopes +folded into it and its children. Prop values are getters, so the panel lists their names +and never reads them. + +The panel reads the tree through the development hooks in `solid-js`, so it is empty in a +production build of the runtime. It only watches while it is open. + +For reactivity inspection, see [Solid Devtools](https://github.com/thetarnav/solid-devtools). diff --git a/src/dev-toolbar/icons.tsx b/src/dev-toolbar/icons.tsx index cb0317b..3c813b4 100644 --- a/src/dev-toolbar/icons.tsx +++ b/src/dev-toolbar/icons.tsx @@ -505,3 +505,93 @@ export function TrashIcon(props: JSX.IntrinsicElements['svg'] & { title: string ); } + +export function TreeIcon(props: JSX.IntrinsicElements['svg'] & { title: string }): JSX.Element { + return ( + + {props.title} + + + + + + + + ); +} + +export function PauseIcon(props: JSX.IntrinsicElements['svg'] & { title: string }): JSX.Element { + return ( + + {props.title} + + + ); +} + +export function PlayIcon(props: JSX.IntrinsicElements['svg'] & { title: string }): JSX.Element { + return ( + + {props.title} + + + ); +} + +export function ExpandIcon(props: JSX.IntrinsicElements['svg'] & { title: string }): JSX.Element { + return ( + + {props.title} + + + ); +} + +export function CollapseIcon(props: JSX.IntrinsicElements['svg'] & { title: string }): JSX.Element { + return ( + + {props.title} + + + ); +} diff --git a/src/dev-toolbar/index.tsx b/src/dev-toolbar/index.tsx index ce62fb6..a9627b5 100644 --- a/src/dev-toolbar/index.tsx +++ b/src/dev-toolbar/index.tsx @@ -1,22 +1,36 @@ import type { JSX } from '@solidjs/web'; import { clientOnly, httpStatus, isServer, Portal } from '@solidjs/web'; -import { createEffect, createSignal, Errored, onSettled } from 'solid-js'; +import { createEffect, createSignal, Errored, getOwner, onSettled } from 'solid-js'; import { Toolbar } from 'terracotta/toolbar'; import version from '../version.js'; import IconButton from '../ui/IconButton.js'; import { Text } from '../ui/Text.js'; import { type ServerFunctionInstance, ServerFunctionViewer } from './functions/index.js'; import { captureServerFunctionCall } from './functions/tracker.js'; -import { ErrorIcon, FunctionIcon, SolidIcon } from './icons.js'; +import { ErrorIcon, FunctionIcon, SolidIcon, TreeIcon } from './icons.js'; +import { excludeOwner, includeOwner } from './ownership/registry.js'; import './index.css'; const ErrorViewer = clientOnly(() => import('./error-viewer/index.js'), { lazy: true }); +const OwnershipViewer = clientOnly(() => import('./ownership/index.js'), { lazy: true }); export interface DevToolbarProps { children?: JSX.Element; } +/** + * Owns the app the toolbar wraps. Everything created here counts as app code, + * even though the toolbar's own scope encloses it. + */ +function AppScope(props: { children?: JSX.Element }): JSX.Element { + includeOwner(getOwner()); + return <>{props.children}; +} + export function DevToolbar(props: DevToolbarProps) { + // Everything the toolbar creates stays out of the tree it renders. + excludeOwner(getOwner()); + const [ref, setRef] = createSignal(); createEffect( @@ -123,9 +137,9 @@ export function DevToolbar(props: DevToolbarProps) { }, ); - const [content, setContent] = createSignal<'fn' | 'err' | undefined>(undefined); + const [content, setContent] = createSignal<'fn' | 'err' | 'own' | undefined>(undefined); - function toggleContent(value: 'fn' | 'err') { + function toggleContent(value: 'fn' | 'err' | 'own') { if (content() === value) { setContent(undefined); } else { @@ -197,6 +211,9 @@ export function DevToolbar(props: DevToolbarProps) { toggleContent('fn')}> + toggleContent('own')}> + +
@@ -208,6 +225,7 @@ export function DevToolbar(props: DevToolbarProps) {
+ ; }} > - {props.children} + {props.children} ); diff --git a/src/dev-toolbar/ownership/format.ts b/src/dev-toolbar/ownership/format.ts new file mode 100644 index 0000000..5993948 --- /dev/null +++ b/src/dev-toolbar/ownership/format.ts @@ -0,0 +1,83 @@ +const MAX_STRING = 40; +const MAX_ENTRIES = 4; + +/** Short name for the type of a value. */ +export function typeName(value: unknown): string { + if (value === null) return 'null'; + if (Array.isArray(value)) return 'array'; + if (value instanceof Date) return 'date'; + if (value instanceof Map) return 'map'; + if (value instanceof Set) return 'set'; + if (value instanceof Promise) return 'promise'; + if (typeof value === 'object') { + if (typeof Node === 'function' && value instanceof Node) return 'node'; + const name = (value as object).constructor?.name; + return name && name !== 'Object' ? name.toLowerCase() : 'object'; + } + return typeof value; +} + +/** + * One line preview of a value. Nested values are only expanded one level, so a + * preview stays short enough for a tree row. + */ +export function previewValue(value: unknown, depth = 0): string { + if (value === undefined) return 'undefined'; + if (value === null) return 'null'; + + switch (typeof value) { + case 'string': { + const text = value.length > MAX_STRING ? `${value.slice(0, MAX_STRING)}…` : value; + return `"${text}"`; + } + case 'number': + case 'boolean': + return String(value); + case 'bigint': + return `${value}n`; + case 'symbol': + return value.toString(); + case 'function': + return value.name ? `ƒ ${value.name}()` : 'ƒ ()'; + } + + if (value instanceof Date) return value.toISOString(); + if (value instanceof Error) return `${value.name}: ${value.message}`; + if (value instanceof Promise) return 'Promise'; + if (typeof Node === 'function' && value instanceof Node) { + const element = value as unknown as Element; + return element.tagName ? `<${element.tagName.toLowerCase()}>` : value.nodeName; + } + if (value instanceof Map) return `Map(${value.size})`; + if (value instanceof Set) return `Set(${value.size})`; + + if (Array.isArray(value)) { + if (depth > 0) return `Array(${value.length})`; + const items = value.slice(0, MAX_ENTRIES).map((item) => previewValue(item, depth + 1)); + if (value.length > MAX_ENTRIES) items.push(`…${value.length - MAX_ENTRIES} more`); + return `[${items.join(', ')}]`; + } + + const name = (value as object).constructor?.name; + const prefix = name && name !== 'Object' ? `${name} ` : ''; + if (depth > 0) return `${prefix}{…}`; + + let keys: string[]; + try { + keys = Object.keys(value as object); + } catch { + return `${prefix}{…}`; + } + if (keys.length === 0) return `${prefix}{}`; + const entries = keys.slice(0, MAX_ENTRIES).map((key) => { + let inner: unknown; + try { + inner = (value as Record)[key]; + } catch { + return `${key}: …`; + } + return `${key}: ${previewValue(inner, depth + 1)}`; + }); + if (keys.length > MAX_ENTRIES) entries.push(`…${keys.length - MAX_ENTRIES} more`); + return `${prefix}{ ${entries.join(', ')} }`; +} diff --git a/src/dev-toolbar/ownership/index.tsx b/src/dev-toolbar/ownership/index.tsx new file mode 100644 index 0000000..853e777 --- /dev/null +++ b/src/dev-toolbar/ownership/index.tsx @@ -0,0 +1,465 @@ +import type { JSX } from '@solidjs/web'; +import { createEffect, createMemo, createSignal, For, getOwner, Show } from 'solid-js'; +import { Badge } from '../../ui/Badge.js'; +import IconButton from '../../ui/IconButton.js'; +import Placeholder from '../../ui/Placeholder.js'; +import { Text } from '../../ui/Text.js'; +import { CollapseIcon, ExpandIcon, PauseIcon, PlayIcon, TreeIcon } from '../icons.js'; +import { previewValue, typeName } from './format.js'; +import { + excludeOwner, + isOwnershipAvailable, + snapshotOwnershipTree, + startOwnershipTracking, + subscribeOwnershipTree, +} from './registry.js'; +import { ancestorsOf, EMPTY_TREE, type OwnershipTree, type TreeNode } from './tree.js'; +import './styles.css'; + +/** How long a row stays marked as new after it first appears. */ +const FRESH_MS = 900; + +interface Row { + node: TreeNode; + /** Depth in the visible tree, which differs from the owner depth when filtering. */ + indent: number; + expandable: boolean; + expanded: boolean; +} + +export interface OwnershipViewerProps { + show?: boolean; +} + +export default function OwnershipViewer(props: OwnershipViewerProps): JSX.Element { + // The panel renders inside the app it inspects, so its own scope is marked. + excludeOwner(getOwner()); + + const [tree, setTree] = createSignal(EMPTY_TREE); + const [componentsOnly, setComponentsOnly] = createSignal(true); + const [paused, setPaused] = createSignal(false); + const [query, setQuery] = createSignal(''); + const [collapsed, setCollapsed] = createSignal([]); + const [selected, setSelected] = createSignal(); + + const firstSeen = new Map(); + + // Takes the mode as an argument because reading a signal inside an effect + // callback is not tracked. + function refresh(mode: boolean): void { + const next = snapshotOwnershipTree({ componentsOnly: mode }); + const now = Date.now(); + for (const node of next.nodes) { + if (!firstSeen.has(node.id)) firstSeen.set(node.id, now); + } + // An unchanged fingerprint means the app tree did not move. Skipping the + // write stops the panel's own render from feeding itself another update. + setTree((current) => (current.fingerprint === next.fingerprint ? current : next)); + } + + createEffect( + () => ({ watching: !!props.show && !paused(), mode: componentsOnly() }), + (state) => { + if (!state.watching) return; + const read = () => refresh(state.mode); + const stop = startOwnershipTracking(); + const unsubscribe = subscribeOwnershipTree(read); + read(); + return () => { + unsubscribe(); + stop(); + }; + }, + ); + + const byId = createMemo(() => new Map(tree().nodes.map((node) => [node.id, node]))); + + const matches = createMemo(() => { + const search = query().trim().toLowerCase(); + if (!search) return undefined; + const nodes = byId(); + const keep = new Set(); + for (const node of tree().nodes) { + const hit = + node.name.toLowerCase().includes(search) || + node.kind.includes(search) || + node.signals.some( + (signal) => + signal.name.toLowerCase().includes(search) || + previewValue(signal.value).toLowerCase().includes(search), + ) || + node.scopes.some( + (scope) => + scope.name.toLowerCase().includes(search) || + previewValue(scope.value).toLowerCase().includes(search), + ); + if (!hit) continue; + keep.add(node.id); + for (const parent of ancestorsOf(nodes, node.id)) keep.add(parent); + } + return keep; + }); + + const rows = createMemo(() => { + const nodes = byId(); + const visible = matches(); + const hidden = collapsed(); + const out: Row[] = []; + + const walk = (id: string, indent: number) => { + const node = nodes.get(id); + if (!node) return; + if (visible && !visible.has(id)) return; + const children = visible + ? node.children.filter((child) => visible.has(child)) + : node.children; + // A search result is always open, so matches deeper down stay reachable. + const expanded = visible ? true : !hidden.includes(id); + out.push({ node, indent, expandable: children.length > 0, expanded }); + if (!expanded) return; + for (const child of children) walk(child, indent + 1); + }; + + for (const root of tree().roots) walk(root, 0); + return out; + }); + + function toggle(id: string): void { + setCollapsed((current) => + current.includes(id) ? current.filter((item) => item !== id) : [...current, id], + ); + } + + function collapseAll(): void { + setCollapsed( + tree() + .nodes.filter((node) => node.children.length > 0) + .map((node) => node.id), + ); + } + + const selectedNode = createMemo(() => { + const id = selected(); + return id ? byId().get(id) : undefined; + }); + + const selectedPath = createMemo(() => { + const id = selected(); + if (!id) return []; + const nodes = byId(); + return ancestorsOf(nodes, id) + .map((parent) => nodes.get(parent)?.name) + .filter((name): name is string => !!name) + .reverse(); + }); + + return ( + +
+
+
+
+ + Ownership +
+ setQuery(event.currentTarget.value)} + /> +
+ + +
+
+ + {`${rows().length} of ${tree().nodes.length}`} + + setPaused((current) => !current)}> + } + children={} + /> + + setCollapsed([])}> + + + + + +
+
+ +
+ + + The ownership tree needs a development build of solid-js. + + + } + > +
+ 0} + fallback={ + + + {query() ? 'Nothing matches this filter.' : 'No owners observed yet.'} + + + } + > + + {(row) => ( +
+ +
+ )} +
+
+
+ + +
+
+
+
+
+ ); +} diff --git a/src/dev-toolbar/ownership/registry.ts b/src/dev-toolbar/ownership/registry.ts new file mode 100644 index 0000000..8315315 --- /dev/null +++ b/src/dev-toolbar/ownership/registry.ts @@ -0,0 +1,193 @@ +import { DEV } from 'solid-js'; +import { buildOwnershipTree, EMPTY_TREE, type OwnershipTree, type RawNode } from './tree.js'; + +let nextId = 1; +const ids = new WeakMap(); +const excluded = new WeakSet(); +const included = new WeakSet(); + +/** Owners the runtime told us about, weak so the app can still collect them. */ +const tracked = new Set>(); +const trackedRefs = new WeakMap>(); +const collected = + typeof FinalizationRegistry === 'function' + ? new FinalizationRegistry>((ref) => tracked.delete(ref)) + : undefined; + +const listeners = new Set<() => void>(); +let uninstall: (() => void) | undefined; +let watchers = 0; +let frame: number | undefined; +/** An owner inside the toolbar. The walk climbs from here to the app root. */ +let seedOwner: RawNode | undefined; + +/** True when the app runs a development build of solid-js. */ +export function isOwnershipAvailable(): boolean { + return !!DEV && typeof DEV.getChildren === 'function'; +} + +function identify(node: object): string { + let id = ids.get(node); + if (!id) { + id = `o${nextId++}`; + ids.set(node, id); + } + return id; +} + +function track(owner: RawNode | null | undefined): void { + if (!owner || typeof owner !== 'object' || trackedRefs.has(owner)) return; + const ref = new WeakRef(owner); + trackedRefs.set(owner, ref); + tracked.add(ref); + collected?.register(owner, ref); +} + +function notify(): void { + if (frame !== undefined || listeners.size === 0) return; + frame = requestAnimationFrame(() => { + frame = undefined; + for (const listener of listeners) listener(); + }); +} + +/** + * Installs the devtools hooks on the reactive runtime. Existing hooks are kept + * and still called, so other tools sharing the slot keep working. + */ +export function startOwnershipTracking(): () => void { + if (!isOwnershipAvailable()) return () => {}; + watchers++; + if (uninstall) return release; + + const hooks = DEV!.hooks; + const previousOwner = hooks.onOwner; + const previousGraph = hooks.onGraph; + const previousUpdate = hooks.onUpdate; + + hooks.onOwner = (owner) => { + previousOwner?.(owner); + track(owner as RawNode); + notify(); + }; + hooks.onGraph = (value, owner) => { + previousGraph?.(value, owner); + if (owner) track(owner as RawNode); + notify(); + }; + hooks.onUpdate = () => { + previousUpdate?.(); + notify(); + }; + + uninstall = () => { + hooks.onOwner = previousOwner; + hooks.onGraph = previousGraph; + hooks.onUpdate = previousUpdate; + uninstall = undefined; + if (frame !== undefined) cancelAnimationFrame(frame); + frame = undefined; + }; + return release; +} + +/** Drops one watcher. The hooks come off once nothing watches any more. */ +function release(): void { + watchers = Math.max(0, watchers - 1); + if (watchers === 0) uninstall?.(); +} + +/** Calls `listener` after the tree changed, at most once per frame. */ +export function subscribeOwnershipTree(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +/** + * Marks an owner as belonging to the toolbar itself. Its subtree never shows up + * in the tree, so the panel does not list its own components. + */ +export function excludeOwner(owner: unknown): void { + if (!owner || typeof owner !== 'object') return; + excluded.add(owner); + seedOwner ??= owner as RawNode; +} + +/** + * Marks an owner as app code again. The toolbar wraps the app, so the scope + * holding `props.children` carries this marker and stays in the tree. + */ +export function includeOwner(owner: unknown): void { + if (owner && typeof owner === 'object') included.add(owner); +} + +function isExcluded(owner: RawNode): boolean { + return excluded.has(owner); +} + +function isIncluded(owner: RawNode): boolean { + return included.has(owner); +} + +function childrenOf(owner: RawNode): RawNode[] { + try { + // The runtime keeps the newest child first, so reversing puts the tree in + // creation order, which is the order the app reads in. + return (DEV!.getChildren(owner as never) as RawNode[]).reverse(); + } catch { + return []; + } +} + +function signalsOf(owner: RawNode): RawNode[] { + try { + return DEV!.getSignals(owner as never) as RawNode[]; + } catch { + return []; + } +} + +function rootOf(owner: RawNode): RawNode { + let current = owner; + while (current._parent) current = current._parent; + return current; +} + +export interface SnapshotOptions { + componentsOnly?: boolean; + includeDisposed?: boolean; +} + +/** + * Reads the current owner tree. + * + * The walk starts at every known root: the one above the toolbar, plus the root + * of every owner the hooks reported. That covers owners created before the + * panel opened and roots the toolbar does not sit under. + */ +export function snapshotOwnershipTree(options?: SnapshotOptions): OwnershipTree { + if (!isOwnershipAvailable()) return EMPTY_TREE; + + const roots = new Set(); + if (seedOwner) roots.add(rootOf(seedOwner)); + for (const ref of tracked) { + const owner = ref.deref(); + if (!owner) { + tracked.delete(ref); + continue; + } + roots.add(rootOf(owner)); + } + + return buildOwnershipTree([...roots], { + children: childrenOf, + signals: signalsOf, + identify, + isExcluded, + isIncluded, + componentsOnly: options?.componentsOnly ?? true, + includeDisposed: options?.includeDisposed, + }); +} diff --git a/src/dev-toolbar/ownership/styles.css b/src/dev-toolbar/ownership/styles.css new file mode 100644 index 0000000..515f7ed --- /dev/null +++ b/src/dev-toolbar/ownership/styles.css @@ -0,0 +1,374 @@ +[data-solid-ownership-viewer] { + --start-dt-kind-component: oklch(0.68 0.13 245); + --start-dt-kind-root: oklch(0.72 0.12 195); + --start-dt-kind-memo: oklch(0.7 0.14 300); + --start-dt-kind-effect: oklch(0.72 0.15 150); + --start-dt-kind-render-effect: oklch(0.78 0.13 80); + --start-dt-kind-tracked-effect: oklch(0.72 0.12 210); + --start-dt-kind-scope: oklch(0.6 0.02 265); + + color: var(--start-dt-text); + + display: flex; + flex-direction: column; + + height: 100%; + min-height: 0; +} + +[data-solid-ownership-nav] { + display: flex; + flex-direction: row; + align-items: center; + + gap: 0.5rem; + + padding: 0.5rem 0.75rem; + + border-bottom: var(--start-dt-border-soft) 1px solid; +} + +[data-solid-ownership-nav-title] { + display: flex; + flex-direction: row; + align-items: center; + + gap: 0.5rem; + + flex-shrink: 0; +} + +[data-solid-ownership-nav-actions] { + display: flex; + flex-direction: row; + align-items: center; + + gap: 0.375rem; + + margin-left: auto; +} + +[data-solid-ownership-count] { + color: var(--start-dt-text-muted); + white-space: nowrap; +} + +[data-solid-ownership-search] { + flex: 1; + min-width: 6rem; + max-width: 18rem; + + padding: 0.25rem 0.5rem; + + border-radius: 0.5rem; + border: var(--start-dt-border) 1px solid; + background: var(--start-dt-surface); + color: var(--start-dt-text); + + font-family: inherit; + font-size: 0.75rem; + line-height: 1rem; +} + +[data-solid-ownership-search]:focus { + outline: none; + border-color: var(--start-dt-accent); +} + +[data-solid-ownership-modes] { + display: flex; + + border-radius: 9999px; + border: var(--start-dt-border) 1px solid; + overflow: hidden; +} + +[data-solid-ownership-mode] { + padding: 0.1875rem 0.625rem; + + border: none; + background: var(--start-dt-surface); + color: var(--start-dt-text-muted); + + cursor: pointer; +} + +[data-solid-ownership-mode]:hover { + background: var(--start-dt-surface-hover); +} + +[data-solid-ownership-mode][data-active] { + background: var(--start-dt-accent-soft); + color: var(--start-dt-accent); +} + +[data-solid-ownership-body] { + display: flex; + flex-direction: row; + + flex: 1; + min-height: 0; +} + +[data-solid-ownership-rows] { + display: flex; + flex-direction: column; + + flex: 1; + min-width: 0; + min-height: 0; + + padding: 0.375rem 0.25rem; + + overflow: auto; +} + +[data-solid-ownership-row] { + display: flex; + flex-direction: row; + align-items: center; + + gap: 0.125rem; + + border-radius: 0.375rem; + + min-width: 0; +} + +[data-solid-ownership-row]:hover { + background: var(--start-dt-surface-hover); +} + +[data-solid-ownership-row][data-selected] { + background: var(--start-dt-surface-active); +} + +[data-solid-ownership-row][data-fresh] { + animation: solid-ownership-pulse 900ms ease-out; +} + +@keyframes solid-ownership-pulse { + 0% { + background: var(--start-dt-accent-soft); + } + 100% { + background: transparent; + } +} + +[data-solid-ownership-chevron] { + display: inline-flex; + align-items: center; + justify-content: center; + + width: 1rem; + height: 1rem; + flex-shrink: 0; + + border: none; + background: none; + color: var(--start-dt-text-muted); + cursor: pointer; +} + +[data-solid-ownership-chevron]::before { + content: ''; + + width: 0.3125rem; + height: 0.3125rem; + + border-right: 1.5px currentColor solid; + border-bottom: 1.5px currentColor solid; + + transform: rotate(-45deg); + transition: transform 150ms cubic-bezier(0.4, 0, 0.2, 1); +} + +[data-solid-ownership-chevron][data-leaf] { + cursor: default; +} + +[data-solid-ownership-chevron][data-leaf]::before { + content: none; +} + +[data-solid-ownership-chevron][data-expanded]::before { + transform: rotate(45deg); +} + +[data-solid-ownership-label] { + display: flex; + flex-direction: row; + align-items: center; + + gap: 0.375rem; + + flex: 1; + min-width: 0; + + padding: 0.1875rem 0.25rem; + + border: none; + background: none; + color: inherit; + + text-align: left; + cursor: pointer; +} + +[data-solid-ownership-name] { + overflow: hidden; + text-overflow: ellipsis; +} + +[data-solid-ownership-kind] { + width: 0.5rem; + height: 0.5rem; + + flex-shrink: 0; + + border-radius: 9999px; + background: var(--start-dt-kind-scope); +} + +[data-solid-ownership-kind='component'] { + background: var(--start-dt-kind-component); +} + +[data-solid-ownership-kind='root'] { + background: var(--start-dt-kind-root); +} + +[data-solid-ownership-kind='memo'] { + background: var(--start-dt-kind-memo); +} + +[data-solid-ownership-kind='effect'] { + background: var(--start-dt-kind-effect); +} + +[data-solid-ownership-kind='render-effect'] { + background: var(--start-dt-kind-render-effect); +} + +[data-solid-ownership-kind='tracked-effect'] { + background: var(--start-dt-kind-tracked-effect); +} + +[data-solid-ownership-detail] { + display: flex; + flex-direction: column; + + width: 20rem; + flex-shrink: 0; + min-height: 0; + + overflow-y: auto; + + border-left: var(--start-dt-border-soft) 1px solid; +} + +[data-solid-ownership-detail-content] { + display: flex; + flex-direction: column; + + gap: 0.5rem; + + padding: 0.625rem 0.75rem; +} + +[data-solid-ownership-detail-head] { + display: flex; + flex-direction: row; + align-items: center; + + gap: 0.375rem; + + min-width: 0; +} + +[data-solid-ownership-path] { + color: var(--start-dt-text-muted); +} + +[data-solid-ownership-detail-block] { + display: flex; + flex-direction: column; + + gap: 0.25rem; + + padding-top: 0.5rem; + + border-top: var(--start-dt-border-soft) 1px solid; +} + +[data-solid-ownership-note] { + color: var(--start-dt-text-muted); +} + +[data-solid-ownership-signals] { + display: flex; + flex-direction: column; + + gap: 0.125rem; +} + +[data-solid-ownership-signal] { + display: grid; + grid-template-columns: minmax(4rem, 40%) 1fr; + align-items: baseline; + + gap: 0.5rem; + + padding: 0.125rem 0.25rem; + + border-radius: 0.25rem; +} + +[data-solid-ownership-signal]:hover { + background: var(--start-dt-surface-hover); +} + +[data-solid-ownership-scope-name] { + display: inline-flex; + align-items: center; + + gap: 0.375rem; + + min-width: 0; +} + +[data-solid-ownership-signal-value] { + color: var(--start-dt-text-muted); + + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +[data-solid-ownership-chips] { + display: flex; + flex-wrap: wrap; + + gap: 0.25rem; +} + +[data-solid-ownership-chip] { + display: inline-flex; + align-items: center; + + gap: 0.25rem; + + padding: 0.125rem 0.5rem; + + border-radius: 9999px; + border: var(--start-dt-border) 1px solid; + background: var(--start-dt-surface); + color: var(--start-dt-text); + + cursor: pointer; +} + +button[data-solid-ownership-chip]:hover { + background: var(--start-dt-surface-hover); +} diff --git a/src/dev-toolbar/ownership/tree.test.ts b/src/dev-toolbar/ownership/tree.test.ts new file mode 100644 index 0000000..8049604 --- /dev/null +++ b/src/dev-toolbar/ownership/tree.test.ts @@ -0,0 +1,205 @@ +import { describe, expect, it } from 'vitest'; +import { + ancestorsOf, + buildOwnershipTree, + ownerKind, + ownerName, + type BuildOptions, + type RawNode, + type TreeNode, +} from './tree.js'; + +const REACTIVE_DISPOSED = 1 << 6; + +interface FakeOptions { + name?: string; + component?: string; + effect?: number; + memo?: boolean; + root?: boolean; + disposed?: boolean; + value?: unknown; + signals?: RawNode[]; + children?: RawNode[]; +} + +/** Builds an owner shaped like the ones the runtime creates. */ +function owner(options: FakeOptions = {}): RawNode { + const node: RawNode = { + _children: options.children ?? [], + _signals: options.signals ?? [], + }; + if (options.name) node._name = options.name; + if (options.component) node._component = { name: options.component, props: {}, fn() {} }; + if (options.memo || options.effect !== undefined) { + node._deps = null; + node._fn = () => undefined; + node._value = options.value; + } + if (options.effect !== undefined) node._type = options.effect; + if (options.root) node._root = true; + if (options.disposed) node._flags = REACTIVE_DISPOSED; + return node; +} + +function signal(name: string, value: unknown): RawNode { + return { _name: name, _value: value }; +} + +function build(roots: RawNode[], over: Partial = {}) { + const ids = new Map(); + return buildOwnershipTree(roots, { + children: (node) => node._children ?? [], + signals: (node) => node._signals ?? [], + identify: (node) => { + let id = ids.get(node); + if (!id) { + id = `n${ids.size + 1}`; + ids.set(node, id); + } + return id; + }, + isExcluded: () => false, + isIncluded: () => false, + componentsOnly: true, + ...over, + }); +} + +const names = (nodes: TreeNode[]) => nodes.map((node) => node.name); + +describe('ownerKind', () => { + it('reads the kind off the raw owner', () => { + expect(ownerKind(owner({ component: 'App' }))).toBe('component'); + expect(ownerKind(owner({ memo: true }))).toBe('memo'); + expect(ownerKind(owner({ effect: 2 }))).toBe('effect'); + expect(ownerKind(owner({ effect: 1 }))).toBe('render-effect'); + expect(ownerKind(owner({ root: true }))).toBe('root'); + expect(ownerKind(owner())).toBe('scope'); + }); +}); + +describe('ownerName', () => { + it('wraps component names in angle brackets', () => { + expect(ownerName(owner({ component: 'App' }), 'component')).toBe(''); + expect(ownerName(owner({ component: '' }), 'component')).toBe(''); + }); + + it('falls back to the kind when an owner has no name', () => { + expect(ownerName(owner({ name: 'count' }), 'memo')).toBe('count'); + expect(ownerName(owner(), 'scope')).toBe('scope'); + }); +}); + +describe('buildOwnershipTree in component mode', () => { + it('keeps components and folds the scopes between them', () => { + const child = owner({ component: 'Child' }); + const memo = owner({ memo: true, name: 'total', value: 7, children: [child] }); + const root = owner({ component: 'App', children: [memo] }); + + const tree = build([root]); + + expect(names(tree.nodes)).toEqual(['', '']); + expect(tree.nodes[0]!.scopes).toEqual([ + { id: expect.any(String), kind: 'memo', name: 'total', value: 7, hasValue: true }, + ]); + expect(tree.nodes[0]!.children).toEqual([tree.nodes[1]!.id]); + }); + + it('gives a folded scope its signals to the component above', () => { + const scope = owner({ memo: true, name: 'derived', signals: [signal('inner', 1)] }); + const root = owner({ component: 'App', signals: [signal('outer', 0)], children: [scope] }); + + const tree = build([root]); + + expect(tree.nodes[0]!.signals.map((entry) => entry.name)).toEqual(['outer', 'inner']); + }); + + it('lists prop names of a component', () => { + const root = owner({ component: 'Greeting' }); + root._component.props = { name: 'ada', greeting: 'hi' }; + + expect(build([root]).nodes[0]!.props).toEqual(['name', 'greeting']); + }); +}); + +describe('buildOwnershipTree in owner mode', () => { + it('keeps every owner', () => { + const memo = owner({ memo: true, name: 'total' }); + const root = owner({ component: 'App', children: [memo] }); + + expect(names(build([root], { componentsOnly: false }).nodes)).toEqual(['', 'total']); + }); +}); + +describe('buildOwnershipTree visibility', () => { + it('hides an excluded subtree', () => { + const hidden = owner({ component: 'Toolbar' }); + const root = owner({ component: 'App', children: [hidden] }); + + const tree = build([root], { isExcluded: (node) => node === hidden }); + + expect(names(tree.nodes)).toEqual(['']); + }); + + it('shows an included scope inside a hidden subtree, without the marker itself', () => { + const app = owner({ component: 'App' }); + const marker = owner({ component: 'AppScope', children: [app] }); + const toolbar = owner({ component: 'Toolbar', children: [marker] }); + + const tree = build([toolbar], { + isExcluded: (node) => node === toolbar, + isIncluded: (node) => node === marker, + }); + + expect(names(tree.nodes)).toEqual(['']); + expect(tree.roots).toEqual([tree.nodes[0]!.id]); + }); + + it('drops disposed owners unless asked for them', () => { + const gone = owner({ component: 'Gone', disposed: true }); + const root = owner({ component: 'App', children: [gone] }); + + expect(names(build([root]).nodes)).toEqual(['']); + expect(names(build([root], { includeDisposed: true }).nodes)).toEqual(['', '']); + }); + + it('visits an owner once even when two roots reach it', () => { + const shared = owner({ component: 'Shared' }); + const first = owner({ component: 'First', children: [shared] }); + const second = owner({ component: 'Second', children: [shared] }); + + expect(names(build([first, second]).nodes)).toEqual(['', '', '']); + }); +}); + +describe('fingerprint', () => { + it('changes when the tree gains a signal', () => { + const root = owner({ component: 'App' }); + const before = build([root]).fingerprint; + root._signals = [signal('count', 0)]; + + expect(build([root]).fingerprint).not.toBe(before); + }); + + it('stays the same when nothing moved', () => { + const root = owner({ component: 'App', signals: [signal('count', 0)] }); + + expect(build([root]).fingerprint).toBe(build([root]).fingerprint); + }); +}); + +describe('ancestorsOf', () => { + it('walks up to the root', () => { + const leaf = owner({ component: 'Leaf' }); + const middle = owner({ component: 'Middle', children: [leaf] }); + const root = owner({ component: 'Root', children: [middle] }); + const tree = build([root]); + const byId = new Map(tree.nodes.map((node) => [node.id, node])); + + expect(ancestorsOf(byId, tree.nodes[2]!.id).map((id) => byId.get(id)!.name)).toEqual([ + '', + '', + ]); + }); +}); diff --git a/src/dev-toolbar/ownership/tree.ts b/src/dev-toolbar/ownership/tree.ts new file mode 100644 index 0000000..6aad353 --- /dev/null +++ b/src/dev-toolbar/ownership/tree.ts @@ -0,0 +1,253 @@ +/** Raw owner or signal from the runtime. Only the fields the tree needs are read. */ +export type RawNode = Record; + +const REACTIVE_DISPOSED = 1 << 6; + +const EFFECT_RENDER = 1; +const EFFECT_USER = 2; +const EFFECT_TRACKED = 3; + +export type OwnerKind = + | 'component' + | 'root' + | 'memo' + | 'effect' + | 'render-effect' + | 'tracked-effect' + | 'scope'; + +const KIND_LABELS: Record = { + component: 'component', + root: 'root', + memo: 'memo', + effect: 'effect', + 'render-effect': 'render effect', + 'tracked-effect': 'tracked effect', + scope: 'scope', +}; + +export interface OwnedSignal { + id: string; + name: string; + value: unknown; +} + +/** A scope folded into the component above it, such as a memo or an effect. */ +export interface FoldedScope { + id: string; + kind: OwnerKind; + name: string; + value: unknown; + hasValue: boolean; +} + +export interface TreeNode { + id: string; + parentId: string | undefined; + kind: OwnerKind; + name: string; + depth: number; + children: string[]; + /** Signals this owner created, plus those of the scopes it stands in for. */ + signals: OwnedSignal[]; + /** + * Prop names of a component. Props are getters, so the tree lists the names + * and never reads the values. + */ + props: string[] | undefined; + /** Current value of a computed owner. */ + value: unknown; + hasValue: boolean; + disposed: boolean; + /** Owners this node stands in for, when scopes are folded away. */ + scopes: FoldedScope[]; +} + +export interface OwnershipTree { + nodes: TreeNode[]; + roots: string[]; + /** Cheap identity of the tree. Equal fingerprints mean nothing changed. */ + fingerprint: string; +} + +export const EMPTY_TREE: OwnershipTree = { nodes: [], roots: [], fingerprint: 'empty' }; + +export function isComponent(owner: RawNode): boolean { + return !!owner._component; +} + +function isComputed(owner: RawNode): boolean { + return '_deps' in owner && typeof owner._fn === 'function'; +} + +export function ownerKind(owner: RawNode): OwnerKind { + if (isComponent(owner)) return 'component'; + if (isComputed(owner)) { + switch (owner._type) { + case EFFECT_RENDER: + return 'render-effect'; + case EFFECT_USER: + return 'effect'; + case EFFECT_TRACKED: + return 'tracked-effect'; + default: + return 'memo'; + } + } + if (owner._root) return 'root'; + return 'scope'; +} + +export function ownerName(owner: RawNode, kind: OwnerKind): string { + if (kind === 'component') { + const name = owner._component?.name; + return `<${typeof name === 'string' && name.length > 0 ? name : 'Anonymous'}>`; + } + const name = owner._name; + if (typeof name === 'string' && name.length > 0) return name; + return KIND_LABELS[kind]; +} + +function propNames(owner: RawNode): string[] | undefined { + const props = owner._component?.props; + if (!props || typeof props !== 'object') return undefined; + try { + return Object.keys(props); + } catch { + return undefined; + } +} + +function isDisposed(owner: RawNode): boolean { + return typeof owner._flags === 'number' && (owner._flags & REACTIVE_DISPOSED) !== 0; +} + +export interface BuildOptions { + children(owner: RawNode): RawNode[]; + signals(owner: RawNode): RawNode[]; + identify(node: object): string; + /** Owners that belong to the toolbar. Their subtree is hidden. */ + isExcluded(owner: RawNode): boolean; + /** Owners that are app code again, even inside a hidden subtree. */ + isIncluded(owner: RawNode): boolean; + /** Show components only, folding the scopes between them away. */ + componentsOnly: boolean; + /** Keep owners the runtime already disposed. */ + includeDisposed?: boolean; +} + +/** + * Builds the owner tree, depth first. + * + * In component mode only component owners become rows. The scopes between them + * are folded into the nearest component above, and the signals those scopes own + * are listed on that component, so a component shows everything created under + * it. + */ +export function buildOwnershipTree(roots: RawNode[], options: BuildOptions): OwnershipTree { + const nodes: TreeNode[] = []; + const topLevel: string[] = []; + const seen = new Set(); + + function describe(owner: RawNode, parentId: string | undefined, depth: number): TreeNode { + const kind = ownerKind(owner); + const node: TreeNode = { + id: options.identify(owner), + parentId, + kind, + name: ownerName(owner, kind), + depth, + children: [], + signals: [], + props: kind === 'component' ? propNames(owner) : undefined, + value: '_value' in owner ? owner._value : undefined, + hasValue: '_value' in owner, + disposed: isDisposed(owner), + scopes: [], + }; + nodes.push(node); + if (parentId === undefined) topLevel.push(node.id); + return node; + } + + function collectSignals(owner: RawNode, into: TreeNode): void { + for (const signal of options.signals(owner)) { + if (!signal || typeof signal !== 'object') continue; + const name = signal._name; + into.signals.push({ + id: options.identify(signal), + name: typeof name === 'string' && name.length > 0 ? name : 'signal', + value: signal._value, + }); + } + } + + function walk(owner: RawNode, host: TreeNode | undefined, depth: number, hidden: boolean): void { + if (!owner || typeof owner !== 'object' || seen.has(owner)) return; + seen.add(owner); + + // The nearest marker decides. The toolbar wraps the app, so the app's own + // scope sits inside the toolbar's hidden subtree and turns visibility back + // on for everything below it. The marker itself is toolbar code, so it + // never becomes a row. + if (options.isIncluded(owner)) { + for (const child of options.children(owner)) walk(child, undefined, 0, false); + return; + } + + if (options.isExcluded(owner) || hidden) { + for (const child of options.children(owner)) walk(child, undefined, 0, true); + return; + } + + if (isDisposed(owner) && !options.includeDisposed) return; + + const shown = !options.componentsOnly || isComponent(owner); + + if (shown) { + const node = describe(owner, host?.id, depth); + if (host) host.children.push(node.id); + collectSignals(owner, node); + for (const child of options.children(owner)) walk(child, node, depth + 1, false); + return; + } + + // Folded scope. Its signals and children belong to the component above it. + if (host) { + const kind = ownerKind(owner); + host.scopes.push({ + id: options.identify(owner), + kind, + name: ownerName(owner, kind), + value: '_value' in owner ? owner._value : undefined, + hasValue: '_value' in owner, + }); + collectSignals(owner, host); + } + for (const child of options.children(owner)) walk(child, host, depth, false); + } + + for (const root of roots) walk(root, undefined, 0, false); + + let fingerprint = `${nodes.length}:${topLevel.length}`; + for (const node of nodes) { + fingerprint += `|${node.id}${node.kind}${node.children.length}${node.signals.length}${ + node.scopes.length + }${node.disposed ? 'd' : ''}`; + for (const signal of node.signals) fingerprint += `,${signal.id}`; + for (const scope of node.scopes) fingerprint += `;${scope.id}`; + } + + return { nodes, roots: topLevel, fingerprint }; +} + +/** Ids of `id` and every node above it, used to keep matches visible. */ +export function ancestorsOf(nodes: Map, id: string): string[] { + const path: string[] = []; + let current = nodes.get(id); + while (current?.parentId) { + path.push(current.parentId); + current = nodes.get(current.parentId); + } + return path; +} diff --git a/tests/e2e/devtools.spec.ts b/tests/e2e/devtools.spec.ts index 0f63476..5515702 100644 --- a/tests/e2e/devtools.spec.ts +++ b/tests/e2e/devtools.spec.ts @@ -78,6 +78,36 @@ test('shows server-function calls', async ({ page }) => { expect(warnings).not.toContainEqual(expect.stringContaining('STRICT_READ_UNTRACKED')); }); +test('maps the ownership tree', async ({ page }) => { + await page.goto('/'); + const toggle = page.getByRole('button', { name: 'View Ownership Tree' }); + const rows = page.locator('[data-solid-ownership-name]'); + + await toggle.click(); + await expect(rows).toHaveText(['', '', '', '']); + + // A component owns the signals and scopes created inside it. + await page.locator('[data-solid-ownership-label]').filter({ hasText: '' }).click(); + const detail = page.locator('[data-solid-ownership-detail]'); + await expect(detail).toContainText('Signals (1)'); + await expect(detail).toContainText('count'); + await expect(detail).toContainText('doubled'); + + // Props are listed by name, never read. + await page.locator('[data-solid-ownership-label]').filter({ hasText: '' }).click(); + await expect(detail).toContainText('Props (1)'); + await expect(detail).toContainText('name'); + + // Owner mode adds the scopes that component mode folds away. + await page.getByRole('button', { name: 'Owners', exact: true }).click(); + await expect(rows.filter({ hasText: 'doubled' })).toHaveCount(1); + + // Search keeps the ancestors of a match so the row stays reachable. + await page.getByRole('button', { name: 'Components', exact: true }).click(); + await page.locator('[data-solid-ownership-search]').fill('doubled'); + await expect(rows).toHaveText(['', '']); +}); + test('mounts once and disposes', async ({ page }) => { await page.goto('/?mount'); diff --git a/tests/fixture/app.tsx b/tests/fixture/app.tsx index 55e72eb..e697b0e 100644 --- a/tests/fixture/app.tsx +++ b/tests/fixture/app.tsx @@ -1,6 +1,6 @@ import { render } from '@solidjs/web'; import { DevToolbar, mountDevToolbar, pushServerFunctionCall } from '@solidjs/start-devtools'; -import { createSignal, Show } from 'solid-js'; +import { createMemo, createSignal, Show } from 'solid-js'; function Broken(): never { throw new Error('client boom'); @@ -32,12 +32,29 @@ function emitServerFunctionResponse() { responseStatus = 500; } +function Greeting(props: { name: string }) { + return

{`hello ${props.name}`}

; +} + +function Counter() { + const [count, setCount] = createSignal(0, { name: 'count' }); + const doubled = createMemo(() => count() * 2, { name: 'doubled' }); + + return ( + + ); +} + function App() { const [broken, setBroken] = createSignal(false); return (

app content

+ + From 9cb91943ed7682fa2a8bfbda50580ab39c9db7b4 Mon Sep 17 00:00:00 2001 From: "Alexis H. Munsayac" Date: Fri, 11 Sep 2026 21:58:07 +0800 Subject: [PATCH 2/4] docs: add a file explorer demo for the ownership panel examples/explorer is an app whose component tree changes while you use it. Run it with `pnpm demo`. - `FolderNode` renders itself for every nested folder, so opening a folder mounts a component per child and the ownership tree takes the shape of the folder. - `SelectionProvider` owns the selection signals that every row reads out of context. - `PreviewPane` sits behind a toggle, so hiding it disposes an owner and the scopes it holds. - Several components create memos, which component mode folds into the component that owns them. Component names now drop the `[solid-refresh]` prefix that the hot reload transform adds to its wrapper, so rows read as the component the app declared. Co-Authored-By: Claude Opus 5 --- README.md | 8 + examples/explorer/README.md | 30 ++ examples/explorer/src/App.tsx | 254 +++++++++++ examples/explorer/src/css.d.ts | 1 + examples/explorer/src/styles.css | 256 +++++++++++ examples/explorer/src/tree-data.ts | 66 +++ examples/explorer/vite.config.ts | 15 + package.json | 2 + pnpm-lock.yaml | 560 +++++++++++++++++++++++++ src/dev-toolbar/ownership/tree.test.ts | 6 + src/dev-toolbar/ownership/tree.ts | 8 +- tsconfig.tests.json | 12 +- 12 files changed, 1215 insertions(+), 3 deletions(-) create mode 100644 examples/explorer/README.md create mode 100644 examples/explorer/src/App.tsx create mode 100644 examples/explorer/src/css.d.ts create mode 100644 examples/explorer/src/styles.css create mode 100644 examples/explorer/src/tree-data.ts create mode 100644 examples/explorer/vite.config.ts diff --git a/README.md b/README.md index a669dcb..68a6089 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,14 @@ does not include the toolbar. The same import is safe in development and production entries. +## Demo + +`examples/explorer` is a file explorer whose component tree grows as you open folders. + +```sh +pnpm demo +``` + ## Ownership tree The ownership panel shows the app as a tree of owners. diff --git a/examples/explorer/README.md b/examples/explorer/README.md new file mode 100644 index 0000000..031011f --- /dev/null +++ b/examples/explorer/README.md @@ -0,0 +1,30 @@ +# Ownership demo + +A file explorer that grows and shrinks its component tree as you use it. + +```sh +pnpm demo +``` + +The command builds the package and starts the app on http://localhost:5173. + +## What it shows + +The app is built with `@solidjs/vite-plugin` in start mode, so the plugin owns the entries +and mounts the toolbar itself. `examples/explorer/src/App.tsx` is the whole app. + +- `FolderNode` renders itself for every nested folder, so the ownership tree has the same + shape as the folder you opened. +- `SelectionProvider` owns the selection signals. Every row reads them out of context, + which is visible in the tree as one owner holding the signals many components use. +- `PreviewPane` is mounted behind a toggle, so hiding it disposes an owner and its scopes. +- `Stats`, `Breadcrumbs` and `FolderNode` each create memos, which component mode folds + into the component that owns them. + +## Things to try + +1. Open the ownership panel and expand `routes` in the app. New rows appear and flash. +2. Hide the preview. `` and the memo and effect it owns leave the tree. +3. Select `` and see the two signals every row depends on. +4. Switch to owner mode to see the roots, memos and effects that component mode folds away. +5. Search for `folder-stats` to find every folder memo at once. diff --git a/examples/explorer/src/App.tsx b/examples/explorer/src/App.tsx new file mode 100644 index 0000000..227def5 --- /dev/null +++ b/examples/explorer/src/App.tsx @@ -0,0 +1,254 @@ +import type { JSX } from '@solidjs/web'; +import { + createContext, + createEffect, + createMemo, + createSignal, + For, + Show, + useContext, +} from 'solid-js'; +import { + countEntries, + formatBytes, + PROJECT, + type Entry, + type FileEntry, + type FolderEntry, +} from './tree-data.js'; +import './styles.css'; + +interface Selection { + path: () => string; + entry: () => Entry | undefined; + select: (path: string, entry: Entry) => void; +} + +const SelectionContext = createContext(); + +function useSelection(): Selection { + const selection = useContext(SelectionContext); + if (!selection) throw new Error('SelectionProvider is missing'); + return selection; +} + +/** Owns the selection. Every row below reads it out of context. */ +function SelectionProvider(props: { children: JSX.Element }): JSX.Element { + const [path, setPath] = createSignal('app', { name: 'selected-path' }); + const [entry, setEntry] = createSignal(PROJECT, { name: 'selected-entry' }); + + const value: Selection = { + path, + entry, + select(next, item) { + setPath(next); + setEntry(() => item); + }, + }; + + return {props.children}; +} + +function FileNode(props: { entry: FileEntry; path: string; depth: number }): JSX.Element { + const selection = useSelection(); + const active = createMemo(() => selection.path() === props.path, { name: 'file-active' }); + + return ( + + ); +} + +/** + * Renders itself for every nested folder. Opening a folder mounts a component + * for each child, so the ownership tree grows with the folder. + */ +function FolderNode(props: { entry: FolderEntry; path: string; depth: number }): JSX.Element { + const selection = useSelection(); + const [open, setOpen] = createSignal(props.depth < 1, { name: 'folder-open' }); + const stats = createMemo(() => countEntries(props.entry), { name: 'folder-stats' }); + + return ( + <> + + + + {(child) => + child.kind === 'folder' ? ( + + ) : ( + + ) + } + + + + ); +} + +function Breadcrumbs(): JSX.Element { + const selection = useSelection(); + const segments = createMemo(() => selection.path().split('/'), { name: 'path-segments' }); + + return ( + + ); +} + +function Stats(props: { entry: Entry }): JSX.Element { + const totals = createMemo(() => countEntries(props.entry), { name: 'entry-totals' }); + const average = createMemo(() => (totals().files === 0 ? 0 : totals().bytes / totals().files), { + name: 'average-size', + }); + + return ( +
+
+ Files + {totals().files} +
+
+ Folders + {totals().folders} +
+
+ Size + {formatBytes(totals().bytes)} +
+
+ Average + {formatBytes(Math.round(average()))} +
+
+ ); +} + +/** Mounted and disposed by the toggle, so the tree gains and loses a subtree. */ +function PreviewPane(): JSX.Element { + const selection = useSelection(); + const lines = createMemo( + () => { + const entry = selection.entry(); + if (!entry) return []; + if (entry.kind === 'folder') { + return entry.entries.map( + (child) => `${child.kind === 'folder' ? '📁' : '📄'} ${child.name}`, + ); + } + return [ + `// ${entry.name}`, + `// ${entry.language}, ${formatBytes(entry.size)}`, + 'export function handler() {', + ' return new Response("ok");', + '}', + ]; + }, + { name: 'preview-lines' }, + ); + + createEffect( + () => selection.path(), + (path) => { + document.title = `${path} — explorer`; + }, + { name: 'sync-title' }, + ); + + return ( +
+      {(line) => 
{line}
}
+
+ ); +} + +function Inspector(): JSX.Element { + const selection = useSelection(); + const [showPreview, setShowPreview] = createSignal(true, { name: 'show-preview' }); + + return ( +
+
+ + +
+ {(entry) => } + + + +
+ ); +} + +function Explorer(): JSX.Element { + return ( +
+
+ Project +
+
+ +
+
+ ); +} + +export default function App(): JSX.Element { + return ( + +
+
+
+

Explorer

+

+ A demo app for the ownership panel. Open the toolbar, pick the tree icon, then expand + a folder and watch the components appear. +

+
+
+
+ + +
+
+
+ ); +} diff --git a/examples/explorer/src/css.d.ts b/examples/explorer/src/css.d.ts new file mode 100644 index 0000000..35306c6 --- /dev/null +++ b/examples/explorer/src/css.d.ts @@ -0,0 +1 @@ +declare module '*.css'; diff --git a/examples/explorer/src/styles.css b/examples/explorer/src/styles.css new file mode 100644 index 0000000..09a425f --- /dev/null +++ b/examples/explorer/src/styles.css @@ -0,0 +1,256 @@ +:root { + color-scheme: dark; + + --bg: oklch(0.17 0.02 265); + --surface: oklch(0.22 0.02 265); + --surface-hover: oklch(0.27 0.025 265); + --border: oklch(0.31 0.02 265); + --text: oklch(0.94 0.005 265); + --muted: oklch(0.71 0.015 265); + --accent: oklch(0.68 0.13 245); +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + background: var(--bg); + color: var(--text); + font-family: system-ui, sans-serif; +} + +.page { + max-width: 64rem; + margin: 0 auto; + padding: 3rem 1.5rem 8rem; + + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +h1 { + margin: 0; + font-size: 1.75rem; +} + +.subtitle { + margin: 0.375rem 0 0; + max-width: 42rem; + color: var(--muted); +} + +.columns { + display: grid; + grid-template-columns: minmax(16rem, 22rem) 1fr; + gap: 1rem; + align-items: start; +} + +@media (max-width: 48rem) { + .columns { + grid-template-columns: 1fr; + } +} + +.explorer, +.inspector { + border: var(--border) 1px solid; + border-radius: 0.75rem; + background: var(--surface); + overflow: hidden; +} + +.explorer-head { + padding: 0.625rem 0.875rem; + border-bottom: var(--border) 1px solid; +} + +.explorer-title { + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--muted); +} + +.rows { + display: flex; + flex-direction: column; + + padding: 0.375rem; + max-height: 26rem; + overflow: auto; +} + +.row { + display: flex; + align-items: center; + gap: 0.5rem; + + padding: 0.3125rem 0.5rem; + + border: none; + border-radius: 0.375rem; + background: none; + color: var(--text); + + font: inherit; + font-size: 0.875rem; + text-align: left; + cursor: pointer; +} + +.row:hover { + background: var(--surface-hover); +} + +.row.active { + background: color-mix(in oklch, var(--accent) 22%, transparent); +} + +.row-name { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.row-name.folder { + font-weight: 600; +} + +.row-meta { + color: var(--muted); + font-size: 0.75rem; + font-variant-numeric: tabular-nums; +} + +.chevron { + width: 0.5rem; + height: 0.5rem; + flex-shrink: 0; + + border-right: 1.5px var(--muted) solid; + border-bottom: 1.5px var(--muted) solid; + + transform: rotate(-45deg); + transition: transform 150ms ease; +} + +.chevron.open { + transform: rotate(45deg); +} + +.dot { + width: 0.5rem; + height: 0.5rem; + flex-shrink: 0; + + border-radius: 9999px; + background: var(--muted); +} + +.dot.lang-tsx { + background: oklch(0.72 0.13 245); +} + +.dot.lang-ts { + background: oklch(0.74 0.12 260); +} + +.dot.lang-css { + background: oklch(0.75 0.14 320); +} + +.inspector { + display: flex; + flex-direction: column; + min-height: 20rem; +} + +.inspector-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + + padding: 0.625rem 0.875rem; + border-bottom: var(--border) 1px solid; +} + +.breadcrumbs { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.25rem; + + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.8125rem; +} + +.crumb-sep { + color: var(--muted); +} + +.toggle { + padding: 0.3125rem 0.75rem; + + border: var(--border) 1px solid; + border-radius: 0.5rem; + background: var(--bg); + color: var(--text); + + font: inherit; + font-size: 0.8125rem; + cursor: pointer; + white-space: nowrap; +} + +.toggle:hover { + background: var(--surface-hover); +} + +.stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(7rem, 1fr)); + gap: 0.75rem; + + padding: 0.875rem; +} + +.stat { + display: flex; + flex-direction: column; + gap: 0.125rem; +} + +.stat-label { + color: var(--muted); + font-size: 0.6875rem; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.stat-value { + font-size: 1.25rem; + font-weight: 600; + font-variant-numeric: tabular-nums; +} + +.preview { + margin: 0; + padding: 0.875rem; + + border-top: var(--border) 1px solid; + background: var(--bg); + color: var(--muted); + + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.8125rem; + line-height: 1.5; + + overflow: auto; +} diff --git a/examples/explorer/src/tree-data.ts b/examples/explorer/src/tree-data.ts new file mode 100644 index 0000000..2788b08 --- /dev/null +++ b/examples/explorer/src/tree-data.ts @@ -0,0 +1,66 @@ +export interface FileEntry { + kind: 'file'; + name: string; + size: number; + language: string; +} + +export interface FolderEntry { + kind: 'folder'; + name: string; + entries: Entry[]; +} + +export type Entry = FileEntry | FolderEntry; + +function file(name: string, size: number, language: string): FileEntry { + return { kind: 'file', name, size, language }; +} + +function folder(name: string, entries: Entry[]): FolderEntry { + return { kind: 'folder', name, entries }; +} + +/** Fixed data so the server render and the client render agree. */ +export const PROJECT: FolderEntry = folder('app', [ + folder('routes', [ + file('index.tsx', 1240, 'tsx'), + file('about.tsx', 640, 'tsx'), + folder('orders', [ + file('[id].tsx', 2180, 'tsx'), + file('layout.tsx', 820, 'tsx'), + folder('components', [ + file('OrderRow.tsx', 1460, 'tsx'), + file('OrderTotals.tsx', 980, 'tsx'), + ]), + ]), + ]), + folder('lib', [ + file('db.ts', 3120, 'ts'), + file('session.ts', 1580, 'ts'), + folder('hooks', [file('use-cart.ts', 940, 'ts'), file('use-theme.ts', 520, 'ts')]), + ]), + folder('styles', [file('app.css', 2260, 'css'), file('reset.css', 410, 'css')]), + file('entry-client.tsx', 380, 'tsx'), + file('entry-server.tsx', 460, 'tsx'), +]); + +export function countEntries(entry: Entry): { files: number; folders: number; bytes: number } { + if (entry.kind === 'file') return { files: 1, folders: 0, bytes: entry.size }; + return entry.entries.reduce( + (total, child) => { + const inner = countEntries(child); + return { + files: total.files + inner.files, + folders: total.folders + inner.folders, + bytes: total.bytes + inner.bytes, + }; + }, + { files: 0, folders: 1, bytes: 0 }, + ); +} + +export function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + return `${(bytes / 1024).toFixed(1)} kB`; +} diff --git a/examples/explorer/vite.config.ts b/examples/explorer/vite.config.ts new file mode 100644 index 0000000..82bbf8e --- /dev/null +++ b/examples/explorer/vite.config.ts @@ -0,0 +1,15 @@ +import solid from '@solidjs/vite-plugin'; +import { fileURLToPath } from 'node:url'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + root: fileURLToPath(new URL('.', import.meta.url)), + plugins: [ + solid({ + // Start mode owns the entries, so the demo is just a root component. + // The plugin mounts the toolbar because the package is installed. + ssr: true, + start: { devtools: true }, + }), + ], +}); diff --git a/package.json b/package.json index 393af14..845566a 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ }, "scripts": { "build": "rolldown -c", + "demo": "pnpm build && vite --config examples/explorer/vite.config.ts", "check": "publint && attw --pack . --profile esm-only", "format": "oxfmt --write", "format:check": "oxfmt --check", @@ -59,6 +60,7 @@ "@dom-expressions/compiler": "^0.50.0-next.43", "@jridgewell/trace-mapping": "^0.3.31", "@playwright/test": "^1.62.1", + "@solidjs/vite-plugin": "3.0.0-next.35", "@solidjs/web": "^2.0.0-rc.0", "@types/node": "^24.0.0", "error-stack-parser-es": "^2.0.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 95beed4..da44693 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: '@playwright/test': specifier: ^1.62.1 version: 1.62.1 + '@solidjs/vite-plugin': + specifier: 3.0.0-next.35 + version: 3.0.0-next.35(@solidjs/web@2.0.0-rc.0(solid-js@2.0.0-rc.0))(solid-js@2.0.0-rc.0)(supports-color@7.2.0)(vite@8.2.1(@types/node@24.13.3)) '@solidjs/web': specifier: ^2.0.0-rc.0 version: 2.0.0-rc.0(solid-js@2.0.0-rc.0) @@ -71,6 +74,10 @@ importers: packages: + '@ampproject/remapping@2.3.0': + resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} + engines: {node: '>=6.0.0'} + '@andrewbranch/untar.js@1.0.4': resolution: {integrity: sha512-pVXSwPsLuw8IGLo2Di0EaOfsk+ntVvpkk942J/sHYIkwvtKUakEcPh7HBgZ6tuimgzKSEHgCvO4XgQ05DEbwDw==} @@ -83,10 +90,91 @@ packages: resolution: {integrity: sha512-9ytjzGwxjm9Uz7I9avfbt5vlQt6uk9uRRESzJjqrznl6WKvI6dwYTo+vJ3U02Wrq/mR3iql/PzhvHhKdJIAjDQ==} engines: {node: '>=20'} + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.7': + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.7': + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.8': + resolution: {integrity: sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.29.7': + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.18.6': + resolution: {integrity: sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-plugin-utils@7.29.7': + resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.7': + resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.8': + resolution: {integrity: sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-syntax-jsx@7.29.7': + resolution: {integrity: sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + '@babel/runtime@7.29.7': resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} engines: {node: '>=6.9.0'} + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.8': + resolution: {integrity: sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} + engines: {node: '>=6.9.0'} + '@braidai/lang@1.1.2': resolution: {integrity: sha512-qBcknbBufNHlui137Hft8xauQMTZDKdophmLFv05r2eNmdIv/MlPuP4TdUknHG68UdWLgVZwgxVe735HzJNIwA==} @@ -186,12 +274,21 @@ packages: '@emnapi/core@1.11.2': resolution: {integrity: sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==} + '@emnapi/core@1.11.3': + resolution: {integrity: sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==} + '@emnapi/runtime@1.11.2': resolution: {integrity: sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==} + '@emnapi/runtime@1.11.3': + resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==} + '@emnapi/wasi-threads@1.2.2': resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} + '@emnapi/wasi-threads@1.2.3': + resolution: {integrity: sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==} + '@inquirer/external-editor@1.0.3': resolution: {integrity: sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==} engines: {node: '>=18'} @@ -201,6 +298,12 @@ packages: '@types/node': optional: true + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + '@jridgewell/resolve-uri@3.1.2': resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} engines: {node: '>=6.0.0'} @@ -501,9 +604,67 @@ packages: resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==} engines: {node: '>=10'} + '@solidjs/babel-plugin@2.0.0-rc.7': + resolution: {integrity: sha512-cKcyVbOh8WC7ywV3txxfNrFRtTa7t8O3tXDXRf3RE3DKyZr+hZmbEOq6q/T6smaJXS3ekzuUDVlQrG1+HErnhg==} + peerDependencies: + '@babel/core': ^7.20.12 + '@tsrx/core': 0.1.63 + peerDependenciesMeta: + '@tsrx/core': + optional: true + + '@solidjs/compiler-darwin-arm64@2.0.0-rc.7': + resolution: {integrity: sha512-xJ7FoPrFV94LMuEPNJ2nIGFlqhVGR+s5GAr0nzMRMdWAimriF0sZ8clxlIqPS3v2oaNE9JW7EDCdJysD3JezWg==} + cpu: [arm64] + os: [darwin] + + '@solidjs/compiler-darwin-x64@2.0.0-rc.7': + resolution: {integrity: sha512-rcu8wcxeO0QWXu69yFaSf3ZR1KlsPDCzmRmdA8fX/cte96Ox0r6GfMXE+5CH/gg9dSaXY3qwwmpSG9AEvLxZQg==} + cpu: [x64] + os: [darwin] + + '@solidjs/compiler-linux-arm64-gnu@2.0.0-rc.7': + resolution: {integrity: sha512-EhiLKgLcFkHWYOx3Pds3px0onhTbzpDfenhsuDy1R7ViZYALXXBlZ+EUSE85rKwyCM0sgcR/kVMswL2V8sJXng==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@solidjs/compiler-linux-x64-gnu@2.0.0-rc.7': + resolution: {integrity: sha512-ymN3hIqzH3msWt3lcU3vqILnzRlB1rNbv1BlUqXkwVZByjFw/bJQ+BgncIq0WqNH6ciQ5nhZ1E7ECgGw/SNwEA==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@solidjs/compiler-wasm32-wasi@2.0.0-rc.7': + resolution: {integrity: sha512-k4vN+EHRtoIxj0D8d2VYHPEnWIok6kokbZskpn1pXrOoXBkj0OmqzzyJVBvSRYx6cWDWEjCKSWGuufRRZtgmjA==} + engines: {node: '>=14.0.0'} + + '@solidjs/compiler-win32-x64-msvc@2.0.0-rc.7': + resolution: {integrity: sha512-pZtrkWmiiZ/NRwyMASAitwAa4EO2jlt4z8Z5K9NoljnBreWrCsz4HmIKSIeZ+17++WwAyeZo2byon0AEv6FzRQ==} + cpu: [x64] + os: [win32] + + '@solidjs/compiler@2.0.0-rc.7': + resolution: {integrity: sha512-jhFq/QcoUM34760NuQiSvMDInZ+3AEHwA354VELRDpAJwuiMpPbEvkSH6qqfgneDzVwCiCtLbjScqUEymU7R1A==} + '@solidjs/signals@2.0.0-rc.0': resolution: {integrity: sha512-oKZSfvsCcKw1uJjOGbUkJ+OqlhXLHtZ+rShSyu9KH0lUH7UUwfMfsKeh81JPiQxDDg4YLhEwI38hg0JkwzTdvA==} + '@solidjs/vite-plugin@3.0.0-next.35': + resolution: {integrity: sha512-8Mlftd+WfZkwOoCZRyMxA8innT8b2D/qawTu+28RW/Hj4eSmStSLx4dHjYeH9MxyOwo7DQStAyHAADk5LFQVRw==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + '@solidjs/start-devtools': ^1.0.0-next.2 + '@solidjs/web': ^2.0.0-rc.0 + '@testing-library/jest-dom': ^5.16.6 || ^5.17.0 || ^6.* + solid-js: ^2.0.0-rc.0 + vite: ^8.0.0 || ^9.0.0 + peerDependenciesMeta: + '@solidjs/start-devtools': + optional: true + '@testing-library/jest-dom': + optional: true + '@solidjs/web@2.0.0-rc.0': resolution: {integrity: sha512-pYSaA9+dH8H1h/d/ZF/P2kR6omfzFGNcdzKhWTcg9fJghXhn8+5UrXUr2iYxDdYNOXZzxxFQhYHSJ7P4HKDqgw==} peerDependencies: @@ -515,6 +676,18 @@ packages: '@tybys/wasm-util@0.10.3': resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -863,6 +1036,11 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + baseline-browser-mapping@2.11.21: + resolution: {integrity: sha512-uh8vpY/1/YyFkunIDFH/12p7/7VdPKA1hejMVEbdkEaWnUz0Hesvx5EbiU6XxjyHZIOju+ZMbQJkRh+es3/spQ==} + engines: {node: '>=6.0.0'} + hasBin: true + better-path-resolve@1.0.0: resolution: {integrity: sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g==} engines: {node: '>=4'} @@ -871,6 +1049,14 @@ packages: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} + browserslist@4.28.9: + resolution: {integrity: sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + caniuse-lite@1.0.30001810: + resolution: {integrity: sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==} + ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} @@ -938,6 +1124,15 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} @@ -966,6 +1161,9 @@ packages: oxc-resolver: optional: true + electron-to-chromium@1.5.426: + resolution: {integrity: sha512-2Gcq6inCQs/AqfHP3f5ftCzk+pqeW2VlA1LgmPqEj2hfBO8HiZRspNYkD4HzFBe4u6lGqca4BspFr6Ix4Q400g==} + emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} @@ -976,6 +1174,10 @@ packages: resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==} engines: {node: '>=8.6'} + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + environment@1.1.0: resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} engines: {node: '>=18'} @@ -1050,6 +1252,10 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + get-caller-file@2.0.5: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} @@ -1082,6 +1288,9 @@ packages: highlight.js@10.7.3: resolution: {integrity: sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==} + html-entities@2.3.3: + resolution: {integrity: sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA==} + html-to-image@1.11.13: resolution: {integrity: sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==} @@ -1120,6 +1329,10 @@ packages: resolution: {integrity: sha512-2AT6j+gXe/1ueqbW6fLZJiIw3F8iXGJtt0yDrZaBhAZEG1raiTxKWU+IPqMCzQAXOUCKdA4UDMgacKH25XG2Cw==} engines: {node: '>=4'} + is-what@4.1.16: + resolution: {integrity: sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A==} + engines: {node: '>=12.13'} + is-windows@1.0.2: resolution: {integrity: sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==} engines: {node: '>=0.10.0'} @@ -1127,6 +1340,9 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + js-yaml@3.15.1: resolution: {integrity: sha512-S99WuO3HlhO3XN41EtYUNl9zzXjoJx7QvmipxsJVxtCBT0YHEFy+iOJhjSvrmV12nYhWpZaM8lPHkJm0yUMbag==} hasBin: true @@ -1135,6 +1351,16 @@ packages: resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==} hasBin: true + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + jsonfile@4.0.0: resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==} @@ -1223,6 +1449,9 @@ packages: resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} engines: {node: 20 || >=22} + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -1240,6 +1469,10 @@ packages: mdast-util-to-hast@13.2.1: resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==} + merge-anything@5.1.7: + resolution: {integrity: sha512-eRtbOb1N5iyH0tkQDAoQ4Ipsp/5qSR79Dzrz8hEPxRX10RWWR/iQXdoKmBSRCThY1Fh5EhISDtpSc93fpxUniQ==} + engines: {node: '>=12.13'} + merge2@1.4.1: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} @@ -1267,6 +1500,9 @@ packages: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} engines: {node: '>=4'} + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} @@ -1279,6 +1515,10 @@ packages: resolution: {integrity: sha512-Z3lTE9pLaJF47NyMhd4ww1yFTAP8YhYI8SleJiHzM46Fgpm5cnNzSl9XfzFNqbaz+VlJrIj3fXQ4DeN1Rjm6cw==} engines: {node: '>=18'} + node-releases@2.0.55: + resolution: {integrity: sha512-mIrE/Cw9y+9Au6dS5vDKDhQza9YvG6w+ZrS6X+ZzA7yFW/soAeaups4Qzn1bL6g5FVy8WtP79+0j82oPIbqRjQ==} + engines: {node: '>=18'} + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -1344,6 +1584,9 @@ packages: parse5@6.0.1: resolution: {integrity: sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==} + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -1469,6 +1712,10 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + semver@7.8.5: resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} engines: {node: '>=10'} @@ -1649,6 +1896,15 @@ packages: resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} engines: {node: '>= 4.0.0'} + update-browserslist-db@1.3.2: + resolution: {integrity: sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + validate-html-nesting@1.2.4: + resolution: {integrity: sha512-doQi7e8EJ2OWneSG1aZpJluS6A49aZM0+EICXWKm1i6WvqTLmq0tpUcImc4KTWG50mORO0C4YDBtOCSYvElftw==} + validate-npm-package-name@5.0.1: resolution: {integrity: sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} @@ -1702,6 +1958,14 @@ packages: yaml: optional: true + vitefu@1.1.3: + resolution: {integrity: sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==} + peerDependencies: + vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + vite: + optional: true + vitest@4.1.11: resolution: {integrity: sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -1761,6 +2025,9 @@ packages: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + yargs-parser@20.2.9: resolution: {integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==} engines: {node: '>=10'} @@ -1783,6 +2050,11 @@ packages: snapshots: + '@ampproject/remapping@2.3.0': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + '@andrewbranch/untar.js@1.0.4': {} '@arethetypeswrong/cli@0.18.5': @@ -1806,8 +2078,119 @@ snapshots: typescript: 5.6.1-rc validate-npm-package-name: 5.0.1 + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.7': {} + + '@babel/core@7.29.7(supports-color@7.2.0)': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/helpers': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.8(supports-color@7.2.0) + '@babel/types': 7.29.8 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3(supports-color@7.2.0) + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.8': + dependencies: + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.29.7': + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + browserslist: 4.28.9 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.29.7': {} + + '@babel/helper-module-imports@7.18.6': + dependencies: + '@babel/types': 7.29.8 + + '@babel/helper-module-imports@7.29.7(supports-color@7.2.0)': + dependencies: + '@babel/traverse': 7.29.8(supports-color@7.2.0) + '@babel/types': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-module-imports': 7.29.7(supports-color@7.2.0) + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.8(supports-color@7.2.0) + transitivePeerDependencies: + - supports-color + + '@babel/helper-plugin-utils@7.29.7': {} + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/helper-validator-option@7.29.7': {} + + '@babel/helpers@7.29.7': + dependencies: + '@babel/template': 7.29.7 + '@babel/types': 7.29.8 + + '@babel/parser@7.29.8': + dependencies: + '@babel/types': 7.29.8 + + '@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-plugin-utils': 7.29.7 + '@babel/runtime@7.29.7': {} + '@babel/template@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + + '@babel/traverse@7.29.8(supports-color@7.2.0)': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/template': 7.29.7 + '@babel/types': 7.29.8 + debug: 4.4.3(supports-color@7.2.0) + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.8': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@braidai/lang@1.1.2': {} '@changesets/apply-release-plan@7.1.1': @@ -1993,16 +2376,32 @@ snapshots: tslib: 2.8.1 optional: true + '@emnapi/core@1.11.3': + dependencies: + '@emnapi/wasi-threads': 1.2.3 + tslib: 2.8.1 + optional: true + '@emnapi/runtime@1.11.2': dependencies: tslib: 2.8.1 optional: true + '@emnapi/runtime@1.11.3': + dependencies: + tslib: 2.8.1 + optional: true + '@emnapi/wasi-threads@1.2.2': dependencies: tslib: 2.8.1 optional: true + '@emnapi/wasi-threads@1.2.3': + dependencies: + tslib: 2.8.1 + optional: true + '@inquirer/external-editor@1.0.3(@types/node@24.13.3)': dependencies: chardet: 2.2.0 @@ -2010,6 +2409,16 @@ snapshots: optionalDependencies: '@types/node': 24.13.3 + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + '@jridgewell/resolve-uri@3.1.2': {} '@jridgewell/sourcemap-codec@1.5.5': {} @@ -2046,6 +2455,13 @@ snapshots: '@tybys/wasm-util': 0.10.3 optional: true + '@napi-rs/wasm-runtime@1.2.3(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)': + dependencies: + '@emnapi/core': 1.11.3 + '@emnapi/runtime': 1.11.3 + '@tybys/wasm-util': 0.10.3 + optional: true + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -2211,8 +2627,65 @@ snapshots: '@sindresorhus/is@4.6.0': {} + '@solidjs/babel-plugin@2.0.0-rc.7(@babel/core@7.29.7(supports-color@7.2.0))': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-module-imports': 7.18.6 + '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/types': 7.29.8 + html-entities: 2.3.3 + parse5: 7.3.0 + validate-html-nesting: 1.2.4 + + '@solidjs/compiler-darwin-arm64@2.0.0-rc.7': + optional: true + + '@solidjs/compiler-darwin-x64@2.0.0-rc.7': + optional: true + + '@solidjs/compiler-linux-arm64-gnu@2.0.0-rc.7': + optional: true + + '@solidjs/compiler-linux-x64-gnu@2.0.0-rc.7': + optional: true + + '@solidjs/compiler-wasm32-wasi@2.0.0-rc.7': + dependencies: + '@emnapi/core': 1.11.3 + '@emnapi/runtime': 1.11.3 + '@napi-rs/wasm-runtime': 1.2.3(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3) + optional: true + + '@solidjs/compiler-win32-x64-msvc@2.0.0-rc.7': + optional: true + + '@solidjs/compiler@2.0.0-rc.7': + optionalDependencies: + '@solidjs/compiler-darwin-arm64': 2.0.0-rc.7 + '@solidjs/compiler-darwin-x64': 2.0.0-rc.7 + '@solidjs/compiler-linux-arm64-gnu': 2.0.0-rc.7 + '@solidjs/compiler-linux-x64-gnu': 2.0.0-rc.7 + '@solidjs/compiler-wasm32-wasi': 2.0.0-rc.7 + '@solidjs/compiler-win32-x64-msvc': 2.0.0-rc.7 + '@solidjs/signals@2.0.0-rc.0': {} + '@solidjs/vite-plugin@3.0.0-next.35(@solidjs/web@2.0.0-rc.0(solid-js@2.0.0-rc.0))(solid-js@2.0.0-rc.0)(supports-color@7.2.0)(vite@8.2.1(@types/node@24.13.3))': + dependencies: + '@ampproject/remapping': 2.3.0 + '@babel/core': 7.29.7(supports-color@7.2.0) + '@solidjs/babel-plugin': 2.0.0-rc.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@solidjs/compiler': 2.0.0-rc.7 + '@solidjs/web': 2.0.0-rc.0(solid-js@2.0.0-rc.0) + '@types/babel__core': 7.20.5 + merge-anything: 5.1.7 + solid-js: 2.0.0-rc.0 + vite: 8.2.1(@types/node@24.13.3) + vitefu: 1.1.3(vite@8.2.1(@types/node@24.13.3)) + transitivePeerDependencies: + - '@tsrx/core' + - supports-color + '@solidjs/web@2.0.0-rc.0(solid-js@2.0.0-rc.0)': dependencies: seroval: 1.5.6 @@ -2226,6 +2699,27 @@ snapshots: tslib: 2.8.1 optional: true + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.29.8 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.29.8 + '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 @@ -2454,6 +2948,8 @@ snapshots: assertion-error@2.0.1: {} + baseline-browser-mapping@2.11.21: {} + better-path-resolve@1.0.0: dependencies: is-windows: 1.0.2 @@ -2462,6 +2958,16 @@ snapshots: dependencies: fill-range: 7.1.1 + browserslist@4.28.9: + dependencies: + baseline-browser-mapping: 2.11.21 + caniuse-lite: 1.0.30001810 + electron-to-chromium: 1.5.426 + node-releases: 2.0.55 + update-browserslist-db: 1.3.2(browserslist@4.28.9) + + caniuse-lite@1.0.30001810: {} + ccount@2.0.1: {} chai@6.2.2: {} @@ -2524,6 +3030,12 @@ snapshots: csstype@3.2.3: {} + debug@4.4.3(supports-color@7.2.0): + dependencies: + ms: 2.1.3 + optionalDependencies: + supports-color: 7.2.0 + dequal@2.0.3: {} detect-indent@6.1.0: {} @@ -2540,6 +3052,8 @@ snapshots: dts-resolver@3.0.0: {} + electron-to-chromium@1.5.426: {} + emoji-regex@8.0.0: {} emojilib@2.4.0: {} @@ -2549,6 +3063,8 @@ snapshots: ansi-colors: 4.1.3 strip-ansi: 6.0.1 + entities@6.0.1: {} + environment@1.1.0: {} error-stack-parser-es@2.0.1: {} @@ -2612,6 +3128,8 @@ snapshots: fsevents@2.3.3: optional: true + gensync@1.0.0-beta.2: {} + get-caller-file@2.0.5: {} get-tsconfig@5.0.0-beta.5: @@ -2655,6 +3173,8 @@ snapshots: highlight.js@10.7.3: {} + html-entities@2.3.3: {} + html-to-image@1.11.13: {} html-void-elements@3.0.0: {} @@ -2681,10 +3201,14 @@ snapshots: dependencies: better-path-resolve: 1.0.0 + is-what@4.1.16: {} + is-windows@1.0.2: {} isexe@2.0.0: {} + js-tokens@4.0.0: {} + js-yaml@3.15.1: dependencies: argparse: 1.0.10 @@ -2694,6 +3218,10 @@ snapshots: dependencies: argparse: 2.0.1 + jsesc@3.1.0: {} + + json5@2.2.3: {} + jsonfile@4.0.0: optionalDependencies: graceful-fs: 4.2.11 @@ -2755,6 +3283,10 @@ snapshots: lru-cache@11.5.2: {} + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -2784,6 +3316,10 @@ snapshots: unist-util-visit: 5.1.0 vfile: 6.0.3 + merge-anything@5.1.7: + dependencies: + is-what: 4.1.16 + merge2@1.4.1: {} micromark-util-character@2.1.1: @@ -2810,6 +3346,8 @@ snapshots: mri@1.2.0: {} + ms@2.1.3: {} + mz@2.7.0: dependencies: any-promise: 1.3.0 @@ -2825,6 +3363,8 @@ snapshots: emojilib: 2.4.0 skin-tone: 2.0.0 + node-releases@2.0.55: {} + object-assign@4.1.1: {} obug@2.1.4: {} @@ -2893,6 +3433,10 @@ snapshots: parse5@6.0.1: {} + parse5@7.3.0: + dependencies: + entities: 6.0.1 + path-exists@4.0.0: {} path-key@3.1.1: {} @@ -3007,6 +3551,8 @@ snapshots: safer-buffer@2.1.2: {} + semver@6.3.1: {} + semver@7.8.5: {} seroval-plugins@1.5.6(seroval@1.5.6): @@ -3188,6 +3734,14 @@ snapshots: universalify@0.1.2: {} + update-browserslist-db@1.3.2(browserslist@4.28.9): + dependencies: + browserslist: 4.28.9 + escalade: 3.2.0 + picocolors: 1.1.1 + + validate-html-nesting@1.2.4: {} + validate-npm-package-name@5.0.1: {} vfile-message@4.0.3: @@ -3211,6 +3765,10 @@ snapshots: '@types/node': 24.13.3 fsevents: 2.3.3 + vitefu@1.1.3(vite@8.2.1(@types/node@24.13.3)): + optionalDependencies: + vite: 8.2.1(@types/node@24.13.3) + vitest@4.1.11(@types/node@24.13.3)(vite@8.2.1(@types/node@24.13.3)): dependencies: '@vitest/expect': 4.1.11 @@ -3255,6 +3813,8 @@ snapshots: y18n@5.0.8: {} + yallist@3.1.1: {} + yargs-parser@20.2.9: {} yargs@16.2.2: diff --git a/src/dev-toolbar/ownership/tree.test.ts b/src/dev-toolbar/ownership/tree.test.ts index 8049604..2b77470 100644 --- a/src/dev-toolbar/ownership/tree.test.ts +++ b/src/dev-toolbar/ownership/tree.test.ts @@ -85,6 +85,12 @@ describe('ownerName', () => { expect(ownerName(owner({ component: '' }), 'component')).toBe(''); }); + it('drops the hot reload tag from a component name', () => { + expect(ownerName(owner({ component: '[solid-refresh]Counter' }), 'component')).toBe( + '', + ); + }); + it('falls back to the kind when an owner has no name', () => { expect(ownerName(owner({ name: 'count' }), 'memo')).toBe('count'); expect(ownerName(owner(), 'scope')).toBe('scope'); diff --git a/src/dev-toolbar/ownership/tree.ts b/src/dev-toolbar/ownership/tree.ts index 6aad353..b02cba4 100644 --- a/src/dev-toolbar/ownership/tree.ts +++ b/src/dev-toolbar/ownership/tree.ts @@ -98,9 +98,15 @@ export function ownerKind(owner: RawNode): OwnerKind { return 'scope'; } +/** The hot reload transform wraps components, and its wrapper carries the tag. */ +const REFRESH_PREFIX = '[solid-refresh]'; + export function ownerName(owner: RawNode, kind: OwnerKind): string { if (kind === 'component') { - const name = owner._component?.name; + let name = owner._component?.name; + if (typeof name === 'string' && name.startsWith(REFRESH_PREFIX)) { + name = name.slice(REFRESH_PREFIX.length); + } return `<${typeof name === 'string' && name.length > 0 ? name : 'Anonymous'}>`; } const name = owner._name; diff --git a/tsconfig.tests.json b/tsconfig.tests.json index a03c1e2..4a40776 100644 --- a/tsconfig.tests.json +++ b/tsconfig.tests.json @@ -1,9 +1,17 @@ { "extends": "./tsconfig.json", - "include": ["playwright.config.ts", "vitest.config.ts", "src/**/*.test.ts", "tests"], + "include": [ + "playwright.config.ts", + "vitest.config.ts", + "src/**/*.test.ts", + "tests", + "examples" + ], "exclude": [], "compilerOptions": { "noEmit": true, - "types": ["node"] + "types": [ + "node" + ] } } From 76ff6fa750cd625850f6955b92408cedf07016a6 Mon Sep 17 00:00:00 2001 From: "Alexis H. Munsayac" Date: Fri, 11 Sep 2026 22:02:17 +0800 Subject: [PATCH 3/4] 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 --- .changeset/ownership-tree.md | 1 + README.md | 4 +++ examples/explorer/README.md | 5 ++-- src/dev-toolbar/ownership/index.tsx | 23 +++++++++++++++++ src/dev-toolbar/ownership/styles.css | 31 +++++++++++++++++++++++ src/dev-toolbar/ownership/tree.test.ts | 12 +++++++++ src/dev-toolbar/ownership/tree.ts | 34 ++++++++++++++++++++------ 7 files changed, 100 insertions(+), 10 deletions(-) diff --git a/.changeset/ownership-tree.md b/.changeset/ownership-tree.md index a4765da..07438d0 100644 --- a/.changeset/ownership-tree.md +++ b/.changeset/ownership-tree.md @@ -6,4 +6,5 @@ Add an ownership tree panel to the dev toolbar. 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. Selecting a row lists its prop names, the signals it holds with their values, the scopes folded into it and its children. +Components show where they are declared, and clicking the location opens the file in your editor. Rows flash when an owner is created, and the tree can be searched by component, scope or signal. diff --git a/README.md b/README.md index 68a6089..ab32689 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,10 @@ Selecting a row lists its prop names, the signals it holds with their values, th folded into it and its children. Prop values are getters, so the panel lists their names and never reads them. +A component also shows where it is declared. The location comes from the hot reload +transform, which `@solidjs/vite-plugin` runs in development, so it is there without any +extra setup. Clicking it asks the dev server to open the file in your editor. + The panel reads the tree through the development hooks in `solid-js`, so it is empty in a production build of the runtime. It only watches while it is open. diff --git a/examples/explorer/README.md b/examples/explorer/README.md index 031011f..a27b35b 100644 --- a/examples/explorer/README.md +++ b/examples/explorer/README.md @@ -26,5 +26,6 @@ and mounts the toolbar itself. `examples/explorer/src/App.tsx` is the whole app. 1. Open the ownership panel and expand `routes` in the app. New rows appear and flash. 2. Hide the preview. `` and the memo and effect it owns leave the tree. 3. Select `` and see the two signals every row depends on. -4. Switch to owner mode to see the roots, memos and effects that component mode folds away. -5. Search for `folder-stats` to find every folder memo at once. +4. Click the file location under a component name to open it in your editor. +5. Switch to owner mode to see the roots, memos and effects that component mode folds away. +6. Search for `folder-stats` to find every folder memo at once. diff --git a/src/dev-toolbar/ownership/index.tsx b/src/dev-toolbar/ownership/index.tsx index 853e777..d287472 100644 --- a/src/dev-toolbar/ownership/index.tsx +++ b/src/dev-toolbar/ownership/index.tsx @@ -16,6 +16,14 @@ import { import { ancestorsOf, EMPTY_TREE, type OwnershipTree, type TreeNode } from './tree.js'; import './styles.css'; +/** + * 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; @@ -303,6 +311,21 @@ export default function OwnershipViewer(props: OwnershipViewerProps): JSX.Elemen {node().kind} + + {(location) => ( + + )} + + 0}> {selectedPath().join(' › ')} diff --git a/src/dev-toolbar/ownership/styles.css b/src/dev-toolbar/ownership/styles.css index 515f7ed..72dac48 100644 --- a/src/dev-toolbar/ownership/styles.css +++ b/src/dev-toolbar/ownership/styles.css @@ -287,6 +287,26 @@ min-width: 0; } +[data-solid-ownership-location] { + align-self: flex-start; + + padding: 0.125rem 0.375rem; + + border: var(--start-dt-border) 1px solid; + border-radius: 0.375rem; + background: var(--start-dt-surface); + color: var(--start-dt-accent); + + cursor: pointer; + + max-width: 100%; + overflow: hidden; +} + +[data-solid-ownership-location]:hover { + background: var(--start-dt-surface-hover); +} + [data-solid-ownership-path] { color: var(--start-dt-text-muted); } @@ -336,6 +356,17 @@ gap: 0.375rem; min-width: 0; + overflow: hidden; +} + +[data-solid-ownership-scope-name] > [data-solid-text-size] { + overflow: hidden; + text-overflow: ellipsis; +} + +[data-solid-ownership-signal] > [data-solid-text-size]:first-child { + overflow: hidden; + text-overflow: ellipsis; } [data-solid-ownership-signal-value] { diff --git a/src/dev-toolbar/ownership/tree.test.ts b/src/dev-toolbar/ownership/tree.test.ts index 2b77470..dbd1cf4 100644 --- a/src/dev-toolbar/ownership/tree.test.ts +++ b/src/dev-toolbar/ownership/tree.test.ts @@ -93,6 +93,7 @@ describe('ownerName', () => { it('falls back to the kind when an owner has no name', () => { expect(ownerName(owner({ name: 'count' }), 'memo')).toBe('count'); + expect(ownerName(owner({ name: '[solid-refresh]Counter' }), 'memo')).toBe('Counter'); expect(ownerName(owner(), 'scope')).toBe('scope'); }); }); @@ -121,6 +122,17 @@ describe('buildOwnershipTree in component mode', () => { expect(tree.nodes[0]!.signals.map((entry) => entry.name)).toEqual(['outer', 'inner']); }); + it('reads the source location the hot reload transform records', () => { + const root = owner({ component: 'App' }); + root._component.fn.location = 'src/App.tsx:12:0'; + + expect(build([root]).nodes[0]!.location).toBe('src/App.tsx:12:0'); + }); + + it('has no location for a component compiled without the transform', () => { + expect(build([owner({ component: 'App' })]).nodes[0]!.location).toBeUndefined(); + }); + it('lists prop names of a component', () => { const root = owner({ component: 'Greeting' }); root._component.props = { name: 'ada', greeting: 'hi' }; diff --git a/src/dev-toolbar/ownership/tree.ts b/src/dev-toolbar/ownership/tree.ts index b02cba4..2432235 100644 --- a/src/dev-toolbar/ownership/tree.ts +++ b/src/dev-toolbar/ownership/tree.ts @@ -61,6 +61,8 @@ export interface TreeNode { disposed: boolean; /** Owners this node stands in for, when scopes are folded away. */ scopes: FoldedScope[]; + /** Where the component is declared, as `file:line:column`. */ + location: string | undefined; } export interface OwnershipTree { @@ -101,17 +103,32 @@ export function ownerKind(owner: RawNode): OwnerKind { /** The hot reload transform wraps components, and its wrapper carries the tag. */ const REFRESH_PREFIX = '[solid-refresh]'; +function withoutRefreshTag(name: unknown): string | undefined { + if (typeof name !== 'string' || name.length === 0) return undefined; + return name.startsWith(REFRESH_PREFIX) ? name.slice(REFRESH_PREFIX.length) : name; +} + export function ownerName(owner: RawNode, kind: OwnerKind): string { if (kind === 'component') { - let name = owner._component?.name; - if (typeof name === 'string' && name.startsWith(REFRESH_PREFIX)) { - name = name.slice(REFRESH_PREFIX.length); - } - return `<${typeof name === 'string' && name.length > 0 ? name : 'Anonymous'}>`; + return `<${withoutRefreshTag(owner._component?.name) ?? 'Anonymous'}>`; + } + // The memo the hot reload wrapper creates carries the same tag. + return withoutRefreshTag(owner._name) ?? KIND_LABELS[kind]; +} + +/** + * Where a component is declared. + * + * 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 { + try { + const location = owner._component?.fn?.location; + return typeof location === 'string' && location.length > 0 ? location : undefined; + } catch { + return undefined; } - const name = owner._name; - if (typeof name === 'string' && name.length > 0) return name; - return KIND_LABELS[kind]; } function propNames(owner: RawNode): string[] | undefined { @@ -166,6 +183,7 @@ export function buildOwnershipTree(roots: RawNode[], options: BuildOptions): Own children: [], signals: [], props: kind === 'component' ? propNames(owner) : undefined, + location: kind === 'component' ? componentLocation(owner) : undefined, value: '_value' in owner ? owner._value : undefined, hasValue: '_value' in owner, disposed: isDisposed(owner), From 8b170e5e7e017ae748965d2723fe8389f272c837 Mon Sep 17 00:00:00 2001 From: "Alexis H. Munsayac" Date: Fri, 11 Sep 2026 22:04:44 +0800 Subject: [PATCH 4/4] feat: list the ancestry of the selected owner The detail pane gains an ancestry section below the children. - Frames run nearest first, numbered like a stack, with the selected owner as frame zero. - Each frame shows its kind, name and source location, and clicking one selects that owner, so you can walk back up the tree. - The breadcrumb line above the props is gone, because the ancestry says the same thing with more detail. Co-Authored-By: Claude Opus 5 --- .changeset/ownership-tree.md | 2 +- README.md | 3 +- src/dev-toolbar/ownership/index.tsx | 60 ++++++++++++++++++++----- src/dev-toolbar/ownership/styles.css | 67 ++++++++++++++++++++++++++-- tests/e2e/devtools.spec.ts | 10 +++++ 5 files changed, 125 insertions(+), 17 deletions(-) diff --git a/.changeset/ownership-tree.md b/.changeset/ownership-tree.md index 07438d0..1fd94e9 100644 --- a/.changeset/ownership-tree.md +++ b/.changeset/ownership-tree.md @@ -5,6 +5,6 @@ Add an ownership tree panel to the dev toolbar. 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. -Selecting a row lists its prop names, the signals it holds with their values, the scopes folded into it and its children. +Selecting a row lists its prop names, the signals it holds with their values, the scopes folded into it, its children, and the ancestry it was created under. Components show where they are declared, and clicking the location opens the file in your editor. Rows flash when an owner is created, and the tree can be searched by component, scope or signal. diff --git a/README.md b/README.md index ab32689..3c88ba0 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,8 @@ above, so a component shows every signal, memo and effect created inside it. Own shows every owner instead, including roots, memos and effects. Selecting a row lists its prop names, the signals it holds with their values, the scopes -folded into it and its children. Prop values are getters, so the panel lists their names +folded into it, its children, and the ancestry it was created under. Every frame of the +ancestry is clickable, so you can walk back up the tree. Prop values are getters, so the panel lists their names and never reads them. A component also shows where it is declared. The location comes from the hot reload diff --git a/src/dev-toolbar/ownership/index.tsx b/src/dev-toolbar/ownership/index.tsx index d287472..aa849e4 100644 --- a/src/dev-toolbar/ownership/index.tsx +++ b/src/dev-toolbar/ownership/index.tsx @@ -151,14 +151,18 @@ export default function OwnershipViewer(props: OwnershipViewerProps): JSX.Elemen return id ? byId().get(id) : undefined; }); - const selectedPath = createMemo(() => { + const ancestry = createMemo(() => { const id = selected(); if (!id) return []; const nodes = byId(); - return ancestorsOf(nodes, id) - .map((parent) => nodes.get(parent)?.name) - .filter((name): name is string => !!name) - .reverse(); + const current = nodes.get(id); + if (!current) return []; + const frames = [current]; + for (const parent of ancestorsOf(nodes, id)) { + const owner = nodes.get(parent); + if (owner) frames.push(owner); + } + return frames; }); return ( @@ -326,12 +330,6 @@ export default function OwnershipViewer(props: OwnershipViewerProps): JSX.Elemen )} - 0}> - - {selectedPath().join(' › ')} - - -
Value @@ -475,6 +473,46 @@ export default function OwnershipViewer(props: OwnershipViewerProps): JSX.Elemen
+ +
+ + {`Ancestry (${ancestry().length})`} + +
+ + {(frame, index) => ( + + )} + +
+ + The owners this one was created under, nearest first. + +
)}
diff --git a/src/dev-toolbar/ownership/styles.css b/src/dev-toolbar/ownership/styles.css index 72dac48..256b5d9 100644 --- a/src/dev-toolbar/ownership/styles.css +++ b/src/dev-toolbar/ownership/styles.css @@ -307,10 +307,6 @@ background: var(--start-dt-surface-hover); } -[data-solid-ownership-path] { - color: var(--start-dt-text-muted); -} - [data-solid-ownership-detail-block] { display: flex; flex-direction: column; @@ -377,6 +373,69 @@ white-space: nowrap; } +[data-solid-ownership-stack] { + display: flex; + flex-direction: column; + + gap: 0.125rem; +} + +[data-solid-ownership-frame] { + display: flex; + flex-direction: row; + align-items: center; + + gap: 0.375rem; + + padding: 0.1875rem 0.375rem; + + border: none; + border-radius: 0.375rem; + background: none; + color: var(--start-dt-text); + + text-align: left; + cursor: pointer; + + min-width: 0; +} + +[data-solid-ownership-frame]:hover { + background: var(--start-dt-surface-hover); +} + +[data-solid-ownership-frame][data-current] { + background: var(--start-dt-surface-active); +} + +[data-solid-ownership-frame-index] { + width: 1rem; + flex-shrink: 0; + + color: var(--start-dt-text-muted); + + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.625rem; + text-align: right; +} + +[data-solid-ownership-frame-name] { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +[data-solid-ownership-frame-location] { + margin-left: auto; + + color: var(--start-dt-text-muted); + + direction: rtl; + overflow: hidden; + text-overflow: ellipsis; + max-width: 55%; +} + [data-solid-ownership-chips] { display: flex; flex-wrap: wrap; diff --git a/tests/e2e/devtools.spec.ts b/tests/e2e/devtools.spec.ts index 5515702..95bd4f8 100644 --- a/tests/e2e/devtools.spec.ts +++ b/tests/e2e/devtools.spec.ts @@ -98,6 +98,16 @@ test('maps the ownership tree', async ({ page }) => { await expect(detail).toContainText('Props (1)'); await expect(detail).toContainText('name'); + // The ancestry section lists the owners above the selection, nearest first. + const frames = detail.locator('[data-solid-ownership-frame]'); + await expect(frames).toHaveCount(2); + await expect(frames.first()).toContainText(''); + await expect(frames.nth(1)).toContainText(''); + + // Clicking a frame walks up the tree. + await frames.nth(1).click(); + await expect(detail.locator('[data-solid-ownership-detail-head]')).toContainText(''); + // Owner mode adds the scopes that component mode folds away. await page.getByRole('button', { name: 'Owners', exact: true }).click(); await expect(rows.filter({ hasText: 'doubled' })).toHaveCount(1);