From a89ed605355edbab46e67805af71e54309295c8e Mon Sep 17 00:00:00 2001 From: "Alexis H. Munsayac" Date: Fri, 11 Sep 2026 21:06:21 +0800 Subject: [PATCH 1/5] feat: add a reactivity graph panel The toolbar gains a third panel that maps the running reactive graph. - Signals, memos and effects are nodes, and an edge points from a source to the computation that reads it. - Hovering a node shows its value, type, state, owner path and edge counts. - Selecting a node highlights everything upstream and downstream of it and lists its sources and observers. - Nodes pulse and count changes while the app updates. - The graph can be filtered by kind, searched by name, value or owner, paused, panned and zoomed. The panel reads the graph through the development hooks in solid-js, so it stays empty in a production build of the runtime. It only installs those hooks while it is open. The toolbar marks its own scope so its signals never show up in the graph, and marks the scope that owns the app so the wrapped app still does. Co-Authored-By: Claude Opus 5 --- .changeset/reactivity-graph.md | 10 + README.md | 18 +- src/dev-toolbar/icons.tsx | 70 +++ src/dev-toolbar/index.css | 4 + src/dev-toolbar/index.tsx | 28 +- src/dev-toolbar/reactivity/format.test.ts | 71 +++ src/dev-toolbar/reactivity/format.ts | 115 ++++ src/dev-toolbar/reactivity/index.tsx | 613 ++++++++++++++++++++ src/dev-toolbar/reactivity/layout.test.ts | 82 +++ src/dev-toolbar/reactivity/layout.ts | 156 +++++ src/dev-toolbar/reactivity/registry.test.ts | 42 ++ src/dev-toolbar/reactivity/registry.ts | 479 +++++++++++++++ src/dev-toolbar/reactivity/styles.css | 529 +++++++++++++++++ tests/e2e/devtools.spec.ts | 40 ++ tests/fixture/app.tsx | 15 +- 15 files changed, 2264 insertions(+), 8 deletions(-) create mode 100644 .changeset/reactivity-graph.md create mode 100644 src/dev-toolbar/reactivity/format.test.ts create mode 100644 src/dev-toolbar/reactivity/format.ts create mode 100644 src/dev-toolbar/reactivity/index.tsx create mode 100644 src/dev-toolbar/reactivity/layout.test.ts create mode 100644 src/dev-toolbar/reactivity/layout.ts create mode 100644 src/dev-toolbar/reactivity/registry.test.ts create mode 100644 src/dev-toolbar/reactivity/registry.ts create mode 100644 src/dev-toolbar/reactivity/styles.css diff --git a/.changeset/reactivity-graph.md b/.changeset/reactivity-graph.md new file mode 100644 index 0000000..7937230 --- /dev/null +++ b/.changeset/reactivity-graph.md @@ -0,0 +1,10 @@ +--- +'@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. +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..d06add6 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,14 @@ 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). +## 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 and list what it +reads and what reads it. + +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/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/format.test.ts b/src/dev-toolbar/reactivity/format.test.ts new file mode 100644 index 0000000..e7f36e3 --- /dev/null +++ b/src/dev-toolbar/reactivity/format.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest'; +import { formatValue, formatValueDetail, 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'); + }); +}); + +describe('formatValueDetail', () => { + it('pretty prints objects', () => { + expect(formatValueDetail({ a: 1 })).toBe('{\n "a": 1\n}'); + }); + + it('survives circular values', () => { + const value: Record = { name: 'root' }; + value.self = value; + + expect(formatValueDetail(value)).toContain('[circular]'); + }); + + it('falls back to the preview for values JSON cannot hold', () => { + expect(formatValueDetail(undefined)).toBe('undefined'); + expect(formatValueDetail(new Error('boom'))).toBe('Error: boom'); + }); +}); diff --git a/src/dev-toolbar/reactivity/format.ts b/src/dev-toolbar/reactivity/format.ts new file mode 100644 index 0000000..d3191ea --- /dev/null +++ b/src/dev-toolbar/reactivity/format.ts @@ -0,0 +1,115 @@ +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(', ')} }`; +} + +/** Multi line view of a value for the details pane. Falls back to the preview. */ +export function formatValueDetail(value: unknown): string { + if (value === undefined || typeof value === 'function' || typeof value === 'symbol') { + return formatValue(value); + } + if (value instanceof Error) return `${value.name}: ${value.message}`; + if (typeof value !== 'object' || value === null) return formatValue(value); + try { + const seen = new WeakSet(); + const text = JSON.stringify( + value, + (_key, inner) => { + if (typeof inner === 'bigint') return `${inner}n`; + if (typeof inner === 'function') return formatValue(inner); + if (inner && typeof inner === 'object') { + if (seen.has(inner)) return '[circular]'; + seen.add(inner); + if (typeof Node === 'function' && inner instanceof Node) return formatValue(inner); + } + return inner; + }, + 2, + ); + return text ?? formatValue(value); + } catch { + return formatValue(value); + } +} diff --git a/src/dev-toolbar/reactivity/index.tsx b/src/dev-toolbar/reactivity/index.tsx new file mode 100644 index 0000000..5af765b --- /dev/null +++ b/src/dev-toolbar/reactivity/index.tsx @@ -0,0 +1,613 @@ +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, formatValueDetail, typeName } from './format.js'; +import { edgePath, layoutGraph, NODE_HEIGHT, NODE_WIDTH } from './layout.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)); + }); + + const layout = createMemo(() => + layoutGraph( + visibleNodes().map((node) => ({ id: node.id, name: node.name })), + visibleEdges(), + ), + ); + + 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..23c404e --- /dev/null +++ b/src/dev-toolbar/reactivity/layout.test.ts @@ -0,0 +1,82 @@ +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 }); + +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('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..69182a5 --- /dev/null +++ b/src/dev-toolbar/reactivity/layout.ts @@ -0,0 +1,156 @@ +import type { ReactiveEdge } 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; +} + +export interface LayoutNode { + id: string; + layer: number; + index: number; + x: number; + y: number; +} + +export interface GraphLayout { + nodes: Map; + width: number; + height: number; + layers: number; +} + +/** Layer of each node, measured as the longest path from a node with no sources. */ +function assignLayers(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); + } + + 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. + */ +function orderLayers( + columns: string[][], + edges: ReactiveEdge[], + layers: Map, +): 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)! || 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); + } +} + +/** Places nodes on a left to right grid, one column per layer. */ +export function layoutGraph(nodes: LayoutInput[], edges: ReactiveEdge[]): GraphLayout { + const layers = assignLayers(nodes, edges); + 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); + + orderLayers(columns, edges, layers); + + const tallest = columns.reduce((max, column) => Math.max(max, column.length), 0); + const contentHeight = tallest * NODE_HEIGHT + Math.max(tallest - 1, 0) * ROW_GAP; + + const placed = new Map(); + columns.forEach((column, layer) => { + const columnHeight = column.length * NODE_HEIGHT + Math.max(column.length - 1, 0) * ROW_GAP; + const offset = (contentHeight - columnHeight) / 2; + column.forEach((id, index) => { + placed.set(id, { + id, + layer, + index, + x: PADDING + layer * (NODE_WIDTH + LAYER_GAP), + y: PADDING + offset + 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..ce6f28d --- /dev/null +++ b/src/dev-toolbar/reactivity/styles.css @@ -0,0 +1,529 @@ +[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] { + margin: 0; + padding: 0.5rem; + + max-height: 12rem; + overflow: auto; + + border-radius: 0.5rem; + background: var(--start-dt-bg); + + white-space: pre-wrap; + word-break: break-word; +} + +[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/tests/e2e/devtools.spec.ts b/tests/e2e/devtools.spec.ts index 0f63476..e524635 100644 --- a/tests/e2e/devtools.spec.ts +++ b/tests/e2e/devtools.spec.ts @@ -78,6 +78,46 @@ test('shows server-function calls', async ({ page }) => { expect(warnings).not.toContainEqual(expect.stringContaining('STRICT_READ_UNTRACKED')); }); +test('maps the 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(); + + // 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'); + + // Filters drop a whole kind from the graph. + 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

+ From 7d52a06f5642f1481a69df7758e16bd24b600f55 Mon Sep 17 00:00:00 2001 From: "Alexis H. Munsayac" Date: Fri, 11 Sep 2026 21:18:00 +0800 Subject: [PATCH 2/5] docs: add a dev toolbar demo app examples/demo is an orders dashboard that exercises every toolbar panel. Run it with `pnpm demo`. - It is built with @solidjs/vite-plugin in start mode, so the plugin owns the entries and mounts the toolbar. - Signals, memos and effects are named, so the reactivity graph reads as the app's own data flow. - An async memo calls a "use server" function, which shows the pending state in the graph and a request and response in the server function panel. - A button throws so the error panel has a source-mapped frame to show. The demo reports its own server function calls through `pushServerFunctionCall`, because @solidjs/web 2.0.0-rc.0 does not report them to the toolbar yet. Co-Authored-By: Claude Opus 5 --- README.md | 8 + examples/demo/README.md | 32 ++ examples/demo/src/App.tsx | 248 ++++++++++++++ examples/demo/src/css.d.ts | 1 + examples/demo/src/orders.ts | 89 +++++ examples/demo/src/report-call.ts | 62 ++++ examples/demo/src/styles.css | 240 +++++++++++++ examples/demo/vite.config.ts | 16 + package.json | 2 + pnpm-lock.yaml | 560 +++++++++++++++++++++++++++++++ tsconfig.tests.json | 12 +- 11 files changed, 1268 insertions(+), 2 deletions(-) create mode 100644 examples/demo/README.md create mode 100644 examples/demo/src/App.tsx create mode 100644 examples/demo/src/css.d.ts create mode 100644 examples/demo/src/orders.ts create mode 100644 examples/demo/src/report-call.ts create mode 100644 examples/demo/src/styles.css create mode 100644 examples/demo/vite.config.ts diff --git a/README.md b/README.md index d06add6..003e5b9 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,14 @@ does not include the toolbar. The same import is safe in development and production entries. +## Demo + +`examples/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, 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/tsconfig.tests.json b/tsconfig.tests.json index a03c1e2..4a40776 100644 --- a/tsconfig.tests.json +++ b/tsconfig.tests.json @@ -1,9 +1,17 @@ { "extends": "./tsconfig.json", - "include": ["playwright.config.ts", "vitest.config.ts", "src/**/*.test.ts", "tests"], + "include": [ + "playwright.config.ts", + "vitest.config.ts", + "src/**/*.test.ts", + "tests", + "examples" + ], "exclude": [], "compilerOptions": { "noEmit": true, - "types": ["node"] + "types": [ + "node" + ] } } From 83162e6b1ba7373a89f1e6a247998a30096d54b8 Mon Sep 17 00:00:00 2001 From: "Alexis H. Munsayac" Date: Fri, 11 Sep 2026 21:30:45 +0800 Subject: [PATCH 3/5] feat: inspect graph values with the shared value tree The reactivity panel showed the selected node's value as pretty printed JSON, which dropped everything JSON cannot hold. It now uses the same expandable tree the server function panel uses for serialized values. - The row primitives move from the seroval viewer to `src/ui/ValueTree.tsx`, so both panels share one look. - `ValueInspector` walks live values. It handles arrays, maps, sets, typed arrays, errors, DOM nodes, functions and class instances, and marks values that point back up the branch as circular. - Children are built when a row opens, so a deep object costs nothing until it is inspected. - Getters are reported, never called, so inspecting a value cannot run app code. Co-Authored-By: Claude Opus 5 --- .changeset/reactivity-graph.md | 1 + README.md | 4 +- src/dev-toolbar/functions/SerovalValue.css | 26 -- src/dev-toolbar/functions/SerovalValue.tsx | 26 -- src/dev-toolbar/functions/SerovalViewer.css | 111 -------- src/dev-toolbar/functions/SerovalViewer.tsx | 251 +++++++----------- src/dev-toolbar/reactivity/ValueInspector.tsx | 194 ++++++++++++++ src/dev-toolbar/reactivity/format.test.ts | 20 +- src/dev-toolbar/reactivity/format.ts | 29 -- src/dev-toolbar/reactivity/index.tsx | 20 +- src/dev-toolbar/reactivity/styles.css | 9 +- .../reactivity/value-entries.test.ts | 90 +++++++ src/dev-toolbar/reactivity/value-entries.ts | 91 +++++++ src/ui/ValueTree.css | 137 ++++++++++ src/ui/ValueTree.tsx | 107 ++++++++ tests/e2e/devtools.spec.ts | 13 + 16 files changed, 743 insertions(+), 386 deletions(-) delete mode 100644 src/dev-toolbar/functions/SerovalValue.css delete mode 100644 src/dev-toolbar/functions/SerovalValue.tsx create mode 100644 src/dev-toolbar/reactivity/ValueInspector.tsx create mode 100644 src/dev-toolbar/reactivity/value-entries.test.ts create mode 100644 src/dev-toolbar/reactivity/value-entries.ts create mode 100644 src/ui/ValueTree.css create mode 100644 src/ui/ValueTree.tsx diff --git a/.changeset/reactivity-graph.md b/.changeset/reactivity-graph.md index 7937230..828c9a8 100644 --- a/.changeset/reactivity-graph.md +++ b/.changeset/reactivity-graph.md @@ -7,4 +7,5 @@ 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 003e5b9..764f7e7 100644 --- a/README.md +++ b/README.md @@ -41,8 +41,8 @@ pnpm demo 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 and list what it -reads and what reads it. +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. 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/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 index e7f36e3..7fc17a0 100644 --- a/src/dev-toolbar/reactivity/format.test.ts +++ b/src/dev-toolbar/reactivity/format.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { formatValue, formatValueDetail, typeName } from './format.js'; +import { formatValue, typeName } from './format.js'; describe('formatValue', () => { it('formats primitives', () => { @@ -51,21 +51,3 @@ describe('typeName', () => { expect(typeName(1)).toBe('number'); }); }); - -describe('formatValueDetail', () => { - it('pretty prints objects', () => { - expect(formatValueDetail({ a: 1 })).toBe('{\n "a": 1\n}'); - }); - - it('survives circular values', () => { - const value: Record = { name: 'root' }; - value.self = value; - - expect(formatValueDetail(value)).toContain('[circular]'); - }); - - it('falls back to the preview for values JSON cannot hold', () => { - expect(formatValueDetail(undefined)).toBe('undefined'); - expect(formatValueDetail(new Error('boom'))).toBe('Error: boom'); - }); -}); diff --git a/src/dev-toolbar/reactivity/format.ts b/src/dev-toolbar/reactivity/format.ts index d3191ea..f44a0e7 100644 --- a/src/dev-toolbar/reactivity/format.ts +++ b/src/dev-toolbar/reactivity/format.ts @@ -84,32 +84,3 @@ export function formatValue(value: unknown, depth = 0): string { if (keys.length > MAX_ENTRIES) entries.push(`…${keys.length - MAX_ENTRIES} more`); return `${prefix}{ ${entries.join(', ')} }`; } - -/** Multi line view of a value for the details pane. Falls back to the preview. */ -export function formatValueDetail(value: unknown): string { - if (value === undefined || typeof value === 'function' || typeof value === 'symbol') { - return formatValue(value); - } - if (value instanceof Error) return `${value.name}: ${value.message}`; - if (typeof value !== 'object' || value === null) return formatValue(value); - try { - const seen = new WeakSet(); - const text = JSON.stringify( - value, - (_key, inner) => { - if (typeof inner === 'bigint') return `${inner}n`; - if (typeof inner === 'function') return formatValue(inner); - if (inner && typeof inner === 'object') { - if (seen.has(inner)) return '[circular]'; - seen.add(inner); - if (typeof Node === 'function' && inner instanceof Node) return formatValue(inner); - } - return inner; - }, - 2, - ); - return text ?? formatValue(value); - } catch { - return formatValue(value); - } -} diff --git a/src/dev-toolbar/reactivity/index.tsx b/src/dev-toolbar/reactivity/index.tsx index 5af765b..396a5dd 100644 --- a/src/dev-toolbar/reactivity/index.tsx +++ b/src/dev-toolbar/reactivity/index.tsx @@ -5,8 +5,9 @@ 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, formatValueDetail, typeName } from './format.js'; +import { formatValue, typeName } from './format.js'; import { edgePath, layoutGraph, NODE_HEIGHT, NODE_WIDTH } from './layout.js'; +import { ValueInspector } from './ValueInspector.js'; import { EMPTY_GRAPH, excludeReactiveOwner, @@ -531,14 +532,15 @@ export default function ReactivityViewer(props: ReactivityViewerProps): JSX.Elem
- Value -
-                          
-                            {node().errored
-                              ? formatValueDetail(node().error)
-                              : formatValueDetail(node().value)}
-                          
-                        
+ + {node().errored ? 'Error' : 'Value'} + +
+ +
diff --git a/src/dev-toolbar/reactivity/styles.css b/src/dev-toolbar/reactivity/styles.css index ce6f28d..809a8f1 100644 --- a/src/dev-toolbar/reactivity/styles.css +++ b/src/dev-toolbar/reactivity/styles.css @@ -479,17 +479,14 @@ } [data-solid-reactivity-detail-value] { - margin: 0; - padding: 0.5rem; + padding: 0.375rem 0.25rem; - max-height: 12rem; + max-height: 16rem; overflow: auto; border-radius: 0.5rem; + border: var(--start-dt-border-soft) 1px solid; background: var(--start-dt-bg); - - white-space: pre-wrap; - word-break: break-word; } [data-solid-reactivity-links] { 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 e524635..959eaf2 100644 --- a/tests/e2e/devtools.spec.ts +++ b/tests/e2e/devtools.spec.ts @@ -112,7 +112,20 @@ test('maps the reactivity graph', async ({ page }) => { 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(); From 66e34bb9695294c9b8c9e2f9c4713a8ec80b9d88 Mon Sep 17 00:00:00 2001 From: "Alexis H. Munsayac" Date: Sat, 12 Sep 2026 01:29:17 +0800 Subject: [PATCH 4/5] fix: move effects that subscribe to nothing before the signals An effect with no sources has no incoming edges, so the layout put it in the first column together with the signals. That column should be about where data comes from, and such an effect reads nothing. Effects with no sources now get a column of their own before the signals, and every other node shifts one column to the right. Co-Authored-By: Claude Opus 5 --- src/dev-toolbar/reactivity/index.tsx | 2 +- src/dev-toolbar/reactivity/layout.test.ts | 24 ++++++++++++++++++- src/dev-toolbar/reactivity/layout.ts | 28 +++++++++++++++++++---- tests/e2e/devtools.spec.ts | 15 ++++++++++++ 4 files changed, 63 insertions(+), 6 deletions(-) diff --git a/src/dev-toolbar/reactivity/index.tsx b/src/dev-toolbar/reactivity/index.tsx index 396a5dd..5a6b532 100644 --- a/src/dev-toolbar/reactivity/index.tsx +++ b/src/dev-toolbar/reactivity/index.tsx @@ -160,7 +160,7 @@ export default function ReactivityViewer(props: ReactivityViewerProps): JSX.Elem const layout = createMemo(() => layoutGraph( - visibleNodes().map((node) => ({ id: node.id, name: node.name })), + visibleNodes().map((node) => ({ id: node.id, name: node.name, kind: node.kind })), visibleEdges(), ), ); diff --git a/src/dev-toolbar/reactivity/layout.test.ts b/src/dev-toolbar/reactivity/layout.test.ts index 23c404e..0e8ccea 100644 --- a/src/dev-toolbar/reactivity/layout.test.ts +++ b/src/dev-toolbar/reactivity/layout.test.ts @@ -1,7 +1,8 @@ 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 }); +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', () => { @@ -53,6 +54,27 @@ describe('layoutGraph', () => { 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('reports an empty layout for an empty graph', () => { const layout = layoutGraph([], []); diff --git a/src/dev-toolbar/reactivity/layout.ts b/src/dev-toolbar/reactivity/layout.ts index 69182a5..a90dc2f 100644 --- a/src/dev-toolbar/reactivity/layout.ts +++ b/src/dev-toolbar/reactivity/layout.ts @@ -1,4 +1,4 @@ -import type { ReactiveEdge } from './registry.js'; +import type { ReactiveEdge, ReactiveNodeKind } from './registry.js'; export const NODE_WIDTH = 150; export const NODE_HEIGHT = 44; @@ -9,8 +9,11 @@ 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; @@ -26,15 +29,19 @@ export interface GraphLayout { layers: number; } -/** Layer of each node, measured as the longest path from a node with no sources. */ -function assignLayers(nodes: LayoutInput[], edges: ReactiveEdge[]): Map { +/** 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(); @@ -109,7 +116,20 @@ function orderLayers( /** Places nodes on a left to right grid, one column per layer. */ export function layoutGraph(nodes: LayoutInput[], edges: ReactiveEdge[]): GraphLayout { - const layers = assignLayers(nodes, edges); + 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); diff --git a/tests/e2e/devtools.spec.ts b/tests/e2e/devtools.spec.ts index 959eaf2..5a78b5e 100644 --- a/tests/e2e/devtools.spec.ts +++ b/tests/e2e/devtools.spec.ts @@ -89,6 +89,21 @@ test('maps the reactivity graph', async ({ page }) => { 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]'); From 7a6a464e243cdb056712a0527b3ba6b5092961aa Mon Sep 17 00:00:00 2001 From: "Alexis H. Munsayac" Date: Sat, 12 Sep 2026 01:35:49 +0800 Subject: [PATCH 5/5] fix: keep the graph still while it updates The layout was rebuilt from scratch on every snapshot, so one new node could reorder a column and shift every other column, moving nodes out from under the pointer. - Each layout is seeded with the one it replaces. Nodes keep the slot they had, and the crossing sweeps can no longer reorder them. - New nodes are inserted where the sweeps put them, so they still land near what they read. - Columns hang from the top instead of being centred, so a column that grows no longer moves the others. Adding two rows to the demo now moves 13 of 72 surviving nodes, each by one or two slots because a new node landed above them, and none of them change order. Co-Authored-By: Claude Opus 5 --- src/dev-toolbar/reactivity/index.tsx | 15 +++-- src/dev-toolbar/reactivity/layout.test.ts | 34 ++++++++++++ src/dev-toolbar/reactivity/layout.ts | 68 ++++++++++++++++++++--- 3 files changed, 104 insertions(+), 13 deletions(-) diff --git a/src/dev-toolbar/reactivity/index.tsx b/src/dev-toolbar/reactivity/index.tsx index 5a6b532..b263cd8 100644 --- a/src/dev-toolbar/reactivity/index.tsx +++ b/src/dev-toolbar/reactivity/index.tsx @@ -6,7 +6,7 @@ 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 } from './layout.js'; +import { edgePath, layoutGraph, NODE_HEIGHT, NODE_WIDTH, type GraphLayout } from './layout.js'; import { ValueInspector } from './ValueInspector.js'; import { EMPTY_GRAPH, @@ -158,12 +158,17 @@ export default function ReactivityViewer(props: ReactivityViewerProps): JSX.Elem return graph().edges.filter((edge) => ids.has(edge.from) && ids.has(edge.to)); }); - const layout = createMemo(() => - layoutGraph( + // 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]))); diff --git a/src/dev-toolbar/reactivity/layout.test.ts b/src/dev-toolbar/reactivity/layout.test.ts index 0e8ccea..68cc41c 100644 --- a/src/dev-toolbar/reactivity/layout.test.ts +++ b/src/dev-toolbar/reactivity/layout.test.ts @@ -75,6 +75,40 @@ describe('layoutGraph', () => { 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([], []); diff --git a/src/dev-toolbar/reactivity/layout.ts b/src/dev-toolbar/reactivity/layout.ts index a90dc2f..b226424 100644 --- a/src/dev-toolbar/reactivity/layout.ts +++ b/src/dev-toolbar/reactivity/layout.ts @@ -67,12 +67,14 @@ function assignLayers(nodes: LayoutInput[], incoming: Map): Ma /** * 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. + * 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(); @@ -101,7 +103,9 @@ function orderLayers( 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)! || a.localeCompare(b)); + 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)); } } @@ -114,8 +118,37 @@ function orderLayers( } } -/** Places nodes on a left to right grid, one column per layer. */ -export function layoutGraph(nodes: LayoutInput[], edges: ReactiveEdge[]): GraphLayout { +/** + * 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); @@ -134,22 +167,41 @@ export function layoutGraph(nodes: LayoutInput[], edges: ReactiveEdge[]): GraphL const columns: string[][] = Array.from({ length: columnCount }, () => []); for (const node of nodes) columns[layers.get(node.id)!]!.push(node.id); - orderLayers(columns, edges, layers); + // 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) => { - const columnHeight = column.length * NODE_HEIGHT + Math.max(column.length - 1, 0) * ROW_GAP; - const offset = (contentHeight - columnHeight) / 2; column.forEach((id, index) => { placed.set(id, { id, layer, index, x: PADDING + layer * (NODE_WIDTH + LAYER_GAP), - y: PADDING + offset + index * (NODE_HEIGHT + ROW_GAP), + y: PADDING + index * (NODE_HEIGHT + ROW_GAP), }); }); });