diff --git a/.changeset/ownership-tree.md b/.changeset/ownership-tree.md new file mode 100644 index 0000000..1fd94e9 --- /dev/null +++ b/.changeset/ownership-tree.md @@ -0,0 +1,10 @@ +--- +'@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, 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 764f7e7..0c127d1 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ Development error and server-function tooling for Solid Start mode. - Runtime error inspection with source-mapped stack frames. - Server-function request and response inspection. - A reactivity graph of the live signals, memos and effects in the app. +- An ownership tree of the components and scopes the app created. ```sh pnpm add @solidjs/start-devtools@next @@ -31,10 +32,14 @@ The same import is safe in development and production entries. ## Demo -`examples/demo` is a small orders dashboard that exercises every panel. +Two demo apps live in `examples`. + +- `examples/demo` is a small orders dashboard that exercises every panel. +- `examples/explorer` is a file explorer whose component tree grows as you open folders. ```sh pnpm demo +pnpm demo:explorer ``` ## Reactivity graph @@ -47,4 +52,24 @@ reads and what reads it, and inspect its value as an expandable tree. The panel reads the graph 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 component tree 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, 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 +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. + +For more inspection tools, see [Solid Devtools](https://github.com/thetarnav/solid-devtools). diff --git a/examples/explorer/README.md b/examples/explorer/README.md new file mode 100644 index 0000000..57f3b4f --- /dev/null +++ b/examples/explorer/README.md @@ -0,0 +1,31 @@ +# Ownership demo + +A file explorer that grows and shrinks its component tree as you use it. + +```sh +pnpm demo:explorer +``` + +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. 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/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 80f8e17..42ee50a 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "scripts": { "build": "rolldown -c", "demo": "pnpm build && vite --config examples/demo/vite.config.ts", + "demo:explorer": "pnpm build && vite --config examples/explorer/vite.config.ts", "check": "publint && attw --pack . --profile esm-only", "format": "oxfmt --write", "format:check": "oxfmt --check", 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/icons.tsx b/src/dev-toolbar/icons.tsx index 30379f7..4f5f0f9 100644 --- a/src/dev-toolbar/icons.tsx +++ b/src/dev-toolbar/icons.tsx @@ -506,7 +506,7 @@ export function TrashIcon(props: JSX.IntrinsicElements['svg'] & { title: string ); } -export function GraphIcon(props: JSX.IntrinsicElements['svg'] & { title: string }): JSX.Element { +export function TreeIcon(props: JSX.IntrinsicElements['svg'] & { title: string }): JSX.Element { return ( {props.title} - - - - + + + + ); @@ -556,6 +556,66 @@ export function PlayIcon(props: JSX.IntrinsicElements['svg'] & { title: string } ); } +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} + + + ); +} + +export function GraphIcon(props: JSX.IntrinsicElements['svg'] & { title: string }): JSX.Element { + return ( + + {props.title} + + + + + + + + ); +} + export function FitIcon(props: JSX.IntrinsicElements['svg'] & { title: string }): JSX.Element { return ( ); } + +export function EyeIcon(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 ea206a7..b58658e 100644 --- a/src/dev-toolbar/index.tsx +++ b/src/dev-toolbar/index.tsx @@ -7,12 +7,14 @@ 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, GraphIcon, SolidIcon } from './icons.js'; +import { ErrorIcon, FunctionIcon, GraphIcon, SolidIcon, TreeIcon } from './icons.js'; +import { excludeOwner, includeOwner } from './ownership/registry.js'; import { excludeReactiveOwner, includeReactiveOwner } from './reactivity/registry.js'; import './index.css'; const ErrorViewer = clientOnly(() => import('./error-viewer/index.js'), { lazy: true }); const ReactivityViewer = clientOnly(() => import('./reactivity/index.js'), { lazy: true }); +const OwnershipViewer = clientOnly(() => import('./ownership/index.js'), { lazy: true }); export interface DevToolbarProps { children?: JSX.Element; @@ -23,13 +25,17 @@ export interface DevToolbarProps { * even though the toolbar's own scope encloses it. */ function AppScope(props: { children?: JSX.Element }): JSX.Element { - includeReactiveOwner(getOwner()); + const owner = getOwner(); + includeReactiveOwner(owner); + includeOwner(owner); return <>{props.children}; } export function DevToolbar(props: DevToolbarProps) { - // Everything the toolbar creates stays out of the reactivity graph it renders. - excludeReactiveOwner(getOwner()); + // Everything the toolbar creates stays out of the graph and the tree it renders. + const owner = getOwner(); + excludeReactiveOwner(owner); + excludeOwner(owner); const [ref, setRef] = createSignal(); @@ -137,9 +143,15 @@ export function DevToolbar(props: DevToolbarProps) { }, ); - const [content, setContent] = createSignal<'fn' | 'err' | 'rx' | undefined>(undefined); + const [content, setContent] = createSignal<'fn' | 'err' | 'rx' | 'own' | undefined>(undefined); - function toggleContent(value: 'fn' | 'err' | 'rx') { + // 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) { setContent(undefined); } else { @@ -214,6 +226,9 @@ export function DevToolbar(props: DevToolbarProps) { toggleContent('rx')}> + toggleContent('own')}> + +
@@ -225,7 +240,22 @@ export function DevToolbar(props: DevToolbarProps) {
- + { + setOwnershipFocus({ owner }); + setContent('own'); + }} + /> + { + setGraphFocus({ node }); + setContent('rx'); + }} + /> 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..8a2e3c2 --- /dev/null +++ b/src/dev-toolbar/ownership/index.tsx @@ -0,0 +1,591 @@ +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, EyeIcon, PauseIcon, PlayIcon, TreeIcon } from '../icons.js'; +import { previewValue, typeName } from './format.js'; +import { + excludeOwner, + isOwnershipAvailable, + ownerChainIds, + snapshotOwnershipTree, + startOwnershipTracking, + subscribeOwnershipTree, +} from './registry.js'; +import { + ancestorsOf, + EMPTY_TREE, + type OwnerKind, + type OwnershipTree, + type TreeNode, +} from './tree.js'; +import './styles.css'; + +/** Owners the reactivity graph draws. Components, roots and plain scopes hold no value. */ +function isInGraph(kind: OwnerKind): boolean { + return kind !== 'component' && kind !== 'root' && kind !== 'scope'; +} + +/** + * Asks the dev server to open the file. Vite serves this endpoint in + * development. A failure is ignored, since the panel has nowhere to report it. + */ +function openInEditor(location: string): void { + void fetch(`/__open-in-editor?file=${encodeURIComponent(location)}`).catch(() => {}); +} + +/** How long a row stays marked as new after it first appears. */ +const FRESH_MS = 900; + +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; + /** 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 { + // 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(); + 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. + 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(); + }; + }, + ); + + // 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(() => { + 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 ancestry = createMemo(() => { + const id = selected(); + if (!id) return []; + const nodes = byId(); + 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 ( + +
+
+
+
+ + 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. + + + } + > +
{ + rowsElement = element; + }} + > + 0} + fallback={ + + + {query() ? 'Nothing matches this filter.' : 'No owners observed yet.'} + + + } + > + + {(row) => ( +
+ +
+ )} +
+
+
+ + + {(node) => ( + + )} + +
+
+
+
+
+ ); +} diff --git a/src/dev-toolbar/ownership/registry.ts b/src/dev-toolbar/ownership/registry.ts new file mode 100644 index 0000000..ad2c76c --- /dev/null +++ b/src/dev-toolbar/ownership/registry.ts @@ -0,0 +1,193 @@ +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; +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 stopListening: (() => 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(); + }); +} + +/** + * 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++; + 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. Stops listening once nothing watches any more. */ +function release(): void { + watchers = Math.max(0, watchers - 1); + 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. */ +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 []; + } +} + +/** + * Ids of an owner and every owner above it, nearest first. Another panel uses + * this to find the closest row the tree shows for a runtime node. + */ +export function ownerChainIds(owner: object): string[] { + const chain: string[] = []; + for (let current = owner as RawNode | null | undefined; current; current = current._parent) { + chain.push(identify(current)); + } + return chain; +} + +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..188e6dc --- /dev/null +++ b/src/dev-toolbar/ownership/styles.css @@ -0,0 +1,508 @@ +[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; + + /* The browser pads buttons, which leaves the arrow no room in a 1rem box. */ + padding: 0; + + border: none; + background: none; + color: var(--start-dt-text-muted); + cursor: pointer; +} + +[data-solid-ownership-chevron]::before { + content: ''; + + box-sizing: content-box; + width: 0.3125rem; + height: 0.3125rem; + flex-shrink: 0; + + 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-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-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 auto; + 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); +} + +/* The action stays out of the way until the row is pointed at or focused. */ +[data-solid-ownership-view-graph] { + display: inline-flex; + align-items: center; + justify-content: center; + + padding: 0.125rem; + + 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); +} + +/* The selected owner's own action sits in the heading, so it is always shown. */ +[data-solid-ownership-detail-head] [data-solid-ownership-view-graph] { + margin-left: auto; + opacity: 1; +} + +/* 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, +[data-solid-ownership-detail-head] [data-solid-ownership-view-graph] svg { + width: 0.875rem; + height: 0.875rem; +} + +[data-solid-ownership-scope-name] { + display: inline-flex; + align-items: center; + + 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] { + color: var(--start-dt-text-muted); + + overflow: hidden; + text-overflow: ellipsis; + 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; + + 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..ce6915e --- /dev/null +++ b/src/dev-toolbar/ownership/tree.test.ts @@ -0,0 +1,262 @@ +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('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({ name: '[solid-refresh]Counter' }), 'memo')).toBe('Counter'); + 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, + time: 0, + node: memo, + }, + ]); + 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('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('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' }; + + 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('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)] }); + + 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..266abd7 --- /dev/null +++ b/src/dev-toolbar/ownership/tree.ts @@ -0,0 +1,302 @@ +/** 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; + /** 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; +} + +/** 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; + /** 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; +} + +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; + /** 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[]; + /** Where the component is declared, as `file:line:column`. */ + location: string | undefined; + /** The runtime owner, so another panel can find the same memo or effect. */ + owner: RawNode; +} + +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'; +} + +/** 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') { + 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; + } +} + +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; + } +} + +/** 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; +} + +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, + location: kind === 'component' ? componentLocation(owner) : undefined, + value: '_value' in owner ? owner._value : undefined, + hasValue: '_value' in owner, + time: timeOf(owner), + disposed: isDisposed(owner), + scopes: [], + owner, + }; + 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, + time: timeOf(signal), + node: signal, + }); + } + } + + 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, + time: timeOf(owner), + node: 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) { + // 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 }; +} + +/** 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/src/dev-toolbar/reactivity/index.tsx b/src/dev-toolbar/reactivity/index.tsx index b263cd8..73f6494 100644 --- a/src/dev-toolbar/reactivity/index.tsx +++ b/src/dev-toolbar/reactivity/index.tsx @@ -6,11 +6,19 @@ 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, excludeReactiveOwner, + reactiveNodeId, isReactivityAvailable, snapshotReactivityGraph, startReactivityTracking, @@ -46,7 +54,11 @@ function stateBadge(node: ReactiveNode): JSX.Element { return 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 ( <>
@@ -76,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)}} + > + +
@@ -88,6 +112,13 @@ 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 }; + /** Opens the owner of a node in the ownership tree. */ + onViewOwner?: (owner: object) => void; } export default function ReactivityViewer(props: ReactivityViewerProps): JSX.Element { @@ -106,6 +137,8 @@ export default function ReactivityViewer(props: ReactivityViewerProps): JSX.Elem let viewport: HTMLDivElement | undefined; let fittedSize = ''; let moved = false; + let appliedFocus: { node: object } | undefined; + let centerFrame: number | undefined; function refresh(): void { const next = snapshotReactivityGraph(); @@ -242,6 +275,53 @@ 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; + } + + /** + * 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( + () => ({ 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); + // Keeps the auto fit from taking the view back before the frame runs. + moved = true; + centerSoon(id); + }, + ); + // Refit while the graph grows. Once the user pans or zooms, the view is // theirs and only the fit button moves it. createEffect( @@ -309,6 +389,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; @@ -316,16 +400,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; @@ -506,7 +600,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`, + }} >
@@ -522,20 +624,27 @@ export default function ReactivityViewer(props: ReactivityViewerProps): JSX.Elem
- + )} + 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/registry.ts b/src/dev-toolbar/reactivity/registry.ts index 263e270..26f7354 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. @@ -48,6 +49,10 @@ export interface ReactiveNode { error: unknown; /** Names of the owners above this node, outermost first. */ ownerPath: string[]; + /** The runtime node, so another panel can find the same node. */ + raw: object; + /** The runtime owner the node was created under. */ + owner: object | undefined; sources: string[]; observers: string[]; /** Times the node's clock advanced while the panel was open. */ @@ -99,7 +104,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. */ @@ -119,6 +124,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); @@ -136,46 +146,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; } @@ -192,10 +185,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. */ @@ -274,17 +271,44 @@ function nameOf(node: RawNode, kind: ReactiveNodeKind): string { return KIND_LABELS[kind]; } -const DEFAULT_NAMES = new Set(Object.values(KIND_LABELS)); +// The runtime names an unnamed memo `computed`, which says as little as a kind label. +const DEFAULT_NAMES = new Set([...Object.values(KIND_LABELS), 'computed']); + +/** The hot reload transform wraps components, and its wrapper carries this tag. */ +const REFRESH_PREFIX = '[solid-refresh]'; + +/** The owner a node was created under. Signals keep it in the registry. */ +function ownerOf(node: RawNode): RawNode | undefined { + return ('_parent' in node ? node._parent : signalOwners.get(node)) ?? undefined; +} + +/** + * The label an owner shows in a path. Components show their name. Owners that + * only carry a default kind name, and the memo the hot reload wrapper creates, + * say nothing about where the node lives, so they show nothing. + */ +function ownerLabel(owner: RawNode): string | undefined { + const component = owner._component?.name; + if (typeof component === 'string') { + const name = component.startsWith(REFRESH_PREFIX) + ? component.slice(REFRESH_PREFIX.length) + : component; + return `<${name || 'Anonymous'}>`; + } + const name = owner._name; + if (typeof name !== 'string' || name.length === 0) return undefined; + if (name.startsWith(REFRESH_PREFIX) || DEFAULT_NAMES.has(name)) return undefined; + return name; +} function ownerPathOf(node: RawNode): string[] { const path: string[] = []; - let owner: RawNode | null | undefined = - '_parent' in node ? node._parent : (signalOwners.get(node) ?? null); - for (; owner; owner = owner._parent) { - const name = owner._name; - // Owners that only carry a default kind name say nothing about where the - // node lives, so the path keeps real labels only. - if (typeof name === 'string' && name.length > 0 && !DEFAULT_NAMES.has(name)) path.push(name); + for (let owner = ownerOf(node); owner; owner = owner._parent ?? undefined) { + // The scope that owns the app sits inside the toolbar. Everything above it + // is the toolbar's own wrapping, so the path stops there. + if (included.has(owner)) break; + const label = ownerLabel(owner); + if (label) path.push(label); } return path.reverse(); } @@ -433,6 +457,8 @@ export function snapshotReactivityGraph(options?: SnapshotOptions): ReactiveGrap lazy: (flags & REACTIVE_LAZY) !== 0, error: node._error, ownerPath: ownerPathOf(node), + raw: node, + owner: ownerOf(node), sources: [], observers: [], updates: entry.updates, diff --git a/src/dev-toolbar/reactivity/styles.css b/src/dev-toolbar/reactivity/styles.css index 809a8f1..3e00873 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; @@ -524,3 +557,19 @@ overflow: hidden; text-overflow: ellipsis; } + +/* The owner path in the detail pane opens the ownership tree. */ +[data-solid-reactivity-owner] { + padding: 0; + + border: none; + background: none; + color: var(--start-dt-accent); + + text-align: left; + cursor: pointer; +} + +[data-solid-reactivity-owner]:hover { + text-decoration: underline; +} diff --git a/tests/e2e/devtools.spec.ts b/tests/e2e/devtools.spec.ts index 5a78b5e..cf69381 100644 --- a/tests/e2e/devtools.spec.ts +++ b/tests/e2e/devtools.spec.ts @@ -110,6 +110,16 @@ 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, + ); + + // 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]'); @@ -144,6 +154,141 @@ test('maps the reactivity graph', async ({ page }) => { await page.getByRole('button', { name: 'Memos' }).click(); await expect(nodes.filter({ hasText: /^doubled/ })).toHaveCount(0); await expect(count).toBeVisible(); + + // The owner field opens the ownership tree on the component that owns the node. + if ((await count.getAttribute('data-solid-reactivity-node')) !== 'selected') { + await count.click(); + } + // The path names the components the app declared, not the toolbar around it. + await expect( + page.locator('[data-solid-reactivity-detail] [data-solid-reactivity-owner]'), + ).toHaveText(''); + await page.locator('[data-solid-reactivity-detail] [data-solid-reactivity-owner]').click(); + await expect(page.locator('[data-solid-ownership-row][data-selected]')).toContainText( + '', + ); +}); + +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(['', '', '', '']); + + // The expand arrow keeps its full size inside the button. + const arrow = await page + .locator('[data-solid-ownership-chevron]') + .first() + .evaluate((element) => { + const style = getComputedStyle(element, '::before'); + return [style.width, style.height]; + }); + expect(arrow).toEqual(['5px', '5px']); + + // 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]'); + await expect(detail).toContainText('Signals (1)'); + await expect(detail).toContainText('count'); + await expect(detail).toContainText('doubled'); + + // Values stay live while the panel is open, even when the tree does not move. + // 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( + detail + .locator('[data-solid-ownership-signal]') + .filter({ has: page.getByText('count', { exact: true }) }), + ).toContainText('1'); + + // 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'); + + // 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); + + // 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(['', '']); + + // 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'); + + // 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); + + // 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('2'); + + // In owner mode a memo is a row of its own, and its heading links to the graph. + await toggle.click(); + await page.getByRole('button', { name: 'Owners', exact: true }).click(); + await page + .locator('[data-solid-ownership-label]') + .filter({ hasText: /^doubled$/ }) + .click(); + await detail + .locator('[data-solid-ownership-detail-head]') + .getByRole('button', { name: 'View in graph' }) + .click(); + await expect( + page + .locator('[data-solid-reactivity-node]') + .filter({ hasText: /^doubled/ }) + .first(), + ).toHaveAttribute('data-solid-reactivity-node', 'selected'); }); test('mounts once and disposes', async ({ page }) => { diff --git a/tests/fixture/app.tsx b/tests/fixture/app.tsx index 0aff74e..272abab 100644 --- a/tests/fixture/app.tsx +++ b/tests/fixture/app.tsx @@ -32,8 +32,11 @@ function emitServerFunctionResponse() { responseStatus = 500; } -function App() { - const [broken, setBroken] = createSignal(false); +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' }); @@ -45,12 +48,21 @@ function App() { { name: 'report-doubled' }, ); + return ( + + ); +} + +function App() { + const [broken, setBroken] = createSignal(false); + return (

app content

- + +