diff --git a/.changeset/dock-three-views.md b/.changeset/dock-three-views.md new file mode 100644 index 0000000..a0b34cc --- /dev/null +++ b/.changeset/dock-three-views.md @@ -0,0 +1,11 @@ +--- +'@solidjs/start-devtools': minor +--- + +- The dock has three views in its header: Health, Activity and Structure. The header shows the `solid-js` version the app runs, in place of the toolbar's title and version. +- Health lists thrown errors and runtime issues together, with search and severity chips. An error shows the function it was thrown in, when it was caught, the action it was caught during with a link to it, its stack frames beside the source, and "Open in editor". +- Activity merges actions, server-function calls, async loads and waits, and errors into one stream over the last 30 seconds, with a live lanes strip on a time axis, filter chips, time groups and a pause button. Repeated actions fold into one row. An action shows what it wrote, what re-ran and which effects ran, then waterfalls of its re-runs and server calls. +- Structure shows the ownership tree or the reactivity graph. Tree rows show signal and memo counts and how recently and how often their computations update. "Hide Solid internals" folds Solid's own components away. The detail lists props, signals and memos by kind, with a signal's previous value. +- ⌘K (Ctrl+K) opens a search across components, signals, memos, actions and server functions while the dock is open. +- The timeline and the async history keep everything from the last 30 seconds instead of a fixed number of entries, so a loop such as an animation frame no longer pushes other actions out. A fast run of one action draws as one bar. +- Every list and detail split is resizable. diff --git a/README.md b/README.md index 41e6258..ebce45c 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,19 @@ pnpm add @solidjs/start-devtools@next The toolbar is a dock at the bottom of the page. Closed, it is a small grip at the bottom center. Click it, drag it up, or press Alt+Shift+D to open it. Drag its top edge to change its height. It remembers whether it was open, its tab and its height across reloads, and an -error opens it on the Errors tab. +error opens it on the Health view. + +The dock has three views, picked in the header: + +- **Health**: thrown errors and the issues the runtime reports, in one list. +- **Activity**: one stream of actions, server-function calls, async loads and errors, with + a strip of lanes over time. +- **Structure**: the ownership tree, or the reactivity graph. + +Press ⌘K (Ctrl+K) while the dock is open to find a component, signal, memo, action or +server function by name. + +The header and the pull tab show the `solid-js` version the app runs. The Vite plugin mounts it automatically. Integrations can use the package directly: diff --git a/src/dev-toolbar/activity/detail.tsx b/src/dev-toolbar/activity/detail.tsx new file mode 100644 index 0000000..5b56a54 --- /dev/null +++ b/src/dev-toolbar/activity/detail.tsx @@ -0,0 +1,734 @@ +import type { JSX } from '@solidjs/web'; +import { createMemo, createSignal, For, onCleanup, Show } from 'solid-js'; +import type { ChangeRecord } from 'solid-js/attribution'; +import { RecordRows } from '../async/index.js'; +import { concernOf, type AsyncRecord } from '../async/tracker.js'; +import { Badge } from '../../ui/Badge.js'; +import type { ErrorRecord } from '../error-record.js'; +import { copyCallReport, ServerFunctionContent } from '../functions/index.js'; +import type { ServerFunctionInstance } from '../functions/index.js'; +import { openInEditor } from '../open-in-editor.js'; +import { Text } from '../../ui/Text.js'; +import { asyncDuration, asyncOrigin, asyncReport, asyncTitle } from '../report/async-report.js'; +import { copyText } from '../report/CopyReportButton.js'; +import { pageRoute } from '../report/markdown.js'; +import { ms, plural, timelineReport } from '../report/timeline-report.js'; +import { entryDuration } from '../timeline/group.js'; +import type { TimelineEntry, TimelineRun } from '../timeline/tracker.js'; +import { + actionKey, + barMs, + callBars, + callDuration, + callName, + callStatus, + clockTime, + describeThrown, + entryLocation, + eventName, + firstMemoDelta, + GLYPHS, + runBars, + serverKey, + summarizeChain, + type LiveLoad, + type ValueMemory, + type WaterfallBar, +} from './model.js'; + +/** How long a button shows that it copied. */ +const COPIED_MS = 1500; + +function OpenIcon(): JSX.Element { + return ( + + ); +} + +/** A text button that copies a report for an agent to read. */ +export function CopyReport(props: { + label: string; + report: () => string | Promise; +}): JSX.Element { + const [state, setState] = createSignal<'idle' | 'busy' | 'copied' | 'failed'>('idle'); + let timer: ReturnType | undefined; + onCleanup(() => clearTimeout(timer)); + + async function copy(): Promise { + if (state() === 'busy') return; + setState('busy'); + try { + await copyText(await props.report()); + setState('copied'); + } catch (error) { + console.warn('[solid dev toolbar] failed to copy the report', error); + setState('failed'); + } + clearTimeout(timer); + timer = setTimeout(() => setState('idle'), COPIED_MS); + } + + return ( + + ); +} + +function Glyph(props: { kind: 'act' | 'srv' | 'wait' | 'err' }): JSX.Element { + return ( + + ); +} + +function Section(props: { title: string; aside?: string; children: JSX.Element }): JSX.Element { + return ( + <> +
+ {props.title} + + {props.aside} + +
+ {props.children} + + ); +} + +// ---------------------------------------------------------------- action + +/** `0 → 1` with the old value red and the new one green. */ +function Delta(props: { prev: string | undefined; value: string | undefined }): JSX.Element { + return ( + + {props.value}} + > + {props.prev} + {' → '} + {props.value} + + + ); +} + +function causeText(cause: ChangeRecord): string { + if (cause.kind === 'derived') return `${cause.name} changed`; + const values = + cause.value === undefined + ? '' + : cause.prev === undefined + ? ` ${cause.value}` + : ` ${cause.prev} → ${cause.value}`; + return `${cause.kind} ${cause.name}${values}`; +} + +/** The changes behind a run, each with the changes behind it. */ +function CauseList(props: { causes: ChangeRecord[] }): JSX.Element { + return ( +
    + + {(cause) => ( +
  • + {`← ${causeText(cause)}`} + + + +
  • + )} +
    +
