diff --git a/.changeset/reactivity-graph.md b/.changeset/reactivity-graph.md new file mode 100644 index 0000000..828c9a8 --- /dev/null +++ b/.changeset/reactivity-graph.md @@ -0,0 +1,11 @@ +--- +'@solidjs/start-devtools': patch +--- + +Add a reactivity graph panel to the dev toolbar. + +The panel maps live signals, memos and effects as a directed graph. +Hovering a node shows its value, state, owner and edge counts. +Selecting a node highlights everything upstream and downstream of it and lists its sources and observers. +The selected node's value is shown as an expandable tree, the same one the server function panel uses for serialized values. +Nodes pulse and count changes as the app updates, and the graph can be filtered by kind or searched by name, value or owner. diff --git a/README.md b/README.md index d6804b4..764f7e7 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. +- A reactivity graph of the live signals, memos and effects in the app. ```sh pnpm add @solidjs/start-devtools@next @@ -25,4 +29,22 @@ 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/demo` is a small orders dashboard that exercises every panel. + +```sh +pnpm demo +``` + +## Reactivity graph + +The graph panel maps the running reactive graph. Signals, memos and effects are nodes, +and an edge points from a source to the computation that reads it. Hover a node for its +value, state and edge counts. Select one to dim the rest of the graph, list what it +reads and what reads it, and inspect its value as an expandable tree. + +The panel reads the graph through the development hooks in `solid-js`, so it is empty in a +production build of the runtime. It only watches while it is open. + +For component tree inspection, see [Solid Devtools](https://github.com/thetarnav/solid-devtools). diff --git a/examples/demo/README.md b/examples/demo/README.md new file mode 100644 index 0000000..5fd0e55 --- /dev/null +++ b/examples/demo/README.md @@ -0,0 +1,32 @@ +# Dev toolbar demo + +A small orders dashboard that exercises every panel of the toolbar. + +```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/demo/src/App.tsx` is the whole app. + +- The reactivity graph has named nodes. `orders`, `query`, `status-filter`, `sort-order` + and `currency` are signals. `matching-orders`, `sorted-orders`, `revenue`, `open-orders`, + `average-order`, `exchange-rate` and `converted-revenue` are memos. `sync-title` is an + effect that writes the document title. +- `exchange-rate` is an async memo backed by a `"use server"` function. While it is in + flight, it and `converted-revenue` read as pending in the graph. +- The server function panel lists the same call with its request and response bodies. +- The error panel catches the error behind the "Throw an error" button, with a + source-mapped stack frame. + +## Things to try + +1. Open the graph and select `revenue`. Everything it reads turns blue and everything that + reads it turns green. +2. Type in the search box and watch `matching-orders` and the nodes after it count changes. +3. Switch the currency and open the graph quickly to catch the pending state. +4. Turn the `Render` filter off to hide the nodes the JSX compiler creates. diff --git a/examples/demo/src/App.tsx b/examples/demo/src/App.tsx new file mode 100644 index 0000000..fb9b421 --- /dev/null +++ b/examples/demo/src/App.tsx @@ -0,0 +1,248 @@ +import { createEffect, createMemo, createSignal, For, Loading, Show } from 'solid-js'; +import { createOrder, SEED_ORDERS, type Order, type OrderStatus } from './orders.js'; +import { reportCall } from './report-call.js'; +import './styles.css'; + +type Filter = 'all' | OrderStatus; +type Sort = 'newest' | 'total'; + +const FILTERS: Filter[] = ['all', 'paid', 'open', 'refunded']; +const CURRENCIES = ['USD', 'EUR', 'JPY']; + +/** + * Runs on the server. The toolbar's server function panel shows the request and + * the response, including the serialized body. + */ +async function loadRate(currency: string): Promise<{ currency: string; rate: number }> { + 'use server'; + const rates: Record = { USD: 1, EUR: 0.92, JPY: 155.4 }; + await new Promise((resolve) => setTimeout(resolve, 450)); + return { currency, rate: rates[currency] ?? 1 }; +} + +function Boom(): never { + throw new Error('The demo threw this on purpose.'); +} + +export default function App() { + const [orders, setOrders] = createSignal(SEED_ORDERS, { name: 'orders' }); + const [query, setQuery] = createSignal('', { name: 'query' }); + const [filter, setFilter] = createSignal('all', { name: 'status-filter' }); + const [sort, setSort] = createSignal('newest', { name: 'sort-order' }); + const [currency, setCurrency] = createSignal('USD', { name: 'currency' }); + const [broken, setBroken] = createSignal(false, { name: 'broken' }); + + const matching = createMemo( + () => { + const search = query().trim().toLowerCase(); + const status = filter(); + return orders().filter((order) => { + if (status !== 'all' && order.status !== status) return false; + if (!search) return true; + return ( + order.customer.toLowerCase().includes(search) || + order.item.toLowerCase().includes(search) || + order.id.toLowerCase().includes(search) + ); + }); + }, + { name: 'matching-orders' }, + ); + + const sorted = createMemo( + () => + [...matching()].sort((a, b) => + sort() === 'total' ? b.total - a.total : b.placedAt - a.placedAt, + ), + { name: 'sorted-orders' }, + ); + + const revenue = createMemo( + () => + matching().reduce((total, order) => total + (order.status === 'paid' ? order.total : 0), 0), + { name: 'revenue' }, + ); + + const openCount = createMemo(() => matching().filter((order) => order.status === 'open').length, { + name: 'open-orders', + }); + + const averageOrder = createMemo( + () => (matching().length === 0 ? 0 : revenue() / matching().length), + { name: 'average-order' }, + ); + + // An async memo. While the server function is in flight the graph shows this + // node, and everything derived from it, as pending. + const rate = createMemo( + async () => { + const target = currency(); + const answer = await reportCall('loadRate', { currency: target }, () => loadRate(target)); + return answer.rate; + }, + { name: 'exchange-rate' }, + ); + + const converted = createMemo(() => revenue() * rate(), { name: 'converted-revenue' }); + + createEffect( + () => ({ open: openCount(), total: matching().length }), + (counts) => { + document.title = `${counts.open} open of ${counts.total} orders`; + }, + { name: 'sync-title' }, + ); + + function addOrder(): void { + setOrders((current) => [createOrder(), ...current]); + } + + function refund(id: string): void { + setOrders((current) => + current.map((order) => (order.id === id ? { ...order, status: 'refunded' } : order)), + ); + } + + return ( +
+
+
+

Orders

+

+ A demo app for the Solid Start dev toolbar. Open the toolbar and pick the graph icon. +

+
+
+ + +
+
+ +
+
+ Paid revenue + {`$${revenue().toFixed(2)}`} + signal → matching-orders → revenue +
+
+ Average order + {`$${averageOrder().toFixed(2)}`} + revenue ÷ matching-orders +
+
+ Open + {openCount()} + drives the document title effect +
+
+ {`Revenue in ${currency()}`} + loading…}> + {converted().toFixed(2)} + + server function through an async memo +
+
+ +
+ setQuery(event.currentTarget.value)} + /> +
+ + {(value) => ( + + )} + +
+
+ + +
+
+ + {(value) => ( + + )} + +
+
+ + + + + + + + + + + + + + + + } + > + {(order) => ( + + + + + + + + + )} + + +
OrderCustomerItemStatusTotal +
+ Nothing matches this filter. +
{order.id}{order.customer}{order.item} + {order.status} + {`$${order.total.toFixed(2)}`} + + + +
+ + + + +
+ ); +} diff --git a/examples/demo/src/css.d.ts b/examples/demo/src/css.d.ts new file mode 100644 index 0000000..35306c6 --- /dev/null +++ b/examples/demo/src/css.d.ts @@ -0,0 +1 @@ +declare module '*.css'; diff --git a/examples/demo/src/orders.ts b/examples/demo/src/orders.ts new file mode 100644 index 0000000..f942a31 --- /dev/null +++ b/examples/demo/src/orders.ts @@ -0,0 +1,89 @@ +export type OrderStatus = 'paid' | 'open' | 'refunded'; + +export interface Order { + id: string; + customer: string; + item: string; + total: number; + status: OrderStatus; + placedAt: number; +} + +const CUSTOMERS = [ + 'Ada Lovelace', + 'Grace Hopper', + 'Alan Turing', + 'Katherine Johnson', + 'Rosalind Franklin', + 'Linus Pauling', +]; + +const ITEMS = [ + 'Reactive mug', + 'Signal sticker pack', + 'Memo notebook', + 'Effect hoodie', + 'Owner tote bag', + 'Graph poster', +]; + +/** Fixed so the server render and the client render agree. */ +const EPOCH = Date.UTC(2026, 8, 1, 9, 0, 0); + +export const SEED_ORDERS: Order[] = [ + { id: 'ORD-0001', customer: 'Ada Lovelace', item: 'Reactive mug', total: 24.5, status: 'paid' }, + { + id: 'ORD-0002', + customer: 'Grace Hopper', + item: 'Effect hoodie', + total: 89, + status: 'paid', + }, + { id: 'ORD-0003', customer: 'Alan Turing', item: 'Graph poster', total: 32, status: 'open' }, + { + id: 'ORD-0004', + customer: 'Katherine Johnson', + item: 'Memo notebook', + total: 18.75, + status: 'paid', + }, + { + id: 'ORD-0005', + customer: 'Rosalind Franklin', + item: 'Signal sticker pack', + total: 12, + status: 'refunded', + }, + { + id: 'ORD-0006', + customer: 'Linus Pauling', + item: 'Owner tote bag', + total: 41.2, + status: 'open', + }, + { id: 'ORD-0007', customer: 'Ada Lovelace', item: 'Graph poster', total: 32, status: 'paid' }, + { + id: 'ORD-0008', + customer: 'Grace Hopper', + item: 'Signal sticker pack', + total: 12, + status: 'open', + }, +].map((order, index) => ({ ...order, placedAt: EPOCH - index * 37 * 60_000 }) as Order); + +let nextId = SEED_ORDERS.length + 1; + +/** A new order for the add button. Only ever called from a click. */ +export function createOrder(): Order { + const customer = CUSTOMERS[Math.floor(Math.random() * CUSTOMERS.length)]!; + const item = ITEMS[Math.floor(Math.random() * ITEMS.length)]!; + const statuses: OrderStatus[] = ['paid', 'open', 'open', 'refunded']; + return { + id: `ORD-${String(nextId++).padStart(4, '0')}`, + customer, + item, + total: Math.round((12 + Math.random() * 180) * 100) / 100, + status: statuses[Math.floor(Math.random() * statuses.length)]!, + placedAt: Date.now(), + }; +} diff --git a/examples/demo/src/report-call.ts b/examples/demo/src/report-call.ts new file mode 100644 index 0000000..ab15390 --- /dev/null +++ b/examples/demo/src/report-call.ts @@ -0,0 +1,62 @@ +import { pushServerFunctionCall } from '@solidjs/start-devtools'; +import { isServer } from '@solidjs/web'; + +let counter = 0; + +/** + * Reports a server function call to the toolbar so the demo's server function + * panel has something to show. + * + * `@solidjs/web` 2.0.0-rc.0 does not report calls to the toolbar on its own + * yet. `pushServerFunctionCall` is the same public API an integration would + * use to feed the panel. + */ +export async function reportCall( + name: string, + args: unknown, + run: () => Promise, +): Promise { + if (isServer) return run(); + + const instance = `${name}-${++counter}`; + const json = { 'Content-Type': 'application/json' }; + + pushServerFunctionCall({ + type: 'request', + id: name, + instance, + time: performance.now(), + meta: { name }, + source: new Request('/_server', { + method: 'POST', + headers: json, + body: JSON.stringify(args), + }), + }); + + try { + const result = await run(); + pushServerFunctionCall({ + type: 'response', + id: name, + instance, + time: performance.now(), + meta: { name }, + source: new Response(JSON.stringify(result), { status: 200, headers: json }), + }); + return result; + } catch (error) { + pushServerFunctionCall({ + type: 'response', + id: name, + instance, + time: performance.now(), + meta: { name }, + source: new Response(JSON.stringify({ message: String(error) }), { + status: 500, + headers: json, + }), + }); + throw error; + } +} diff --git a/examples/demo/src/styles.css b/examples/demo/src/styles.css new file mode 100644 index 0000000..9c42817 --- /dev/null +++ b/examples/demo/src/styles.css @@ -0,0 +1,240 @@ +:root { + color-scheme: dark; + + --bg: oklch(0.18 0.02 265); + --surface: oklch(0.23 0.02 265); + --surface-hover: oklch(0.27 0.025 265); + --border: oklch(0.32 0.02 265); + --text: oklch(0.94 0.005 265); + --muted: oklch(0.72 0.015 265); + --accent: oklch(0.68 0.13 245); + --good: oklch(0.72 0.15 150); + --warn: oklch(0.8 0.13 80); + --bad: oklch(0.66 0.19 25); +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + background: var(--bg); + color: var(--text); + font-family: system-ui, sans-serif; +} + +.page { + max-width: 68rem; + margin: 0 auto; + padding: 3rem 1.5rem 8rem; + + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.masthead { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; +} + +h1 { + margin: 0; + font-size: 1.75rem; +} + +.subtitle { + margin: 0.375rem 0 0; + color: var(--muted); + max-width: 40rem; +} + +.masthead-actions { + display: flex; + gap: 0.5rem; +} + +button { + padding: 0.4375rem 0.875rem; + + border-radius: 0.5rem; + border: var(--border) 1px solid; + background: var(--surface); + color: var(--text); + + font: inherit; + font-size: 0.875rem; + cursor: pointer; +} + +button:hover { + background: var(--surface-hover); +} + +button.primary { + border-color: transparent; + background: var(--accent); + color: oklch(0.16 0.02 265); + font-weight: 600; +} + +button.link { + border-color: transparent; + background: transparent; + color: var(--accent); + padding: 0.25rem 0.5rem; +} + +.stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr)); + gap: 0.75rem; +} + +.stat { + display: flex; + flex-direction: column; + gap: 0.25rem; + + padding: 0.875rem 1rem; + + border-radius: 0.75rem; + border: var(--border) 1px solid; + background: var(--surface); +} + +.stat-label { + color: var(--muted); + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.stat-value { + font-size: 1.5rem; + font-weight: 600; + font-variant-numeric: tabular-nums; +} + +.stat-value.pending { + color: var(--muted); + font-size: 1.25rem; +} + +.stat-note { + color: var(--muted); + font-size: 0.6875rem; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; +} + +.controls { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.75rem; +} + +input[type='search'], +select { + padding: 0.4375rem 0.625rem; + + border-radius: 0.5rem; + border: var(--border) 1px solid; + background: var(--surface); + color: var(--text); + + font: inherit; + font-size: 0.875rem; +} + +input[type='search'] { + flex: 1; + min-width: 14rem; +} + +.chips { + display: flex; + gap: 0.25rem; +} + +.chip { + border-radius: 9999px; + font-size: 0.8125rem; + text-transform: capitalize; +} + +.chip[aria-pressed='true'] { + border-color: var(--accent); + color: var(--accent); +} + +.orders { + width: 100%; + border-collapse: collapse; + + border-radius: 0.75rem; + border: var(--border) 1px solid; + overflow: hidden; +} + +.orders th, +.orders td { + padding: 0.625rem 0.875rem; + text-align: left; + border-bottom: var(--border) 1px solid; + font-size: 0.875rem; +} + +.orders th { + background: var(--surface); + color: var(--muted); + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.orders tbody tr:last-child td { + border-bottom: none; +} + +.right { + text-align: right; +} + +.mono { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-variant-numeric: tabular-nums; +} + +.empty { + color: var(--muted); + text-align: center; + padding: 2rem; +} + +.status { + padding: 0.125rem 0.5rem; + + border-radius: 9999px; + font-size: 0.75rem; + font-weight: 600; +} + +.status-paid { + background: color-mix(in oklch, var(--good) 20%, transparent); + color: var(--good); +} + +.status-open { + background: color-mix(in oklch, var(--warn) 20%, transparent); + color: var(--warn); +} + +.status-refunded { + background: color-mix(in oklch, var(--bad) 20%, transparent); + color: var(--bad); +} diff --git a/examples/demo/vite.config.ts b/examples/demo/vite.config.ts new file mode 100644 index 0000000..9d2b223 --- /dev/null +++ b/examples/demo/vite.config.ts @@ -0,0 +1,16 @@ +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 toolbar is mounted by the plugin because the package is installed. + ssr: true, + start: { devtools: true }, + serverFunctions: true, + }), + ], +}); diff --git a/package.json b/package.json index 393af14..80f8e17 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ }, "scripts": { "build": "rolldown -c", + "demo": "pnpm build && vite --config examples/demo/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/functions/SerovalValue.css b/src/dev-toolbar/functions/SerovalValue.css deleted file mode 100644 index 3bc3a52..0000000 --- a/src/dev-toolbar/functions/SerovalValue.css +++ /dev/null @@ -1,26 +0,0 @@ -[data-solid-seroval-value] { - display: flex; - gap: 0.25rem; - align-items: center; -} - -[data-solid-seroval-value='key'] { - color: oklch(0.78 0.1 305); -} - -[data-solid-seroval-value='string'] { - color: oklch(0.78 0.11 150); -} - -[data-solid-seroval-value='number'] { - color: oklch(0.8 0.11 80); -} - -[data-solid-seroval-value='keyword'] { - color: oklch(0.74 0.1 250); - font-style: italic; -} - -[data-solid-seroval-separator] { - color: var(--start-dt-text-muted); -} diff --git a/src/dev-toolbar/functions/SerovalValue.tsx b/src/dev-toolbar/functions/SerovalValue.tsx deleted file mode 100644 index ab9b5cc..0000000 --- a/src/dev-toolbar/functions/SerovalValue.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { Text } from '../../ui/Text.js'; -import './SerovalValue.css'; - -interface SerovalValueProps { - value: string | number | boolean | undefined | null; - kind?: 'key' | 'string' | 'number' | 'keyword'; -} - -export function SerovalValue(props: SerovalValueProps) { - return ( - - {`${props.value}`} - - ); -} - -export function PropertySeparator() { - return ( - - : - - ); -} diff --git a/src/dev-toolbar/functions/SerovalViewer.css b/src/dev-toolbar/functions/SerovalViewer.css index dce2671..914e770 100644 --- a/src/dev-toolbar/functions/SerovalViewer.css +++ b/src/dev-toolbar/functions/SerovalViewer.css @@ -16,117 +16,6 @@ monospace; } -[data-solid-seroval-tree-node] { - display: flex; - flex-direction: column; -} - -[data-solid-seroval-tree-row] { - display: flex; - align-items: center; - gap: 0.375rem; - - padding: 0.125rem 0.25rem; - border-radius: 0.25rem; - - min-width: 0; - width: 100%; - - border: none; - background: none; - color: inherit; - font: inherit; - text-align: left; - outline: none; -} - -button[data-solid-seroval-tree-row] { - cursor: pointer; -} - -button[data-solid-seroval-tree-row]:hover, -button[data-solid-seroval-tree-row]:focus-visible { - background-color: var(--start-dt-surface-hover, transparent); -} - -[data-solid-seroval-tree-chevron] { - display: inline-flex; - align-items: center; - justify-content: center; - - width: 0.875rem; - flex-shrink: 0; - - color: var(--start-dt-text-muted, inherit); -} - -[data-solid-seroval-tree-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-seroval-tree-chevron][data-leaf]::before { - content: none; -} - -[data-solid-seroval-tree-row][data-expanded] > [data-solid-seroval-tree-chevron]::before { - transform: rotate(45deg); -} - -[data-solid-seroval-tree-children] { - display: flex; - flex-direction: column; - - margin-left: 0.6875rem; - padding-left: 0.625rem; - - border-left: 1px var(--start-dt-border-soft, oklch(87% 0.065 274.039)) solid; -} - -[data-solid-seroval-tree-key] { - display: inline-flex; - align-items: center; - gap: 0.125rem; - - flex-shrink: 0; -} - -[data-solid-seroval-tree-preview] { - color: var(--start-dt-text-muted, inherit); - font-size: 0.75rem; - line-height: 1rem; - - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -[data-solid-seroval-tree-circular] { - display: inline-flex; - align-items: center; - gap: 0.25rem; -} - -[data-solid-seroval-tree-circular] > svg { - width: 1rem; - height: 1rem; - - color: var(--start-dt-accent, currentColor); -} - [data-solid-seroval-tree-raw] { padding: 0.25rem 0; } - -[data-solid-seroval-value='plain'] { - color: var(--start-dt-text, rgb(249 250 251)); -} diff --git a/src/dev-toolbar/functions/SerovalViewer.tsx b/src/dev-toolbar/functions/SerovalViewer.tsx index 9e1d7bb..d0303f2 100644 --- a/src/dev-toolbar/functions/SerovalViewer.tsx +++ b/src/dev-toolbar/functions/SerovalViewer.tsx @@ -5,7 +5,7 @@ import { ChunkReader } from '@solidjs/web/server-functions/client'; import { Badge } from '../../ui/Badge.js'; import { HexViewer } from './HexViewer.js'; -import { PropertySeparator, SerovalValue } from './SerovalValue.js'; +import { TreeBranch, TreeKey, TreeLeaf, TreeMark, ValueToken } from '../../ui/ValueTree.js'; import './SerovalViewer.css'; @@ -478,69 +478,6 @@ function previewNode(ctx: RenderContext, node: SerovalNode, depth: number): stri } } -interface EntryKeyProps { - value: string | number; - kind?: 'key' | 'number' | 'keyword'; -} - -function EntryKey(props: EntryKeyProps): JSX.Element { - return ( - - - - - ); -} - -interface LeafRowProps { - label?: JSX.Element; - children: JSX.Element; -} - -function LeafRow(props: LeafRowProps): JSX.Element { - return ( -
-
- - {props.label} - {props.children} -
-
- ); -} - -interface ExpandableRowProps { - label?: JSX.Element; - badges?: JSX.Element; - preview: JSX.Element; - open?: boolean; - children: JSX.Element; -} - -function ExpandableRow(props: ExpandableRowProps): JSX.Element { - const [open, setOpen] = createSignal(props.open ?? false); - return ( -
- - -
{props.children}
-
-
- ); -} - interface TreeValueProps { ctx: RenderContext; node: SerovalNode; @@ -558,12 +495,12 @@ function TreeValue(props: TreeValueProps): JSX.Element { const index = node.i; if (props.seen.includes(index)) { return ( - - + + {`circular #${index}`} - - + + ); } return ( @@ -571,9 +508,9 @@ function TreeValue(props: TreeValueProps): JSX.Element { when={ctx.getNode(index)} keyed fallback={ - + {`#${index} pending`} - + } > {(target) => ( @@ -602,58 +539,58 @@ function TreeValue(props: TreeValueProps): JSX.Element { // Number = 0, case 0: return ( - - - + + + ); // String = 1, case 1: return ( - - - + + + ); // Constant = 2, case 2: return ( - - - + + + ); // BigInt = 3, case 3: return ( - - - + + + ); // Date = 5, case 5: return ( - + Date - - + + ); // RegExp = 6, case 6: return ( - + RegExp - - + + ); // WKSymbol = 17, case 17: return ( - - - + + + ); // Set = 7, case 7: return ( - } + label={} /> )} - + ); // Map = 8, case 8: return ( - {([key, value], index) => ( - } + } preview={`{${previewNode(ctx, key, 1)} => ${previewNode(ctx, value, 1)}}`} > } + label={} /> } + label={} /> - + )} - + ); // Array = 9, case 9: return ( - {(child, index) => child === 0 ? ( - }> - - + }> + + ) : ( } + label={} /> ) } - + ); // Object = 10, case 10: // NullConstructor = 11, case 11: return ( - - } + label={} /> )} - + ); // Promise = 12, case 12: return ( - } + label={} /> - + ); // Error = 13, case 13: // AggregateError = 14, case 14: return ( - - }> - - + }> + + {(properties) => ( @@ -795,14 +730,14 @@ function TreeValue(props: TreeValueProps): JSX.Element { node={value} seen={seen} label={ - + } /> )} )} - + ); // TypedArray = 15, case 15: @@ -811,30 +746,30 @@ function TreeValue(props: TreeValueProps): JSX.Element { // DataView = 20, case 20: return ( - - }> - - - }> - - + }> + + + }> + + } + label={} /> - + ); // ArrayBuffer = 19, case 19: return ( - ; })()} - + ); // Boxed = 21, case 21: return ( - } + label={} /> - + ); // PromiseConstructor = 22, case 22: return ( - {previewNode(ctx, node, 0)}} @@ -879,33 +814,33 @@ function TreeValue(props: TreeValueProps): JSX.Element { when={ctx.getPromise(node.s)} keyed fallback={ - }> + }> pending - + } > {(result) => ( <> - }> + }> {result.t === 23 ? 'success' : 'failure'} - + } + label={} /> )} - + ); // Plugin = 25, case 25: return ( - {([key, value]) => ( - } /> + } /> )} - + ); // IteratorFactoryInstance = 28, case 28: // AsyncIteratorFactoryInstance = 30, case 30: return ( - } + label={} /> - + ); // StreamConstructor = 31, case 31: return ( - {previewNode(ctx, node, 0)}} @@ -949,9 +884,9 @@ function TreeValue(props: TreeValueProps): JSX.Element { }> + }> waiting - + } > {(chunk) => ( @@ -959,15 +894,15 @@ function TreeValue(props: TreeValueProps): JSX.Element { ctx={ctx} node={chunk.f} seen={seen} - label={} + label={} /> )} - + ); case 35: return ( - )} - + ); default: return ( - + {getNodeType(node)} - + ); } } diff --git a/src/dev-toolbar/icons.tsx b/src/dev-toolbar/icons.tsx index cb0317b..30379f7 100644 --- a/src/dev-toolbar/icons.tsx +++ b/src/dev-toolbar/icons.tsx @@ -505,3 +505,73 @@ export function TrashIcon(props: JSX.IntrinsicElements['svg'] & { title: string ); } + +export function GraphIcon(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 FitIcon(props: JSX.IntrinsicElements['svg'] & { title: string }): JSX.Element { + return ( + + {props.title} + + + ); +} diff --git a/src/dev-toolbar/index.css b/src/dev-toolbar/index.css index 3c44a7b..4ac7d93 100644 --- a/src/dev-toolbar/index.css +++ b/src/dev-toolbar/index.css @@ -104,6 +104,10 @@ width: 1.25rem; } +[data-solid-dev-toolbar-panel][data-wide] { + width: 72rem; +} + [data-solid-dev-toolbar-panel] { border: var(--start-dt-border) 1px solid; background: var(--start-dt-bg-glass); diff --git a/src/dev-toolbar/index.tsx b/src/dev-toolbar/index.tsx index ce62fb6..ea206a7 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, GraphIcon, SolidIcon } from './icons.js'; +import { excludeReactiveOwner, includeReactiveOwner } from './reactivity/registry.js'; import './index.css'; const ErrorViewer = clientOnly(() => import('./error-viewer/index.js'), { lazy: true }); +const ReactivityViewer = clientOnly(() => import('./reactivity/index.js'), { lazy: true }); 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 { + includeReactiveOwner(getOwner()); + return <>{props.children}; +} + export function DevToolbar(props: DevToolbarProps) { + // Everything the toolbar creates stays out of the reactivity graph it renders. + excludeReactiveOwner(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' | 'rx' | undefined>(undefined); - function toggleContent(value: 'fn' | 'err') { + function toggleContent(value: 'fn' | 'err' | 'rx') { if (content() === value) { setContent(undefined); } else { @@ -197,6 +211,9 @@ export function DevToolbar(props: DevToolbarProps) { toggleContent('fn')}> + toggleContent('rx')}> + +
@@ -208,6 +225,7 @@ export function DevToolbar(props: DevToolbarProps) {
+ ; }} > - {props.children} + {props.children} ); diff --git a/src/dev-toolbar/reactivity/ValueInspector.tsx b/src/dev-toolbar/reactivity/ValueInspector.tsx new file mode 100644 index 0000000..a20e7c3 --- /dev/null +++ b/src/dev-toolbar/reactivity/ValueInspector.tsx @@ -0,0 +1,194 @@ +import type { JSX } from '@solidjs/web'; +import { createMemo, For, Show } from 'solid-js'; +import { Badge } from '../../ui/Badge.js'; +import { TreeBranch, TreeKey, TreeLeaf, TreeMark, ValueToken } from '../../ui/ValueTree.js'; +import { formatValue, typeName } from './format.js'; +import { containerEntries, entryCount, ownEntries } from './value-entries.js'; + +function LinkIcon(props: { title: string }): JSX.Element { + return ( + + {props.title} + + + ); +} + +export interface ValueInspectorProps { + value: unknown; + label?: JSX.Element; + /** Open the row on first render. */ + open?: boolean; + /** Values already shown higher up this branch, used to catch cycles. */ + seen?: object[]; +} + +/** + * Renders a live value as an expandable tree, the same shape the server + * function panel uses for serialized values. + * + * Children are only built when a row opens, so a deep object costs nothing + * until it is inspected. + */ +export function ValueInspector(props: ValueInspectorProps): JSX.Element { + // The tree is rebuilt when the value changes, so the reads below have to + // happen inside a tracking scope. + const tree = createMemo(() => + renderValue(props.value, props.label, props.open, props.seen ?? []), + ); + return <>{tree()}; +} + +function renderValue( + value: unknown, + label: JSX.Element | undefined, + open: boolean | undefined, + seen: object[], +): JSX.Element { + if (value === null) { + return ( + + + + ); + } + + switch (typeof value) { + case 'undefined': + return ( + + + + ); + case 'string': + return ( + + + + ); + case 'number': + return ( + + + + ); + case 'bigint': + return ( + + + + ); + case 'boolean': + return ( + + + + ); + case 'symbol': + return ( + + + + ); + case 'function': + return ( + + function + + + ); + } + + // Everything primitive returned above, so what is left is an object. + const object = value as object; + + if (seen.includes(object)) { + return ( + + + + circular + + + ); + } + + // Values with nothing useful to expand read better as one row. + if (object instanceof Date || object instanceof RegExp || object instanceof Promise) { + return ( + + {typeName(object)} + + + ); + } + + if (typeof Node === 'function' && object instanceof Node) { + return ( + + dom + + + ); + } + + const count = entryCount(object); + const type = typeName(object); + const badges = ( + <> + {type} + + {`${count}`} + + + ); + + const nested = [...seen, object]; + + return ( + + {(() => { + const entries = containerEntries(object) ?? ownEntries(object); + const total = count ?? entries.length; + return ( + <> + + + + } + > + {(entry) => ( + }> + getter + + } + > + } + /> + + )} + + entries.length}> + + + + + + ); + })()} + + ); +} diff --git a/src/dev-toolbar/reactivity/format.test.ts b/src/dev-toolbar/reactivity/format.test.ts new file mode 100644 index 0000000..7fc17a0 --- /dev/null +++ b/src/dev-toolbar/reactivity/format.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; +import { formatValue, typeName } from './format.js'; + +describe('formatValue', () => { + it('formats primitives', () => { + expect(formatValue(undefined)).toBe('undefined'); + expect(formatValue(null)).toBe('null'); + expect(formatValue(42)).toBe('42'); + expect(formatValue(true)).toBe('true'); + expect(formatValue('hi')).toBe('"hi"'); + expect(formatValue(10n)).toBe('10n'); + }); + + it('truncates long strings', () => { + expect(formatValue('x'.repeat(80))).toBe(`"${'x'.repeat(48)}…"`); + }); + + it('names functions', () => { + expect(formatValue(function load() {})).toBe('ƒ load()'); + }); + + it('previews arrays and objects one level deep', () => { + expect(formatValue([1, 2])).toBe('[1, 2]'); + expect(formatValue({ a: 1, b: 'x' })).toBe('{ a: 1, b: "x" }'); + expect(formatValue({ nested: { deep: 1 } })).toBe('{ nested: {…} }'); + }); + + it('counts the entries it leaves out', () => { + expect(formatValue([1, 2, 3, 4, 5, 6, 7, 8])).toBe('[1, 2, 3, 4, 5, 6, …2 more]'); + }); + + it('keeps class names', () => { + class User { + name = 'ada'; + } + expect(formatValue(new User())).toBe('User { name: "ada" }'); + }); + + it('reads collections by size', () => { + expect(formatValue(new Map([['a', 1]]))).toBe('Map(1)'); + expect(formatValue(new Set([1, 2]))).toBe('Set(2)'); + }); +}); + +describe('typeName', () => { + it('names the kind of value', () => { + expect(typeName(null)).toBe('null'); + expect(typeName([])).toBe('array'); + expect(typeName({})).toBe('object'); + expect(typeName(new Date())).toBe('date'); + expect(typeName(1)).toBe('number'); + }); +}); diff --git a/src/dev-toolbar/reactivity/format.ts b/src/dev-toolbar/reactivity/format.ts new file mode 100644 index 0000000..f44a0e7 --- /dev/null +++ b/src/dev-toolbar/reactivity/format.ts @@ -0,0 +1,86 @@ +const MAX_STRING = 48; +const MAX_ENTRIES = 6; + +/** Short name for the type of a value, used as a badge in the node card. */ +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; +} + +function quote(value: string): string { + const text = value.length > MAX_STRING ? `${value.slice(0, MAX_STRING)}…` : value; + return `"${text}"`; +} + +/** + * One line preview of a value. Nested values are only expanded one level, so + * the result stays short enough for a graph node or a hover card. + */ +export function formatValue(value: unknown, depth = 0): string { + if (value === undefined) return 'undefined'; + if (value === null) return 'null'; + + switch (typeof value) { + case 'string': + return quote(value); + 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) => formatValue(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}: ${formatValue(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/reactivity/index.tsx b/src/dev-toolbar/reactivity/index.tsx new file mode 100644 index 0000000..b263cd8 --- /dev/null +++ b/src/dev-toolbar/reactivity/index.tsx @@ -0,0 +1,620 @@ +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 { FitIcon, GraphIcon, PauseIcon, PlayIcon } from '../icons.js'; +import { formatValue, typeName } from './format.js'; +import { edgePath, layoutGraph, NODE_HEIGHT, NODE_WIDTH, type GraphLayout } from './layout.js'; +import { ValueInspector } from './ValueInspector.js'; +import { + EMPTY_GRAPH, + excludeReactiveOwner, + isReactivityAvailable, + snapshotReactivityGraph, + startReactivityTracking, + subscribeReactivityGraph, + type ReactiveEdge, + type ReactiveGraph, + type ReactiveNode, + type ReactiveNodeKind, +} from './registry.js'; +import './styles.css'; + +const KINDS: { value: ReactiveNodeKind; label: string }[] = [ + { value: 'signal', label: 'Signals' }, + { value: 'memo', label: 'Memos' }, + { value: 'effect', label: 'Effects' }, + { value: 'render-effect', label: 'Render' }, +]; + +const HOVER_CARD_WIDTH = 288; + +interface ViewTransform { + x: number; + y: number; + k: number; +} + +function stateBadge(node: ReactiveNode): JSX.Element { + if (node.errored) return error; + if (node.pending) return pending; + if (node.state === 'dirty') return dirty; + if (node.state === 'check') return check; + if (node.uninitialized) return empty; + return clean; +} + +function NodeSummary(props: { node: ReactiveNode }): JSX.Element { + return ( + <> +
+ + + {props.node.name} + + {stateBadge(props.node)} +
+
+ value + + {formatValue(props.node.value)} + +
+
+ type + {typeName(props.node.value)} +
+
+ updates + {`${props.node.updates}`} +
+
+ edges + + {`${props.node.sources.length} in / ${props.node.observers.length} out`} + +
+ 0}> +
+ owner + {props.node.ownerPath.join(' › ')} +
+
+ + ); +} + +export interface ReactivityViewerProps { + show?: boolean; +} + +export default function ReactivityViewer(props: ReactivityViewerProps): JSX.Element { + // The panel watches the same runtime it renders in. Marking its owner keeps + // the toolbar's own signals out of the graph. + excludeReactiveOwner(getOwner()); + + const [graph, setGraph] = createSignal(EMPTY_GRAPH); + const [paused, setPaused] = createSignal(false); + const [query, setQuery] = createSignal(''); + const [hiddenKinds, setHiddenKinds] = createSignal([]); + const [selected, setSelected] = createSignal(); + const [hovered, setHovered] = createSignal(); + const [view, setView] = createSignal({ x: 0, y: 0, k: 1 }); + + let viewport: HTMLDivElement | undefined; + let fittedSize = ''; + let moved = false; + + function refresh(): void { + const next = snapshotReactivityGraph(); + // Equal fingerprints mean the app graph did not move. Skipping the write + // stops the panel's own render from feeding itself another update. + setGraph((current) => (current.fingerprint === next.fingerprint ? current : next)); + } + + createEffect( + () => !!props.show && !paused(), + (watching) => { + if (!watching) return; + const stop = startReactivityTracking(); + const unsubscribe = subscribeReactivityGraph(refresh); + refresh(); + return () => { + unsubscribe(); + stop(); + }; + }, + ); + + createEffect( + () => !!props.show, + (visible) => { + if (!visible) { + fittedSize = ''; + moved = false; + } + }, + ); + + const visibleNodes = createMemo(() => { + const search = query().trim().toLowerCase(); + const hidden = hiddenKinds(); + return graph().nodes.filter((node) => { + if (hidden.includes(node.kind)) return false; + if (!search) return true; + return ( + node.name.toLowerCase().includes(search) || + node.kind.includes(search) || + formatValue(node.value).toLowerCase().includes(search) || + node.ownerPath.join(' ').toLowerCase().includes(search) + ); + }); + }); + + const visibleEdges = createMemo(() => { + const ids = new Set(visibleNodes().map((node) => node.id)); + return graph().edges.filter((edge) => ids.has(edge.from) && ids.has(edge.to)); + }); + + // Each layout is seeded with the one it replaces, so nodes keep their slot + // as the graph grows instead of jumping around under the pointer. + let lastLayout: GraphLayout | undefined; + const layout = createMemo(() => { + lastLayout = layoutGraph( + visibleNodes().map((node) => ({ id: node.id, name: node.name, kind: node.kind })), + visibleEdges(), + lastLayout, + ); + return lastLayout; + }); + + const nodesById = createMemo(() => new Map(visibleNodes().map((node) => [node.id, node]))); + + const related = createMemo(() => { + const id = selected(); + if (!id) return undefined; + const edges = visibleEdges(); + const upstream = new Set(); + const downstream = new Set(); + + const walk = (start: string, back: boolean, out: Set) => { + const queue = [start]; + while (queue.length > 0) { + const current = queue.pop()!; + for (const edge of edges) { + const next = back + ? edge.to === current + ? edge.from + : null + : edge.from === current + ? edge.to + : null; + if (next && !out.has(next)) { + out.add(next); + queue.push(next); + } + } + } + }; + + walk(id, true, upstream); + walk(id, false, downstream); + return { upstream, downstream }; + }); + + function roleOf(id: string): string { + const current = related(); + if (!current) return 'plain'; + if (id === selected()) return 'selected'; + if (current.upstream.has(id)) return 'upstream'; + if (current.downstream.has(id)) return 'downstream'; + return 'muted'; + } + + function edgeRoleOf(edge: ReactiveEdge): string { + const current = related(); + if (!current) return 'plain'; + const from = roleOf(edge.from); + const to = roleOf(edge.to); + if (from === 'muted' || to === 'muted') return 'muted'; + if (from === 'upstream' || to === 'upstream') return 'upstream'; + if (from === 'downstream' || to === 'downstream') return 'downstream'; + return 'selected'; + } + + // Takes the layout as an argument so effects can pass the value they already + // read. Reading a memo inside an effect callback is not tracked. + function fit(current = layout()): void { + const box = viewport?.getBoundingClientRect(); + if (!box || current.width === 0 || current.height === 0) return; + // Shrinking past this makes the labels unreadable. Larger graphs are + // meant to be panned instead. + const k = Math.max( + 0.55, + Math.min(1, (box.width - 16) / current.width, (box.height - 16) / current.height), + ); + setView({ + k, + x: (box.width - current.width * k) / 2, + y: (box.height - current.height * k) / 2, + }); + } + + // Refit while the graph grows. Once the user pans or zooms, the view is + // theirs and only the fit button moves it. + createEffect( + () => layout(), + (current) => { + const size = `${current.width}x${current.height}`; + if (moved || current.width === 0 || size === fittedSize) return; + fittedSize = size; + fit(current); + }, + ); + + function onWheel(event: WheelEvent): void { + if (!viewport) return; + event.preventDefault(); + moved = true; + const box = viewport.getBoundingClientRect(); + const pointerX = event.clientX - box.left; + const pointerY = event.clientY - box.top; + setView((current) => { + const k = Math.min(2.5, Math.max(0.2, current.k * Math.exp(-event.deltaY / 400))); + const ratio = k / current.k; + return { + k, + x: pointerX - (pointerX - current.x) * ratio, + y: pointerY - (pointerY - current.y) * ratio, + }; + }); + } + + function onPanStart(event: PointerEvent): void { + const target = event.target as HTMLElement; + if (target.closest('[data-solid-reactivity-node]')) return; + const origin = view(); + const startX = event.clientX; + const startY = event.clientY; + const surface = event.currentTarget as HTMLElement; + moved = true; + surface.setPointerCapture(event.pointerId); + surface.dataset.panning = ''; + + const move = (moved: PointerEvent) => { + setView({ + k: origin.k, + x: origin.x + (moved.clientX - startX), + y: origin.y + (moved.clientY - startY), + }); + }; + const end = () => { + surface.releasePointerCapture(event.pointerId); + delete surface.dataset.panning; + surface.removeEventListener('pointermove', move); + surface.removeEventListener('pointerup', end); + surface.removeEventListener('pointercancel', end); + }; + + surface.addEventListener('pointermove', move); + surface.addEventListener('pointerup', end); + surface.addEventListener('pointercancel', end); + } + + function toggleKind(kind: ReactiveNodeKind): void { + setHiddenKinds((current) => + current.includes(kind) ? current.filter((item) => item !== kind) : [...current, kind], + ); + } + + const hoverCard = createMemo(() => { + const id = hovered(); + if (!id || id === selected()) return undefined; + const node = nodesById().get(id); + const position = layout().nodes.get(id); + if (!node || !position) return undefined; + const current = view(); + const width = viewport?.clientWidth ?? 0; + const left = current.x + (position.x + NODE_WIDTH) * current.k + 12; + const flip = width > 0 && left + HOVER_CARD_WIDTH > width; + return { + node, + x: flip ? Math.max(8, current.x + position.x * current.k - HOVER_CARD_WIDTH - 12) : left, + y: Math.max(8, current.y + position.y * current.k - 8), + }; + }); + + const selectedNode = createMemo(() => { + const id = selected(); + return id ? nodesById().get(id) : undefined; + }); + + return ( + +
+
+
+
+ + Reactivity graph +
+ setQuery(event.currentTarget.value)} + /> +
+ + {(kind) => ( + + )} + +
+
+ + {`${visibleNodes().length} nodes / ${visibleEdges().length} edges`} + + setPaused((current) => !current)}> + } + children={} + /> + + { + moved = false; + fit(); + }} + > + + +
+
+ +
+ + + The reactivity graph needs a development build of solid-js. + + + } + > +
{ + viewport = element; + }} + onWheel={onWheel} + onPointerDown={onPanStart} + > + 0} + fallback={ + + + No observed signals yet. Interact with the app and they appear here. + + + } + > +
+ + + + + + + + {(edge) => ( + + {(from) => ( + + {(to) => ( + + )} + + )} + + )} + + + + + {(node) => ( + + {(position) => ( + + )} + + )} + +
+
+ + + {(card) => ( +
+ +
+ )} +
+ + 0}> +
+ + {`${graph().dropped} more nodes are not shown. Filter to narrow the graph.`} + +
+
+
+ + +
+
+
+
+
+ ); +} diff --git a/src/dev-toolbar/reactivity/layout.test.ts b/src/dev-toolbar/reactivity/layout.test.ts new file mode 100644 index 0000000..68cc41c --- /dev/null +++ b/src/dev-toolbar/reactivity/layout.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it } from 'vitest'; +import { edgePath, layoutGraph, LAYER_GAP, NODE_HEIGHT, NODE_WIDTH } from './layout.js'; + +const node = (id: string) => ({ id, name: id, kind: 'signal' as const }); +const effect = (id: string) => ({ id, name: id, kind: 'effect' as const }); + +describe('layoutGraph', () => { + it('places a node with no sources in the first column', () => { + const layout = layoutGraph([node('a')], []); + + expect(layout.layers).toBe(1); + expect(layout.nodes.get('a')!.layer).toBe(0); + }); + + it('puts each node one column after its deepest source', () => { + const layout = layoutGraph( + [node('a'), node('b'), node('c')], + [ + { from: 'a', to: 'b' }, + { from: 'b', to: 'c' }, + { from: 'a', to: 'c' }, + ], + ); + + expect(layout.nodes.get('a')!.layer).toBe(0); + expect(layout.nodes.get('b')!.layer).toBe(1); + expect(layout.nodes.get('c')!.layer).toBe(2); + expect(layout.nodes.get('c')!.x - layout.nodes.get('b')!.x).toBe(NODE_WIDTH + LAYER_GAP); + }); + + it('does not hang on a cycle', () => { + const layout = layoutGraph( + [node('a'), node('b')], + [ + { from: 'a', to: 'b' }, + { from: 'b', to: 'a' }, + ], + ); + + expect(layout.nodes.size).toBe(2); + }); + + it('ignores edges pointing at nodes that are filtered out', () => { + const layout = layoutGraph([node('a')], [{ from: 'hidden', to: 'a' }]); + + expect(layout.nodes.get('a')!.layer).toBe(0); + }); + + it('stacks nodes of one column without overlap', () => { + const layout = layoutGraph([node('a'), node('b')], []); + const first = layout.nodes.get('a')!; + const second = layout.nodes.get('b')!; + + expect(Math.abs(second.y - first.y)).toBeGreaterThanOrEqual(NODE_HEIGHT); + }); + + it('puts an effect that subscribes to nothing before the signals', () => { + const layout = layoutGraph([node('a'), effect('lonely'), node('b')], [{ from: 'a', to: 'b' }]); + + expect(layout.nodes.get('lonely')!.layer).toBe(0); + expect(layout.nodes.get('a')!.layer).toBe(1); + expect(layout.nodes.get('b')!.layer).toBe(2); + }); + + it('leaves an effect with sources where its sources put it', () => { + const layout = layoutGraph([node('a'), effect('watcher')], [{ from: 'a', to: 'watcher' }]); + + expect(layout.nodes.get('a')!.layer).toBe(0); + expect(layout.nodes.get('watcher')!.layer).toBe(1); + }); + + it('keeps one column when every node is a detached effect', () => { + const layout = layoutGraph([effect('one'), effect('two')], []); + + expect(layout.layers).toBe(1); + }); + + it('keeps a node in its slot when another node appears', () => { + const first = layoutGraph([node('a'), node('b')], []); + const second = layoutGraph([node('a'), node('b'), node('c')], [], first); + + expect(second.nodes.get('a')!.y).toBe(first.nodes.get('a')!.y); + expect(second.nodes.get('b')!.y).toBe(first.nodes.get('b')!.y); + expect(second.nodes.get('c')!.index).toBe(2); + }); + + it('keeps the previous order when the caller passes the nodes in another order', () => { + const first = layoutGraph([node('a'), node('b')], []); + const second = layoutGraph([node('b'), node('a')], [], first); + + expect(second.nodes.get('a')!.index).toBe(0); + expect(second.nodes.get('b')!.index).toBe(1); + }); + + it('does not move one column when another column grows', () => { + const edges = [{ from: 'a', to: 'out' }]; + const first = layoutGraph([node('a'), node('out')], edges); + const second = layoutGraph([node('a'), node('b'), node('c'), node('out')], edges, first); + + expect(second.nodes.get('out')!.y).toBe(first.nodes.get('out')!.y); + }); + + it('gives the same layout for the same graph', () => { + const nodes = [node('a'), node('b'), node('c')]; + const edges = [{ from: 'a', to: 'c' }]; + const first = layoutGraph(nodes, edges); + const second = layoutGraph(nodes, edges); + + expect([...second.nodes.entries()]).toEqual([...first.nodes.entries()]); + }); + + it('reports an empty layout for an empty graph', () => { + const layout = layoutGraph([], []); + + expect(layout.width).toBe(0); + expect(layout.layers).toBe(0); + }); +}); + +describe('edgePath', () => { + it('starts at the right edge of the source and ends at the left edge of the target', () => { + const from = { id: 'a', layer: 0, index: 0, x: 0, y: 0 }; + const to = { id: 'b', layer: 1, index: 0, x: 280, y: 0 }; + const middle = NODE_HEIGHT / 2; + const curve = Math.max((to.x - NODE_WIDTH) * 0.5, 40); + + expect(edgePath(from, to)).toBe( + `M ${NODE_WIDTH} ${middle} C ${NODE_WIDTH + curve} ${middle}, ${to.x - curve} ${middle}, ${to.x} ${middle}`, + ); + }); + + it('keeps a minimum curve when the nodes almost touch', () => { + const from = { id: 'a', layer: 0, index: 0, x: 0, y: 0 }; + const to = { id: 'b', layer: 1, index: 1, x: NODE_WIDTH + 10, y: 60 }; + + expect(edgePath(from, to)).toContain(`C ${NODE_WIDTH + 40} ${NODE_HEIGHT / 2}`); + }); +}); diff --git a/src/dev-toolbar/reactivity/layout.ts b/src/dev-toolbar/reactivity/layout.ts new file mode 100644 index 0000000..b226424 --- /dev/null +++ b/src/dev-toolbar/reactivity/layout.ts @@ -0,0 +1,228 @@ +import type { ReactiveEdge, ReactiveNodeKind } from './registry.js'; + +export const NODE_WIDTH = 150; +export const NODE_HEIGHT = 44; +export const LAYER_GAP = 76; +export const ROW_GAP = 14; +export const PADDING = 24; + +export interface LayoutInput { + id: string; + name: string; + kind: ReactiveNodeKind; +} + +const EFFECT_KINDS = new Set(['effect', 'render-effect', 'tracked-effect']); + +export interface LayoutNode { + id: string; + layer: number; + index: number; + x: number; + y: number; +} + +export interface GraphLayout { + nodes: Map; + width: number; + height: number; + layers: number; +} + +/** Sources of each node, ignoring edges that point outside the given nodes. */ +function incomingEdges(nodes: LayoutInput[], edges: ReactiveEdge[]): Map { + const incoming = new Map(); + for (const node of nodes) incoming.set(node.id, []); + for (const edge of edges) { + if (!incoming.has(edge.from) || !incoming.has(edge.to)) continue; + incoming.get(edge.to)!.push(edge.from); + } + return incoming; +} + +/** Layer of each node, measured as the longest path from a node with no sources. */ +function assignLayers(nodes: LayoutInput[], incoming: Map): Map { + const layers = new Map(); + const visiting = new Set(); + + function layerOf(id: string): number { + const known = layers.get(id); + if (known !== undefined) return known; + // A cycle has no longest path. Break it by treating the back edge as a source. + if (visiting.has(id)) return 0; + visiting.add(id); + let layer = 0; + for (const source of incoming.get(id) ?? []) { + layer = Math.max(layer, layerOf(source) + 1); + } + visiting.delete(id); + layers.set(id, layer); + return layer; + } + + for (const node of nodes) layerOf(node.id); + return layers; +} + +/** + * Orders nodes inside each layer so edges cross as little as possible. Each + * sweep moves a node towards the average position of its neighbours in the + * previous layer. Ties keep the order the node had in the last layout, so a + * node that gained no neighbours stays where the reader last saw it. + */ +function orderLayers( + columns: string[][], + edges: ReactiveEdge[], + layers: Map, + rank: (id: string) => number, +): void { + const sourcesOf = new Map(); + const targetsOf = new Map(); + for (const edge of edges) { + if (!layers.has(edge.from) || !layers.has(edge.to)) continue; + (targetsOf.get(edge.from) ?? targetsOf.set(edge.from, []).get(edge.from)!).push(edge.to); + (sourcesOf.get(edge.to) ?? sourcesOf.set(edge.to, []).get(edge.to)!).push(edge.from); + } + + const positions = new Map(); + for (const column of columns) { + column.forEach((id, index) => positions.set(id, index)); + } + + function sweep(neighbours: Map, order: number[]): void { + for (const columnIndex of order) { + const column = columns[columnIndex]!; + const scores = new Map(); + column.forEach((id, index) => { + const related = neighbours.get(id) ?? []; + if (related.length === 0) { + scores.set(id, index); + return; + } + let total = 0; + for (const other of related) total += positions.get(other) ?? 0; + scores.set(id, total / related.length); + }); + column.sort( + (a, b) => scores.get(a)! - scores.get(b)! || rank(a) - rank(b) || a.localeCompare(b), + ); + column.forEach((id, index) => positions.set(id, index)); + } + } + + const down = columns.map((_, index) => index); + const up = [...down].reverse(); + for (let pass = 0; pass < 2; pass++) { + sweep(sourcesOf, down); + sweep(targetsOf, up); + } +} + +/** + * Restores the order known nodes had, keeping new nodes at the position the + * crossing sweeps chose for them. + */ +function keepKnownOrder(column: string[], rank: (id: string) => number, newNode: number): string[] { + const swept = new Map(column.map((id, index) => [id, index])); + const known = column.filter((id) => rank(id) !== newNode).sort((a, b) => rank(a) - rank(b)); + const fresh = column.filter((id) => rank(id) === newNode); + + const out = [...known]; + for (const id of fresh) { + const target = swept.get(id)!; + let index = 0; + while (index < out.length && swept.get(out[index]!)! < target) index++; + out.splice(index, 0, id); + } + return out; +} + +/** + * Places nodes on a left to right grid, one column per layer. + * + * Pass the layout this one replaces to keep the result stable: nodes hold the + * slot they had, and new ones land after them. Without it the graph reshuffles + * on every snapshot, which is the one thing a live view must not do. + */ +export function layoutGraph( + nodes: LayoutInput[], + edges: ReactiveEdge[], + previous?: GraphLayout, +): GraphLayout { + const incoming = incomingEdges(nodes, edges); + const layers = assignLayers(nodes, incoming); + + // An effect that subscribes to nothing reads nothing, so it does not belong + // in the column the signals start from. It gets a column of its own before + // them, which keeps the first signal column about sources of data. + const detached = nodes.filter( + (node) => EFFECT_KINDS.has(node.kind) && incoming.get(node.id)!.length === 0, + ); + if (detached.length > 0) { + for (const [id, layer] of layers) layers.set(id, layer + 1); + for (const node of detached) layers.set(node.id, 0); + } + + const columnCount = nodes.length === 0 ? 0 : Math.max(...layers.values()) + 1; + const columns: string[][] = Array.from({ length: columnCount }, () => []); + for (const node of nodes) columns[layers.get(node.id)!]!.push(node.id); + + // A node the last layout did not have sorts after the ones it did. + const NEW_NODE = Number.MAX_SAFE_INTEGER; + const previousIndex = new Map(); + if (previous) { + for (const [id, node] of previous.nodes) previousIndex.set(id, node.index); + } + const rank = (id: string) => previousIndex.get(id) ?? NEW_NODE; + + for (const column of columns) { + column.sort((a, b) => rank(a) - rank(b) || a.localeCompare(b)); + } + + orderLayers(columns, edges, layers, rank); + + // The sweeps are free to reorder anything, which would move nodes the reader + // is looking at. Keep the order known nodes already had, and slot the new + // ones in where the sweeps put them. + columns.forEach((column, index) => { + columns[index] = keepKnownOrder(column, rank, NEW_NODE); + }); + + const tallest = columns.reduce((max, column) => Math.max(max, column.length), 0); + const contentHeight = tallest * NODE_HEIGHT + Math.max(tallest - 1, 0) * ROW_GAP; + + // Columns hang from the top. Centring them would move every column whenever + // one of them grew. + const placed = new Map(); + columns.forEach((column, layer) => { + column.forEach((id, index) => { + placed.set(id, { + id, + layer, + index, + x: PADDING + layer * (NODE_WIDTH + LAYER_GAP), + y: PADDING + index * (NODE_HEIGHT + ROW_GAP), + }); + }); + }); + + const width = + columnCount === 0 ? 0 : PADDING * 2 + columnCount * NODE_WIDTH + (columnCount - 1) * LAYER_GAP; + + return { + nodes: placed, + width, + height: contentHeight + PADDING * 2, + layers: columnCount, + }; +} + +/** Curve from the right edge of one node to the left edge of another. */ +export function edgePath(from: LayoutNode, to: LayoutNode): string { + const startX = from.x + NODE_WIDTH; + const startY = from.y + NODE_HEIGHT / 2; + const endX = to.x; + const endY = to.y + NODE_HEIGHT / 2; + const distance = Math.max(Math.abs(endX - startX) * 0.5, 40); + return `M ${startX} ${startY} C ${startX + distance} ${startY}, ${endX - distance} ${endY}, ${endX} ${endY}`; +} diff --git a/src/dev-toolbar/reactivity/registry.test.ts b/src/dev-toolbar/reactivity/registry.test.ts new file mode 100644 index 0000000..794781f --- /dev/null +++ b/src/dev-toolbar/reactivity/registry.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; +import { + EMPTY_GRAPH, + excludeReactiveOwner, + includeReactiveOwner, + isReactivityAvailable, + snapshotReactivityGraph, + startReactivityTracking, + subscribeReactivityGraph, +} from './registry.js'; + +// This suite runs against the server build of solid-js, which ships no dev +// hooks. It covers the path an app takes in production. +describe('registry without the dev runtime', () => { + it('reports that the graph is unavailable', () => { + expect(isReactivityAvailable()).toBe(false); + }); + + it('returns an empty graph', () => { + expect(snapshotReactivityGraph()).toBe(EMPTY_GRAPH); + }); + + it('keeps tracking a no-op', () => { + const stop = startReactivityTracking(); + + expect(() => stop()).not.toThrow(); + }); + + it('ignores owners that are not objects', () => { + expect(() => excludeReactiveOwner(null)).not.toThrow(); + expect(() => includeReactiveOwner(undefined)).not.toThrow(); + }); + + it('never calls a listener', () => { + let calls = 0; + const unsubscribe = subscribeReactivityGraph(() => calls++); + snapshotReactivityGraph(); + unsubscribe(); + + expect(calls).toBe(0); + }); +}); diff --git a/src/dev-toolbar/reactivity/registry.ts b/src/dev-toolbar/reactivity/registry.ts new file mode 100644 index 0000000..263e270 --- /dev/null +++ b/src/dev-toolbar/reactivity/registry.ts @@ -0,0 +1,479 @@ +import { DEV } from 'solid-js'; + +// Flag bits used by @solidjs/signals. They are internal to the runtime, so the +// values are copied here and every read is defensive. +const REACTIVE_CHECK = 1 << 0; +const REACTIVE_DIRTY = 1 << 1; +const REACTIVE_DISPOSED = 1 << 6; +const REACTIVE_LAZY = 1 << 9; + +const STATUS_PENDING = 1 << 0; +const STATUS_ERROR = 1 << 1; +const STATUS_UNINITIALIZED = 1 << 2; + +const EFFECT_RENDER = 1; +const EFFECT_USER = 2; +const EFFECT_TRACKED = 3; + +/** How many nodes a single snapshot may contain. Anything past this is dropped. */ +const NODE_LIMIT = 600; + +/** Above this many tracked nodes the per flush counter pass is skipped. */ +const STATS_LIMIT = 2000; + +/** Raw reactive node. Only the internal fields the graph needs are read. */ +type RawNode = Record; + +export type ReactiveNodeKind = + | 'signal' + | 'memo' + | 'render-effect' + | 'effect' + | 'tracked-effect' + | 'store'; + +export type ReactiveNodeState = 'clean' | 'check' | 'dirty' | 'disposed'; + +export interface ReactiveNode { + id: string; + kind: ReactiveNodeKind; + name: string; + /** Current value, read straight off the node so nothing is tracked. */ + value: unknown; + state: ReactiveNodeState; + pending: boolean; + errored: boolean; + uninitialized: boolean; + lazy: boolean; + error: unknown; + /** Names of the owners above this node, outermost first. */ + ownerPath: string[]; + sources: string[]; + observers: string[]; + /** Times the node's clock advanced while the panel was open. */ + updates: number; + /** Timestamp of the last observed change. */ + updatedAt: number; +} + +export interface ReactiveEdge { + from: string; + to: string; +} + +export interface ReactiveGraph { + nodes: ReactiveNode[]; + edges: ReactiveEdge[]; + /** Cheap identity of the snapshot. Equal fingerprints mean nothing changed. */ + fingerprint: string; + /** Nodes left out because the snapshot hit the node limit. */ + dropped: number; +} + +export const EMPTY_GRAPH: ReactiveGraph = { + nodes: [], + edges: [], + fingerprint: 'empty', + dropped: 0, +}; + +interface NodeStats { + time: number; + updates: number; + updatedAt: number; +} + +let nextId = 1; +const ids = new WeakMap(); +const stats = new WeakMap(); +const signalOwners = new WeakMap(); +const excluded = new WeakSet(); +const included = new WeakSet(); + +/** Live nodes 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 graph 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 isReactivityAvailable(): boolean { + return !!DEV && typeof DEV.getSources === 'function'; +} + +function idOf(node: RawNode): string { + let id = ids.get(node); + if (!id) { + id = `n${nextId++}`; + ids.set(node, id); + } + return id; +} + +function track(node: RawNode | null | undefined): void { + if (!node || typeof node !== 'object' || trackedRefs.has(node)) return; + const ref = new WeakRef(node); + trackedRefs.set(node, ref); + tracked.add(ref); + collected?.register(node, 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 startReactivityTracking(): () => void { + if (!isReactivityAvailable()) return () => {}; + watchers++; + if (uninstall) return release; + + const hooks = DEV!.hooks; + const previousOwner = hooks.onOwner; + const previousGraph = hooks.onGraph; + const previousUpdate = hooks.onUpdate; + + hooks.onOwner = (owner) => { + previousOwner?.(owner); + track(owner as RawNode); + notify(); + }; + hooks.onGraph = (value, owner) => { + previousGraph?.(value, owner); + if (value && typeof value === 'object') { + if (owner) signalOwners.set(value, owner as RawNode); + track(value as RawNode); + } + notify(); + }; + hooks.onUpdate = () => { + previousUpdate?.(); + recordUpdates(); + notify(); + }; + + uninstall = () => { + hooks.onOwner = previousOwner; + hooks.onGraph = previousGraph; + hooks.onUpdate = previousUpdate; + uninstall = undefined; + if (frame !== undefined) cancelAnimationFrame(frame); + frame = undefined; + }; + return release; +} + +/** + * Refreshes the change counters of every tracked node. Runs on each flush so a + * node that changes twice between two renders is counted twice. Large graphs + * skip it, because the counter is a convenience and the walk is not free. + */ +function recordUpdates(): void { + if (tracked.size > STATS_LIMIT) return; + for (const ref of tracked) { + const node = ref.deref(); + if (node) statsOf(node); + } +} + +/** 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 graph changed, at most once per frame. */ +export function subscribeReactivityGraph(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +/** + * Marks an owner as belonging to the toolbar itself. Nodes under it never show + * up in the graph, so the panel does not watch its own reactivity. + */ +export function excludeReactiveOwner(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 graph. + */ +export function includeReactiveOwner(owner: unknown): void { + if (owner && typeof owner === 'object') included.add(owner); +} + +function isExcluded(node: RawNode): boolean { + let owner: RawNode | null | undefined = + '_parent' in node ? node : (signalOwners.get(node) ?? null); + // The nearest marker wins, so an included scope inside the toolbar's own + // subtree still reports as app code. + for (; owner; owner = owner._parent) { + if (included.has(owner)) return false; + if (excluded.has(owner)) return true; + } + return false; +} + +function isComputed(node: RawNode): boolean { + return '_deps' in node && typeof node._fn === 'function'; +} + +/** Owners that only scope other nodes, such as roots and components. */ +function isPlainOwner(node: RawNode): boolean { + return '_parent' in node && !isComputed(node); +} + +function kindOf(node: RawNode): ReactiveNodeKind { + if (!isComputed(node)) return node._isStoreNode ? 'store' : 'signal'; + switch (node._type) { + case EFFECT_RENDER: + return 'render-effect'; + case EFFECT_USER: + return 'effect'; + case EFFECT_TRACKED: + return 'tracked-effect'; + default: + return 'memo'; + } +} + +const KIND_LABELS: Record = { + signal: 'signal', + memo: 'memo', + effect: 'effect', + 'render-effect': 'render effect', + 'tracked-effect': 'tracked effect', + store: 'store', +}; + +function nameOf(node: RawNode, kind: ReactiveNodeKind): string { + const name = node._name; + if (typeof name === 'string' && name.length > 0) return name; + return KIND_LABELS[kind]; +} + +const DEFAULT_NAMES = new Set(Object.values(KIND_LABELS)); + +function ownerPathOf(node: RawNode): string[] { + const path: string[] = []; + let owner: RawNode | null | undefined = + '_parent' in node ? node._parent : (signalOwners.get(node) ?? null); + for (; owner; owner = owner._parent) { + const name = owner._name; + // Owners that only carry a default kind name say nothing about where the + // node lives, so the path keeps real labels only. + if (typeof name === 'string' && name.length > 0 && !DEFAULT_NAMES.has(name)) path.push(name); + } + return path.reverse(); +} + +function stateOf(node: RawNode): ReactiveNodeState { + const flags = typeof node._flags === 'number' ? node._flags : 0; + if (flags & REACTIVE_DISPOSED) return 'disposed'; + if (flags & REACTIVE_DIRTY) return 'dirty'; + if (flags & REACTIVE_CHECK) return 'check'; + return 'clean'; +} + +function statsOf(node: RawNode): NodeStats { + const time = typeof node._time === 'number' ? node._time : 0; + let entry = stats.get(node); + if (!entry) { + entry = { time, updates: 0, updatedAt: 0 }; + stats.set(node, entry); + return entry; + } + if (time !== entry.time) { + entry.time = time; + entry.updates += 1; + entry.updatedAt = Date.now(); + } + return entry; +} + +function sourcesOf(node: RawNode): RawNode[] { + if (!isComputed(node)) return []; + try { + return DEV!.getSources(node as never) as RawNode[]; + } catch { + return []; + } +} + +function observersOf(node: RawNode): RawNode[] { + try { + return DEV!.getObservers(node as never) as RawNode[]; + } catch { + return []; + } +} + +/** Topmost owner above `node`. */ +function rootOf(node: RawNode): RawNode { + let owner = node; + while (owner._parent) owner = owner._parent; + return owner; +} + +/** + * Collects every node under `owner`. This finds nodes created before the + * toolbar started watching. Toolbar nodes are dropped later, by `isExcluded`, + * because an app scope can sit inside the toolbar's own subtree. + */ +function collectOwnerTree(owner: RawNode, out: RawNode[]): void { + out.push(owner); + try { + for (const signal of DEV!.getSignals(owner as never)) { + if (!signal || typeof signal !== 'object') continue; + // The walk knows the owner, so record it. Signals created before the + // hooks went on have no entry yet, and without one they cannot be + // attributed to the app or to the toolbar. + signalOwners.set(signal, owner); + out.push(signal as RawNode); + } + for (const child of DEV!.getChildren(owner as never)) { + collectOwnerTree(child as RawNode, out); + } + } catch { + // A node disposed mid walk. Whatever was collected is still usable. + } +} + +export interface SnapshotOptions { + /** Keep nodes the runtime already disposed. Off by default. */ + includeDisposed?: boolean; +} + +/** + * Reads the current reactive graph. + * + * Registered nodes are the seed. The walk then follows sources and observers so + * nodes created before the toolbar started watching still show up when they are + * connected to something known. + */ +export function snapshotReactivityGraph(options?: SnapshotOptions): ReactiveGraph { + if (!isReactivityAvailable()) return EMPTY_GRAPH; + const includeDisposed = options?.includeDisposed ?? false; + + const queue: RawNode[] = []; + const seen = new Set(); + const kept = new Map(); + let dropped = 0; + + for (const ref of tracked) { + const node = ref.deref(); + if (!node) { + tracked.delete(ref); + continue; + } + queue.push(node); + } + + const roots = new Set(); + if (seedOwner) roots.add(rootOf(seedOwner)); + for (const node of queue) { + if ('_parent' in node) roots.add(rootOf(node)); + } + for (const root of roots) collectOwnerTree(root, queue); + + for (let index = 0; index < queue.length; index++) { + const node = queue[index]!; + if (seen.has(node)) continue; + seen.add(node); + + // Roots and components are scopes, not graph nodes. They still contribute + // their name to the owner path of everything below them. + if (isPlainOwner(node)) continue; + if (isExcluded(node)) continue; + + const state = stateOf(node); + if (state === 'disposed' && !includeDisposed) continue; + + if (kept.size >= NODE_LIMIT) { + dropped++; + continue; + } + + const kind = kindOf(node); + const entry = statsOf(node); + const statusFlags = typeof node._statusFlags === 'number' ? node._statusFlags : 0; + const flags = typeof node._flags === 'number' ? node._flags : 0; + + kept.set(node, { + id: idOf(node), + kind, + name: nameOf(node, kind), + value: node._value, + state, + pending: (statusFlags & STATUS_PENDING) !== 0, + errored: (statusFlags & STATUS_ERROR) !== 0, + uninitialized: (statusFlags & STATUS_UNINITIALIZED) !== 0, + lazy: (flags & REACTIVE_LAZY) !== 0, + error: node._error, + ownerPath: ownerPathOf(node), + sources: [], + observers: [], + updates: entry.updates, + updatedAt: entry.updatedAt, + }); + + for (const source of sourcesOf(node)) queue.push(source); + for (const observer of observersOf(node)) queue.push(observer); + } + + const edges: ReactiveEdge[] = []; + const edgeKeys = new Set(); + + for (const [node, descriptor] of kept) { + for (const source of sourcesOf(node)) { + const from = kept.get(source); + if (!from) continue; + descriptor.sources.push(from.id); + const key = `${from.id}>${descriptor.id}`; + if (edgeKeys.has(key)) continue; + edgeKeys.add(key); + edges.push({ from: from.id, to: descriptor.id }); + } + for (const observer of observersOf(node)) { + const to = kept.get(observer); + if (!to) continue; + descriptor.observers.push(to.id); + const key = `${descriptor.id}>${to.id}`; + if (edgeKeys.has(key)) continue; + edgeKeys.add(key); + edges.push({ from: descriptor.id, to: to.id }); + } + } + + const nodes = [...kept.values()]; + let fingerprint = `${nodes.length}:${edges.length}:${dropped}`; + for (const node of nodes) { + fingerprint += `|${node.id}${node.state}${node.updates}${node.pending ? 'p' : ''}${ + node.errored ? 'e' : '' + }${node.sources.length}${node.observers.length}`; + } + + return { nodes, edges, fingerprint, dropped }; +} diff --git a/src/dev-toolbar/reactivity/styles.css b/src/dev-toolbar/reactivity/styles.css new file mode 100644 index 0000000..809a8f1 --- /dev/null +++ b/src/dev-toolbar/reactivity/styles.css @@ -0,0 +1,526 @@ +[data-solid-reactivity-viewer] { + --start-dt-kind-signal: oklch(0.68 0.13 245); + --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 195); + --start-dt-kind-store: oklch(0.72 0.15 20); + + color: var(--start-dt-text); + + display: flex; + flex-direction: column; + + height: 100%; + min-height: 0; +} + +[data-solid-reactivity-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-reactivity-nav-title] { + display: flex; + flex-direction: row; + align-items: center; + + gap: 0.5rem; + + flex-shrink: 0; +} + +[data-solid-reactivity-nav-actions] { + display: flex; + flex-direction: row; + align-items: center; + + gap: 0.375rem; + + margin-left: auto; +} + +[data-solid-reactivity-count] { + color: var(--start-dt-text-muted); + white-space: nowrap; +} + +[data-solid-reactivity-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-reactivity-search]:focus { + outline: none; + border-color: var(--start-dt-accent); +} + +[data-solid-reactivity-filters] { + display: flex; + flex-direction: row; + align-items: center; + + gap: 0.25rem; +} + +[data-solid-reactivity-filter] { + display: flex; + align-items: center; + + gap: 0.25rem; + + padding: 0.1875rem 0.5rem; + + border-radius: 9999px; + border: var(--start-dt-border) 1px solid; + background: var(--start-dt-surface); + color: var(--start-dt-text); + + cursor: pointer; +} + +[data-solid-reactivity-filter]::before { + content: ''; + + width: 0.5rem; + height: 0.5rem; + + border-radius: 9999px; + background: var(--start-dt-kind-signal); +} + +[data-solid-reactivity-filter='memo']::before { + background: var(--start-dt-kind-memo); +} + +[data-solid-reactivity-filter='effect']::before { + background: var(--start-dt-kind-effect); +} + +[data-solid-reactivity-filter='render-effect']::before { + background: var(--start-dt-kind-render-effect); +} + +[data-solid-reactivity-filter]:hover { + background: var(--start-dt-surface-hover); +} + +[data-solid-reactivity-filter][data-off] { + color: var(--start-dt-text-muted); + opacity: 0.55; +} + +[data-solid-reactivity-body] { + display: flex; + flex-direction: row; + + flex: 1; + min-height: 0; +} + +[data-solid-reactivity-canvas] { + position: relative; + + flex: 1; + min-width: 0; + min-height: 0; + + overflow: hidden; + + cursor: grab; + + background-image: radial-gradient(var(--start-dt-border-soft) 1px, transparent 1px); + background-size: 24px 24px; +} + +[data-solid-reactivity-canvas][data-panning] { + cursor: grabbing; +} + +[data-solid-reactivity-surface] { + position: absolute; + top: 0; + left: 0; + + transform-origin: 0 0; +} + +/* The toolbar sizes every svg like an icon, so this rule needs the extra + selector to win and let the edge layer fill the graph. */ +[data-solid-reactivity-surface] [data-solid-reactivity-edges] { + position: absolute; + top: 0; + left: 0; + + width: 100%; + height: 100%; + + overflow: visible; + color: var(--start-dt-border); + pointer-events: none; +} + +[data-solid-reactivity-edge] { + fill: none; + stroke: var(--start-dt-border); + stroke-width: 1.5; +} + +[data-solid-reactivity-edge='muted'] { + opacity: 0.15; +} + +[data-solid-reactivity-edge='upstream'] { + stroke: var(--start-dt-kind-signal); + color: var(--start-dt-kind-signal); + stroke-width: 2; +} + +[data-solid-reactivity-edge='downstream'] { + stroke: var(--start-dt-kind-effect); + color: var(--start-dt-kind-effect); + stroke-width: 2; +} + +[data-solid-reactivity-edge='selected'] { + stroke: var(--start-dt-text); + color: var(--start-dt-text); + stroke-width: 2; +} + +[data-solid-reactivity-node] { + position: absolute; + top: 0; + left: 0; + + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: center; + + gap: 0.125rem; + + padding: 0.25rem 0.5rem 0.25rem 0.625rem; + + border-radius: 0.625rem; + border: var(--start-dt-border) 1px solid; + border-left: var(--start-dt-kind-signal) 3px solid; + background: var(--start-dt-surface); + color: var(--start-dt-text); + + text-align: left; + cursor: pointer; + + transition: + border-color 120ms ease, + background 120ms ease, + opacity 120ms ease; +} + +[data-solid-reactivity-node][data-kind='memo'] { + border-left-color: var(--start-dt-kind-memo); +} + +[data-solid-reactivity-node][data-kind='effect'] { + border-left-color: var(--start-dt-kind-effect); +} + +[data-solid-reactivity-node][data-kind='render-effect'] { + border-left-color: var(--start-dt-kind-render-effect); +} + +[data-solid-reactivity-node][data-kind='tracked-effect'] { + border-left-color: var(--start-dt-kind-tracked-effect); +} + +[data-solid-reactivity-node][data-kind='store'] { + border-left-color: var(--start-dt-kind-store); +} + +[data-solid-reactivity-node]:hover { + background: var(--start-dt-surface-hover); +} + +[data-solid-reactivity-node='muted'] { + opacity: 0.3; +} + +[data-solid-reactivity-node='selected'] { + border-color: var(--start-dt-text); + background: var(--start-dt-surface-active); +} + +[data-solid-reactivity-node='upstream'] { + border-color: var(--start-dt-kind-signal); +} + +[data-solid-reactivity-node='downstream'] { + border-color: var(--start-dt-kind-effect); +} + +[data-solid-reactivity-node][data-pending] { + border-color: var(--start-dt-kind-render-effect); +} + +[data-solid-reactivity-node][data-errored] { + border-color: var(--start-dt-danger); +} + +[data-solid-reactivity-node][data-fresh] { + animation: solid-reactivity-pulse 700ms ease-out; +} + +@keyframes solid-reactivity-pulse { + 0% { + box-shadow: 0 0 0 0 var(--start-dt-accent-soft); + background: var(--start-dt-surface-active); + } + 100% { + box-shadow: 0 0 0 10px transparent; + background: var(--start-dt-surface); + } +} + +[data-solid-reactivity-node-name] { + max-width: 100%; + + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.75rem; + font-weight: 600; + line-height: 1rem; +} + +[data-solid-reactivity-node-value] { + max-width: 100%; + + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + + color: var(--start-dt-text-muted); + + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.6875rem; + line-height: 0.875rem; +} + +[data-solid-reactivity-node-updates] { + position: absolute; + top: -0.375rem; + right: -0.375rem; + + min-width: 1.125rem; + padding: 0 0.25rem; + + border-radius: 9999px; + background: var(--start-dt-accent-soft); + color: var(--start-dt-accent); + + text-align: center; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.625rem; + font-weight: 600; + line-height: 1.125rem; +} + +[data-solid-reactivity-hovercard] { + position: absolute; + + width: 18rem; + padding: 0.5rem 0.625rem; + + display: flex; + flex-direction: column; + gap: 0.25rem; + + border-radius: 0.75rem; + border: var(--start-dt-border) 1px solid; + background: var(--start-dt-bg-glass); + backdrop-filter: blur(16px) saturate(140%); + -webkit-backdrop-filter: blur(16px) saturate(140%); + box-shadow: var(--start-dt-shadow); + + pointer-events: none; + z-index: 1; +} + +[data-solid-reactivity-card-head] { + display: flex; + flex-direction: row; + align-items: center; + + gap: 0.375rem; + + min-width: 0; +} + +[data-solid-reactivity-card-head] > [data-solid-text-size] { + overflow: hidden; + text-overflow: ellipsis; +} + +[data-solid-reactivity-card-row] { + display: grid; + grid-template-columns: 4rem 1fr; + align-items: baseline; + + gap: 0.5rem; + + min-width: 0; +} + +[data-solid-reactivity-card-row] > [data-solid-text-size]:first-child { + color: var(--start-dt-text-muted); +} + +[data-solid-reactivity-value] { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +[data-solid-reactivity-kind] { + width: 0.5rem; + height: 0.5rem; + + flex-shrink: 0; + + border-radius: 9999px; + background: var(--start-dt-kind-signal); +} + +[data-solid-reactivity-kind='memo'] { + background: var(--start-dt-kind-memo); +} + +[data-solid-reactivity-kind='effect'] { + background: var(--start-dt-kind-effect); +} + +[data-solid-reactivity-kind='render-effect'] { + background: var(--start-dt-kind-render-effect); +} + +[data-solid-reactivity-kind='tracked-effect'] { + background: var(--start-dt-kind-tracked-effect); +} + +[data-solid-reactivity-kind='store'] { + background: var(--start-dt-kind-store); +} + +[data-solid-reactivity-notice] { + position: absolute; + bottom: 0.5rem; + left: 0.5rem; + + padding: 0.25rem 0.5rem; + + border-radius: 0.5rem; + border: var(--start-dt-border) 1px solid; + background: var(--start-dt-bg-glass); + color: var(--start-dt-text-muted); +} + +[data-solid-reactivity-detail] { + display: flex; + flex-direction: column; + + width: 19rem; + flex-shrink: 0; + min-height: 0; + + overflow-y: auto; + + border-left: var(--start-dt-border-soft) 1px solid; +} + +[data-solid-reactivity-detail-content] { + display: flex; + flex-direction: column; + + gap: 0.5rem; + + padding: 0.625rem 0.75rem; +} + +[data-solid-reactivity-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-reactivity-detail-value] { + padding: 0.375rem 0.25rem; + + max-height: 16rem; + overflow: auto; + + border-radius: 0.5rem; + border: var(--start-dt-border-soft) 1px solid; + background: var(--start-dt-bg); +} + +[data-solid-reactivity-links] { + display: flex; + flex-direction: column; + + gap: 0.125rem; +} + +[data-solid-reactivity-link] { + display: flex; + flex-direction: row; + align-items: center; + + gap: 0.375rem; + + padding: 0.1875rem 0.375rem; + + border: none; + border-radius: 0.375rem; + background: transparent; + color: var(--start-dt-text); + + text-align: left; + cursor: pointer; + + min-width: 0; +} + +[data-solid-reactivity-link]:hover { + background: var(--start-dt-surface-hover); +} + +[data-solid-reactivity-link] > [data-solid-text-size] { + overflow: hidden; + text-overflow: ellipsis; +} diff --git a/src/dev-toolbar/reactivity/value-entries.test.ts b/src/dev-toolbar/reactivity/value-entries.test.ts new file mode 100644 index 0000000..ef292b1 --- /dev/null +++ b/src/dev-toolbar/reactivity/value-entries.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from 'vitest'; +import { containerEntries, entryCount, ownEntries } from './value-entries.js'; + +describe('ownEntries', () => { + it('lists enumerable own properties', () => { + expect(ownEntries({ a: 1, b: 'two' })).toEqual([ + { key: 'a', value: 1 }, + { key: 'b', value: 'two' }, + ]); + }); + + it('never calls a getter', () => { + let calls = 0; + const value = { + get slow() { + calls++; + return 1; + }, + }; + + expect(ownEntries(value)).toEqual([{ key: 'slow', value: undefined, accessor: true }]); + expect(calls).toBe(0); + }); + + it('skips non-enumerable properties and symbols', () => { + const value: Record = { shown: 1 }; + Object.defineProperty(value, 'hidden', { value: 2, enumerable: false }); + value[Symbol('tag') as unknown as string] = 3; + + expect(ownEntries(value).map((entry) => entry.key)).toEqual(['shown']); + }); + + it('stops at the entry limit', () => { + const value = Object.fromEntries( + Array.from({ length: 150 }, (_, index) => [`k${index}`, index]), + ); + + expect(ownEntries(value)).toHaveLength(100); + }); +}); + +describe('containerEntries', () => { + it('indexes arrays', () => { + expect(containerEntries(['a', 'b'])).toEqual([ + { key: 0, keyKind: 'number', value: 'a' }, + { key: 1, keyKind: 'number', value: 'b' }, + ]); + }); + + it('formats map keys', () => { + const entries = containerEntries(new Map([['id', 7]])); + + expect(entries).toEqual([{ key: '"id"', keyKind: 'key', value: 7 }]); + }); + + it('indexes sets', () => { + expect(containerEntries(new Set(['x']))).toEqual([{ key: 0, keyKind: 'number', value: 'x' }]); + }); + + it('reads typed arrays by index', () => { + expect(containerEntries(new Uint8Array([1, 2]))).toEqual([ + { key: 0, keyKind: 'number', value: 1 }, + { key: 1, keyKind: 'number', value: 2 }, + ]); + }); + + it('breaks errors into their parts', () => { + const entries = containerEntries(new Error('boom')); + + expect(entries?.map((entry) => entry.key)).toEqual(['name', 'message', 'stack']); + expect(entries?.[1]?.value).toBe('boom'); + }); + + it('leaves plain objects to ownEntries', () => { + expect(containerEntries({ a: 1 })).toBeUndefined(); + }); +}); + +describe('entryCount', () => { + it('counts what a container holds', () => { + expect(entryCount([1, 2, 3])).toBe(3); + expect(entryCount(new Set([1]))).toBe(1); + expect(entryCount(new Map())).toBe(0); + expect(entryCount(new Uint8Array(4))).toBe(4); + }); + + it('has no count for a plain object', () => { + expect(entryCount({ a: 1 })).toBeUndefined(); + }); +}); diff --git a/src/dev-toolbar/reactivity/value-entries.ts b/src/dev-toolbar/reactivity/value-entries.ts new file mode 100644 index 0000000..f521cf9 --- /dev/null +++ b/src/dev-toolbar/reactivity/value-entries.ts @@ -0,0 +1,91 @@ +import { formatValue } from './format.js'; + +/** Entries listed per container. Anything past this is summarised. */ +const ENTRY_LIMIT = 100; + +export interface Entry { + key: string | number; + keyKind?: 'key' | 'number' | 'keyword'; + value: unknown; + /** Set when the property is an accessor, which the inspector never calls. */ + accessor?: boolean; +} + +export function ownEntries(value: object): Entry[] { + const entries: Entry[] = []; + let keys: (string | symbol)[]; + try { + keys = Reflect.ownKeys(value); + } catch { + return entries; + } + for (const key of keys) { + if (typeof key === 'symbol') continue; + if (entries.length >= ENTRY_LIMIT) break; + let descriptor: PropertyDescriptor | undefined; + try { + descriptor = Object.getOwnPropertyDescriptor(value, key); + } catch { + continue; + } + if (!descriptor || descriptor.enumerable === false) continue; + // Getters can run app code and can throw, so the inspector reports them + // instead of reading them. + if (!('value' in descriptor)) { + entries.push({ key, value: undefined, accessor: true }); + continue; + } + entries.push({ key, value: descriptor.value }); + } + return entries; +} + +export function containerEntries(value: object): Entry[] | undefined { + if (Array.isArray(value)) { + return value + .slice(0, ENTRY_LIMIT) + .map((item, index) => ({ key: index, keyKind: 'number' as const, value: item })); + } + if (value instanceof Map) { + const entries: Entry[] = []; + for (const [key, item] of value) { + if (entries.length >= ENTRY_LIMIT) break; + entries.push({ key: formatValue(key, 1), keyKind: 'key', value: item }); + } + return entries; + } + if (value instanceof Set) { + const entries: Entry[] = []; + let index = 0; + for (const item of value) { + if (entries.length >= ENTRY_LIMIT) break; + entries.push({ key: index++, keyKind: 'number', value: item }); + } + return entries; + } + if (ArrayBuffer.isView(value) && !(value instanceof DataView)) { + const view = value as unknown as ArrayLike; + const entries: Entry[] = []; + for (let index = 0; index < view.length && index < ENTRY_LIMIT; index++) { + entries.push({ key: index, keyKind: 'number', value: view[index] }); + } + return entries; + } + if (value instanceof Error) { + return [ + { key: 'name', value: value.name }, + { key: 'message', value: value.message }, + { key: 'stack', value: value.stack }, + ]; + } + return undefined; +} + +export function entryCount(value: object): number | undefined { + if (Array.isArray(value)) return value.length; + if (value instanceof Map || value instanceof Set) return value.size; + if (ArrayBuffer.isView(value) && !(value instanceof DataView)) { + return (value as unknown as ArrayLike).length; + } + return undefined; +} diff --git a/src/ui/ValueTree.css b/src/ui/ValueTree.css new file mode 100644 index 0000000..913252d --- /dev/null +++ b/src/ui/ValueTree.css @@ -0,0 +1,137 @@ +[data-solid-value-token] { + display: flex; + gap: 0.25rem; + align-items: center; +} + +[data-solid-value-token='plain'] { + color: var(--start-dt-text, rgb(249 250 251)); +} + +[data-solid-value-token='key'] { + color: oklch(0.78 0.1 305); +} + +[data-solid-value-token='string'] { + color: oklch(0.78 0.11 150); +} + +[data-solid-value-token='number'] { + color: oklch(0.8 0.11 80); +} + +[data-solid-value-token='keyword'] { + color: oklch(0.74 0.1 250); + font-style: italic; +} + +[data-solid-value-separator] { + color: var(--start-dt-text-muted); +} + +[data-solid-value-tree-node] { + display: flex; + flex-direction: column; +} + +[data-solid-value-tree-row] { + display: flex; + align-items: center; + gap: 0.375rem; + + padding: 0.125rem 0.25rem; + border-radius: 0.25rem; + + min-width: 0; + width: 100%; + + border: none; + background: none; + color: inherit; + font: inherit; + text-align: left; + outline: none; +} + +button[data-solid-value-tree-row] { + cursor: pointer; +} + +button[data-solid-value-tree-row]:hover, +button[data-solid-value-tree-row]:focus-visible { + background-color: var(--start-dt-surface-hover, transparent); +} + +[data-solid-value-tree-chevron] { + display: inline-flex; + align-items: center; + justify-content: center; + + width: 0.875rem; + flex-shrink: 0; + + color: var(--start-dt-text-muted, inherit); +} + +[data-solid-value-tree-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-value-tree-chevron][data-leaf]::before { + content: none; +} + +[data-solid-value-tree-row][data-expanded] > [data-solid-value-tree-chevron]::before { + transform: rotate(45deg); +} + +[data-solid-value-tree-children] { + display: flex; + flex-direction: column; + + margin-left: 0.6875rem; + padding-left: 0.625rem; + + border-left: 1px var(--start-dt-border-soft, oklch(87% 0.065 274.039)) solid; +} + +[data-solid-value-tree-key] { + display: inline-flex; + align-items: center; + gap: 0.125rem; + + flex-shrink: 0; +} + +[data-solid-value-tree-preview] { + color: var(--start-dt-text-muted, inherit); + font-size: 0.75rem; + line-height: 1rem; + + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +[data-solid-value-tree-mark] { + display: inline-flex; + align-items: center; + gap: 0.25rem; +} + +[data-solid-value-tree-mark] > svg { + width: 1rem; + height: 1rem; + + color: var(--start-dt-accent, currentColor); +} diff --git a/src/ui/ValueTree.tsx b/src/ui/ValueTree.tsx new file mode 100644 index 0000000..af9b3a5 --- /dev/null +++ b/src/ui/ValueTree.tsx @@ -0,0 +1,107 @@ +import type { JSX } from '@solidjs/web'; +import { createSignal, Show } from 'solid-js'; +import { Text } from './Text.js'; +import './ValueTree.css'; + +export type ValueTokenKind = 'key' | 'string' | 'number' | 'keyword' | 'plain'; + +export interface ValueTokenProps { + value: string | number | boolean | undefined | null; + kind?: ValueTokenKind; +} + +/** One coloured token in a value tree, such as a key, a string or a number. */ +export function ValueToken(props: ValueTokenProps): JSX.Element { + return ( + + {`${props.value}`} + + ); +} + +export function ValueSeparator(): JSX.Element { + return ( + + : + + ); +} + +export interface TreeKeyProps { + value: string | number; + kind?: Exclude; +} + +/** The key half of a row, followed by its separator. */ +export function TreeKey(props: TreeKeyProps): JSX.Element { + return ( + + + + + ); +} + +export interface TreeLeafProps { + label?: JSX.Element; + children: JSX.Element; +} + +/** A row with nothing to expand. */ +export function TreeLeaf(props: TreeLeafProps): JSX.Element { + return ( +
+
+ + {props.label} + {props.children} +
+
+ ); +} + +export interface TreeBranchProps { + label?: JSX.Element; + badges?: JSX.Element; + /** Shown in place of the children while the row is closed. */ + preview: JSX.Element; + open?: boolean; + children: JSX.Element; +} + +/** A row that opens to show its children. */ +export function TreeBranch(props: TreeBranchProps): JSX.Element { + const [open, setOpen] = createSignal(props.open ?? false); + return ( +
+ + +
{props.children}
+
+
+ ); +} + +export interface TreeMarkProps { + children: JSX.Element; +} + +/** Marks a row that points back at a value already shown higher up. */ +export function TreeMark(props: TreeMarkProps): JSX.Element { + return {props.children}; +} diff --git a/tests/e2e/devtools.spec.ts b/tests/e2e/devtools.spec.ts index 0f63476..5a78b5e 100644 --- a/tests/e2e/devtools.spec.ts +++ b/tests/e2e/devtools.spec.ts @@ -78,6 +78,74 @@ test('shows server-function calls', async ({ page }) => { expect(warnings).not.toContainEqual(expect.stringContaining('STRICT_READ_UNTRACKED')); }); +test('maps the reactivity graph', async ({ page }) => { + await page.goto('/'); + const toggle = page.getByRole('button', { name: 'View Reactivity Graph' }); + const nodes = page.locator('[data-solid-reactivity-node]'); + const count = nodes.filter({ hasText: /^count/ }).first(); + + await toggle.click(); + await expect(count).toBeVisible(); + await expect(nodes.filter({ hasText: /^doubled/ }).first()).toBeVisible(); + await expect(nodes.filter({ hasText: /^report-doubled/ }).first()).toBeVisible(); + + // Effects that subscribe to nothing sit in their own column before the signals. + const columns = await nodes.evaluateAll((elements) => + elements.map((element) => ({ + x: Math.round(element.getBoundingClientRect().x), + kind: (element as HTMLElement).dataset.kind, + })), + ); + const leftmost = Math.min(...columns.map((entry) => entry.x)); + expect( + columns + .filter((entry) => entry.x === leftmost) + .every((entry) => entry.kind?.includes('effect')), + ).toBe(true); + expect(columns.some((entry) => entry.kind === 'signal' && entry.x > leftmost)).toBe(true); + + // Hovering a node explains it without selecting it. + await count.hover(); + const card = page.locator('[data-solid-reactivity-hovercard]'); + await expect(card).toContainText('number'); + await expect(card).toContainText('2 out'); + + // Selecting a node lists what reads it and dims the rest of the graph. + await count.click(); + const detail = page.locator('[data-solid-reactivity-detail]'); + await expect(detail).toContainText('Observers (2)'); + await expect(detail.locator('[data-solid-reactivity-link]').first()).toContainText('doubled'); + await expect(nodes.filter({ hasText: /^doubled/ }).first()).toHaveAttribute( + 'data-solid-reactivity-node', + 'downstream', + ); + + // The panel covers the page, so close it before driving the app. + await toggle.click(); + await page.locator('#increment-count').click(); + await toggle.click(); + await expect(count).toContainText('1'); + await expect(nodes.filter({ hasText: /^doubled/ }).first()).toContainText('2'); + + // The value pane is the same expandable tree the server function viewer uses. + const tree = page.locator('[data-solid-reactivity-detail-value]'); + await expect(tree.locator('[data-solid-value-token="number"]')).toHaveText('1'); + + await nodes + .filter({ hasText: /^effect\[/ }) + .first() + .click(); + await expect(tree.locator('[data-solid-value-tree-row]').first()).toContainText('array'); + await expect(tree.locator('[data-solid-value-tree-key]').first()).toContainText('0'); + await expect(tree).toContainText('
'); + + // Filters drop a whole kind from the graph. + await count.click(); + await page.getByRole('button', { name: 'Memos' }).click(); + await expect(nodes.filter({ hasText: /^doubled/ })).toHaveCount(0); + await expect(count).toBeVisible(); +}); + 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..0aff74e 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 { createEffect, createMemo, createSignal, Show } from 'solid-js'; function Broken(): never { throw new Error('client boom'); @@ -34,10 +34,23 @@ function emitServerFunctionResponse() { function App() { const [broken, setBroken] = createSignal(false); + const [count, setCount] = createSignal(0, { name: 'count' }); + const doubled = createMemo(() => count() * 2, { name: 'doubled' }); + + createEffect( + () => doubled(), + (value) => { + Reflect.set(window, '__doubled', value); + }, + { name: 'report-doubled' }, + ); 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" + ] } }