From f744157f003aa6f9afacf15dde36f8c8df20eaa2 Mon Sep 17 00:00:00 2001 From: "Alexis H. Munsayac" Date: Fri, 11 Sep 2026 21:45:16 +0800 Subject: [PATCH 01/12] 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 02/12] 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 03/12] 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 04/12] 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); From 92c6ba97605d52edae0e72eca186ab543f89de9f Mon Sep 17 00:00:00 2001 From: "Alexis H. Munsayac" Date: Tue, 15 Sep 2026 01:27:52 +0800 Subject: [PATCH 05/12] style: format tsconfig.tests.json The file was rewritten by a script that expanded its arrays one entry per line, which oxfmt rejects. Co-Authored-By: Claude Opus 5 --- tsconfig.tests.json | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/tsconfig.tests.json b/tsconfig.tests.json index 4a40776..5d98845 100644 --- a/tsconfig.tests.json +++ b/tsconfig.tests.json @@ -1,17 +1,9 @@ { "extends": "./tsconfig.json", - "include": [ - "playwright.config.ts", - "vitest.config.ts", - "src/**/*.test.ts", - "tests", - "examples" - ], + "include": ["playwright.config.ts", "vitest.config.ts", "src/**/*.test.ts", "tests", "examples"], "exclude": [], "compilerOptions": { "noEmit": true, - "types": [ - "node" - ] + "types": ["node"] } } From 414ea1e164bc2ce72735fe02849f9395d8b47e82 Mon Sep 17 00:00:00 2001 From: "Alexis H. Munsayac" Date: Tue, 15 Sep 2026 01:44:26 +0800 Subject: [PATCH 06/12] feat: open ownership entries in the reactivity graph - Tree rows no longer show signal and scope count badges. - Signals, memos and effects in the ownership detail pane get a View in graph action. It switches to the reactivity graph and selects that node. - The graph clears a search or kind filter that would hide the node, and centers the view on it. - The two panels keep separate ids, so tree entries keep the runtime node and the graph looks up its own id from it. Co-Authored-By: Claude Opus 5 --- src/dev-toolbar/index.tsx | 14 ++++++-- src/dev-toolbar/ownership/index.tsx | 40 ++++++++++++++++++---- src/dev-toolbar/ownership/styles.css | 24 +++++++++++++- src/dev-toolbar/ownership/tree.test.ts | 20 ++++++++++- src/dev-toolbar/ownership/tree.ts | 6 ++++ src/dev-toolbar/reactivity/index.tsx | 46 ++++++++++++++++++++++++++ src/dev-toolbar/reactivity/registry.ts | 5 +++ tests/e2e/devtools.spec.ts | 18 ++++++++++ 8 files changed, 163 insertions(+), 10 deletions(-) diff --git a/src/dev-toolbar/index.tsx b/src/dev-toolbar/index.tsx index 5927f34..4165267 100644 --- a/src/dev-toolbar/index.tsx +++ b/src/dev-toolbar/index.tsx @@ -145,6 +145,10 @@ export function DevToolbar(props: DevToolbarProps) { const [content, setContent] = createSignal<'fn' | 'err' | 'rx' | 'own' | undefined>(undefined); + // A panel can ask the graph to show a node. Each request is a new object, so + // asking for the same node twice still moves the view. + const [graphFocus, setGraphFocus] = createSignal<{ node: object }>(); + function toggleContent(value: 'fn' | 'err' | 'rx' | 'own') { if (content() === value) { setContent(undefined); @@ -234,8 +238,14 @@ export function DevToolbar(props: DevToolbarProps) { - - + + { + setGraphFocus({ node }); + setContent('rx'); + }} + /> void; } export default function OwnershipViewer(props: OwnershipViewerProps): JSX.Element { @@ -280,12 +282,6 @@ export default function OwnershipViewer(props: OwnershipViewerProps): JSX.Elemen > {row.node.name} - 0}> - {`${row.node.signals.length} signals`} - - 0}> - {`${row.node.scopes.length} scopes`} - disposed @@ -400,6 +396,19 @@ export default function OwnershipViewer(props: OwnershipViewerProps): JSX.Elemen > {previewValue(signal.value)} + + + )} @@ -434,6 +443,25 @@ export default function OwnershipViewer(props: OwnershipViewerProps): JSX.Elemen > {scope.hasValue ? previewValue(scope.value) : scope.kind} + + + )} diff --git a/src/dev-toolbar/ownership/styles.css b/src/dev-toolbar/ownership/styles.css index 256b5d9..45740a8 100644 --- a/src/dev-toolbar/ownership/styles.css +++ b/src/dev-toolbar/ownership/styles.css @@ -331,7 +331,7 @@ [data-solid-ownership-signal] { display: grid; - grid-template-columns: minmax(4rem, 40%) 1fr; + grid-template-columns: minmax(4rem, 40%) 1fr auto; align-items: baseline; gap: 0.5rem; @@ -345,6 +345,28 @@ background: var(--start-dt-surface-hover); } +/* The action stays out of the way until the row is pointed at or focused. */ +[data-solid-ownership-view-graph] { + padding: 0 0.375rem; + + border: none; + border-radius: 0.25rem; + background: none; + color: var(--start-dt-accent); + + cursor: pointer; + opacity: 0; +} + +[data-solid-ownership-signal]:hover [data-solid-ownership-view-graph], +[data-solid-ownership-view-graph]:focus-visible { + opacity: 1; +} + +[data-solid-ownership-view-graph]:hover { + background: var(--start-dt-accent-soft); +} + [data-solid-ownership-scope-name] { display: inline-flex; align-items: center; diff --git a/src/dev-toolbar/ownership/tree.test.ts b/src/dev-toolbar/ownership/tree.test.ts index dbd1cf4..cd2f7ee 100644 --- a/src/dev-toolbar/ownership/tree.test.ts +++ b/src/dev-toolbar/ownership/tree.test.ts @@ -108,7 +108,14 @@ describe('buildOwnershipTree in component mode', () => { expect(names(tree.nodes)).toEqual(['', '']); expect(tree.nodes[0]!.scopes).toEqual([ - { id: expect.any(String), kind: 'memo', name: 'total', value: 7, hasValue: true }, + { + id: expect.any(String), + kind: 'memo', + name: 'total', + value: 7, + hasValue: true, + node: memo, + }, ]); expect(tree.nodes[0]!.children).toEqual([tree.nodes[1]!.id]); }); @@ -133,6 +140,17 @@ describe('buildOwnershipTree in component mode', () => { expect(build([owner({ component: 'App' })]).nodes[0]!.location).toBeUndefined(); }); + it('keeps the runtime nodes so another panel can find them', () => { + const inner = signal('inner', 1); + const scope = owner({ memo: true, name: 'derived', signals: [inner] }); + const root = owner({ component: 'App', children: [scope] }); + + const tree = build([root]); + + expect(tree.nodes[0]!.signals[0]!.node).toBe(inner); + expect(tree.nodes[0]!.scopes[0]!.node).toBe(scope); + }); + 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 2432235..5618081 100644 --- a/src/dev-toolbar/ownership/tree.ts +++ b/src/dev-toolbar/ownership/tree.ts @@ -30,6 +30,8 @@ export interface OwnedSignal { id: string; name: string; value: unknown; + /** The runtime node, so another panel can find the same signal. */ + node: RawNode; } /** A scope folded into the component above it, such as a memo or an effect. */ @@ -39,6 +41,8 @@ export interface FoldedScope { name: string; value: unknown; hasValue: boolean; + /** The runtime owner, so another panel can find the same memo or effect. */ + node: RawNode; } export interface TreeNode { @@ -202,6 +206,7 @@ export function buildOwnershipTree(roots: RawNode[], options: BuildOptions): Own id: options.identify(signal), name: typeof name === 'string' && name.length > 0 ? name : 'signal', value: signal._value, + node: signal, }); } } @@ -245,6 +250,7 @@ export function buildOwnershipTree(roots: RawNode[], options: BuildOptions): Own name: ownerName(owner, kind), value: '_value' in owner ? owner._value : undefined, hasValue: '_value' in owner, + node: owner, }); collectSignals(owner, host); } diff --git a/src/dev-toolbar/reactivity/index.tsx b/src/dev-toolbar/reactivity/index.tsx index b263cd8..cea0a8b 100644 --- a/src/dev-toolbar/reactivity/index.tsx +++ b/src/dev-toolbar/reactivity/index.tsx @@ -11,6 +11,7 @@ import { ValueInspector } from './ValueInspector.js'; import { EMPTY_GRAPH, excludeReactiveOwner, + reactiveNodeId, isReactivityAvailable, snapshotReactivityGraph, startReactivityTracking, @@ -88,6 +89,11 @@ function NodeSummary(props: { node: ReactiveNode }): JSX.Element { export interface ReactivityViewerProps { show?: boolean; + /** + * A node another panel asked to show. Pass a new object for every request, + * so asking for the same node twice still moves the view. + */ + focus?: { node: object }; } export default function ReactivityViewer(props: ReactivityViewerProps): JSX.Element { @@ -106,6 +112,8 @@ export default function ReactivityViewer(props: ReactivityViewerProps): JSX.Elem let viewport: HTMLDivElement | undefined; let fittedSize = ''; let moved = false; + let appliedFocus: { node: object } | undefined; + let pendingCenter: string | undefined; function refresh(): void { const next = snapshotReactivityGraph(); @@ -242,11 +250,49 @@ export default function ReactivityViewer(props: ReactivityViewerProps): JSX.Elem }); } + /** Moves the view so a node sits in the middle. False when the canvas is not there yet. */ + function centerOn(position: { x: number; y: number }): boolean { + const box = viewport?.getBoundingClientRect(); + if (!box || box.width === 0) return false; + moved = true; + setView((current) => ({ + k: current.k, + x: box.width / 2 - (position.x + NODE_WIDTH / 2) * current.k, + y: box.height / 2 - (position.y + NODE_HEIGHT / 2) * current.k, + })); + return true; + } + + // Another panel can ask for a node. Filters that would hide it are cleared, + // and the request waits for a snapshot that contains the node. + createEffect( + () => ({ request: props.focus, nodes: graph().nodes, visible: !!props.show }), + ({ request, nodes, visible }) => { + if (!request || !visible || request === appliedFocus) return; + const id = reactiveNodeId(request.node); + const target = nodes.find((node) => node.id === id); + if (!target) return; + appliedFocus = request; + setQuery(''); + setHiddenKinds((current) => current.filter((kind) => kind !== target.kind)); + setSelected(id); + const position = lastLayout?.nodes.get(id); + if (!position || !centerOn(position)) pendingCenter = id; + }, + ); + // Refit while the graph grows. Once the user pans or zooms, the view is // theirs and only the fit button moves it. createEffect( () => layout(), (current) => { + if (pendingCenter) { + const position = current.nodes.get(pendingCenter); + if (position && centerOn(position)) { + pendingCenter = undefined; + return; + } + } const size = `${current.width}x${current.height}`; if (moved || current.width === 0 || size === fittedSize) return; fittedSize = size; diff --git a/src/dev-toolbar/reactivity/registry.ts b/src/dev-toolbar/reactivity/registry.ts index 263e270..7941fe7 100644 --- a/src/dev-toolbar/reactivity/registry.ts +++ b/src/dev-toolbar/reactivity/registry.ts @@ -119,6 +119,11 @@ function idOf(node: RawNode): string { return id; } +/** The graph's id for a runtime node, so another panel can point the graph at it. */ +export function reactiveNodeId(node: object): string { + return idOf(node as RawNode); +} + function track(node: RawNode | null | undefined): void { if (!node || typeof node !== 'object' || trackedRefs.has(node)) return; const ref = new WeakRef(node); diff --git a/tests/e2e/devtools.spec.ts b/tests/e2e/devtools.spec.ts index a43af42..dc95d2d 100644 --- a/tests/e2e/devtools.spec.ts +++ b/tests/e2e/devtools.spec.ts @@ -184,6 +184,24 @@ test('maps the ownership tree', async ({ page }) => { await page.getByRole('button', { name: 'Components', exact: true }).click(); await page.locator('[data-solid-ownership-search]').fill('doubled'); await expect(rows).toHaveText(['', '']); + + // Rows carry no count badges. + await expect(page.locator('[data-solid-ownership-row] [data-solid-badge="info"]')).toHaveCount(0); + + // A signal opens in the reactivity graph with that node selected. + await page.locator('[data-solid-ownership-search]').fill(''); + await page.locator('[data-solid-ownership-label]').filter({ hasText: '' }).click(); + const countEntry = detail + .locator('[data-solid-ownership-signal]') + .filter({ has: page.getByText('count', { exact: true }) }); + await countEntry.hover(); + await countEntry.getByRole('button', { name: 'View in graph' }).click(); + await expect( + page + .locator('[data-solid-reactivity-node]') + .filter({ hasText: /^count/ }) + .first(), + ).toHaveAttribute('data-solid-reactivity-node', 'selected'); }); test('mounts once and disposes', async ({ page }) => { From 8a889d307b8c4c591804382150991ee1adf63d30 Mon Sep 17 00:00:00 2001 From: "Alexis H. Munsayac" Date: Tue, 15 Sep 2026 01:47:02 +0800 Subject: [PATCH 07/12] fix: place the graph hover card above or below the node The hover card sat beside the node, aligned to its top, so it covered the node and the nodes in the next column. - The card is centered on the node and placed under it, or over it when the space below is too short. - It stays inside the canvas, and a caret points at the node it describes. - The card height is measured after each render, so the choice of side uses its real size. - Placement lives in `placeHoverCard`, covered by unit tests, and the e2e test checks the card never overlaps the hovered node. Co-Authored-By: Claude Opus 5 --- src/dev-toolbar/reactivity/index.tsx | 49 +++++++++++++++----- src/dev-toolbar/reactivity/layout.test.ts | 55 ++++++++++++++++++++++- src/dev-toolbar/reactivity/layout.ts | 49 ++++++++++++++++++++ src/dev-toolbar/reactivity/styles.css | 33 ++++++++++++++ tests/e2e/devtools.spec.ts | 7 +++ 5 files changed, 182 insertions(+), 11 deletions(-) diff --git a/src/dev-toolbar/reactivity/index.tsx b/src/dev-toolbar/reactivity/index.tsx index cea0a8b..1154b9d 100644 --- a/src/dev-toolbar/reactivity/index.tsx +++ b/src/dev-toolbar/reactivity/index.tsx @@ -6,7 +6,14 @@ import Placeholder from '../../ui/Placeholder.js'; import { Text } from '../../ui/Text.js'; import { FitIcon, GraphIcon, PauseIcon, PlayIcon } from '../icons.js'; import { formatValue, typeName } from './format.js'; -import { edgePath, layoutGraph, NODE_HEIGHT, NODE_WIDTH, type GraphLayout } from './layout.js'; +import { + edgePath, + layoutGraph, + NODE_HEIGHT, + NODE_WIDTH, + placeHoverCard, + type GraphLayout, +} from './layout.js'; import { ValueInspector } from './ValueInspector.js'; import { EMPTY_GRAPH, @@ -355,6 +362,10 @@ export default function ReactivityViewer(props: ReactivityViewerProps): JSX.Elem ); } + let hoverCardElement: HTMLDivElement | undefined; + // Measured after each render, so the next placement knows how tall the card is. + let hoverCardHeight = 160; + const hoverCard = createMemo(() => { const id = hovered(); if (!id || id === selected()) return undefined; @@ -362,16 +373,26 @@ export default function ReactivityViewer(props: ReactivityViewerProps): JSX.Elem const position = layout().nodes.get(id); if (!node || !position) return undefined; const current = view(); - const width = viewport?.clientWidth ?? 0; - const left = current.x + (position.x + NODE_WIDTH) * current.k + 12; - const flip = width > 0 && left + HOVER_CARD_WIDTH > width; - return { - node, - x: flip ? Math.max(8, current.x + position.x * current.k - HOVER_CARD_WIDTH - 12) : left, - y: Math.max(8, current.y + position.y * current.k - 8), - }; + const placement = placeHoverCard({ + node: { + left: current.x + position.x * current.k, + top: current.y + position.y * current.k, + width: NODE_WIDTH * current.k, + height: NODE_HEIGHT * current.k, + }, + canvas: { width: viewport?.clientWidth ?? 0, height: viewport?.clientHeight ?? 0 }, + card: { width: HOVER_CARD_WIDTH, height: hoverCardHeight }, + }); + return { node, placement }; }); + createEffect( + () => hoverCard(), + () => { + if (hoverCardElement) hoverCardHeight = hoverCardElement.offsetHeight; + }, + ); + const selectedNode = createMemo(() => { const id = selected(); return id ? nodesById().get(id) : undefined; @@ -552,7 +573,15 @@ export default function ReactivityViewer(props: ReactivityViewerProps): JSX.Elem {(card) => (
{ + hoverCardElement = element; + }} + style={{ + left: `${card().placement.left}px`, + top: `${card().placement.y}px`, + '--start-dt-caret-x': `${card().placement.caret}px`, + }} >
diff --git a/src/dev-toolbar/reactivity/layout.test.ts b/src/dev-toolbar/reactivity/layout.test.ts index 68cc41c..cc3ebe8 100644 --- a/src/dev-toolbar/reactivity/layout.test.ts +++ b/src/dev-toolbar/reactivity/layout.test.ts @@ -1,5 +1,12 @@ import { describe, expect, it } from 'vitest'; -import { edgePath, layoutGraph, LAYER_GAP, NODE_HEIGHT, NODE_WIDTH } from './layout.js'; +import { + edgePath, + layoutGraph, + LAYER_GAP, + NODE_HEIGHT, + NODE_WIDTH, + placeHoverCard, +} from './layout.js'; const node = (id: string) => ({ id, name: id, kind: 'signal' as const }); const effect = (id: string) => ({ id, name: id, kind: 'effect' as const }); @@ -136,3 +143,49 @@ describe('edgePath', () => { expect(edgePath(from, to)).toContain(`C ${NODE_WIDTH + 40} ${NODE_HEIGHT / 2}`); }); }); + +describe('placeHoverCard', () => { + const canvas = { width: 800, height: 600 }; + const card = { width: 288, height: 150 }; + const node = (left: number, top: number) => ({ left, top, width: 150, height: 44 }); + + it('puts the card under the node when there is room', () => { + const placement = placeHoverCard({ node: node(300, 100), canvas, card }); + + expect(placement.side).toBe('below'); + expect(placement.y).toBe(100 + 44 + 8); + }); + + it('puts the card over the node when the space below is too short', () => { + const placement = placeHoverCard({ node: node(300, 520), canvas, card }); + + expect(placement.side).toBe('above'); + expect(placement.y).toBe(520 - 8); + }); + + it('never overlaps the node vertically', () => { + for (const top of [0, 120, 300, 450, 556]) { + const placement = placeHoverCard({ node: node(300, top), canvas, card }); + const cardTop = placement.side === 'below' ? placement.y : placement.y - card.height; + const cardBottom = cardTop + card.height; + expect(cardBottom <= top || cardTop >= top + 44).toBe(true); + } + }); + + it('centers the card on the node', () => { + const placement = placeHoverCard({ node: node(300, 100), canvas, card }); + + expect(placement.left + card.width / 2).toBe(300 + 75); + expect(placement.caret).toBe(card.width / 2); + }); + + it('keeps the card inside the canvas and points the caret at the node', () => { + const atLeft = placeHoverCard({ node: node(0, 100), canvas, card }); + const atRight = placeHoverCard({ node: node(650, 100), canvas, card }); + + expect(atLeft.left).toBe(8); + expect(atLeft.caret).toBe(75 - 8); + expect(atRight.left).toBe(800 - 288 - 8); + expect(atRight.left + atRight.caret).toBe(650 + 75); + }); +}); diff --git a/src/dev-toolbar/reactivity/layout.ts b/src/dev-toolbar/reactivity/layout.ts index b226424..a2d176b 100644 --- a/src/dev-toolbar/reactivity/layout.ts +++ b/src/dev-toolbar/reactivity/layout.ts @@ -217,6 +217,55 @@ export function layoutGraph( }; } +export interface HoverCardInput { + /** The hovered node's box, in canvas pixels. */ + node: { left: number; top: number; width: number; height: number }; + canvas: { width: number; height: number }; + card: { width: number; height: number }; + /** Space between the node and the card. */ + gap?: number; + /** Space kept between the card and the canvas edge. */ + margin?: number; +} + +export interface HoverCardPlacement { + left: number; + /** The card's top edge when below the node, its bottom edge when above. */ + y: number; + side: 'above' | 'below'; + /** Where the caret points, measured from the card's left edge. */ + caret: number; +} + +/** + * Places the hover card under or over the node, never beside it. A card beside + * the node covers the nodes in the next column. + * + * The card prefers the side under the node. It moves over the node only when + * the space below is too short and the space above is larger. + */ +export function placeHoverCard(input: HoverCardInput): HoverCardPlacement { + const gap = input.gap ?? 8; + const margin = input.margin ?? 8; + const { node, canvas, card } = input; + + const center = node.left + node.width / 2; + const maxLeft = Math.max(margin, canvas.width - card.width - margin); + const left = Math.min(Math.max(center - card.width / 2, margin), maxLeft); + + const bottom = node.top + node.height; + const roomBelow = canvas.height - bottom - gap - margin; + const roomAbove = node.top - gap - margin; + const side = roomBelow >= card.height || roomBelow >= roomAbove ? 'below' : 'above'; + + return { + left, + y: side === 'below' ? bottom + gap : node.top - gap, + side, + caret: Math.min(Math.max(center - left, 12), card.width - 12), + }; +} + /** Curve from the right edge of one node to the left edge of another. */ export function edgePath(from: LayoutNode, to: LayoutNode): string { const startX = from.x + NODE_WIDTH; diff --git a/src/dev-toolbar/reactivity/styles.css b/src/dev-toolbar/reactivity/styles.css index 809a8f1..c40ef8c 100644 --- a/src/dev-toolbar/reactivity/styles.css +++ b/src/dev-toolbar/reactivity/styles.css @@ -367,6 +367,39 @@ z-index: 1; } +/* Above the node, the card hangs from its bottom edge. */ +[data-solid-reactivity-hovercard][data-placement='above'] { + transform: translateY(-100%); +} + +/* The caret points at the node the card describes. */ +[data-solid-reactivity-hovercard]::before { + content: ''; + + position: absolute; + left: var(--start-dt-caret-x, 50%); + + width: 0.625rem; + height: 0.625rem; + + border: var(--start-dt-border) 1px solid; + background: var(--start-dt-bg); + + transform: translateX(-50%) rotate(45deg); +} + +[data-solid-reactivity-hovercard][data-placement='below']::before { + top: -0.3125rem; + border-right: none; + border-bottom: none; +} + +[data-solid-reactivity-hovercard][data-placement='above']::before { + bottom: -0.3125rem; + border-left: none; + border-top: none; +} + [data-solid-reactivity-card-head] { display: flex; flex-direction: row; diff --git a/tests/e2e/devtools.spec.ts b/tests/e2e/devtools.spec.ts index dc95d2d..2601626 100644 --- a/tests/e2e/devtools.spec.ts +++ b/tests/e2e/devtools.spec.ts @@ -110,6 +110,13 @@ test('maps the reactivity graph', async ({ page }) => { await expect(card).toContainText('number'); await expect(card).toContainText('2 out'); + // The card sits above or below the node, never over it. + const cardBox = (await card.boundingBox())!; + const nodeBox = (await count.boundingBox())!; + expect(cardBox.y + cardBox.height <= nodeBox.y || cardBox.y >= nodeBox.y + nodeBox.height).toBe( + true, + ); + // Selecting a node lists what reads it and dims the rest of the graph. await count.click(); const detail = page.locator('[data-solid-reactivity-detail]'); From 4c92a9142206b0038f169627204b550a978fd003 Mon Sep 17 00:00:00 2001 From: "Alexis H. Munsayac" Date: Tue, 15 Sep 2026 01:52:16 +0800 Subject: [PATCH 08/12] fix: tidy the detail panes and the View in graph action - The detail pane of the graph and of the ownership tree only appears once something is selected, so the canvas and the tree use the full width otherwise. - View in graph is an eye icon instead of text. Its accessible name is still View in graph. - The jump centers the node on the next frame. Selecting it mounts the detail pane, which narrows the canvas, so centering right away left the node off center by half the pane width. Co-Authored-By: Claude Opus 5 --- src/dev-toolbar/icons.tsx | 26 +++++++++++++++ src/dev-toolbar/ownership/index.tsx | 35 ++++++------------- src/dev-toolbar/ownership/styles.css | 12 ++++++- src/dev-toolbar/reactivity/index.tsx | 50 ++++++++++++++-------------- tests/e2e/devtools.spec.ts | 19 +++++++++++ 5 files changed, 92 insertions(+), 50 deletions(-) diff --git a/src/dev-toolbar/icons.tsx b/src/dev-toolbar/icons.tsx index 73704ef..4f5f0f9 100644 --- a/src/dev-toolbar/icons.tsx +++ b/src/dev-toolbar/icons.tsx @@ -635,3 +635,29 @@ export function FitIcon(props: JSX.IntrinsicElements['svg'] & { title: string }) ); } + +export function EyeIcon(props: JSX.IntrinsicElements['svg'] & { title: string }): JSX.Element { + return ( + + {props.title} + + + + ); +} diff --git a/src/dev-toolbar/ownership/index.tsx b/src/dev-toolbar/ownership/index.tsx index aa87a39..e97885f 100644 --- a/src/dev-toolbar/ownership/index.tsx +++ b/src/dev-toolbar/ownership/index.tsx @@ -4,7 +4,7 @@ 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 { CollapseIcon, ExpandIcon, EyeIcon, PauseIcon, PlayIcon, TreeIcon } from '../icons.js'; import { previewValue, typeName } from './format.js'; import { excludeOwner, @@ -292,16 +292,9 @@ export default function OwnershipViewer(props: OwnershipViewerProps): JSX.Elemen - + )} + diff --git a/src/dev-toolbar/ownership/styles.css b/src/dev-toolbar/ownership/styles.css index 45740a8..6250fb5 100644 --- a/src/dev-toolbar/ownership/styles.css +++ b/src/dev-toolbar/ownership/styles.css @@ -347,7 +347,11 @@ /* The action stays out of the way until the row is pointed at or focused. */ [data-solid-ownership-view-graph] { - padding: 0 0.375rem; + display: inline-flex; + align-items: center; + justify-content: center; + + padding: 0.125rem; border: none; border-radius: 0.25rem; @@ -367,6 +371,12 @@ background: var(--start-dt-accent-soft); } +/* The toolbar sizes every svg as a large icon. Row actions need a small one. */ +[data-solid-ownership-signal] [data-solid-ownership-view-graph] svg { + width: 0.875rem; + height: 0.875rem; +} + [data-solid-ownership-scope-name] { display: inline-flex; align-items: center; diff --git a/src/dev-toolbar/reactivity/index.tsx b/src/dev-toolbar/reactivity/index.tsx index 1154b9d..3149b8c 100644 --- a/src/dev-toolbar/reactivity/index.tsx +++ b/src/dev-toolbar/reactivity/index.tsx @@ -120,7 +120,7 @@ export default function ReactivityViewer(props: ReactivityViewerProps): JSX.Elem let fittedSize = ''; let moved = false; let appliedFocus: { node: object } | undefined; - let pendingCenter: string | undefined; + let centerFrame: number | undefined; function refresh(): void { const next = snapshotReactivityGraph(); @@ -270,6 +270,21 @@ export default function ReactivityViewer(props: ReactivityViewerProps): JSX.Elem return true; } + /** + * Centers a node on the next frame. Selecting a node mounts the detail pane, + * which narrows the canvas, so measuring right away would center against the + * old width. A few frames are allowed for the layout to include the node. + */ + function centerSoon(id: string, attempts = 5): void { + if (centerFrame !== undefined) cancelAnimationFrame(centerFrame); + centerFrame = requestAnimationFrame(() => { + centerFrame = undefined; + const position = lastLayout?.nodes.get(id); + if (position && centerOn(position)) return; + if (attempts > 1) centerSoon(id, attempts - 1); + }); + } + // Another panel can ask for a node. Filters that would hide it are cleared, // and the request waits for a snapshot that contains the node. createEffect( @@ -283,8 +298,9 @@ export default function ReactivityViewer(props: ReactivityViewerProps): JSX.Elem setQuery(''); setHiddenKinds((current) => current.filter((kind) => kind !== target.kind)); setSelected(id); - const position = lastLayout?.nodes.get(id); - if (!position || !centerOn(position)) pendingCenter = id; + // Keeps the auto fit from taking the view back before the frame runs. + moved = true; + centerSoon(id); }, ); @@ -293,13 +309,6 @@ export default function ReactivityViewer(props: ReactivityViewerProps): JSX.Elem createEffect( () => layout(), (current) => { - if (pendingCenter) { - const position = current.nodes.get(pendingCenter); - if (position && centerOn(position)) { - pendingCenter = undefined; - return; - } - } const size = `${current.width}x${current.height}`; if (moved || current.width === 0 || size === fittedSize) return; fittedSize = size; @@ -597,18 +606,9 @@ export default function ReactivityViewer(props: ReactivityViewerProps): JSX.Elem - + )} + diff --git a/tests/e2e/devtools.spec.ts b/tests/e2e/devtools.spec.ts index 2601626..81fa63c 100644 --- a/tests/e2e/devtools.spec.ts +++ b/tests/e2e/devtools.spec.ts @@ -117,6 +117,9 @@ test('maps the reactivity graph', async ({ page }) => { true, ); + // The detail pane only appears once a node is selected. + await expect(page.locator('[data-solid-reactivity-detail]')).toHaveCount(0); + // Selecting a node lists what reads it and dims the rest of the graph. await count.click(); const detail = page.locator('[data-solid-reactivity-detail]'); @@ -161,6 +164,9 @@ test('maps the ownership tree', async ({ page }) => { await toggle.click(); await expect(rows).toHaveText(['', '', '', '']); + // The detail pane only appears once an owner is selected. + await expect(page.locator('[data-solid-ownership-detail]')).toHaveCount(0); + // 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]'); @@ -209,6 +215,19 @@ test('maps the ownership tree', async ({ page }) => { .filter({ hasText: /^count/ }) .first(), ).toHaveAttribute('data-solid-reactivity-node', 'selected'); + + // The node lands in the middle of the canvas, measured after the detail pane + // has narrowed it. + await expect + .poll(async () => { + const canvasBox = (await page.locator('[data-solid-reactivity-canvas]').boundingBox())!; + const nodeBox = (await page + .locator('[data-solid-reactivity-node="selected"]') + .first() + .boundingBox())!; + return Math.abs(nodeBox.x + nodeBox.width / 2 - (canvasBox.x + canvasBox.width / 2)); + }) + .toBeLessThan(4); }); test('mounts once and disposes', async ({ page }) => { From 1e045181cb47691dc735ff9d69c3f7366ecb9b7e Mon Sep 17 00:00:00 2001 From: "Alexis H. Munsayac" Date: Tue, 15 Sep 2026 01:54:12 +0800 Subject: [PATCH 09/12] fix: keep the graph watching after View in graph Opening the graph from the ownership panel stopped its live updates. Each panel installed the dev hooks itself, kept the hooks it found, and put them back when it stopped. View in graph starts the graph and stops the ownership panel in the same flush. The graph installed first, then the ownership panel put back the hooks it had found earlier, which removed the graph's hooks. - A shared hub in `dev-hooks.ts` installs the hooks once and calls every listener. It still calls the hooks it found, and puts them back only when the last listener leaves. - Both registries listen through the hub, so panels can start and stop in any order. - Unit tests cover a listener that keeps working when an older one leaves, and the e2e test updates the app after the jump and checks the graph shows it. Co-Authored-By: Claude Opus 5 --- src/dev-toolbar/dev-hooks.test.ts | 86 ++++++++++++++++++++++++++ src/dev-toolbar/dev-hooks.ts | 81 ++++++++++++++++++++++++ src/dev-toolbar/ownership/registry.ts | 58 +++++++---------- src/dev-toolbar/reactivity/registry.ts | 66 ++++++++------------ tests/e2e/devtools.spec.ts | 11 ++++ 5 files changed, 228 insertions(+), 74 deletions(-) create mode 100644 src/dev-toolbar/dev-hooks.test.ts create mode 100644 src/dev-toolbar/dev-hooks.ts diff --git a/src/dev-toolbar/dev-hooks.test.ts b/src/dev-toolbar/dev-hooks.test.ts new file mode 100644 index 0000000..6faf176 --- /dev/null +++ b/src/dev-toolbar/dev-hooks.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createDevHooksHub, type DevHooksSlot } from './dev-hooks.js'; + +describe('createDevHooksHub', () => { + it('leaves the slot alone until something listens', () => { + const original = () => {}; + const slot: DevHooksSlot = { onUpdate: original }; + + createDevHooksHub(slot); + + expect(slot.onUpdate).toBe(original); + }); + + it('calls the hooks it found and every listener', () => { + const found = vi.fn(); + const slot: DevHooksSlot = { onUpdate: found }; + const hub = createDevHooksHub(slot); + const first = vi.fn(); + const second = vi.fn(); + + hub.listen({ onUpdate: first }); + hub.listen({ onUpdate: second }); + slot.onUpdate!(); + + expect(found).toHaveBeenCalledTimes(1); + expect(first).toHaveBeenCalledTimes(1); + expect(second).toHaveBeenCalledTimes(1); + }); + + // Opening the graph from the ownership panel starts one listener and stops + // another in the same flush. The listener that started must keep working. + it('keeps a listener working when an older one leaves', () => { + const slot: DevHooksSlot = {}; + const hub = createDevHooksHub(slot); + const tree = vi.fn(); + const graph = vi.fn(); + + const stopTree = hub.listen({ onOwner: tree }); + hub.listen({ onOwner: graph }); + stopTree(); + slot.onOwner!({}); + + expect(graph).toHaveBeenCalledTimes(1); + expect(tree).not.toHaveBeenCalled(); + }); + + it('puts the original hooks back when the last listener leaves', () => { + const original = () => {}; + const slot: DevHooksSlot = { onGraph: original }; + const hub = createDevHooksHub(slot); + + const stopFirst = hub.listen({}); + const stopSecond = hub.listen({}); + stopFirst(); + expect(slot.onGraph).not.toBe(original); + stopSecond(); + + expect(slot.onGraph).toBe(original); + }); + + it('ignores a stop called twice', () => { + const slot: DevHooksSlot = {}; + const hub = createDevHooksHub(slot); + const graph = vi.fn(); + + const stop = hub.listen({}); + hub.listen({ onUpdate: graph }); + stop(); + stop(); + slot.onUpdate!(); + + expect(graph).toHaveBeenCalledTimes(1); + }); + + it('installs again after every listener left', () => { + const slot: DevHooksSlot = {}; + const hub = createDevHooksHub(slot); + const graph = vi.fn(); + + hub.listen({})(); + hub.listen({ onOwner: graph }); + slot.onOwner!({}); + + expect(graph).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/dev-toolbar/dev-hooks.ts b/src/dev-toolbar/dev-hooks.ts new file mode 100644 index 0000000..9345e74 --- /dev/null +++ b/src/dev-toolbar/dev-hooks.ts @@ -0,0 +1,81 @@ +import { DEV } from 'solid-js'; + +/** The dev hooks of solid-js the toolbar uses. */ +export interface DevHooksSlot { + onOwner?: (owner: any) => void; + onGraph?: (value: any, owner: any) => void; + onUpdate?: () => void; +} + +export type DevHooksListener = DevHooksSlot; + +export interface DevHooksHub { + /** Adds a listener. The returned function removes it. */ + listen(listener: DevHooksListener): () => void; +} + +/** + * Shares one set of dev hooks between every panel. + * + * Each panel used to install the hooks itself, keeping the hooks it found and + * putting them back when it stopped. When one panel started and another stopped + * in the same flush, the second put back hooks that no longer belonged there, + * and the first panel stopped seeing updates. + * + * The hub installs once and calls every listener. It keeps calling the hooks it + * found, so other tools sharing the slot still work, and puts them back only + * when the last listener leaves. + */ +export function createDevHooksHub(slot: DevHooksSlot): DevHooksHub { + const listeners = new Set(); + let original: DevHooksSlot | undefined; + + function install(): void { + const found: DevHooksSlot = { + onOwner: slot.onOwner, + onGraph: slot.onGraph, + onUpdate: slot.onUpdate, + }; + original = found; + slot.onOwner = (owner) => { + found.onOwner?.(owner); + for (const listener of listeners) listener.onOwner?.(owner); + }; + slot.onGraph = (value, owner) => { + found.onGraph?.(value, owner); + for (const listener of listeners) listener.onGraph?.(value, owner); + }; + slot.onUpdate = () => { + found.onUpdate?.(); + for (const listener of listeners) listener.onUpdate?.(); + }; + } + + function uninstall(): void { + if (!original) return; + slot.onOwner = original.onOwner; + slot.onGraph = original.onGraph; + slot.onUpdate = original.onUpdate; + original = undefined; + } + + return { + listen(listener) { + listeners.add(listener); + if (!original) install(); + return () => { + if (!listeners.delete(listener)) return; + if (listeners.size === 0) uninstall(); + }; + }, + }; +} + +let shared: DevHooksHub | undefined; + +/** Listens to the dev hooks of solid-js. Does nothing outside a development build. */ +export function listenToDevHooks(listener: DevHooksListener): () => void { + if (!DEV) return () => {}; + shared ??= createDevHooksHub(DEV.hooks); + return shared.listen(listener); +} diff --git a/src/dev-toolbar/ownership/registry.ts b/src/dev-toolbar/ownership/registry.ts index 8315315..1ba98a2 100644 --- a/src/dev-toolbar/ownership/registry.ts +++ b/src/dev-toolbar/ownership/registry.ts @@ -1,4 +1,5 @@ import { DEV } from 'solid-js'; +import { listenToDevHooks } from '../dev-hooks.js'; import { buildOwnershipTree, EMPTY_TREE, type OwnershipTree, type RawNode } from './tree.js'; let nextId = 1; @@ -15,7 +16,7 @@ const collected = : undefined; const listeners = new Set<() => void>(); -let uninstall: (() => void) | undefined; +let stopListening: (() => void) | undefined; let watchers = 0; let frame: number | undefined; /** An owner inside the toolbar. The walk climbs from here to the app root. */ @@ -52,49 +53,36 @@ function notify(): void { } /** - * Installs the devtools hooks on the reactive runtime. Existing hooks are kept - * and still called, so other tools sharing the slot keep working. + * Starts watching the reactive runtime. The hooks are shared with the other + * panels through one hub, so panels can start and stop in any order. */ 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; - }; + stopListening ??= listenToDevHooks({ + onOwner(owner) { + track(owner as RawNode); + notify(); + }, + onGraph(_value, owner) { + if (owner) track(owner as RawNode); + notify(); + }, + onUpdate() { + notify(); + }, + }); return release; } -/** Drops one watcher. The hooks come off once nothing watches any more. */ +/** Drops one watcher. Stops listening once nothing watches any more. */ function release(): void { watchers = Math.max(0, watchers - 1); - if (watchers === 0) uninstall?.(); + if (watchers > 0 || !stopListening) return; + stopListening(); + stopListening = undefined; + if (frame !== undefined) cancelAnimationFrame(frame); + frame = undefined; } /** Calls `listener` after the tree changed, at most once per frame. */ diff --git a/src/dev-toolbar/reactivity/registry.ts b/src/dev-toolbar/reactivity/registry.ts index 7941fe7..84e1379 100644 --- a/src/dev-toolbar/reactivity/registry.ts +++ b/src/dev-toolbar/reactivity/registry.ts @@ -1,4 +1,5 @@ import { DEV } from 'solid-js'; +import { listenToDevHooks } from '../dev-hooks.js'; // Flag bits used by @solidjs/signals. They are internal to the runtime, so the // values are copied here and every read is defensive. @@ -99,7 +100,7 @@ const collected = : undefined; const listeners = new Set<() => void>(); -let uninstall: (() => void) | undefined; +let stopListening: (() => void) | undefined; let watchers = 0; let frame: number | undefined; /** An owner inside the toolbar. The graph walk climbs from here to the app root. */ @@ -141,46 +142,29 @@ function notify(): void { } /** - * Installs the devtools hooks on the reactive runtime. Existing hooks are kept - * and still called, so other tools sharing the slot keep working. + * Starts watching the reactive runtime. The hooks are shared with the other + * panels through one hub, so panels can start and stop in any order. */ export function startReactivityTracking(): () => void { if (!isReactivityAvailable()) 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 (value && typeof value === 'object') { - if (owner) signalOwners.set(value, owner as RawNode); - track(value as RawNode); - } - notify(); - }; - hooks.onUpdate = () => { - previousUpdate?.(); - recordUpdates(); - notify(); - }; - - uninstall = () => { - hooks.onOwner = previousOwner; - hooks.onGraph = previousGraph; - hooks.onUpdate = previousUpdate; - uninstall = undefined; - if (frame !== undefined) cancelAnimationFrame(frame); - frame = undefined; - }; + stopListening ??= listenToDevHooks({ + onOwner(owner) { + track(owner as RawNode); + notify(); + }, + onGraph(value, owner) { + if (value && typeof value === 'object') { + if (owner) signalOwners.set(value, owner as RawNode); + track(value as RawNode); + } + notify(); + }, + onUpdate() { + recordUpdates(); + notify(); + }, + }); return release; } @@ -197,10 +181,14 @@ function recordUpdates(): void { } } -/** Drops one watcher. The hooks come off once nothing watches any more. */ +/** Drops one watcher. Stops listening once nothing watches any more. */ function release(): void { watchers = Math.max(0, watchers - 1); - if (watchers === 0) uninstall?.(); + if (watchers > 0 || !stopListening) return; + stopListening(); + stopListening = undefined; + if (frame !== undefined) cancelAnimationFrame(frame); + frame = undefined; } /** Calls `listener` after the graph changed, at most once per frame. */ diff --git a/tests/e2e/devtools.spec.ts b/tests/e2e/devtools.spec.ts index 81fa63c..152bfc3 100644 --- a/tests/e2e/devtools.spec.ts +++ b/tests/e2e/devtools.spec.ts @@ -228,6 +228,17 @@ test('maps the ownership tree', async ({ page }) => { return Math.abs(nodeBox.x + nodeBox.width / 2 - (canvasBox.x + canvasBox.width / 2)); }) .toBeLessThan(4); + + // The graph keeps watching after the jump. The ownership panel stops in the + // same flush the graph starts, which used to take the graph's hooks away. + // The panel covers the page, so the click goes straight to the element. + await page.evaluate(() => (document.querySelector('#increment-count') as HTMLElement).click()); + await expect( + page + .locator('[data-solid-reactivity-node]') + .filter({ hasText: /^count/ }) + .first(), + ).toContainText('1'); }); test('mounts once and disposes', async ({ page }) => { From 53621f957df904a60cb66bffe8a465a994e0b8b8 Mon Sep 17 00:00:00 2001 From: "Alexis H. Munsayac" Date: Tue, 15 Sep 2026 02:03:49 +0800 Subject: [PATCH 10/12] fix: show live values in the ownership tree The panel skips a snapshot whose fingerprint matches the one it shows. The fingerprint only held ids, kinds and counts, so a signal changing value without the tree moving never rendered, and the panel kept showing the first value. Signals, folded scopes and computed owners now record the runtime clock of their last write, and the fingerprint includes it. Co-Authored-By: Claude Opus 5 --- src/dev-toolbar/ownership/tree.test.ts | 21 +++++++++++++++++++++ src/dev-toolbar/ownership/tree.ts | 26 +++++++++++++++++++++----- 2 files changed, 42 insertions(+), 5 deletions(-) diff --git a/src/dev-toolbar/ownership/tree.test.ts b/src/dev-toolbar/ownership/tree.test.ts index cd2f7ee..ce6915e 100644 --- a/src/dev-toolbar/ownership/tree.test.ts +++ b/src/dev-toolbar/ownership/tree.test.ts @@ -114,6 +114,7 @@ describe('buildOwnershipTree in component mode', () => { name: 'total', value: 7, hasValue: true, + time: 0, node: memo, }, ]); @@ -218,6 +219,26 @@ describe('fingerprint', () => { expect(build([root]).fingerprint).not.toBe(before); }); + it('changes when a signal is written', () => { + const count = signal('count', 0); + const root = owner({ component: 'App', signals: [count] }); + const before = build([root]).fingerprint; + count._value = 1; + count._time = 1; + + expect(build([root]).fingerprint).not.toBe(before); + }); + + it('changes when a folded memo recomputes', () => { + const memo = owner({ memo: true, name: 'total', value: 1 }); + const root = owner({ component: 'App', children: [memo] }); + const before = build([root]).fingerprint; + memo._value = 2; + memo._time = 3; + + expect(build([root]).fingerprint).not.toBe(before); + }); + it('stays the same when nothing moved', () => { const root = owner({ component: 'App', signals: [signal('count', 0)] }); diff --git a/src/dev-toolbar/ownership/tree.ts b/src/dev-toolbar/ownership/tree.ts index 5618081..0ef0a49 100644 --- a/src/dev-toolbar/ownership/tree.ts +++ b/src/dev-toolbar/ownership/tree.ts @@ -30,6 +30,8 @@ export interface OwnedSignal { id: string; name: string; value: unknown; + /** Clock tick of the last write, so a new value changes the fingerprint. */ + time: number; /** The runtime node, so another panel can find the same signal. */ node: RawNode; } @@ -41,6 +43,8 @@ export interface FoldedScope { name: string; value: unknown; hasValue: boolean; + /** Clock tick of the last recompute, so a new value changes the fingerprint. */ + time: number; /** The runtime owner, so another panel can find the same memo or effect. */ node: RawNode; } @@ -62,6 +66,8 @@ export interface TreeNode { /** Current value of a computed owner. */ value: unknown; hasValue: boolean; + /** Clock tick of the last recompute of a computed owner. */ + time: number; disposed: boolean; /** Owners this node stands in for, when scopes are folded away. */ scopes: FoldedScope[]; @@ -145,6 +151,11 @@ function propNames(owner: RawNode): string[] | undefined { } } +/** The runtime clock of a node's last write. Zero for owners that hold no value. */ +function timeOf(node: RawNode): number { + return typeof node._time === 'number' ? node._time : 0; +} + function isDisposed(owner: RawNode): boolean { return typeof owner._flags === 'number' && (owner._flags & REACTIVE_DISPOSED) !== 0; } @@ -190,6 +201,7 @@ export function buildOwnershipTree(roots: RawNode[], options: BuildOptions): Own location: kind === 'component' ? componentLocation(owner) : undefined, value: '_value' in owner ? owner._value : undefined, hasValue: '_value' in owner, + time: timeOf(owner), disposed: isDisposed(owner), scopes: [], }; @@ -206,6 +218,7 @@ export function buildOwnershipTree(roots: RawNode[], options: BuildOptions): Own id: options.identify(signal), name: typeof name === 'string' && name.length > 0 ? name : 'signal', value: signal._value, + time: timeOf(signal), node: signal, }); } @@ -250,6 +263,7 @@ export function buildOwnershipTree(roots: RawNode[], options: BuildOptions): Own name: ownerName(owner, kind), value: '_value' in owner ? owner._value : undefined, hasValue: '_value' in owner, + time: timeOf(owner), node: owner, }); collectSignals(owner, host); @@ -261,11 +275,13 @@ export function buildOwnershipTree(roots: RawNode[], options: BuildOptions): Own 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}`; + // Write times are part of it, so a value changing without the tree moving + // still renders. + fingerprint += `|${node.id}${node.kind}@${node.time}${node.children.length}${ + node.signals.length + }${node.scopes.length}${node.disposed ? 'd' : ''}`; + for (const signal of node.signals) fingerprint += `,${signal.id}@${signal.time}`; + for (const scope of node.scopes) fingerprint += `;${scope.id}@${scope.time}`; } return { nodes, roots: topLevel, fingerprint }; From 56510533aa4a4e25bcb9de4834c2421793aef727 Mon Sep 17 00:00:00 2001 From: "Alexis H. Munsayac" Date: Tue, 15 Sep 2026 02:03:49 +0800 Subject: [PATCH 11/12] feat: jump from the graph owner to the ownership tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The owner field in the graph's detail pane opens the ownership tree, so the two panels can move back and forth. - A signal opens its owner. A memo or effect is an owner itself, so it opens itself, which lands on its component in component mode. - The tree selects the closest row it shows for that owner, opens the rows above it, clears the search and scrolls the row into view. - The owner path names components, such as ``. It stops at the scope that owns the app, so the toolbar's own wrapping no longer shows, and it skips unnamed memos and the hot reload wrapper. - The e2e tests cover the jump, the path text and live values in the tree. Co-Authored-By: Claude Opus 5 --- src/dev-toolbar/index.tsx | 12 ++++++- src/dev-toolbar/ownership/index.tsx | 37 ++++++++++++++++++- src/dev-toolbar/ownership/registry.ts | 12 +++++++ src/dev-toolbar/reactivity/index.tsx | 42 +++++++++++++++++++--- src/dev-toolbar/reactivity/registry.ts | 49 +++++++++++++++++++++----- src/dev-toolbar/reactivity/styles.css | 16 +++++++++ tests/e2e/devtools.spec.ts | 24 ++++++++++++- 7 files changed, 177 insertions(+), 15 deletions(-) diff --git a/src/dev-toolbar/index.tsx b/src/dev-toolbar/index.tsx index 4165267..b58658e 100644 --- a/src/dev-toolbar/index.tsx +++ b/src/dev-toolbar/index.tsx @@ -148,6 +148,8 @@ export function DevToolbar(props: DevToolbarProps) { // A panel can ask the graph to show a node. Each request is a new object, so // asking for the same node twice still moves the view. const [graphFocus, setGraphFocus] = createSignal<{ node: object }>(); + // The graph can ask the ownership tree to show an owner the same way. + const [ownershipFocus, setOwnershipFocus] = createSignal<{ owner: object }>(); function toggleContent(value: 'fn' | 'err' | 'rx' | 'own') { if (content() === value) { @@ -238,9 +240,17 @@ export function DevToolbar(props: DevToolbarProps) { - + { + setOwnershipFocus({ owner }); + setContent('own'); + }} + /> { setGraphFocus({ node }); setContent('rx'); diff --git a/src/dev-toolbar/ownership/index.tsx b/src/dev-toolbar/ownership/index.tsx index e97885f..7353b86 100644 --- a/src/dev-toolbar/ownership/index.tsx +++ b/src/dev-toolbar/ownership/index.tsx @@ -9,6 +9,7 @@ import { previewValue, typeName } from './format.js'; import { excludeOwner, isOwnershipAvailable, + ownerChainIds, snapshotOwnershipTree, startOwnershipTracking, subscribeOwnershipTree, @@ -39,6 +40,11 @@ export interface OwnershipViewerProps { show?: boolean; /** Opens a signal, memo or effect in the reactivity graph. */ onViewInGraph?: (node: object) => void; + /** + * An owner another panel asked to show. Pass a new object for every request, + * so asking for the same owner twice still moves the tree. + */ + focus?: { owner: object }; } export default function OwnershipViewer(props: OwnershipViewerProps): JSX.Element { @@ -53,6 +59,8 @@ export default function OwnershipViewer(props: OwnershipViewerProps): JSX.Elemen const [selected, setSelected] = createSignal(); const firstSeen = new Map(); + let appliedFocus: { owner: object } | undefined; + let rowsElement: HTMLDivElement | undefined; // Takes the mode as an argument because reading a signal inside an effect // callback is not tracked. @@ -82,6 +90,27 @@ export default function OwnershipViewer(props: OwnershipViewerProps): JSX.Elemen }, ); + // The graph can ask for an owner. The request selects the closest row the + // tree shows for it, opens the rows above that row, and waits for a snapshot + // that contains it. + createEffect( + () => ({ request: props.focus, nodes: tree().nodes, visible: !!props.show }), + ({ request, nodes, visible }) => { + if (!request || !visible || request === appliedFocus) return; + const known = new Map(nodes.map((node) => [node.id, node])); + const id = ownerChainIds(request.owner).find((candidate) => known.has(candidate)); + if (!id) return; + appliedFocus = request; + const open = new Set([id, ...ancestorsOf(known, id)]); + setQuery(''); + setCollapsed((current) => current.filter((item) => !open.has(item))); + setSelected(id); + // The row exists after this flush renders, so scroll on the next frame. + requestAnimationFrame(() => { + rowsElement?.querySelector(`[data-owner-id="${id}"]`)?.scrollIntoView({ block: 'nearest' }); + }); + }, + ); const byId = createMemo(() => new Map(tree().nodes.map((node) => [node.id, node]))); const matches = createMemo(() => { @@ -232,7 +261,12 @@ export default function OwnershipViewer(props: OwnershipViewerProps): JSX.Elemen } > -
+
{ + rowsElement = element; + }} + > 0} fallback={ @@ -247,6 +281,7 @@ export default function OwnershipViewer(props: OwnershipViewerProps): JSX.Elemen {(row) => (
clean; } -function NodeSummary(props: { node: ReactiveNode }): JSX.Element { +function ownerText(node: ReactiveNode): string { + return node.ownerPath.length > 0 ? node.ownerPath.join(' › ') : 'unnamed owner'; +} + +function NodeSummary(props: { node: ReactiveNode; onOwner?: () => void }): JSX.Element { return ( <>
@@ -84,10 +88,22 @@ function NodeSummary(props: { node: ReactiveNode }): JSX.Element { {`${props.node.sources.length} in / ${props.node.observers.length} out`}
- 0}> +
owner - {props.node.ownerPath.join(' › ')} + {ownerText(props.node)}} + > + +
@@ -101,6 +117,8 @@ export interface ReactivityViewerProps { * so asking for the same node twice still moves the view. */ focus?: { node: object }; + /** Opens the owner of a node in the ownership tree. */ + onViewOwner?: (owner: object) => void; } export default function ReactivityViewer(props: ReactivityViewerProps): JSX.Element { @@ -610,7 +628,23 @@ export default function ReactivityViewer(props: ReactivityViewerProps): JSX.Elem {(node) => (