+ ); +} + +function RunCauses(props: { + runs: TimelineRun[]; + onViewInGraph?: (node: object) => void; +}): JSX.Element { + return ( +
+ + {(run) => ( + <> + + No tracked change caused this run, such as a retry after an error. + + } + > + + + + {(node) => ( + + )} + + + )} + +
+ ); +} + +function RunRows(props: { + bars: WaterfallBar[]; + onViewInGraph?: (node: object) => void; +}): JSX.Element { + const [open, setOpen] = createSignal([]); + + function toggle(key: string): void { + // Step 1: close the row if it is open. + const next: string[] = []; + let wasOpen = false; + for (const item of open()) { + if (item === key) wasOpen = true; + else next.push(item); + } + // Step 2: otherwise open it. + if (!wasOpen) next.push(key); + setOpen(next); + } + + return ( + bar.key}> + {(bar) => ( + <> +
toggle(bar().key)} + onKeyDown={(event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + toggle(bar().key); + } + }} + > + {bar().name} +
+ +
+ {barMs(bar().ms)} +
+ + + + + )} +
+ ); +} + +function OutcomePill(props: { entry: TimelineEntry }): JSX.Element { + const outcome = () => props.entry.interaction?.outcome; + return ( + + + {outcome() ?? (props.entry.interaction ? 'idle' : 'pending')} + + + ); +} + +function KvRow(props: { label: string; value: string }): JSX.Element { + return ( +
+ + {props.label} + + + {props.value} + +
+ ); +} + +export interface ActionDetailProps { + entry: TimelineEntry; + /** How many entries the row folded. */ + count: number; + memory: ValueMemory; + /** Read to re-draw when the memory learned of new values. */ + observed: () => number; + onSelect: (key: string) => void; + onShowInStructure?: (node: object) => void; + onViewInGraph?: (node: object) => void; +} + +export function ActionDetail(props: ActionDetailProps): JSX.Element { + const chain = createMemo(() => summarizeChain(props.entry)); + const location = createMemo(() => entryLocation(props.entry)); + const rerun = createMemo(() => { + props.observed(); + return firstMemoDelta(props.entry, props.memory); + }); + const runs = createMemo(() => runBars(props.entry)); + const calls = createMemo(() => callBars(props.entry)); + const node = createMemo(() => { + for (const run of props.entry.runs) { + const live = run.node?.deref(); + if (live) return live; + } + return undefined; + }); + const sub = createMemo(() => { + const parts: string[] = []; + const found = location(); + if (found) parts.push(found.label); + const event = eventName(props.entry); + if (event) parts.push(event); + parts.push(ms(entryDuration(props.entry))); + parts.push(clockTime(props.entry.at)); + return parts.join(' · '); + }); + + return ( +
+

+ + {props.entry.label} + + 1}> + {`×${props.count}`} + +

+
+ {sub()} + 1}>{` · latest of ${props.count}`} +
+ +
+
+ Wrote + nothing}> + {(wrote) => ( + <> + {wrote().name} +
+ + {` +${wrote().more} more`} +
+ + )} +
+
+ +
+ Re-ran + + {chain().reran || 'nothing'} + + + {(item) => ( +
+ {`${item().name} `} + +
+ )} +
+
+ +
+ Effects + + {chain().effects || 'none'} + +
+
+ + + {(interaction) => ( +
+
+ + + + + + + + +
+
+ )} +
+ + +
+ + +
+ {`${plural(props.entry.droppedRuns, 're-run')} not listed.`} +
+
+
+
+ + +
+ bar.key}> + {(bar) => ( + + )} + +
+
+ + +
+ + {(hold) => ( +
+ {`${ms(hold.holdMs)}${hold.blockers.length ? `, waiting on ${hold.blockers.join(', ')}` : ''}`} +
+ )} +
+
+
+ +
+ + {(found) => ( + + )} + + + {(live) => ( + + )} + + timelineReport({ entry: props.entry, route: pageRoute() })} + /> +
+
+ ); +} + +// ---------------------------------------------------------------- server call + +export interface ServerDetailProps { + value: ServerFunctionInstance; + /** The action the call ran for. */ + from: TimelineEntry | undefined; + onSelect: (key: string) => void; + onDelete: () => void; +} + +export function ServerDetail(props: ServerDetailProps): JSX.Element { + const sub = createMemo(() => { + const parts: string[] = [callStatus(props.value), props.value.request.source.method]; + const duration = callDuration(props.value); + if (duration !== undefined) parts.push(ms(duration)); + parts.push(clockTime(props.value.request.time)); + return parts.join(' · '); + }); + + return ( +
+

