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 d6804b4..3c88ba0 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,11 @@ Development error and server-function tooling for Solid Start mode. -`@solidjs/start-devtools` provides the toolbar used by the Solid Vite plugin in development. It includes runtime error inspection, source-mapped stack frames, and server-function request and response inspection. +`@solidjs/start-devtools` provides the toolbar used by the Solid Vite plugin in development. + +- Runtime error inspection with source-mapped stack frames. +- Server-function request and response inspection. +- An ownership tree of the components and scopes the app created. ```sh pnpm add @solidjs/start-devtools@next @@ -25,4 +29,32 @@ does not include the toolbar. The same import is safe in development and production entries. -For component and reactivity inspection, see [Solid Devtools](https://github.com/thetarnav/solid-devtools). +## Demo + +`examples/explorer` is a file explorer whose component tree grows as you open folders. + +```sh +pnpm demo +``` + +## Ownership tree + +The ownership panel shows the app as a tree of owners. + +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 reactivity inspection, 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..a27b35b --- /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 +``` + +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 393af14..845566a 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ }, "scripts": { "build": "rolldown -c", + "demo": "pnpm build && vite --config examples/explorer/vite.config.ts", "check": "publint && attw --pack . --profile esm-only", "format": "oxfmt --write", "format:check": "oxfmt --check", @@ -59,6 +60,7 @@ "@dom-expressions/compiler": "^0.50.0-next.43", "@jridgewell/trace-mapping": "^0.3.31", "@playwright/test": "^1.62.1", + "@solidjs/vite-plugin": "3.0.0-next.35", "@solidjs/web": "^2.0.0-rc.0", "@types/node": "^24.0.0", "error-stack-parser-es": "^2.0.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 95beed4..da44693 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: '@playwright/test': specifier: ^1.62.1 version: 1.62.1 + '@solidjs/vite-plugin': + specifier: 3.0.0-next.35 + version: 3.0.0-next.35(@solidjs/web@2.0.0-rc.0(solid-js@2.0.0-rc.0))(solid-js@2.0.0-rc.0)(supports-color@7.2.0)(vite@8.2.1(@types/node@24.13.3)) '@solidjs/web': specifier: ^2.0.0-rc.0 version: 2.0.0-rc.0(solid-js@2.0.0-rc.0) @@ -71,6 +74,10 @@ importers: packages: + '@ampproject/remapping@2.3.0': + resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} + engines: {node: '>=6.0.0'} + '@andrewbranch/untar.js@1.0.4': resolution: {integrity: sha512-pVXSwPsLuw8IGLo2Di0EaOfsk+ntVvpkk942J/sHYIkwvtKUakEcPh7HBgZ6tuimgzKSEHgCvO4XgQ05DEbwDw==} @@ -83,10 +90,91 @@ packages: resolution: {integrity: sha512-9ytjzGwxjm9Uz7I9avfbt5vlQt6uk9uRRESzJjqrznl6WKvI6dwYTo+vJ3U02Wrq/mR3iql/PzhvHhKdJIAjDQ==} engines: {node: '>=20'} + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.7': + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.7': + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.8': + resolution: {integrity: sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.29.7': + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.18.6': + resolution: {integrity: sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-plugin-utils@7.29.7': + resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.7': + resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.8': + resolution: {integrity: sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-syntax-jsx@7.29.7': + resolution: {integrity: sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + '@babel/runtime@7.29.7': resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} engines: {node: '>=6.9.0'} + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.8': + resolution: {integrity: sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} + engines: {node: '>=6.9.0'} + '@braidai/lang@1.1.2': resolution: {integrity: sha512-qBcknbBufNHlui137Hft8xauQMTZDKdophmLFv05r2eNmdIv/MlPuP4TdUknHG68UdWLgVZwgxVe735HzJNIwA==} @@ -186,12 +274,21 @@ packages: '@emnapi/core@1.11.2': resolution: {integrity: sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==} + '@emnapi/core@1.11.3': + resolution: {integrity: sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==} + '@emnapi/runtime@1.11.2': resolution: {integrity: sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==} + '@emnapi/runtime@1.11.3': + resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==} + '@emnapi/wasi-threads@1.2.2': resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} + '@emnapi/wasi-threads@1.2.3': + resolution: {integrity: sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==} + '@inquirer/external-editor@1.0.3': resolution: {integrity: sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==} engines: {node: '>=18'} @@ -201,6 +298,12 @@ packages: '@types/node': optional: true + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + '@jridgewell/resolve-uri@3.1.2': resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} engines: {node: '>=6.0.0'} @@ -501,9 +604,67 @@ packages: resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==} engines: {node: '>=10'} + '@solidjs/babel-plugin@2.0.0-rc.7': + resolution: {integrity: sha512-cKcyVbOh8WC7ywV3txxfNrFRtTa7t8O3tXDXRf3RE3DKyZr+hZmbEOq6q/T6smaJXS3ekzuUDVlQrG1+HErnhg==} + peerDependencies: + '@babel/core': ^7.20.12 + '@tsrx/core': 0.1.63 + peerDependenciesMeta: + '@tsrx/core': + optional: true + + '@solidjs/compiler-darwin-arm64@2.0.0-rc.7': + resolution: {integrity: sha512-xJ7FoPrFV94LMuEPNJ2nIGFlqhVGR+s5GAr0nzMRMdWAimriF0sZ8clxlIqPS3v2oaNE9JW7EDCdJysD3JezWg==} + cpu: [arm64] + os: [darwin] + + '@solidjs/compiler-darwin-x64@2.0.0-rc.7': + resolution: {integrity: sha512-rcu8wcxeO0QWXu69yFaSf3ZR1KlsPDCzmRmdA8fX/cte96Ox0r6GfMXE+5CH/gg9dSaXY3qwwmpSG9AEvLxZQg==} + cpu: [x64] + os: [darwin] + + '@solidjs/compiler-linux-arm64-gnu@2.0.0-rc.7': + resolution: {integrity: sha512-EhiLKgLcFkHWYOx3Pds3px0onhTbzpDfenhsuDy1R7ViZYALXXBlZ+EUSE85rKwyCM0sgcR/kVMswL2V8sJXng==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@solidjs/compiler-linux-x64-gnu@2.0.0-rc.7': + resolution: {integrity: sha512-ymN3hIqzH3msWt3lcU3vqILnzRlB1rNbv1BlUqXkwVZByjFw/bJQ+BgncIq0WqNH6ciQ5nhZ1E7ECgGw/SNwEA==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@solidjs/compiler-wasm32-wasi@2.0.0-rc.7': + resolution: {integrity: sha512-k4vN+EHRtoIxj0D8d2VYHPEnWIok6kokbZskpn1pXrOoXBkj0OmqzzyJVBvSRYx6cWDWEjCKSWGuufRRZtgmjA==} + engines: {node: '>=14.0.0'} + + '@solidjs/compiler-win32-x64-msvc@2.0.0-rc.7': + resolution: {integrity: sha512-pZtrkWmiiZ/NRwyMASAitwAa4EO2jlt4z8Z5K9NoljnBreWrCsz4HmIKSIeZ+17++WwAyeZo2byon0AEv6FzRQ==} + cpu: [x64] + os: [win32] + + '@solidjs/compiler@2.0.0-rc.7': + resolution: {integrity: sha512-jhFq/QcoUM34760NuQiSvMDInZ+3AEHwA354VELRDpAJwuiMpPbEvkSH6qqfgneDzVwCiCtLbjScqUEymU7R1A==} + '@solidjs/signals@2.0.0-rc.0': resolution: {integrity: sha512-oKZSfvsCcKw1uJjOGbUkJ+OqlhXLHtZ+rShSyu9KH0lUH7UUwfMfsKeh81JPiQxDDg4YLhEwI38hg0JkwzTdvA==} + '@solidjs/vite-plugin@3.0.0-next.35': + resolution: {integrity: sha512-8Mlftd+WfZkwOoCZRyMxA8innT8b2D/qawTu+28RW/Hj4eSmStSLx4dHjYeH9MxyOwo7DQStAyHAADk5LFQVRw==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + '@solidjs/start-devtools': ^1.0.0-next.2 + '@solidjs/web': ^2.0.0-rc.0 + '@testing-library/jest-dom': ^5.16.6 || ^5.17.0 || ^6.* + solid-js: ^2.0.0-rc.0 + vite: ^8.0.0 || ^9.0.0 + peerDependenciesMeta: + '@solidjs/start-devtools': + optional: true + '@testing-library/jest-dom': + optional: true + '@solidjs/web@2.0.0-rc.0': resolution: {integrity: sha512-pYSaA9+dH8H1h/d/ZF/P2kR6omfzFGNcdzKhWTcg9fJghXhn8+5UrXUr2iYxDdYNOXZzxxFQhYHSJ7P4HKDqgw==} peerDependencies: @@ -515,6 +676,18 @@ packages: '@tybys/wasm-util@0.10.3': resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -863,6 +1036,11 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + baseline-browser-mapping@2.11.21: + resolution: {integrity: sha512-uh8vpY/1/YyFkunIDFH/12p7/7VdPKA1hejMVEbdkEaWnUz0Hesvx5EbiU6XxjyHZIOju+ZMbQJkRh+es3/spQ==} + engines: {node: '>=6.0.0'} + hasBin: true + better-path-resolve@1.0.0: resolution: {integrity: sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g==} engines: {node: '>=4'} @@ -871,6 +1049,14 @@ packages: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} + browserslist@4.28.9: + resolution: {integrity: sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + caniuse-lite@1.0.30001810: + resolution: {integrity: sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==} + ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} @@ -938,6 +1124,15 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} @@ -966,6 +1161,9 @@ packages: oxc-resolver: optional: true + electron-to-chromium@1.5.426: + resolution: {integrity: sha512-2Gcq6inCQs/AqfHP3f5ftCzk+pqeW2VlA1LgmPqEj2hfBO8HiZRspNYkD4HzFBe4u6lGqca4BspFr6Ix4Q400g==} + emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} @@ -976,6 +1174,10 @@ packages: resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==} engines: {node: '>=8.6'} + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + environment@1.1.0: resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} engines: {node: '>=18'} @@ -1050,6 +1252,10 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + get-caller-file@2.0.5: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} @@ -1082,6 +1288,9 @@ packages: highlight.js@10.7.3: resolution: {integrity: sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==} + html-entities@2.3.3: + resolution: {integrity: sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA==} + html-to-image@1.11.13: resolution: {integrity: sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==} @@ -1120,6 +1329,10 @@ packages: resolution: {integrity: sha512-2AT6j+gXe/1ueqbW6fLZJiIw3F8iXGJtt0yDrZaBhAZEG1raiTxKWU+IPqMCzQAXOUCKdA4UDMgacKH25XG2Cw==} engines: {node: '>=4'} + is-what@4.1.16: + resolution: {integrity: sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A==} + engines: {node: '>=12.13'} + is-windows@1.0.2: resolution: {integrity: sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==} engines: {node: '>=0.10.0'} @@ -1127,6 +1340,9 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + js-yaml@3.15.1: resolution: {integrity: sha512-S99WuO3HlhO3XN41EtYUNl9zzXjoJx7QvmipxsJVxtCBT0YHEFy+iOJhjSvrmV12nYhWpZaM8lPHkJm0yUMbag==} hasBin: true @@ -1135,6 +1351,16 @@ packages: resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==} hasBin: true + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + jsonfile@4.0.0: resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==} @@ -1223,6 +1449,9 @@ packages: resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} engines: {node: 20 || >=22} + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -1240,6 +1469,10 @@ packages: mdast-util-to-hast@13.2.1: resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==} + merge-anything@5.1.7: + resolution: {integrity: sha512-eRtbOb1N5iyH0tkQDAoQ4Ipsp/5qSR79Dzrz8hEPxRX10RWWR/iQXdoKmBSRCThY1Fh5EhISDtpSc93fpxUniQ==} + engines: {node: '>=12.13'} + merge2@1.4.1: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} @@ -1267,6 +1500,9 @@ packages: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} engines: {node: '>=4'} + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} @@ -1279,6 +1515,10 @@ packages: resolution: {integrity: sha512-Z3lTE9pLaJF47NyMhd4ww1yFTAP8YhYI8SleJiHzM46Fgpm5cnNzSl9XfzFNqbaz+VlJrIj3fXQ4DeN1Rjm6cw==} engines: {node: '>=18'} + node-releases@2.0.55: + resolution: {integrity: sha512-mIrE/Cw9y+9Au6dS5vDKDhQza9YvG6w+ZrS6X+ZzA7yFW/soAeaups4Qzn1bL6g5FVy8WtP79+0j82oPIbqRjQ==} + engines: {node: '>=18'} + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -1344,6 +1584,9 @@ packages: parse5@6.0.1: resolution: {integrity: sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==} + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -1469,6 +1712,10 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + semver@7.8.5: resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} engines: {node: '>=10'} @@ -1649,6 +1896,15 @@ packages: resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} engines: {node: '>= 4.0.0'} + update-browserslist-db@1.3.2: + resolution: {integrity: sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + validate-html-nesting@1.2.4: + resolution: {integrity: sha512-doQi7e8EJ2OWneSG1aZpJluS6A49aZM0+EICXWKm1i6WvqTLmq0tpUcImc4KTWG50mORO0C4YDBtOCSYvElftw==} + validate-npm-package-name@5.0.1: resolution: {integrity: sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} @@ -1702,6 +1958,14 @@ packages: yaml: optional: true + vitefu@1.1.3: + resolution: {integrity: sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==} + peerDependencies: + vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + vite: + optional: true + vitest@4.1.11: resolution: {integrity: sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -1761,6 +2025,9 @@ packages: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + yargs-parser@20.2.9: resolution: {integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==} engines: {node: '>=10'} @@ -1783,6 +2050,11 @@ packages: snapshots: + '@ampproject/remapping@2.3.0': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + '@andrewbranch/untar.js@1.0.4': {} '@arethetypeswrong/cli@0.18.5': @@ -1806,8 +2078,119 @@ snapshots: typescript: 5.6.1-rc validate-npm-package-name: 5.0.1 + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.7': {} + + '@babel/core@7.29.7(supports-color@7.2.0)': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/helpers': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.8(supports-color@7.2.0) + '@babel/types': 7.29.8 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3(supports-color@7.2.0) + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.8': + dependencies: + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.29.7': + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + browserslist: 4.28.9 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.29.7': {} + + '@babel/helper-module-imports@7.18.6': + dependencies: + '@babel/types': 7.29.8 + + '@babel/helper-module-imports@7.29.7(supports-color@7.2.0)': + dependencies: + '@babel/traverse': 7.29.8(supports-color@7.2.0) + '@babel/types': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-module-imports': 7.29.7(supports-color@7.2.0) + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.8(supports-color@7.2.0) + transitivePeerDependencies: + - supports-color + + '@babel/helper-plugin-utils@7.29.7': {} + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/helper-validator-option@7.29.7': {} + + '@babel/helpers@7.29.7': + dependencies: + '@babel/template': 7.29.7 + '@babel/types': 7.29.8 + + '@babel/parser@7.29.8': + dependencies: + '@babel/types': 7.29.8 + + '@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-plugin-utils': 7.29.7 + '@babel/runtime@7.29.7': {} + '@babel/template@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + + '@babel/traverse@7.29.8(supports-color@7.2.0)': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/template': 7.29.7 + '@babel/types': 7.29.8 + debug: 4.4.3(supports-color@7.2.0) + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.8': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@braidai/lang@1.1.2': {} '@changesets/apply-release-plan@7.1.1': @@ -1993,16 +2376,32 @@ snapshots: tslib: 2.8.1 optional: true + '@emnapi/core@1.11.3': + dependencies: + '@emnapi/wasi-threads': 1.2.3 + tslib: 2.8.1 + optional: true + '@emnapi/runtime@1.11.2': dependencies: tslib: 2.8.1 optional: true + '@emnapi/runtime@1.11.3': + dependencies: + tslib: 2.8.1 + optional: true + '@emnapi/wasi-threads@1.2.2': dependencies: tslib: 2.8.1 optional: true + '@emnapi/wasi-threads@1.2.3': + dependencies: + tslib: 2.8.1 + optional: true + '@inquirer/external-editor@1.0.3(@types/node@24.13.3)': dependencies: chardet: 2.2.0 @@ -2010,6 +2409,16 @@ snapshots: optionalDependencies: '@types/node': 24.13.3 + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + '@jridgewell/resolve-uri@3.1.2': {} '@jridgewell/sourcemap-codec@1.5.5': {} @@ -2046,6 +2455,13 @@ snapshots: '@tybys/wasm-util': 0.10.3 optional: true + '@napi-rs/wasm-runtime@1.2.3(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)': + dependencies: + '@emnapi/core': 1.11.3 + '@emnapi/runtime': 1.11.3 + '@tybys/wasm-util': 0.10.3 + optional: true + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -2211,8 +2627,65 @@ snapshots: '@sindresorhus/is@4.6.0': {} + '@solidjs/babel-plugin@2.0.0-rc.7(@babel/core@7.29.7(supports-color@7.2.0))': + dependencies: + '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/helper-module-imports': 7.18.6 + '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/types': 7.29.8 + html-entities: 2.3.3 + parse5: 7.3.0 + validate-html-nesting: 1.2.4 + + '@solidjs/compiler-darwin-arm64@2.0.0-rc.7': + optional: true + + '@solidjs/compiler-darwin-x64@2.0.0-rc.7': + optional: true + + '@solidjs/compiler-linux-arm64-gnu@2.0.0-rc.7': + optional: true + + '@solidjs/compiler-linux-x64-gnu@2.0.0-rc.7': + optional: true + + '@solidjs/compiler-wasm32-wasi@2.0.0-rc.7': + dependencies: + '@emnapi/core': 1.11.3 + '@emnapi/runtime': 1.11.3 + '@napi-rs/wasm-runtime': 1.2.3(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3) + optional: true + + '@solidjs/compiler-win32-x64-msvc@2.0.0-rc.7': + optional: true + + '@solidjs/compiler@2.0.0-rc.7': + optionalDependencies: + '@solidjs/compiler-darwin-arm64': 2.0.0-rc.7 + '@solidjs/compiler-darwin-x64': 2.0.0-rc.7 + '@solidjs/compiler-linux-arm64-gnu': 2.0.0-rc.7 + '@solidjs/compiler-linux-x64-gnu': 2.0.0-rc.7 + '@solidjs/compiler-wasm32-wasi': 2.0.0-rc.7 + '@solidjs/compiler-win32-x64-msvc': 2.0.0-rc.7 + '@solidjs/signals@2.0.0-rc.0': {} + '@solidjs/vite-plugin@3.0.0-next.35(@solidjs/web@2.0.0-rc.0(solid-js@2.0.0-rc.0))(solid-js@2.0.0-rc.0)(supports-color@7.2.0)(vite@8.2.1(@types/node@24.13.3))': + dependencies: + '@ampproject/remapping': 2.3.0 + '@babel/core': 7.29.7(supports-color@7.2.0) + '@solidjs/babel-plugin': 2.0.0-rc.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@solidjs/compiler': 2.0.0-rc.7 + '@solidjs/web': 2.0.0-rc.0(solid-js@2.0.0-rc.0) + '@types/babel__core': 7.20.5 + merge-anything: 5.1.7 + solid-js: 2.0.0-rc.0 + vite: 8.2.1(@types/node@24.13.3) + vitefu: 1.1.3(vite@8.2.1(@types/node@24.13.3)) + transitivePeerDependencies: + - '@tsrx/core' + - supports-color + '@solidjs/web@2.0.0-rc.0(solid-js@2.0.0-rc.0)': dependencies: seroval: 1.5.6 @@ -2226,6 +2699,27 @@ snapshots: tslib: 2.8.1 optional: true + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.29.8 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.29.8 + '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 @@ -2454,6 +2948,8 @@ snapshots: assertion-error@2.0.1: {} + baseline-browser-mapping@2.11.21: {} + better-path-resolve@1.0.0: dependencies: is-windows: 1.0.2 @@ -2462,6 +2958,16 @@ snapshots: dependencies: fill-range: 7.1.1 + browserslist@4.28.9: + dependencies: + baseline-browser-mapping: 2.11.21 + caniuse-lite: 1.0.30001810 + electron-to-chromium: 1.5.426 + node-releases: 2.0.55 + update-browserslist-db: 1.3.2(browserslist@4.28.9) + + caniuse-lite@1.0.30001810: {} + ccount@2.0.1: {} chai@6.2.2: {} @@ -2524,6 +3030,12 @@ snapshots: csstype@3.2.3: {} + debug@4.4.3(supports-color@7.2.0): + dependencies: + ms: 2.1.3 + optionalDependencies: + supports-color: 7.2.0 + dequal@2.0.3: {} detect-indent@6.1.0: {} @@ -2540,6 +3052,8 @@ snapshots: dts-resolver@3.0.0: {} + electron-to-chromium@1.5.426: {} + emoji-regex@8.0.0: {} emojilib@2.4.0: {} @@ -2549,6 +3063,8 @@ snapshots: ansi-colors: 4.1.3 strip-ansi: 6.0.1 + entities@6.0.1: {} + environment@1.1.0: {} error-stack-parser-es@2.0.1: {} @@ -2612,6 +3128,8 @@ snapshots: fsevents@2.3.3: optional: true + gensync@1.0.0-beta.2: {} + get-caller-file@2.0.5: {} get-tsconfig@5.0.0-beta.5: @@ -2655,6 +3173,8 @@ snapshots: highlight.js@10.7.3: {} + html-entities@2.3.3: {} + html-to-image@1.11.13: {} html-void-elements@3.0.0: {} @@ -2681,10 +3201,14 @@ snapshots: dependencies: better-path-resolve: 1.0.0 + is-what@4.1.16: {} + is-windows@1.0.2: {} isexe@2.0.0: {} + js-tokens@4.0.0: {} + js-yaml@3.15.1: dependencies: argparse: 1.0.10 @@ -2694,6 +3218,10 @@ snapshots: dependencies: argparse: 2.0.1 + jsesc@3.1.0: {} + + json5@2.2.3: {} + jsonfile@4.0.0: optionalDependencies: graceful-fs: 4.2.11 @@ -2755,6 +3283,10 @@ snapshots: lru-cache@11.5.2: {} + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -2784,6 +3316,10 @@ snapshots: unist-util-visit: 5.1.0 vfile: 6.0.3 + merge-anything@5.1.7: + dependencies: + is-what: 4.1.16 + merge2@1.4.1: {} micromark-util-character@2.1.1: @@ -2810,6 +3346,8 @@ snapshots: mri@1.2.0: {} + ms@2.1.3: {} + mz@2.7.0: dependencies: any-promise: 1.3.0 @@ -2825,6 +3363,8 @@ snapshots: emojilib: 2.4.0 skin-tone: 2.0.0 + node-releases@2.0.55: {} + object-assign@4.1.1: {} obug@2.1.4: {} @@ -2893,6 +3433,10 @@ snapshots: parse5@6.0.1: {} + parse5@7.3.0: + dependencies: + entities: 6.0.1 + path-exists@4.0.0: {} path-key@3.1.1: {} @@ -3007,6 +3551,8 @@ snapshots: safer-buffer@2.1.2: {} + semver@6.3.1: {} + semver@7.8.5: {} seroval-plugins@1.5.6(seroval@1.5.6): @@ -3188,6 +3734,14 @@ snapshots: universalify@0.1.2: {} + update-browserslist-db@1.3.2(browserslist@4.28.9): + dependencies: + browserslist: 4.28.9 + escalade: 3.2.0 + picocolors: 1.1.1 + + validate-html-nesting@1.2.4: {} + validate-npm-package-name@5.0.1: {} vfile-message@4.0.3: @@ -3211,6 +3765,10 @@ snapshots: '@types/node': 24.13.3 fsevents: 2.3.3 + vitefu@1.1.3(vite@8.2.1(@types/node@24.13.3)): + optionalDependencies: + vite: 8.2.1(@types/node@24.13.3) + vitest@4.1.11(@types/node@24.13.3)(vite@8.2.1(@types/node@24.13.3)): dependencies: '@vitest/expect': 4.1.11 @@ -3255,6 +3813,8 @@ snapshots: y18n@5.0.8: {} + yallist@3.1.1: {} + yargs-parser@20.2.9: {} yargs@16.2.2: diff --git a/src/dev-toolbar/icons.tsx b/src/dev-toolbar/icons.tsx index cb0317b..3c813b4 100644 --- a/src/dev-toolbar/icons.tsx +++ b/src/dev-toolbar/icons.tsx @@ -505,3 +505,93 @@ export function TrashIcon(props: JSX.IntrinsicElements['svg'] & { title: string ); } + +export function TreeIcon(props: JSX.IntrinsicElements['svg'] & { title: string }): JSX.Element { + return ( + + {props.title} + + + + + + + + ); +} + +export function PauseIcon(props: JSX.IntrinsicElements['svg'] & { title: string }): JSX.Element { + return ( + + {props.title} + + + ); +} + +export function PlayIcon(props: JSX.IntrinsicElements['svg'] & { title: string }): JSX.Element { + return ( + + {props.title} + + + ); +} + +export function ExpandIcon(props: JSX.IntrinsicElements['svg'] & { title: string }): JSX.Element { + return ( + + {props.title} + + + ); +} + +export function CollapseIcon(props: JSX.IntrinsicElements['svg'] & { title: string }): JSX.Element { + return ( + + {props.title} + + + ); +} diff --git a/src/dev-toolbar/index.tsx b/src/dev-toolbar/index.tsx index ce62fb6..a9627b5 100644 --- a/src/dev-toolbar/index.tsx +++ b/src/dev-toolbar/index.tsx @@ -1,22 +1,36 @@ import type { JSX } from '@solidjs/web'; import { clientOnly, httpStatus, isServer, Portal } from '@solidjs/web'; -import { createEffect, createSignal, Errored, onSettled } from 'solid-js'; +import { createEffect, createSignal, Errored, getOwner, onSettled } from 'solid-js'; import { Toolbar } from 'terracotta/toolbar'; import version from '../version.js'; import IconButton from '../ui/IconButton.js'; import { Text } from '../ui/Text.js'; import { type ServerFunctionInstance, ServerFunctionViewer } from './functions/index.js'; import { captureServerFunctionCall } from './functions/tracker.js'; -import { ErrorIcon, FunctionIcon, SolidIcon } from './icons.js'; +import { ErrorIcon, FunctionIcon, SolidIcon, TreeIcon } from './icons.js'; +import { excludeOwner, includeOwner } from './ownership/registry.js'; import './index.css'; const ErrorViewer = clientOnly(() => import('./error-viewer/index.js'), { lazy: true }); +const OwnershipViewer = clientOnly(() => import('./ownership/index.js'), { lazy: true }); export interface DevToolbarProps { children?: JSX.Element; } +/** + * Owns the app the toolbar wraps. Everything created here counts as app code, + * even though the toolbar's own scope encloses it. + */ +function AppScope(props: { children?: JSX.Element }): JSX.Element { + includeOwner(getOwner()); + return <>{props.children}; +} + export function DevToolbar(props: DevToolbarProps) { + // Everything the toolbar creates stays out of the tree it renders. + excludeOwner(getOwner()); + const [ref, setRef] = createSignal(); createEffect( @@ -123,9 +137,9 @@ export function DevToolbar(props: DevToolbarProps) { }, ); - const [content, setContent] = createSignal<'fn' | 'err' | undefined>(undefined); + const [content, setContent] = createSignal<'fn' | 'err' | 'own' | undefined>(undefined); - function toggleContent(value: 'fn' | 'err') { + function toggleContent(value: 'fn' | 'err' | 'own') { if (content() === value) { setContent(undefined); } else { @@ -197,6 +211,9 @@ export function DevToolbar(props: DevToolbarProps) { toggleContent('fn')}> + toggleContent('own')}> + +
@@ -208,6 +225,7 @@ export function DevToolbar(props: DevToolbarProps) {
+ ; }} > - {props.children} + {props.children} ); diff --git a/src/dev-toolbar/ownership/format.ts b/src/dev-toolbar/ownership/format.ts new file mode 100644 index 0000000..5993948 --- /dev/null +++ b/src/dev-toolbar/ownership/format.ts @@ -0,0 +1,83 @@ +const MAX_STRING = 40; +const MAX_ENTRIES = 4; + +/** Short name for the type of a value. */ +export function typeName(value: unknown): string { + if (value === null) return 'null'; + if (Array.isArray(value)) return 'array'; + if (value instanceof Date) return 'date'; + if (value instanceof Map) return 'map'; + if (value instanceof Set) return 'set'; + if (value instanceof Promise) return 'promise'; + if (typeof value === 'object') { + if (typeof Node === 'function' && value instanceof Node) return 'node'; + const name = (value as object).constructor?.name; + return name && name !== 'Object' ? name.toLowerCase() : 'object'; + } + return typeof value; +} + +/** + * One line preview of a value. Nested values are only expanded one level, so a + * preview stays short enough for a tree row. + */ +export function previewValue(value: unknown, depth = 0): string { + if (value === undefined) return 'undefined'; + if (value === null) return 'null'; + + switch (typeof value) { + case 'string': { + const text = value.length > MAX_STRING ? `${value.slice(0, MAX_STRING)}…` : value; + return `"${text}"`; + } + case 'number': + case 'boolean': + return String(value); + case 'bigint': + return `${value}n`; + case 'symbol': + return value.toString(); + case 'function': + return value.name ? `ƒ ${value.name}()` : 'ƒ ()'; + } + + if (value instanceof Date) return value.toISOString(); + if (value instanceof Error) return `${value.name}: ${value.message}`; + if (value instanceof Promise) return 'Promise'; + if (typeof Node === 'function' && value instanceof Node) { + const element = value as unknown as Element; + return element.tagName ? `<${element.tagName.toLowerCase()}>` : value.nodeName; + } + if (value instanceof Map) return `Map(${value.size})`; + if (value instanceof Set) return `Set(${value.size})`; + + if (Array.isArray(value)) { + if (depth > 0) return `Array(${value.length})`; + const items = value.slice(0, MAX_ENTRIES).map((item) => previewValue(item, depth + 1)); + if (value.length > MAX_ENTRIES) items.push(`…${value.length - MAX_ENTRIES} more`); + return `[${items.join(', ')}]`; + } + + const name = (value as object).constructor?.name; + const prefix = name && name !== 'Object' ? `${name} ` : ''; + if (depth > 0) return `${prefix}{…}`; + + let keys: string[]; + try { + keys = Object.keys(value as object); + } catch { + return `${prefix}{…}`; + } + if (keys.length === 0) return `${prefix}{}`; + const entries = keys.slice(0, MAX_ENTRIES).map((key) => { + let inner: unknown; + try { + inner = (value as Record)[key]; + } catch { + return `${key}: …`; + } + return `${key}: ${previewValue(inner, depth + 1)}`; + }); + if (keys.length > MAX_ENTRIES) entries.push(`…${keys.length - MAX_ENTRIES} more`); + return `${prefix}{ ${entries.join(', ')} }`; +} diff --git a/src/dev-toolbar/ownership/index.tsx b/src/dev-toolbar/ownership/index.tsx new file mode 100644 index 0000000..aa849e4 --- /dev/null +++ b/src/dev-toolbar/ownership/index.tsx @@ -0,0 +1,526 @@ +import type { JSX } from '@solidjs/web'; +import { createEffect, createMemo, createSignal, For, getOwner, Show } from 'solid-js'; +import { Badge } from '../../ui/Badge.js'; +import IconButton from '../../ui/IconButton.js'; +import Placeholder from '../../ui/Placeholder.js'; +import { Text } from '../../ui/Text.js'; +import { CollapseIcon, ExpandIcon, PauseIcon, PlayIcon, TreeIcon } from '../icons.js'; +import { previewValue, typeName } from './format.js'; +import { + excludeOwner, + isOwnershipAvailable, + snapshotOwnershipTree, + startOwnershipTracking, + subscribeOwnershipTree, +} from './registry.js'; +import { ancestorsOf, EMPTY_TREE, type OwnershipTree, type TreeNode } from './tree.js'; +import './styles.css'; + +/** + * 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; +} + +export default function OwnershipViewer(props: OwnershipViewerProps): JSX.Element { + // The panel renders inside the app it inspects, so its own scope is marked. + excludeOwner(getOwner()); + + const [tree, setTree] = createSignal(EMPTY_TREE); + const [componentsOnly, setComponentsOnly] = createSignal(true); + const [paused, setPaused] = createSignal(false); + const [query, setQuery] = createSignal(''); + const [collapsed, setCollapsed] = createSignal([]); + const [selected, setSelected] = createSignal(); + + const firstSeen = new Map(); + + // Takes the mode as an argument because reading a signal inside an effect + // callback is not tracked. + function refresh(mode: boolean): void { + const next = snapshotOwnershipTree({ componentsOnly: mode }); + const now = Date.now(); + for (const node of next.nodes) { + if (!firstSeen.has(node.id)) firstSeen.set(node.id, now); + } + // An unchanged fingerprint means the app tree did not move. Skipping the + // write stops the panel's own render from feeding itself another update. + setTree((current) => (current.fingerprint === next.fingerprint ? current : next)); + } + + createEffect( + () => ({ watching: !!props.show && !paused(), mode: componentsOnly() }), + (state) => { + if (!state.watching) return; + const read = () => refresh(state.mode); + const stop = startOwnershipTracking(); + const unsubscribe = subscribeOwnershipTree(read); + read(); + return () => { + unsubscribe(); + stop(); + }; + }, + ); + + const byId = createMemo(() => new Map(tree().nodes.map((node) => [node.id, node]))); + + const matches = createMemo(() => { + const search = query().trim().toLowerCase(); + if (!search) return undefined; + const nodes = byId(); + const keep = new Set(); + for (const node of tree().nodes) { + const hit = + node.name.toLowerCase().includes(search) || + node.kind.includes(search) || + node.signals.some( + (signal) => + signal.name.toLowerCase().includes(search) || + previewValue(signal.value).toLowerCase().includes(search), + ) || + node.scopes.some( + (scope) => + scope.name.toLowerCase().includes(search) || + previewValue(scope.value).toLowerCase().includes(search), + ); + if (!hit) continue; + keep.add(node.id); + for (const parent of ancestorsOf(nodes, node.id)) keep.add(parent); + } + return keep; + }); + + const rows = createMemo(() => { + const nodes = byId(); + const visible = matches(); + const hidden = collapsed(); + const out: Row[] = []; + + const walk = (id: string, indent: number) => { + const node = nodes.get(id); + if (!node) return; + if (visible && !visible.has(id)) return; + const children = visible + ? node.children.filter((child) => visible.has(child)) + : node.children; + // A search result is always open, so matches deeper down stay reachable. + const expanded = visible ? true : !hidden.includes(id); + out.push({ node, indent, expandable: children.length > 0, expanded }); + if (!expanded) return; + for (const child of children) walk(child, indent + 1); + }; + + for (const root of tree().roots) walk(root, 0); + return out; + }); + + function toggle(id: string): void { + setCollapsed((current) => + current.includes(id) ? current.filter((item) => item !== id) : [...current, id], + ); + } + + function collapseAll(): void { + setCollapsed( + tree() + .nodes.filter((node) => node.children.length > 0) + .map((node) => node.id), + ); + } + + const selectedNode = createMemo(() => { + const id = selected(); + return id ? byId().get(id) : undefined; + }); + + const 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. + + + } + > +
+ 0} + fallback={ + + + {query() ? 'Nothing matches this filter.' : 'No owners observed yet.'} + + + } + > + + {(row) => ( +
+ +
+ )} +
+
+
+ + +
+
+
+
+
+ ); +} diff --git a/src/dev-toolbar/ownership/registry.ts b/src/dev-toolbar/ownership/registry.ts new file mode 100644 index 0000000..8315315 --- /dev/null +++ b/src/dev-toolbar/ownership/registry.ts @@ -0,0 +1,193 @@ +import { DEV } from 'solid-js'; +import { buildOwnershipTree, EMPTY_TREE, type OwnershipTree, type RawNode } from './tree.js'; + +let nextId = 1; +const ids = new WeakMap(); +const excluded = new WeakSet(); +const included = new WeakSet(); + +/** Owners the runtime told us about, weak so the app can still collect them. */ +const tracked = new Set>(); +const trackedRefs = new WeakMap>(); +const collected = + typeof FinalizationRegistry === 'function' + ? new FinalizationRegistry>((ref) => tracked.delete(ref)) + : undefined; + +const listeners = new Set<() => void>(); +let uninstall: (() => void) | undefined; +let watchers = 0; +let frame: number | undefined; +/** An owner inside the toolbar. The walk climbs from here to the app root. */ +let seedOwner: RawNode | undefined; + +/** True when the app runs a development build of solid-js. */ +export function isOwnershipAvailable(): boolean { + return !!DEV && typeof DEV.getChildren === 'function'; +} + +function identify(node: object): string { + let id = ids.get(node); + if (!id) { + id = `o${nextId++}`; + ids.set(node, id); + } + return id; +} + +function track(owner: RawNode | null | undefined): void { + if (!owner || typeof owner !== 'object' || trackedRefs.has(owner)) return; + const ref = new WeakRef(owner); + trackedRefs.set(owner, ref); + tracked.add(ref); + collected?.register(owner, ref); +} + +function notify(): void { + if (frame !== undefined || listeners.size === 0) return; + frame = requestAnimationFrame(() => { + frame = undefined; + for (const listener of listeners) listener(); + }); +} + +/** + * Installs the devtools hooks on the reactive runtime. Existing hooks are kept + * and still called, so other tools sharing the slot keep working. + */ +export function startOwnershipTracking(): () => void { + if (!isOwnershipAvailable()) return () => {}; + watchers++; + if (uninstall) return release; + + const hooks = DEV!.hooks; + const previousOwner = hooks.onOwner; + const previousGraph = hooks.onGraph; + const previousUpdate = hooks.onUpdate; + + hooks.onOwner = (owner) => { + previousOwner?.(owner); + track(owner as RawNode); + notify(); + }; + hooks.onGraph = (value, owner) => { + previousGraph?.(value, owner); + if (owner) track(owner as RawNode); + notify(); + }; + hooks.onUpdate = () => { + previousUpdate?.(); + notify(); + }; + + uninstall = () => { + hooks.onOwner = previousOwner; + hooks.onGraph = previousGraph; + hooks.onUpdate = previousUpdate; + uninstall = undefined; + if (frame !== undefined) cancelAnimationFrame(frame); + frame = undefined; + }; + return release; +} + +/** Drops one watcher. The hooks come off once nothing watches any more. */ +function release(): void { + watchers = Math.max(0, watchers - 1); + if (watchers === 0) uninstall?.(); +} + +/** Calls `listener` after the tree changed, at most once per frame. */ +export function subscribeOwnershipTree(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +/** + * Marks an owner as belonging to the toolbar itself. Its subtree never shows up + * in the tree, so the panel does not list its own components. + */ +export function excludeOwner(owner: unknown): void { + if (!owner || typeof owner !== 'object') return; + excluded.add(owner); + seedOwner ??= owner as RawNode; +} + +/** + * Marks an owner as app code again. The toolbar wraps the app, so the scope + * holding `props.children` carries this marker and stays in the tree. + */ +export function includeOwner(owner: unknown): void { + if (owner && typeof owner === 'object') included.add(owner); +} + +function isExcluded(owner: RawNode): boolean { + return excluded.has(owner); +} + +function isIncluded(owner: RawNode): boolean { + return included.has(owner); +} + +function childrenOf(owner: RawNode): RawNode[] { + try { + // The runtime keeps the newest child first, so reversing puts the tree in + // creation order, which is the order the app reads in. + return (DEV!.getChildren(owner as never) as RawNode[]).reverse(); + } catch { + return []; + } +} + +function signalsOf(owner: RawNode): RawNode[] { + try { + return DEV!.getSignals(owner as never) as RawNode[]; + } catch { + return []; + } +} + +function rootOf(owner: RawNode): RawNode { + let current = owner; + while (current._parent) current = current._parent; + return current; +} + +export interface SnapshotOptions { + componentsOnly?: boolean; + includeDisposed?: boolean; +} + +/** + * Reads the current owner tree. + * + * The walk starts at every known root: the one above the toolbar, plus the root + * of every owner the hooks reported. That covers owners created before the + * panel opened and roots the toolbar does not sit under. + */ +export function snapshotOwnershipTree(options?: SnapshotOptions): OwnershipTree { + if (!isOwnershipAvailable()) return EMPTY_TREE; + + const roots = new Set(); + if (seedOwner) roots.add(rootOf(seedOwner)); + for (const ref of tracked) { + const owner = ref.deref(); + if (!owner) { + tracked.delete(ref); + continue; + } + roots.add(rootOf(owner)); + } + + return buildOwnershipTree([...roots], { + children: childrenOf, + signals: signalsOf, + identify, + isExcluded, + isIncluded, + componentsOnly: options?.componentsOnly ?? true, + includeDisposed: options?.includeDisposed, + }); +} diff --git a/src/dev-toolbar/ownership/styles.css b/src/dev-toolbar/ownership/styles.css new file mode 100644 index 0000000..256b5d9 --- /dev/null +++ b/src/dev-toolbar/ownership/styles.css @@ -0,0 +1,464 @@ +[data-solid-ownership-viewer] { + --start-dt-kind-component: oklch(0.68 0.13 245); + --start-dt-kind-root: oklch(0.72 0.12 195); + --start-dt-kind-memo: oklch(0.7 0.14 300); + --start-dt-kind-effect: oklch(0.72 0.15 150); + --start-dt-kind-render-effect: oklch(0.78 0.13 80); + --start-dt-kind-tracked-effect: oklch(0.72 0.12 210); + --start-dt-kind-scope: oklch(0.6 0.02 265); + + color: var(--start-dt-text); + + display: flex; + flex-direction: column; + + height: 100%; + min-height: 0; +} + +[data-solid-ownership-nav] { + display: flex; + flex-direction: row; + align-items: center; + + gap: 0.5rem; + + padding: 0.5rem 0.75rem; + + border-bottom: var(--start-dt-border-soft) 1px solid; +} + +[data-solid-ownership-nav-title] { + display: flex; + flex-direction: row; + align-items: center; + + gap: 0.5rem; + + flex-shrink: 0; +} + +[data-solid-ownership-nav-actions] { + display: flex; + flex-direction: row; + align-items: center; + + gap: 0.375rem; + + margin-left: auto; +} + +[data-solid-ownership-count] { + color: var(--start-dt-text-muted); + white-space: nowrap; +} + +[data-solid-ownership-search] { + flex: 1; + min-width: 6rem; + max-width: 18rem; + + padding: 0.25rem 0.5rem; + + border-radius: 0.5rem; + border: var(--start-dt-border) 1px solid; + background: var(--start-dt-surface); + color: var(--start-dt-text); + + font-family: inherit; + font-size: 0.75rem; + line-height: 1rem; +} + +[data-solid-ownership-search]:focus { + outline: none; + border-color: var(--start-dt-accent); +} + +[data-solid-ownership-modes] { + display: flex; + + border-radius: 9999px; + border: var(--start-dt-border) 1px solid; + overflow: hidden; +} + +[data-solid-ownership-mode] { + padding: 0.1875rem 0.625rem; + + border: none; + background: var(--start-dt-surface); + color: var(--start-dt-text-muted); + + cursor: pointer; +} + +[data-solid-ownership-mode]:hover { + background: var(--start-dt-surface-hover); +} + +[data-solid-ownership-mode][data-active] { + background: var(--start-dt-accent-soft); + color: var(--start-dt-accent); +} + +[data-solid-ownership-body] { + display: flex; + flex-direction: row; + + flex: 1; + min-height: 0; +} + +[data-solid-ownership-rows] { + display: flex; + flex-direction: column; + + flex: 1; + min-width: 0; + min-height: 0; + + padding: 0.375rem 0.25rem; + + overflow: auto; +} + +[data-solid-ownership-row] { + display: flex; + flex-direction: row; + align-items: center; + + gap: 0.125rem; + + border-radius: 0.375rem; + + min-width: 0; +} + +[data-solid-ownership-row]:hover { + background: var(--start-dt-surface-hover); +} + +[data-solid-ownership-row][data-selected] { + background: var(--start-dt-surface-active); +} + +[data-solid-ownership-row][data-fresh] { + animation: solid-ownership-pulse 900ms ease-out; +} + +@keyframes solid-ownership-pulse { + 0% { + background: var(--start-dt-accent-soft); + } + 100% { + background: transparent; + } +} + +[data-solid-ownership-chevron] { + display: inline-flex; + align-items: center; + justify-content: center; + + width: 1rem; + height: 1rem; + flex-shrink: 0; + + border: none; + background: none; + color: var(--start-dt-text-muted); + cursor: pointer; +} + +[data-solid-ownership-chevron]::before { + content: ''; + + width: 0.3125rem; + height: 0.3125rem; + + border-right: 1.5px currentColor solid; + border-bottom: 1.5px currentColor solid; + + transform: rotate(-45deg); + transition: transform 150ms cubic-bezier(0.4, 0, 0.2, 1); +} + +[data-solid-ownership-chevron][data-leaf] { + cursor: default; +} + +[data-solid-ownership-chevron][data-leaf]::before { + content: none; +} + +[data-solid-ownership-chevron][data-expanded]::before { + transform: rotate(45deg); +} + +[data-solid-ownership-label] { + display: flex; + flex-direction: row; + align-items: center; + + gap: 0.375rem; + + flex: 1; + min-width: 0; + + padding: 0.1875rem 0.25rem; + + border: none; + background: none; + color: inherit; + + text-align: left; + cursor: pointer; +} + +[data-solid-ownership-name] { + overflow: hidden; + text-overflow: ellipsis; +} + +[data-solid-ownership-kind] { + width: 0.5rem; + height: 0.5rem; + + flex-shrink: 0; + + border-radius: 9999px; + background: var(--start-dt-kind-scope); +} + +[data-solid-ownership-kind='component'] { + background: var(--start-dt-kind-component); +} + +[data-solid-ownership-kind='root'] { + background: var(--start-dt-kind-root); +} + +[data-solid-ownership-kind='memo'] { + background: var(--start-dt-kind-memo); +} + +[data-solid-ownership-kind='effect'] { + background: var(--start-dt-kind-effect); +} + +[data-solid-ownership-kind='render-effect'] { + background: var(--start-dt-kind-render-effect); +} + +[data-solid-ownership-kind='tracked-effect'] { + background: var(--start-dt-kind-tracked-effect); +} + +[data-solid-ownership-detail] { + display: flex; + flex-direction: column; + + width: 20rem; + flex-shrink: 0; + min-height: 0; + + overflow-y: auto; + + border-left: var(--start-dt-border-soft) 1px solid; +} + +[data-solid-ownership-detail-content] { + display: flex; + flex-direction: column; + + gap: 0.5rem; + + padding: 0.625rem 0.75rem; +} + +[data-solid-ownership-detail-head] { + display: flex; + flex-direction: row; + align-items: center; + + gap: 0.375rem; + + min-width: 0; +} + +[data-solid-ownership-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; + align-items: baseline; + + gap: 0.5rem; + + padding: 0.125rem 0.25rem; + + border-radius: 0.25rem; +} + +[data-solid-ownership-signal]:hover { + background: var(--start-dt-surface-hover); +} + +[data-solid-ownership-scope-name] { + display: inline-flex; + align-items: center; + + gap: 0.375rem; + + min-width: 0; + 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..dbd1cf4 --- /dev/null +++ b/src/dev-toolbar/ownership/tree.test.ts @@ -0,0 +1,223 @@ +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 }, + ]); + 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('lists prop names of a component', () => { + const root = owner({ component: 'Greeting' }); + root._component.props = { name: 'ada', greeting: 'hi' }; + + expect(build([root]).nodes[0]!.props).toEqual(['name', 'greeting']); + }); +}); + +describe('buildOwnershipTree in owner mode', () => { + it('keeps every owner', () => { + const memo = owner({ memo: true, name: 'total' }); + const root = owner({ component: 'App', children: [memo] }); + + expect(names(build([root], { componentsOnly: false }).nodes)).toEqual(['', 'total']); + }); +}); + +describe('buildOwnershipTree visibility', () => { + it('hides an excluded subtree', () => { + const hidden = owner({ component: 'Toolbar' }); + const root = owner({ component: 'App', children: [hidden] }); + + const tree = build([root], { isExcluded: (node) => node === hidden }); + + expect(names(tree.nodes)).toEqual(['']); + }); + + it('shows an included scope inside a hidden subtree, without the marker itself', () => { + const app = owner({ component: 'App' }); + const marker = owner({ component: 'AppScope', children: [app] }); + const toolbar = owner({ component: 'Toolbar', children: [marker] }); + + const tree = build([toolbar], { + isExcluded: (node) => node === toolbar, + isIncluded: (node) => node === marker, + }); + + expect(names(tree.nodes)).toEqual(['']); + expect(tree.roots).toEqual([tree.nodes[0]!.id]); + }); + + it('drops disposed owners unless asked for them', () => { + const gone = owner({ component: 'Gone', disposed: true }); + const root = owner({ component: 'App', children: [gone] }); + + expect(names(build([root]).nodes)).toEqual(['']); + expect(names(build([root], { includeDisposed: true }).nodes)).toEqual(['', '']); + }); + + it('visits an owner once even when two roots reach it', () => { + const shared = owner({ component: 'Shared' }); + const first = owner({ component: 'First', children: [shared] }); + const second = owner({ component: 'Second', children: [shared] }); + + expect(names(build([first, second]).nodes)).toEqual(['', '', '']); + }); +}); + +describe('fingerprint', () => { + it('changes when the tree gains a signal', () => { + const root = owner({ component: 'App' }); + const before = build([root]).fingerprint; + root._signals = [signal('count', 0)]; + + expect(build([root]).fingerprint).not.toBe(before); + }); + + it('stays the same when nothing moved', () => { + const root = owner({ component: 'App', signals: [signal('count', 0)] }); + + expect(build([root]).fingerprint).toBe(build([root]).fingerprint); + }); +}); + +describe('ancestorsOf', () => { + it('walks up to the root', () => { + const leaf = owner({ component: 'Leaf' }); + const middle = owner({ component: 'Middle', children: [leaf] }); + const root = owner({ component: 'Root', children: [middle] }); + const tree = build([root]); + const byId = new Map(tree.nodes.map((node) => [node.id, node])); + + expect(ancestorsOf(byId, tree.nodes[2]!.id).map((id) => byId.get(id)!.name)).toEqual([ + '', + '', + ]); + }); +}); diff --git a/src/dev-toolbar/ownership/tree.ts b/src/dev-toolbar/ownership/tree.ts new file mode 100644 index 0000000..2432235 --- /dev/null +++ b/src/dev-toolbar/ownership/tree.ts @@ -0,0 +1,277 @@ +/** Raw owner or signal from the runtime. Only the fields the tree needs are read. */ +export type RawNode = Record; + +const REACTIVE_DISPOSED = 1 << 6; + +const EFFECT_RENDER = 1; +const EFFECT_USER = 2; +const EFFECT_TRACKED = 3; + +export type OwnerKind = + | 'component' + | 'root' + | 'memo' + | 'effect' + | 'render-effect' + | 'tracked-effect' + | 'scope'; + +const KIND_LABELS: Record = { + component: 'component', + root: 'root', + memo: 'memo', + effect: 'effect', + 'render-effect': 'render effect', + 'tracked-effect': 'tracked effect', + scope: 'scope', +}; + +export interface OwnedSignal { + id: string; + name: string; + value: unknown; +} + +/** A scope folded into the component above it, such as a memo or an effect. */ +export interface FoldedScope { + id: string; + kind: OwnerKind; + name: string; + value: unknown; + hasValue: boolean; +} + +export interface TreeNode { + id: string; + parentId: string | undefined; + kind: OwnerKind; + name: string; + depth: number; + children: string[]; + /** Signals this owner created, plus those of the scopes it stands in for. */ + signals: OwnedSignal[]; + /** + * Prop names of a component. Props are getters, so the tree lists the names + * and never reads the values. + */ + props: string[] | undefined; + /** Current value of a computed owner. */ + value: unknown; + hasValue: boolean; + disposed: boolean; + /** Owners this node stands in for, when scopes are folded away. */ + scopes: FoldedScope[]; + /** Where the component is declared, as `file:line:column`. */ + location: string | undefined; +} + +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; + } +} + +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, + disposed: isDisposed(owner), + scopes: [], + }; + nodes.push(node); + if (parentId === undefined) topLevel.push(node.id); + return node; + } + + function collectSignals(owner: RawNode, into: TreeNode): void { + for (const signal of options.signals(owner)) { + if (!signal || typeof signal !== 'object') continue; + const name = signal._name; + into.signals.push({ + id: options.identify(signal), + name: typeof name === 'string' && name.length > 0 ? name : 'signal', + value: signal._value, + }); + } + } + + function walk(owner: RawNode, host: TreeNode | undefined, depth: number, hidden: boolean): void { + if (!owner || typeof owner !== 'object' || seen.has(owner)) return; + seen.add(owner); + + // The nearest marker decides. The toolbar wraps the app, so the app's own + // scope sits inside the toolbar's hidden subtree and turns visibility back + // on for everything below it. The marker itself is toolbar code, so it + // never becomes a row. + if (options.isIncluded(owner)) { + for (const child of options.children(owner)) walk(child, undefined, 0, false); + return; + } + + if (options.isExcluded(owner) || hidden) { + for (const child of options.children(owner)) walk(child, undefined, 0, true); + return; + } + + if (isDisposed(owner) && !options.includeDisposed) return; + + const shown = !options.componentsOnly || isComponent(owner); + + if (shown) { + const node = describe(owner, host?.id, depth); + if (host) host.children.push(node.id); + collectSignals(owner, node); + for (const child of options.children(owner)) walk(child, node, depth + 1, false); + return; + } + + // Folded scope. Its signals and children belong to the component above it. + if (host) { + const kind = ownerKind(owner); + host.scopes.push({ + id: options.identify(owner), + kind, + name: ownerName(owner, kind), + value: '_value' in owner ? owner._value : undefined, + hasValue: '_value' in owner, + }); + collectSignals(owner, host); + } + for (const child of options.children(owner)) walk(child, host, depth, false); + } + + for (const root of roots) walk(root, undefined, 0, false); + + let fingerprint = `${nodes.length}:${topLevel.length}`; + for (const node of nodes) { + fingerprint += `|${node.id}${node.kind}${node.children.length}${node.signals.length}${ + node.scopes.length + }${node.disposed ? 'd' : ''}`; + for (const signal of node.signals) fingerprint += `,${signal.id}`; + for (const scope of node.scopes) fingerprint += `;${scope.id}`; + } + + return { nodes, roots: topLevel, fingerprint }; +} + +/** Ids of `id` and every node above it, used to keep matches visible. */ +export function ancestorsOf(nodes: Map, id: string): string[] { + const path: string[] = []; + let current = nodes.get(id); + while (current?.parentId) { + path.push(current.parentId); + current = nodes.get(current.parentId); + } + return path; +} diff --git a/tests/e2e/devtools.spec.ts b/tests/e2e/devtools.spec.ts index 0f63476..95bd4f8 100644 --- a/tests/e2e/devtools.spec.ts +++ b/tests/e2e/devtools.spec.ts @@ -78,6 +78,46 @@ test('shows server-function calls', async ({ page }) => { expect(warnings).not.toContainEqual(expect.stringContaining('STRICT_READ_UNTRACKED')); }); +test('maps the ownership tree', async ({ page }) => { + await page.goto('/'); + const toggle = page.getByRole('button', { name: 'View Ownership Tree' }); + const rows = page.locator('[data-solid-ownership-name]'); + + await toggle.click(); + await expect(rows).toHaveText(['', '', '', '']); + + // A component owns the signals and scopes created inside it. + await page.locator('[data-solid-ownership-label]').filter({ hasText: '' }).click(); + const detail = page.locator('[data-solid-ownership-detail]'); + await expect(detail).toContainText('Signals (1)'); + await expect(detail).toContainText('count'); + await expect(detail).toContainText('doubled'); + + // Props are listed by name, never read. + await page.locator('[data-solid-ownership-label]').filter({ hasText: '' }).click(); + await expect(detail).toContainText('Props (1)'); + await expect(detail).toContainText('name'); + + // 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(['', '']); +}); + test('mounts once and disposes', async ({ page }) => { await page.goto('/?mount'); diff --git a/tests/fixture/app.tsx b/tests/fixture/app.tsx index 55e72eb..e697b0e 100644 --- a/tests/fixture/app.tsx +++ b/tests/fixture/app.tsx @@ -1,6 +1,6 @@ import { render } from '@solidjs/web'; import { DevToolbar, mountDevToolbar, pushServerFunctionCall } from '@solidjs/start-devtools'; -import { createSignal, Show } from 'solid-js'; +import { createMemo, createSignal, Show } from 'solid-js'; function Broken(): never { throw new Error('client boom'); @@ -32,12 +32,29 @@ function emitServerFunctionResponse() { responseStatus = 500; } +function Greeting(props: { name: string }) { + return

{`hello ${props.name}`}

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

app content

+ + diff --git a/tsconfig.tests.json b/tsconfig.tests.json index a03c1e2..4a40776 100644 --- a/tsconfig.tests.json +++ b/tsconfig.tests.json @@ -1,9 +1,17 @@ { "extends": "./tsconfig.json", - "include": ["playwright.config.ts", "vitest.config.ts", "src/**/*.test.ts", "tests"], + "include": [ + "playwright.config.ts", + "vitest.config.ts", + "src/**/*.test.ts", + "tests", + "examples" + ], "exclude": [], "compilerOptions": { "noEmit": true, - "types": ["node"] + "types": [ + "node" + ] } }