+ + {callName(props.value)} + + {(response) => ( + + {response().source.status} + + )} + +

+
{sub()}
+ + + {(entry) => ( +
+ +
+ )} +
+ +
+ +
+ +
+ copyCallReport(props.value)} /> + +
+
+ ); +} + +// ---------------------------------------------------------------- async + +export interface AsyncDetailProps { + record: AsyncRecord; + onViewInGraph?: (node: object) => void; + onViewOwner?: (owner: object) => void; +} + +export function AsyncDetail(props: AsyncDetailProps): JSX.Element { + const node = createMemo(() => { + const current = props.record; + return current.type === 'load' || current.type === 'fallback' + ? current.node?.deref() + : undefined; + }); + const sub = createMemo(() => + [props.record.type, ms(asyncDuration(props.record)), clockTime(props.record.at)].join(' · '), + ); + + return ( +
+

+ + {asyncTitle(props.record)} +

+
{sub()}
+ + + {(concern) => ( +
+
{concern()}
+
+ )} +
+ +
+
+ + {(origin) => ( +
+ + Started by + + + {origin()} + +
+ )} +
+ +
+
+ +
+ + {(current) => ( + <> + + + + + + + + )} + + asyncReport({ record: props.record, route: pageRoute() })} + /> +
+
+ ); +} + +/** A load in flight: the live list of the async panel. */ +export function LiveDetail(props: { + load: LiveLoad; + now: number; + onViewInGraph?: (node: object) => void; + onViewOwner?: (owner: object) => void; +}): JSX.Element { + return ( +
+

+ + {`${props.load.name} loading`} +

+
{`live · ${ms(props.now - props.load.since)}`}
+ +
+
+ {props.load.ownerPath.join(' › ')} +
+
+
+
+ + + + + + +
+
+ ); +} + +// ---------------------------------------------------------------- error + +export interface ErrorDetailProps { + record: ErrorRecord; + entry: TimelineEntry | undefined; + onSelect: (key: string) => void; + onShowError?: () => void; +} + +export function ErrorDetail(props: ErrorDetailProps): JSX.Element { + const thrown = createMemo(() => describeThrown(props.record.error)); + + return ( +
+

+ + {thrown().name} +

+
{clockTime(props.record.at)}
+ +
+
+ {thrown().message} +
+
+ + + {(entry) => ( +
+ +
+ )} +
+ +
+ + + +
+
+ ); +} diff --git a/src/dev-toolbar/activity/index.tsx b/src/dev-toolbar/activity/index.tsx new file mode 100644 index 0000000..0f474cf --- /dev/null +++ b/src/dev-toolbar/activity/index.tsx @@ -0,0 +1,570 @@ +import type { JSX } from '@solidjs/web'; +import { createEffect, createMemo, createSignal, For, getOwner, onCleanup, Show } from 'solid-js'; +import { PauseIcon, PlayIcon } from '../icons.js'; +import Placeholder from '../../ui/Placeholder.js'; +import { ResizeHandle } from '../../ui/ResizeHandle.js'; +import { Text } from '../../ui/Text.js'; +import { useResizableDetail } from '../../ui/use-resizable-detail.js'; +import { isAsyncAvailable, type AsyncHistory, type AsyncRecord } from '../async/tracker.js'; +import type { ErrorRecord } from '../error-record.js'; +import type { ServerFunctionInstance } from '../functions/index.js'; +import { + excludeReactiveOwner, + snapshotReactivityGraph, + startReactivityTracking, + subscribeReactivityGraph, +} from '../reactivity/registry.js'; +import { isTimelineAvailable, type TimelineEntry } from '../timeline/tracker.js'; +import { ActionDetail, AsyncDetail, ErrorDetail, LiveDetail, ServerDetail } from './detail.js'; +import { + actionKey, + bucketRows, + collectRows, + GLYPHS, + frozenSources, + inWindow, + type ActivitySources, + layoutLanes, + viewRange, + WINDOW_MS, + matchesFilter, + matchesOwner, + resolveSelection, + serverKey, + ValueMemory, + type ActivityFilter, + type ActivityRow, + type LiveLoad, + type TimeWindow, +} from './model.js'; +import { Scrub } from './scrub.js'; +import './styles.css'; + +const FILTERS: { value: ActivityFilter; label: string }[] = [ + { value: 'all', label: 'All' }, + { value: 'actions', label: 'Actions' }, + { value: 'server', label: 'Server' }, + { value: 'async', label: 'Async' }, +]; + +/** How often the group labels and the elapsed time of what loads now are redrawn. */ +const TICK_MS = 250; +/** Longest the view waits to read the graph after it changed. */ +const GRAPH_READ_MS = 100; + +export interface ActivityFocus { + /** Select the action that began with this interaction or frame. */ + origin?: object; + /** Select the server-function call with this key. */ + instance?: string; + /** Show only what involves this owner, such as ``. */ + ownerName?: string; +} + +export interface ActivityViewerProps { + show?: boolean; + entries: TimelineEntry[]; + /** The async history, to tell the sources of a load from the nodes that wait on them. */ + history: AsyncHistory; + records: AsyncRecord[]; + instances: Record; + errors: ErrorRecord[]; + /** + * Another view asking Activity to show something. Pass a new object for every + * request, so asking for the same thing twice still selects it. + */ + focus?: ActivityFocus; + /** Clears the timeline and the async history. The button shows only when this is given. */ + onClear?: () => void; + onDeleteInstance: (instance: string) => void; + onViewInGraph?: (node: object) => void; + onViewOwner?: (owner: object) => void; + onShowInStructure?: (node: object) => void; + /** Opens the error at this index of `errors`. */ + onShowError?: (index: number) => void; +} + +function Row(props: { row: ActivityRow; selected: boolean; onSelect: () => void }): JSX.Element { + return ( + + ); +} + +/** Actions, server calls, async waits and thrown errors in one stream, with the selected one beside it. */ +export default function ActivityViewer(props: ActivityViewerProps): JSX.Element { + const detail = useResizableDetail({ + storageKey: 'solid-start-devtools:activity-detail-width', + defaultWidth: 520, + }); + // The live list reads the graph, which must not list the panel's own nodes. + excludeReactiveOwner(getOwner()); + + const [filter, setFilter] = createSignal('all'); + const [unfold, setUnfold] = createSignal(false); + const [selected, setSelected] = createSignal(); + const [timeRange, setTimeRange] = createSignal(); + const [owner, setOwner] = createSignal(); + const [live, setLive] = createSignal([]); + const [paused, setPaused] = createSignal(false); + // What the list showed when it was paused. + // Read by the sampler, outside any tracking scope. + let frozenSrc: ActivitySources | undefined; + // The clock reads these outside any tracking scope. + let frozen = false; + let pausedAt = 0; + let lastSample = 0; + let graphChanged = true; + let liveSignature = ''; + // The list and the strip redraw from a sample taken at most every `TICK_MS`, + // however often the stores change. A loop that updates 60 times a second + // would otherwise rebuild every row 60 times a second. + function currentSources(): ActivitySources { + return { + entries: props.entries, + instances: props.instances, + records: props.records, + errors: props.errors, + live: live(), + }; + } + const [view, setView] = createSignal<{ sources: ActivitySources; now: number }>({ + sources: { entries: [], instances: {}, records: [], errors: [], live: [] }, + now: performance.now(), + }); + const now = () => view().now; + + function sample(taken?: ActivitySources): void { + lastSample = performance.now(); + const frozenNow = frozenSrc; + const current = taken ?? currentSources(); + setView({ + sources: frozenNow ? frozenSources(frozenNow, current) : current, + now: frozen ? pausedAt : lastSample, + }); + } + + const [observed, setObserved] = createSignal(0); + const since = new Map(); + const memory = new ValueMemory(); + + // What is loading now comes from the graph, so it is read only while the panel shows. + createEffect( + () => !!props.show, + (visible) => { + if (!visible) return; + function read(): void { + const nodes = snapshotReactivityGraph().nodes; + const ids = new Set(); + const loads: LiveLoad[] = []; + // Step 1: the nodes that wait on a source of async work are not listed, only the sources. + for (const node of nodes) { + if (!node.pending || node.kind === 'signal') continue; + if (!props.history.isSource(node.raw)) continue; + ids.add(node.id); + if (!since.has(node.id)) since.set(node.id, performance.now()); + loads.push({ + id: node.id, + name: node.name, + ownerPath: node.ownerPath, + since: since.get(node.id)!, + raw: node.raw, + }); + } + // Step 2: forget what stopped loading. + for (const id of since.keys()) if (!ids.has(id)) since.delete(id); + setLive(loads); + // A load that starts or lands shows at once, not at the next tick. + const signature = [...ids].join('|'); + if (signature !== liveSignature) { + liveSignature = signature; + queueMicrotask(() => sample()); + } + } + const stop = startReactivityTracking(); + // The graph is read at the tick and only if it changed, since reading all of it costs. + let readTimer: ReturnType | undefined; + const unsubscribe = subscribeReactivityGraph(() => { + graphChanged = true; + // Reads are spaced out, since reading the whole graph costs. + readTimer ??= setTimeout(() => { + readTimer = undefined; + if (!graphChanged) return; + graphChanged = false; + read(); + }, GRAPH_READ_MS); + }); + const timer = setInterval(() => { + if (graphChanged) { + graphChanged = false; + read(); + } + sample(); + }, TICK_MS); + graphChanged = false; + read(); + // Outside the effect, since reading the stores here would be an untracked read. + queueMicrotask(() => sample()); + return () => { + clearInterval(timer); + clearTimeout(readTimer); + unsubscribe(); + stop(); + }; + }, + ); + onCleanup(() => since.clear()); + + // A change in the stores shows at once when the last sample is old enough, else at the next tick. + createEffect( + () => ({ show: !!props.show, sources: currentSources() }), + ({ show, sources }) => { + if (show && performance.now() - lastSample >= TICK_MS) sample(sources); + }, + ); + + // The values of the memos that re-ran are read as the runs arrive, so the next run can show what it changed from. + createEffect( + () => props.entries, + (entries) => { + memory.observe(entries); + setObserved((count) => count + 1); + }, + ); + + // Another view asked for something. + createEffect( + () => props.focus, + (request) => { + if (!request) return; + if (request.ownerName !== undefined) setOwner(request.ownerName || undefined); + if (request.origin) { + for (const entry of props.entries) { + if (entry.origin !== request.origin) continue; + setFilter('all'); + setTimeRange(undefined); + setSelected(actionKey(entry.id)); + break; + } + } + if (request.instance) { + setFilter('all'); + setTimeRange(undefined); + setSelected(serverKey(request.instance)); + } + }, + ); + + // The strip and the list cover the last `WINDOW_MS`. While paused nothing newer than the pause shows. + const range = createMemo(() => { + const current = viewRange(view().now); + return paused() ? current : { ...current, until: Number.POSITIVE_INFINITY }; + }); + + // A picked time range that has slid out of the strip is dropped. + createEffect( + () => { + const picked = timeRange(); + return picked !== undefined && picked.to < now() - WINDOW_MS; + }, + (stale) => { + if (stale) setTimeRange(undefined); + }, + ); + + function togglePause(): void { + const next = !paused(); + frozen = next; + // Pausing keeps what is shown, so the stores can evict without the view losing rows. + pausedAt = view().now; + frozenSrc = next ? view().sources : undefined; + setPaused(next); + // Resuming jumps back to live. + sample(); + } + + const collected = createMemo(() => + collectRows({ + ...view().sources, + unfold: unfold(), + range: range(), + pinned: selected(), + }), + ); + + const shown = createMemo(() => { + const rows: ActivityRow[] = []; + const kind = filter(); + const name = owner(); + const range = timeRange(); + for (const row of collected().rows) { + if (!matchesFilter(row, kind)) continue; + if (!matchesOwner(row, name)) continue; + if (!inWindow(row, range)) continue; + rows.push(row); + } + return rows; + }); + + const buckets = createMemo(() => bucketRows(shown(), now())); + const lanes = createMemo(() => layoutLanes(collected().rows, now())); + const selection = createMemo(() => resolveSelection(collected().rows, selected())); + + // Each detail reads its own memo, which holds the same object while the list + // redraws. A detail that read the selection itself would be told of every new + // list and rebuild its body, and a body that suspends would never settle. + const actionPick = createMemo(() => selection()?.entry); + const actionCount = createMemo(() => selection()?.row.count ?? 1); + const serverPick = createMemo( + () => { + const source = selection()?.row.source; + return source?.type === 'server' ? source : undefined; + }, + { equals: (a, b) => a?.instance === b?.instance && a?.value === b?.value }, + ); + const asyncPick = createMemo(() => { + const source = selection()?.row.source; + return source?.type === 'async' ? source.record : undefined; + }); + const livePick = createMemo( + () => { + const source = selection()?.row.source; + return source?.type === 'live' ? source.load : undefined; + }, + { equals: (a, b) => a?.raw === b?.raw }, + ); + const errorPick = createMemo( + () => { + const source = selection()?.row.source; + return source?.type === 'error' ? source : undefined; + }, + { equals: (a, b) => a?.record === b?.record && a?.entry === b?.entry }, + ); + + /** The action a server call ran for. */ + function callerOf(instance: string): TimelineEntry | undefined { + for (const entry of props.entries) { + for (const call of entry.calls) if (call.instance === instance) return entry; + } + return undefined; + } + + function clear(): void { + setSelected(undefined); + setTimeRange(undefined); + props.onClear?.(); + } + + return ( + +
+
+ + + Activity needs a development build of solid-js. + + + } + > + +
+
+
+ + {(item) => ( + + )} + + + {(name) => ( + + )} + + + + + + + + +
+ + + {collected().rows.length + ? 'Nothing matches this filter.' + : 'Nothing happened in the last 30 seconds.'} +
+ } + > + bucket.label}> + {(bucket) => ( + <> +
{bucket().label}
+ row.key}> + {(row) => ( + setSelected(row().members[0])} + /> + )} + + + )} +
+ +
+ +
} + > + + {(entry) => ( + + )} + + + {(pick) => ( + { + const instance = pick().instance; + setSelected(undefined); + props.onDeleteInstance(instance); + }} + /> + )} + + + {(record) => ( + + )} + + + {(load) => ( + + )} + + + {(pick) => ( + props.onShowError?.(pick().index) : undefined + } + /> + )} + + + +
+
+ + + + ); +} diff --git a/src/dev-toolbar/activity/model.test.ts b/src/dev-toolbar/activity/model.test.ts new file mode 100644 index 0000000..ac02ecf --- /dev/null +++ b/src/dev-toolbar/activity/model.test.ts @@ -0,0 +1,635 @@ +import { describe, expect, it } from 'vitest'; +import type { AsyncRecord } from '../async/tracker.js'; +import type { ErrorRecord } from '../error-record.js'; +import type { ServerFunctionInstance } from '../functions/index.js'; +import type { TimelineEntry, TimelineRun } from '../timeline/tracker.js'; +import { + axisMarks, + bucketLabel, + bucketRows, + callBars, + collectRows, + countNames, + entryLocation, + firstMemoDelta, + frozenSources, + handlerLocation, + inWindow, + layoutLanes, + makeWindow, + matchesFilter, + matchesOwner, + parseStackFrame, + previewValue, + resolveSelection, + runBars, + shiftWindow, + summarizeChain, + ValueMemory, + viewRange, + WINDOW_MS, + type ActivityRow, +} from './model.js'; + +const ORIGIN = { kind: 'interaction', name: 'click' } as const; + +function run( + id: number, + name: string, + kind: 'memo' | 'effect', + at: number, + selfMs: number, + value?: unknown, +): TimelineRun { + return { + event: { run: id, at, nodeId: id, nodeKind: kind, nodeName: name, selfMs, causes: [] } as never, + node: value === undefined ? undefined : new WeakRef({ _value: value }), + effectMs: undefined, + }; +} + +function entry(id: number, label: string, at: number, extra: Partial = {}) { + return { + id, + // An update with no origin and no named write counts as unnamed, so an entry has an origin by default. + origin: { kind: 'interaction', name: 'click' }, + label, + at, + interaction: undefined, + // An entry with no write and no run is idle and never listed. + writes: [{ name: 'w' } as never], + runs: [], + droppedRuns: 0, + flushes: 1, + flushMs: 2, + holds: [], + navigations: [], + calls: [], + ...extra, + } as TimelineEntry; +} + +function server(at: number, durationMs?: number, name = 'refundOrder'): ServerFunctionInstance { + const request = { + type: 'request', + id: 'fn', + instance: 'fn#1', + source: { method: 'POST' }, + meta: { name }, + time: at, + }; + if (durationMs === undefined) return { request } as never; + return { + request, + response: { type: 'response', time: at + durationMs, source: { status: 200, ok: true } }, + } as never; +} + +function collect(overrides: Partial[0]> = {}) { + return collectRows({ + entries: [], + instances: {}, + records: [], + errors: [], + live: [], + unfold: false, + ...overrides, + }); +} + +describe('collectRows', () => { + it('merges every source into one stream, newest first', () => { + const wait = { + type: 'wait', + id: 1, + at: 300, + event: { blockers: ['user'], holdMs: 40, silent: false, long: false }, + } as unknown as AsyncRecord; + const error: ErrorRecord = { error: new RangeError('bad'), at: 250, origin: undefined }; + const { rows } = collect({ + entries: [entry(2, 'click on b', 200), entry(1, 'click on a', 100)], + instances: { 'fn#1': server(150, 38) }, + records: [wait], + errors: [error], + }); + expect(rows.map((row) => row.kind)).toEqual(['async', 'error', 'action', 'server', 'action']); + }); + + it('names a thrown error by the action it happened in, joined by origin identity', () => { + const inAction = entry(1, 'click on button#a', 100, { origin: ORIGIN as never }); + const { rows } = collect({ + entries: [inAction], + errors: [ + { error: new RangeError('bad'), at: 120, origin: ORIGIN as never }, + { error: new TypeError('oops'), at: 130, origin: undefined }, + ], + }); + const errors = rows.filter((row) => row.kind === 'error'); + expect(errors.map((row) => row.title)).toEqual(['TypeError', 'click on button#a']); + expect(errors[1]!.subtitle).toBe('RangeError: bad'); + }); + + it('folds consecutive actions with one label and sums their stats', () => { + const writes = [{ name: 'x' } as never]; + const { rows, folded } = collect({ + entries: [ + entry(3, 'tick', 300, { writes }), + entry(2, 'tick', 200, { writes }), + entry(1, 'tick', 100, { writes }), + ], + }); + expect(rows).toHaveLength(1); + expect(rows[0]!.title).toBe('tick'); + expect(rows[0]!.count).toBe(3); + expect(rows[0]!.subtitle).toBe('3 writes · every 100ms'); + expect(rows[0]!.members).toEqual(['a:3', 'a:2', 'a:1']); + expect(folded).toBe(2); + }); + + it('shows every entry and the unnamed updates once unfolded', () => { + const unnamed = entry(4, 'update', 400, { + origin: undefined, + writes: [{ name: 'signal' } as never], + }); + const entries = [unnamed, entry(3, 'tick', 300), entry(2, 'tick', 200)]; + expect(collect({ entries }).rows).toHaveLength(1); + const unfolded = collect({ entries, unfold: true }); + expect(unfolded.rows).toHaveLength(3); + expect(unfolded.folded).toBe(2); + }); + + it('writes the subtitle of an action from its counts', () => { + const { rows } = collect({ + entries: [ + entry(1, 'click', 0, { + writes: [{ name: 'a' } as never], + runs: [run(1, 'm', 'memo', 0, 1), run(2, 'e', 'effect', 0, 1)], + calls: [{ event: {} as never, instance: 'fn#1' }], + }), + ], + }); + expect(rows[0]!.subtitle).toBe('1 write · 2 re-runs · 1 server call'); + }); + + it('says where a server call came from', () => { + const { rows } = collect({ + entries: [entry(1, 'click on b', 100, { calls: [{ event: {} as never, instance: 'fn#1' }] })], + instances: { 'fn#1': server(110, 38) }, + }); + const call = rows.find((row) => row.kind === 'server')!; + expect(call.subtitle).toBe('200 · from click on b'); + expect(call.time).toBe('38.0ms'); + }); + + it('keeps a pending call and skips a response that has no request', () => { + const { rows } = collect({ + instances: { 'fn#1': server(10), 'fn#2': { response: {} } as never }, + }); + expect(rows).toHaveLength(1); + expect(rows[0]!.time).toBe('live'); + }); +}); + +describe('idle entries', () => { + it('never lists an entry that changed nothing', () => { + const idle = entry(1, 'click on the pull tab', 0, { writes: [] }); + expect(collect({ entries: [idle] }).rows).toEqual([]); + }); +}); + +describe('filters', () => { + const rows = collect({ + entries: [entry(1, 'click', 100)], + instances: { 'fn#1': server(150, 5) }, + errors: [{ error: new Error('x'), at: 120, origin: undefined }], + live: [{ id: 'n', name: 'user', ownerPath: [''], since: 90, raw: {} }], + }).rows; + + it('keeps errors with the actions', () => { + const kinds = (filter: 'all' | 'actions' | 'server' | 'async') => + rows.filter((row) => matchesFilter(row, filter)).map((row) => row.kind); + expect(kinds('all')).toHaveLength(4); + expect(kinds('actions').sort()).toEqual(['action', 'error']); + expect(kinds('server')).toEqual(['server']); + expect(kinds('async')).toEqual(['async']); + }); + + it('keeps what overlaps the window, and everything with no window', () => { + const [call] = rows.filter((row) => row.kind === 'server') as ActivityRow[]; + expect(inWindow(call!, undefined)).toBe(true); + expect(inWindow(call!, { from: 0, to: 149 })).toBe(false); + expect(inWindow(call!, { from: 154, to: 200 })).toBe(true); + expect(inWindow(call!, { from: 156, to: 200 })).toBe(false); + }); + + it('matches an owner by the names the row involves', () => { + const action = collect({ + entries: [entry(1, 'click', 0, { runs: [run(1, 'OrderRow total', 'memo', 0, 1)] })], + }).rows[0]!; + expect(matchesOwner(action, undefined)).toBe(true); + expect(matchesOwner(action, '')).toBe(true); + expect(matchesOwner(action, '