diff --git a/.changeset/timeline-panel.md b/.changeset/timeline-panel.md new file mode 100644 index 0000000..8eb553e --- /dev/null +++ b/.changeset/timeline-panel.md @@ -0,0 +1,10 @@ +--- +'@solidjs/start-devtools': minor +--- + +Add a Timeline panel that lists what each user action re-ran, and why. + +- Each click, key press or outside update is a row with its writes, re-runs and timing. +- A re-run shows the changes that caused it, down to the write that started them. +- The server-function calls an action made are listed under it, and open in the server-function panel. +- The copy button puts a report of the action on the clipboard for an agent. diff --git a/README.md b/README.md index 8db67d7..e562bd0 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ Development error and server-function tooling for Solid Start mode. - Runtime error inspection with source-mapped stack frames. - The issues the runtime reports, such as a signal read outside a tracking scope. +- A timeline of what each action re-ran, and why. - Server-function request and response inspection. - A reactivity graph of the live signals, memos and effects in the app. - An ownership tree of the components and scopes the app created. @@ -64,6 +65,31 @@ the same findings, mixed with everything else. The panel listens from the moment the toolbar mounts, so findings from the first render are there when you open it. Issues need a development build of `solid-js`. +## Timeline + +The timeline lists what each user action made the app do. A click, a key press or an +input is one row, with the signals it wrote and every memo and effect that re-ran because +of it. An update from outside a handler, such as a timer or a promise, is a row of its own. + +- A row shows the writes, the number of re-runs and how long the update took. +- Select a row for the input delay, the handler time, the time until the update settled, + and the writes with their old and new values. +- Open a re-run to see why it ran: the changes it saw, and the changes behind each of + those, down to the write that started them. +- A re-run marked **no change** produced the same value as before, so its work was wasted. +- The eye opens the memo or effect in the reactivity graph. +- Server-function calls the action made are listed with their status and time. Click one + to open it in the server-function panel. A click that only calls the server still gets a + row. +- Updates no action caused whose writes all went to unnamed signals are hidden, since most + come from Solid's own state, such as a `` condition. The button above the list shows + how many there are and brings them back. + +The panel turns on the attribution engine of `solid-js` (`solid-js/attribution`) when the +toolbar mounts, so the first action is recorded even with the dock closed. Clicks in the +toolbar itself change nothing in the app, so they are left out. The timeline needs a +development build of `solid-js`. + ## Reactivity graph The graph panel maps the running reactive graph. Signals, memos and effects are nodes, diff --git a/src/dev-toolbar/functions/index.tsx b/src/dev-toolbar/functions/index.tsx index 43477b2..364dfd8 100644 --- a/src/dev-toolbar/functions/index.tsx +++ b/src/dev-toolbar/functions/index.tsx @@ -1,4 +1,4 @@ -import { createMemo, createSignal, For, Loading, Show } from 'solid-js'; +import { createEffect, createMemo, createSignal, For, Loading, Show } from 'solid-js'; import type { JSX } from '@solidjs/web'; import { BODY_FORMAT_FILE_KEY, BODY_FORMAT_KEY, BodyFormat } from './body-format.js'; import { Badge } from '../../ui/Badge.js'; @@ -345,6 +345,11 @@ function EmptyServerFunctions(): JSX.Element { export interface ServerFunctionViewerProps { instances: Record; + /** + * A call another panel asked to show. Pass a new object for every request, + * so asking for the same call twice still selects it. + */ + focus?: { instance: string }; onDeleteInstance: (value: string) => void; show?: boolean; } @@ -352,6 +357,13 @@ export interface ServerFunctionViewerProps { export function ServerFunctionViewer(props: ServerFunctionViewerProps): JSX.Element { const [currentInstance, setCurrentInstance] = createSignal(); + createEffect( + () => props.focus, + (request) => { + if (request) setCurrentInstance(request.instance); + }, + ); + const keys = createMemo(() => Object.keys(props.instances)); return ( diff --git a/src/dev-toolbar/functions/records.ts b/src/dev-toolbar/functions/records.ts index 19f7a35..c3a209a 100644 --- a/src/dev-toolbar/functions/records.ts +++ b/src/dev-toolbar/functions/records.ts @@ -26,6 +26,18 @@ export interface CallRecordsChannel { ): () => void; } +/** + * The panel's key for each call the records showed, by the runtime's live + * object for the call. Other panels get the live object from the `"call"` + * record too, so they can point the panel at the same call. + */ +const shownCalls = new WeakMap(); + +/** The panel's key for a call, from the live object of its `"call"` record. */ +export function instanceOfCall(live: object): string | undefined { + return shownCalls.get(live); +} + /** * Feeds the server-function panel from the runtime's records. A call shows * up when its request is sent and completes when it settles; the two @@ -56,12 +68,12 @@ export function connectCallRecords( const offCall = records.subscribe( 'call', (event, live) => { - const instance = instances.get(live); - const events = - instance === undefined - ? callRecordToEvents(event, live, key(event.id)) - : settleRecordToEvents(event, live, instance); - if (instance !== undefined) instances.delete(live); + const instance = instances.get(live) ?? key(event.id); + const events = instances.has(live) + ? settleRecordToEvents(event, live, instance) + : callRecordToEvents(event, live, instance); + instances.delete(live); + if (events.length) shownCalls.set(live, instance); for (const call of events) push(call); }, { bodies: true }, diff --git a/src/dev-toolbar/icons.tsx b/src/dev-toolbar/icons.tsx index 172dd1b..76b1a77 100644 --- a/src/dev-toolbar/icons.tsx +++ b/src/dev-toolbar/icons.tsx @@ -740,3 +740,22 @@ export function IssueIcon(props: JSX.IntrinsicElements['svg'] & { title: string ); } + +export function TimelineIcon(props: JSX.IntrinsicElements['svg'] & { title: string }): JSX.Element { + return ( + + {props.title} + + + ); +} diff --git a/src/dev-toolbar/index.tsx b/src/dev-toolbar/index.tsx index 334e4be..bfe9d41 100644 --- a/src/dev-toolbar/index.tsx +++ b/src/dev-toolbar/index.tsx @@ -21,26 +21,30 @@ import { ErrorIcon, FunctionIcon, GraphIcon, + IssueIcon, MinimizeIcon, SolidIcon, + TimelineIcon, TreeIcon, - IssueIcon, } from './icons.js'; +import { addIssues, captureIssues, type IssueEntry } from './issues/tracker.js'; import { excludeOwner, includeOwner } from './ownership/registry.js'; import { excludeReactiveOwner, includeReactiveOwner } from './reactivity/registry.js'; -import { addIssues, captureIssues, type IssueEntry } from './issues/tracker.js'; +import { startTimeline, TimelineBuilder, type TimelineEntry } from './timeline/tracker.js'; import './index.css'; const ErrorViewer = clientOnly(() => import('./error-viewer/index.js'), { lazy: true }); const ReactivityViewer = clientOnly(() => import('./reactivity/index.js'), { lazy: true }); const OwnershipViewer = clientOnly(() => import('./ownership/index.js'), { lazy: true }); const IssuesViewer = clientOnly(() => import('./issues/index.js'), { lazy: true }); +const TimelineViewer = clientOnly(() => import('./timeline/index.js'), { lazy: true }); -type Tab = 'err' | 'issues' | 'fn' | 'rx' | 'own'; +type Tab = 'err' | 'issues' | 'tl' | 'fn' | 'rx' | 'own'; const TABS: { value: Tab; label: string; icon: typeof ErrorIcon }[] = [ { value: 'err', label: 'Errors', icon: ErrorIcon }, { value: 'issues', label: 'Issues', icon: IssueIcon }, + { value: 'tl', label: 'Timeline', icon: TimelineIcon }, { value: 'fn', label: 'Server functions', icon: FunctionIcon }, { value: 'rx', label: 'Reactivity', icon: GraphIcon }, { value: 'own', label: 'Ownership', icon: TreeIcon }, @@ -278,6 +282,8 @@ export function DevToolbar(props: DevToolbarProps) { const [graphFocus, setGraphFocus] = createSignal<{ node: object }>(); // The graph can ask the ownership tree to show an owner the same way. const [ownershipFocus, setOwnershipFocus] = createSignal<{ owner: object }>(); + // The timeline can ask the server-function panel to show a call. + const [functionsFocus, setFunctionsFocus] = createSignal<{ instance: string }>(); const [errors, setErrors] = createSignal([]); @@ -321,6 +327,14 @@ export function DevToolbar(props: DevToolbarProps) { } const issueCount = createMemo(() => issues().reduce((total, entry) => total + entry.count, 0)); + // The first action can come before the toolbar settles, so the timeline + // starts during setup too. + const timeline = new TimelineBuilder(); + const [timelineEntries, setTimelineEntries] = createSignal([]); + if (!isServer) { + onCleanup(startTimeline(timeline, () => setTimelineEntries(timeline.snapshot()))); + } + const [instances, setInstances] = createSignal< Record >({}); @@ -485,6 +499,22 @@ export function DevToolbar(props: DevToolbarProps) { show('rx'); }} /> + { + timeline.clear(); + setTimelineEntries([]); + }} + onViewCall={(instance) => { + setFunctionsFocus({ instance }); + show('fn'); + }} + onViewInGraph={(node) => { + setGraphFocus({ node }); + show('rx'); + }} + /> { setInstances((current) => { diff --git a/src/dev-toolbar/report/timeline-report.ts b/src/dev-toolbar/report/timeline-report.ts new file mode 100644 index 0000000..34cc297 --- /dev/null +++ b/src/dev-toolbar/report/timeline-report.ts @@ -0,0 +1,134 @@ +import type { ChangeRecord } from 'solid-js/attribution'; +import type { TimelineEntry, TimelineRun } from '../timeline/tracker.js'; +import { code } from './markdown.js'; +import { isSensitiveName, REDACTED, redactText, redactUrl } from './redact.js'; + +export interface TimelineReportInput { + entry: TimelineEntry; + /** The path and query of the page. */ + route: string; +} + +/** Most re-runs the report lists. The rest are counted. */ +const MAX_REPORT_RUNS = 50; + +/** + * A duration for reading. Browsers round the clock to 0.1ms or coarser, so a + * shorter run reads as under that instead of as zero. + */ +export function ms(value: number | undefined): string { + if (value === undefined) return '?'; + if (value < 0.1) return '<0.1ms'; + return value < 1 ? `${value.toFixed(2)}ms` : `${value.toFixed(1)}ms`; +} + +export function plural(count: number, word: string): string { + return `${count} ${word}${count === 1 ? '' : 's'}`; +} + +/** `count` 0 → 1, with the values hidden when the name looks sensitive. */ +export function changeText(change: ChangeRecord): string { + const name = code(change.name); + if (change.kind !== 'write') return `${change.kind} ${name}`; + if (change.value === undefined) return `write ${name}`; + if (isSensitiveName(change.name)) return `write ${name} ${REDACTED}`; + const prev = change.prev === undefined ? '' : `${redactText(change.prev)} → `; + return `write ${name} ${prev}${redactText(change.value)}`; +} + +/** The changes that made a run happen, nearest first, each with what caused it. */ +export function causeLines(causes: ChangeRecord[], depth = 0): string[] { + const out: string[] = []; + for (const cause of causes) { + const text = cause.kind === 'derived' ? `${code(cause.name)} changed` : changeText(cause); + out.push(`${' '.repeat(depth)}- ← ${text}`); + if (cause.causes?.length) out.push(...causeLines(cause.causes, depth + 1)); + } + return out; +} + +function runLine(run: TimelineRun): string { + const { event } = run; + const notes: string[] = [ms(event.selfMs)]; + if (run.effectMs !== undefined) notes.push(`callback ${ms(run.effectMs)}`); + if (!event.changed) notes.push('no change'); + if (event.phase !== 'plain') notes.push(event.phase); + if (event.depsAdded.length) notes.push(`now reads ${event.depsAdded.map(code).join(', ')}`); + if (event.depsRemoved.length) + notes.push(`stopped reading ${event.depsRemoved.map(code).join(', ')}`); + return `- ${event.nodeKind} ${code(event.nodeName)} (${notes.join(', ')})`; +} + +/** A Markdown report of what one action made the app do, for an agent to read. */ +export function timelineReport(input: TimelineReportInput): string { + const { entry } = input; + const { interaction } = entry; + const out: string[] = [`# Solid update: ${code(entry.label)}`, '']; + + out.push(`- Page: ${code(redactUrl(input.route))}`); + if (interaction) { + out.push(`- Outcome: ${interaction.outcome ?? 'pending'}`); + if (interaction.inputDelayMs !== undefined) { + out.push(`- Input delay: ${ms(interaction.inputDelayMs)}`); + } + out.push(`- Handler: ${ms(interaction.handlerMs)}`); + if (interaction.continuationMs !== undefined) { + out.push(`- Async handler continued for ${ms(interaction.continuationMs)}`); + } + out.push(`- Re-runs: ${interaction.runs}, ${ms(interaction.runMs)} in total`); + if (interaction.created) out.push(`- Computations created: ${interaction.created}`); + if (interaction.settledMs !== undefined) { + out.push(`- Settled: ${ms(interaction.settledMs)} after the event`); + } + } else { + out.push(`- Re-runs: ${entry.runs.length + entry.droppedRuns}`); + } + if (entry.flushes) out.push(`- Flushes: ${entry.flushes}, ${ms(entry.flushMs)}`); + + if (entry.writes.length) { + out.push('', '## Writes', ''); + for (const write of entry.writes) out.push(`- ${changeText(write)}`); + } + + if (entry.runs.length) { + out.push('', '## Re-runs, in order', ''); + for (const run of entry.runs.slice(0, MAX_REPORT_RUNS)) { + out.push(runLine(run)); + out.push(...causeLines(run.event.causes, 1)); + } + const rest = entry.runs.length - MAX_REPORT_RUNS + entry.droppedRuns; + if (rest > 0) out.push(`- … ${rest} more re-runs`); + } + + if (entry.calls.length) { + out.push('', '## Server calls', ''); + for (const { event } of entry.calls) { + const status = event.status === undefined ? 'no response' : String(event.status); + const outcome = event.outcome === 'ok' ? '' : ', failed'; + out.push( + `- ${event.method} ${code(event.name ?? event.id)} → ${status} in ${ms(event.durationMs)}${outcome}`, + ); + } + } + + if (entry.holds.length) { + out.push('', '## Holds', ''); + for (const hold of entry.holds) { + const blockers = hold.blockers.length + ? `, waiting on ${hold.blockers.map(code).join(', ')}` + : ''; + const silent = hold.acknowledgements.length ? '' : ', with no pending state shown'; + out.push(`- ${ms(hold.holdMs)}${blockers}${silent}`); + } + } + + if (entry.navigations.length) { + out.push('', '## Navigations', ''); + for (const navigation of entry.navigations) { + const route = navigation.name ? `${code(navigation.name)} ` : ''; + out.push(`- ${route}to ${code(navigation.to ?? '?')}, ${navigation.outcome ?? 'pending'}`); + } + } + + return out.join('\n'); +} diff --git a/src/dev-toolbar/timeline/index.tsx b/src/dev-toolbar/timeline/index.tsx new file mode 100644 index 0000000..32a5e9e --- /dev/null +++ b/src/dev-toolbar/timeline/index.tsx @@ -0,0 +1,465 @@ +import type { JSX } from '@solidjs/web'; +import { createMemo, createSignal, For, Show } from 'solid-js'; +import type { ChangeRecord } from 'solid-js/attribution'; +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 { EyeIcon, TrashIcon } from '../icons.js'; +import { CopyReportButton } from '../report/CopyReportButton.js'; +import { pageRoute } from '../report/markdown.js'; +import { ms, plural, timelineReport } from '../report/timeline-report.js'; +import { + isTimelineAvailable, + isUnnamedUpdate, + type TimelineCall, + type TimelineEntry, + type TimelineRun, +} from './tracker.js'; +import './styles.css'; + +function outcomeBadge(entry: TimelineEntry): JSX.Element { + const outcome = entry.interaction?.outcome; + if (outcome === 'held') return held; + if (outcome === 'committed') return committed; + if (entry.origin && !entry.interaction) return pending; + return {entry.origin ? (outcome ?? 'idle') : 'update'}; +} + +/** The total time of the entry: the interaction's settle time, else its flushes. */ +function duration(entry: TimelineEntry): number { + return entry.interaction?.settledMs ?? entry.flushMs; +} + +function runCount(entry: TimelineEntry): number { + return entry.interaction?.runs ?? entry.runs.length + entry.droppedRuns; +} + +/** `0 → 1` for a write, with the values the runtime previewed. */ +function WriteText(props: { change: ChangeRecord }): JSX.Element { + return ( + <> + {props.change.kind} + + {props.change.name} + + + + {props.change.prev === undefined + ? props.change.value! + : `${props.change.prev} → ${props.change.value}`} + + + + ); +} + +/** Why a run happened: the changes it saw, each with the changes behind it. */ +function CauseTree(props: { causes: ChangeRecord[] }): JSX.Element { + return ( +
    + + {(cause) => ( +
  • + + ← + }> + + {`${cause.name} changed`} + + + + + + +
  • + )} +
    +
+ ); +} + +interface RunRowProps { + run: TimelineRun; + open: boolean; + onToggle: () => void; + onViewInGraph?: (node: object) => void; +} + +function RunRow(props: RunRowProps): JSX.Element { + const node = () => props.run.node?.deref(); + return ( +
+
+ + + {(current) => ( + + )} + +
+ + + No tracked change caused this run, such as a retry after an error. + + } + > + + + +
+ ); +} + +function CallRow(props: { + call: TimelineCall; + onViewCall?: (instance: string) => void; +}): JSX.Element { + const status = () => + props.call.event.status === undefined ? 'ERR' : String(props.call.event.status); + const content = ( + <> + {props.call.event.method} + + {props.call.event.name ?? props.call.event.id} + + {status()} + + {ms(props.call.event.durationMs)} + + + ); + return ( + {content}} + > + {(current) => ( + + )} + + ); +} + +export interface TimelineViewerProps { + show?: boolean; + entries: TimelineEntry[]; + onClear: () => void; + /** Opens a memo or an effect in the reactivity graph. */ + onViewInGraph?: (node: object) => void; + /** Opens a server-function call in its panel, by the panel's key for it. */ + onViewCall?: (instance: string) => void; +} + +export default function TimelineViewer(props: TimelineViewerProps): JSX.Element { + const [query, setQuery] = createSignal(''); + const [showUnnamed, setShowUnnamed] = createSignal(false); + const [selected, setSelected] = createSignal(); + const [openRuns, setOpenRuns] = createSignal([]); + + const unnamedCount = createMemo(() => props.entries.filter(isUnnamedUpdate).length); + + const shown = createMemo(() => { + const search = query().trim().toLowerCase(); + const entries = showUnnamed() + ? props.entries + : props.entries.filter((entry) => !isUnnamedUpdate(entry)); + if (!search) return entries; + return entries.filter( + (entry) => + entry.label.toLowerCase().includes(search) || + entry.writes.some((write) => write.name.toLowerCase().includes(search)) || + entry.runs.some((run) => run.event.nodeName.toLowerCase().includes(search)), + ); + }); + + const selectedEntry = createMemo(() => { + const id = selected(); + return id === undefined ? undefined : props.entries.find((entry) => entry.id === id); + }); + + function select(id: number): void { + setOpenRuns([]); + setSelected((current) => (current === id ? undefined : id)); + } + + function toggleRun(run: number): void { + setOpenRuns((current) => + current.includes(run) ? current.filter((item) => item !== run) : [...current, run], + ); + } + + return ( + +
+
+
+ setQuery(event.currentTarget.value)} + /> + + + +
+ + {`${shown().length} of ${props.entries.length}`} + + { + setSelected(undefined); + props.onClear(); + }} + > + + +
+
+ +
+ + + The timeline needs a development build of solid-js. + + + } + > +
+ + + {props.entries.length + ? 'Nothing matches this filter.' + : 'Interact with the app to see what each action re-runs.'} + + + } + > + + {(entry) => ( + + )} + + +
+ + + {(entry) => ( + + )} + +
+
+
+
+
+ ); +} diff --git a/src/dev-toolbar/timeline/styles.css b/src/dev-toolbar/timeline/styles.css new file mode 100644 index 0000000..9537423 --- /dev/null +++ b/src/dev-toolbar/timeline/styles.css @@ -0,0 +1,355 @@ +[data-solid-timeline-viewer] { + --start-dt-kind-memo: oklch(0.7 0.14 300); + --start-dt-kind-effect: oklch(0.72 0.15 150); + + color: var(--start-dt-text); + + display: flex; + flex-direction: column; + + height: 100%; + min-height: 0; +} + +[data-solid-timeline-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-timeline-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-timeline-search]:focus { + outline: none; + border-color: var(--start-dt-accent); +} + +[data-solid-timeline-unnamed] { + 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-muted); + + cursor: pointer; +} + +[data-solid-timeline-unnamed]:hover { + background: var(--start-dt-surface-hover); +} + +[data-solid-timeline-unnamed][aria-pressed='true'] { + color: var(--start-dt-text); +} + +[data-solid-timeline-nav-actions] { + display: flex; + flex-direction: row; + align-items: center; + + gap: 0.375rem; + + margin-left: auto; +} + +[data-solid-timeline-count] { + color: var(--start-dt-text-muted); + white-space: nowrap; +} + +[data-solid-timeline-body] { + display: flex; + flex-direction: row; + + flex: 1; + min-height: 0; +} + +[data-solid-timeline-list] { + display: flex; + flex-direction: column; + + flex: 1; + min-width: 0; + + overflow: auto; +} + +[data-solid-timeline-row] { + display: flex; + flex-direction: column; + align-items: stretch; + + gap: 0.25rem; + + padding: 0.5rem 0.75rem; + + border: none; + border-bottom: var(--start-dt-border-soft) 1px solid; + background: none; + color: var(--start-dt-text); + text-align: left; + + cursor: pointer; +} + +[data-solid-timeline-row]:hover { + background: var(--start-dt-surface-hover); +} + +[data-solid-timeline-row][data-selected] { + background: var(--start-dt-surface-active); +} + +[data-solid-timeline-row]:focus-visible { + outline: 2px solid var(--start-dt-accent); + outline-offset: -2px; +} + +[data-solid-timeline-row-head] { + display: flex; + flex-direction: row; + align-items: center; + + gap: 0.5rem; + + min-width: 0; +} + +[data-solid-timeline-row-label] { + overflow: hidden; + text-overflow: ellipsis; +} + +[data-solid-timeline-row-meta] { + color: var(--start-dt-text-muted); +} + +[data-solid-timeline-detail] { + display: flex; + flex-direction: column; + + gap: 0.625rem; + + width: 26rem; + flex-shrink: 0; + min-height: 0; + + padding: 0.625rem 0.75rem; + + border-left: var(--start-dt-border-soft) 1px solid; + overflow: auto; +} + +/* The pane scrolls, so its blocks keep their height instead of shrinking to fit. */ +[data-solid-timeline-detail] > * { + flex-shrink: 0; +} + +[data-solid-timeline-detail] [data-solid-kv-key] { + flex-basis: 5.5rem; +} + +[data-solid-timeline-detail-head] { + display: flex; + flex-direction: row; + align-items: center; + + gap: 0.375rem; + + min-width: 0; +} + +[data-solid-timeline-detail-head] [data-solid-copy-report] { + margin-left: auto; + padding: 0.25rem; + border-radius: 0.25rem; +} + +[data-solid-timeline-detail-head] [data-solid-copy-report] > svg { + width: 0.875rem; + height: 0.875rem; +} + +[data-solid-timeline-section] { + display: flex; + flex-direction: column; + + gap: 0.25rem; +} + +[data-solid-timeline-write], +[data-solid-timeline-cause] { + display: flex; + flex-direction: row; + align-items: baseline; + + gap: 0.375rem; + + min-width: 0; +} + +[data-solid-timeline-change-kind] { + color: var(--start-dt-text-muted); + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.6875rem; +} + +[data-solid-timeline-change-value] { + color: oklch(0.8 0.12 80); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +[data-solid-timeline-call] { + display: flex; + flex-direction: row; + align-items: center; + + gap: 0.375rem; + + padding: 0.25rem 0.375rem; + + border: none; + border-radius: 0.375rem; + background: none; + color: var(--start-dt-text); + text-align: left; +} + +button[data-solid-timeline-call] { + cursor: pointer; +} + +button[data-solid-timeline-call]:hover { + background: var(--start-dt-surface-hover); +} + +[data-solid-timeline-run] { + display: flex; + flex-direction: column; + + border-radius: 0.375rem; +} + +[data-solid-timeline-run][data-open] { + background: var(--start-dt-surface); + padding-bottom: 0.375rem; +} + +[data-solid-timeline-run-head] { + display: flex; + flex-direction: row; + align-items: center; + + gap: 0.25rem; +} + +[data-solid-timeline-run-toggle] { + display: flex; + flex-direction: row; + align-items: center; + + gap: 0.375rem; + + flex: 1; + min-width: 0; + + padding: 0.25rem 0.375rem; + + border: none; + border-radius: 0.375rem; + background: none; + color: var(--start-dt-text); + text-align: left; + + cursor: pointer; +} + +[data-solid-timeline-run-toggle]:hover { + background: var(--start-dt-surface-hover); +} + +[data-solid-timeline-run-time] { + margin-left: auto; + color: var(--start-dt-text-muted); +} + +[data-solid-timeline-kind] { + width: 0.5rem; + height: 0.5rem; + flex-shrink: 0; + + border-radius: 9999px; + background: var(--start-dt-kind-effect); +} + +[data-solid-timeline-kind='memo'] { + background: var(--start-dt-kind-memo); +} + +[data-solid-timeline-view-graph] { + display: inline-flex; + align-items: center; + justify-content: center; + + padding: 0.25rem; + + border: none; + border-radius: 0.25rem; + background: none; + color: var(--start-dt-accent); + + cursor: pointer; +} + +[data-solid-timeline-view-graph]:hover { + background: var(--start-dt-accent-soft); +} + +[data-solid-timeline-view-graph] svg { + width: 0.875rem; + height: 0.875rem; +} + +[data-solid-timeline-causes] { + margin: 0; + padding: 0 0 0 1.25rem; + + list-style: none; +} + +[data-solid-timeline-causes] [data-solid-timeline-causes] { + padding-left: 1rem; +} + +[data-solid-timeline-arrow] { + color: var(--start-dt-text-muted); +} + +[data-solid-timeline-note] { + color: var(--start-dt-text-muted); +} diff --git a/src/dev-toolbar/timeline/tracker.test.ts b/src/dev-toolbar/timeline/tracker.test.ts new file mode 100644 index 0000000..1c56905 --- /dev/null +++ b/src/dev-toolbar/timeline/tracker.test.ts @@ -0,0 +1,297 @@ +import { describe, expect, it } from 'vitest'; +import type { + ChangeOrigin, + ChangeRecord, + FlushEvent, + InteractionEvent, + RerunEvent, +} from 'solid-js/attribution'; +import { timelineReport } from '../report/timeline-report.js'; +import type { CallEvent } from '@solidjs/web'; +import { callInteraction, isUnnamedUpdate, rootChanges, TimelineBuilder } from './tracker.js'; + +const click: ChangeOrigin = { + kind: 'interaction', + name: 'click', + target: 'button#inc "count 0"', + at: 100, +}; + +const write: ChangeRecord = { + seq: 1, + kind: 'write', + name: 'count', + nodeId: 5, + prev: '0', + value: '1', + origin: click, + at: 101, +}; + +const derived: ChangeRecord = { + seq: 2, + kind: 'derived', + name: 'doubled', + nodeId: 1, + causes: [write], +}; + +function rerun(over: Partial): RerunEvent { + return { + run: 1, + at: 102, + nodeRuns: 1, + nodeKind: 'memo', + nodeName: 'doubled', + nodeId: 1, + causes: [write], + depCount: 1, + depsAdded: [], + depsRemoved: [], + selfMs: 0.2, + totalMs: 0.2, + changed: true, + phase: 'plain', + held: false, + interaction: click, + ...over, + }; +} + +function flush(over: Partial = {}): FlushEvent { + return { at: 102, durationMs: 1, runs: 2, created: 0, held: false, ...over } as FlushEvent; +} + +function interaction(over: Partial = {}): InteractionEvent { + return { + name: 'click', + target: click.target, + at: 100, + inputDelayMs: 4, + handlerMs: 1, + writes: 1, + runs: 2, + created: 0, + runMs: 0.5, + holds: [], + navigations: [], + settledMs: 6, + outcome: 'committed', + origin: click, + ...over, + }; +} + +describe('rootChanges', () => { + it('finds the writes a chain of derived changes bottoms out in, once each', () => { + expect([...rootChanges([write, derived]).values()]).toEqual([write]); + }); +}); + +describe('TimelineBuilder', () => { + it('groups the records of one interaction into one entry', () => { + const builder = new TimelineBuilder(); + const node = {}; + builder.rerun(rerun({}), node); + builder.rerun( + rerun({ run: 2, nodeKind: 'effect', nodeName: 'title', nodeId: 2, causes: [derived] }), + undefined, + ); + builder.effect({ + at: 103, + durationMs: 0.3, + nodeId: 2, + nodeName: 'title', + run: 2, + interaction: click, + }); + builder.flush(flush({ interaction: click })); + builder.interaction(interaction()); + + const entries = builder.snapshot(); + expect(entries).toHaveLength(1); + const [entry] = entries; + expect(entry!.label).toBe('click on button#inc "count 0"'); + expect(entry!.writes).toEqual([write]); + expect(entry!.runs.map((run) => run.event.nodeName)).toEqual(['doubled', 'title']); + expect(entry!.runs[0]!.node?.deref()).toBe(node); + expect(entry!.runs[1]!.effectMs).toBe(0.3); + expect(entry!.flushes).toBe(1); + expect(entry!.interaction?.settledMs).toBe(6); + }); + + it('starts a new entry for each outside update, ended by its flush', () => { + const builder = new TimelineBuilder(); + const timer: ChangeRecord = { ...write, seq: 9, name: 'clock', origin: { kind: 'external' } }; + builder.rerun(rerun({ interaction: undefined, causes: [timer] }), undefined); + builder.flush(flush()); + builder.rerun( + rerun({ run: 3, interaction: undefined, causes: [{ ...timer, seq: 10 }] }), + undefined, + ); + builder.flush(flush()); + + const entries = builder.snapshot(); + expect(entries).toHaveLength(2); + expect(entries[0]!.label).toBe('update: clock'); + expect(entries[0]!.origin).toBeUndefined(); + }); + + it('leaves out an interaction that changed nothing', () => { + const builder = new TimelineBuilder(); + builder.interaction( + interaction({ writes: 0, runs: 0, origin: { kind: 'interaction', name: 'click' } }), + ); + expect(builder.snapshot()).toHaveLength(0); + }); + + it('gives a changed entry a new object and keeps the rest', () => { + const builder = new TimelineBuilder(); + const other: ChangeOrigin = { kind: 'interaction', name: 'keydown', at: 200 }; + builder.rerun(rerun({}), undefined); + builder.rerun(rerun({ run: 2, interaction: other }), undefined); + const first = builder.snapshot(); + builder.rerun(rerun({ run: 3 }), undefined); + const second = builder.snapshot(); + + expect(second[0]).toBe(first[0]); + expect(second[1]).not.toBe(first[1]); + expect(second[1]!.runs).toHaveLength(2); + }); + + it('keeps the newest entries up to the limit', () => { + const builder = new TimelineBuilder(2); + for (const at of [1, 2, 3]) { + builder.rerun( + rerun({ run: at, interaction: { kind: 'interaction', name: `e${at}`, at } }), + undefined, + ); + } + expect(builder.snapshot().map((entry) => entry.origin?.name)).toEqual(['e3', 'e2']); + }); +}); + +function call(over: Partial = {}): CallEvent { + return { + id: 'load-user', + name: 'loadUser', + at: 105, + durationMs: 40, + method: 'POST', + outcome: 'ok', + status: 200, + origin: click, + ...over, + }; +} + +describe('callInteraction', () => { + it('finds the interaction behind the frame a call was made in', () => { + expect(callInteraction(click)).toBe(click); + expect(callInteraction({ kind: 'effect', name: 'sync', interaction: click })).toBe(click); + expect(callInteraction({ kind: 'external' })).toBeUndefined(); + expect(callInteraction(undefined)).toBeUndefined(); + }); +}); + +describe('TimelineBuilder calls', () => { + it('puts a call under the interaction that made it', () => { + const builder = new TimelineBuilder(); + builder.rerun(rerun({}), undefined); + builder.call( + call({ origin: { kind: 'effect', name: 'sync', interaction: click } }), + 'load-user#1', + ); + const [entry] = builder.snapshot(); + expect(entry!.calls).toEqual([ + expect.objectContaining({ + instance: 'load-user#1', + event: expect.objectContaining({ name: 'loadUser' }), + }), + ]); + }); + + it('keeps a click that only called the server', () => { + const builder = new TimelineBuilder(); + builder.interaction(interaction({ writes: 0, runs: 0, outcome: 'idle' })); + expect(builder.snapshot()).toHaveLength(0); + + builder.call(call(), 'load-user#1'); + const [entry] = builder.snapshot(); + expect(entry!.interaction?.outcome).toBe('idle'); + expect(entry!.calls).toHaveLength(1); + }); + + it('leaves calls no interaction made to the server-function panel', () => { + const builder = new TimelineBuilder(); + builder.call(call({ origin: undefined }), undefined); + expect(builder.snapshot()).toHaveLength(0); + }); +}); + +describe('isUnnamedUpdate', () => { + it('marks updates no action caused that wrote only unnamed signals, or nothing', () => { + const builder = new TimelineBuilder(); + const unnamed: ChangeRecord = { + ...write, + seq: 20, + name: 'signal', + origin: { kind: 'external' }, + }; + const named: ChangeRecord = { ...write, seq: 21, name: 'clock', origin: { kind: 'external' } }; + builder.rerun(rerun({ run: 1, interaction: undefined, causes: [unnamed] }), undefined); + builder.flush(flush()); + builder.rerun(rerun({ run: 2, interaction: undefined, causes: [] }), undefined); + builder.flush(flush()); + builder.rerun(rerun({ run: 3, interaction: undefined, causes: [named] }), undefined); + builder.flush(flush()); + builder.rerun(rerun({ run: 4, causes: [{ ...unnamed, seq: 22, origin: click }] }), undefined); + + expect(builder.snapshot().map(isUnnamedUpdate)).toEqual([false, false, true, true]); + }); +}); + +describe('timelineReport', () => { + it('lists the writes and each re-run with its causes', () => { + const builder = new TimelineBuilder(); + builder.rerun(rerun({}), undefined); + builder.rerun( + rerun({ run: 2, nodeKind: 'effect', nodeName: 'title', causes: [derived], changed: false }), + undefined, + ); + builder.interaction(interaction()); + const report = timelineReport({ entry: builder.snapshot()[0]!, route: '/' }); + + expect(report).toContain('# Solid update: `click on button#inc "count 0"`'); + expect(report).toContain('- Input delay: 4.0ms'); + expect(report).toContain('- Settled: 6.0ms after the event'); + expect(report).toContain('## Writes\n\n- write `count` 0 → 1'); + expect(report).toContain('- memo `doubled` (0.20ms)\n - ← write `count` 0 → 1'); + expect(report).toContain( + '- effect `title` (0.20ms, no change)\n - ← `doubled` changed\n - ← write `count` 0 → 1', + ); + }); + + it('lists the server calls of the action', () => { + const builder = new TimelineBuilder(); + builder.rerun(rerun({}), undefined); + builder.call(call(), undefined); + builder.call( + call({ name: 'save', outcome: 'error', status: undefined, durationMs: 12 }), + undefined, + ); + const report = timelineReport({ entry: builder.snapshot()[0]!, route: '/' }); + expect(report).toContain( + '## Server calls\n\n- POST `loadUser` → 200 in 40.0ms\n- POST `save` → no response in 12.0ms, failed', + ); + }); + + it('hides the values of a write with a sensitive name', () => { + const builder = new TimelineBuilder(); + const secret: ChangeRecord = { ...write, name: 'authToken', prev: 'a', value: 'b' }; + builder.rerun(rerun({ causes: [secret] }), undefined); + const report = timelineReport({ entry: builder.snapshot()[0]!, route: '/' }); + expect(report).toContain('write `authToken` [redacted]'); + expect(report).not.toContain('a → b'); + }); +}); diff --git a/src/dev-toolbar/timeline/tracker.ts b/src/dev-toolbar/timeline/tracker.ts new file mode 100644 index 0000000..da2863c --- /dev/null +++ b/src/dev-toolbar/timeline/tracker.ts @@ -0,0 +1,338 @@ +import type { CallEvent } from '@solidjs/web'; +import { OBSERVE } from 'solid-js'; +import { instanceOfCall } from '../functions/records.js'; +import { + attribution, + formatOrigin, + type ChangeOrigin, + type ChangeRecord, + type EffectRunEvent, + type FlushEvent, + type HoldEvent, + type InteractionEvent, + type NavigationEvent, + type RerunEvent, +} from 'solid-js/attribution'; + +/** One re-run of a memo or an effect, with the time its effect callback took. */ +export interface TimelineRun { + event: RerunEvent; + /** The runtime node, held weakly so the graph can be pointed at it. */ + node: WeakRef | undefined; + /** Time of the effect callback that followed the compute run, for an effect. */ + effectMs: number | undefined; +} + +/** One server-function call the action made. */ +export interface TimelineCall { + event: CallEvent; + /** The server-function panel's key for the call, so the panel can show it. */ + instance: string | undefined; +} + +/** + * What one user action, or one update from outside the app's handlers, made + * the app do: the writes it started with and every re-run they caused. + */ +export interface TimelineEntry { + id: number; + /** + * The interaction the records trace back to. The runtime hands the same + * object to every record of one interaction, so it is the key the records + * are grouped by. `undefined` for an update from a timer, a promise or + * another callback the runtime does not know as an interaction. + */ + origin: ChangeOrigin | undefined; + label: string; + /** When the action began, on the `performance.now()` clock. */ + at: number; + /** The interaction record, which the runtime sends once the update has settled. */ + interaction: InteractionEvent | undefined; + /** The writes the re-runs bottom out in, in order. */ + writes: ChangeRecord[]; + runs: TimelineRun[]; + /** Re-runs left out once the entry held `MAX_RUNS`. */ + droppedRuns: number; + flushes: number; + flushMs: number; + holds: HoldEvent[]; + navigations: NavigationEvent[]; + calls: TimelineCall[]; +} + +/** Most entries the panel keeps. The oldest go first. */ +export const MAX_ENTRIES = 100; +/** Most re-runs one entry keeps. */ +export const MAX_RUNS = 300; + +/** + * The interaction a call ran for. A call made by a handler carries the + * interaction itself. One made for an effect, a navigation or a landing + * carries that frame, which names the interaction behind it. + */ +export function callInteraction(origin: ChangeOrigin | undefined): ChangeOrigin | undefined { + if (!origin) return undefined; + return origin.kind === 'interaction' ? origin : origin.interaction; +} + +/** The writes, landings and refreshes a cause chain bottoms out in. */ +export function rootChanges(causes: ChangeRecord[], out = new Map()) { + for (const cause of causes) { + if (cause.causes?.length) rootChanges(cause.causes, out); + else if (!out.has(cause.seq)) out.set(cause.seq, cause); + } + return out; +} + +/** The names the runtime gives a signal or a memo created without a `name` option. */ +const DEFAULT_NAMES = new Set(['signal', 'computed', 'memo']); + +/** + * An update no action caused whose writes all went to unnamed signals, or + * that wrote nothing. Most come from the runtime's own state, such as the + * condition of a `` or a `` boundary settling, so the panel + * hides them unless asked. An app's own unnamed signal looks the same, which + * is why they are hidden and not dropped. + */ +export function isUnnamedUpdate(entry: TimelineEntry): boolean { + return !entry.origin && entry.writes.every((write) => DEFAULT_NAMES.has(write.name)); +} + +function externalLabel(writes: ChangeRecord[]): string { + const origin = writes.find((write) => write.origin)?.origin; + const source = origin && origin.kind !== 'external' ? formatOrigin(origin) : 'update'; + const names = [...new Set(writes.map((write) => write.name))]; + return names.length ? `${source}: ${names.join(', ')}` : source; +} + +/** + * Builds the timeline from the runtime's records. Pure apart from the clock + * values in the records, so it can be fed by hand in tests. + */ +export class TimelineBuilder { + entries: TimelineEntry[] = []; + private nextId = 1; + private byOrigin = new WeakMap(); + /** The entry collecting re-runs that no interaction caused, until its flush ends. */ + private external: TimelineEntry | undefined; + private changed = new Set(); + /** + * Interactions that changed nothing, kept aside. A click that only calls + * the server looks idle when its record arrives, and its call settles later. + */ + private idle = new WeakMap(); + + constructor(private readonly max = MAX_ENTRIES) {} + + private create(origin: ChangeOrigin | undefined, at: number): TimelineEntry { + const entry: TimelineEntry = { + id: this.nextId++, + origin, + label: origin ? formatOrigin(origin) : 'update', + at: origin?.at ?? at, + interaction: undefined, + writes: [], + runs: [], + droppedRuns: 0, + flushes: 0, + flushMs: 0, + holds: [], + navigations: [], + calls: [], + }; + this.entries.unshift(entry); + for (const dropped of this.entries.splice(this.max)) { + if (dropped.origin) this.byOrigin.delete(dropped.origin); + if (dropped === this.external) this.external = undefined; + } + if (origin) this.byOrigin.set(origin, entry); + return entry; + } + + private entryFor(origin: ChangeOrigin | undefined, at: number): TimelineEntry { + if (origin) return this.byOrigin.get(origin) ?? this.create(origin, at); + this.external ??= this.create(undefined, at); + return this.external; + } + + private touch(entry: TimelineEntry): void { + this.changed.add(entry); + } + + rerun(event: RerunEvent, node: object | undefined): void { + const entry = this.entryFor(event.interaction, event.at); + const known = new Set(entry.writes.map((write) => write.seq)); + for (const root of rootChanges(event.causes).values()) { + if (!known.has(root.seq)) entry.writes.push(root); + } + if (!entry.origin) entry.label = externalLabel(entry.writes); + if (entry.runs.length < MAX_RUNS) { + entry.runs.push({ event, node: node ? new WeakRef(node) : undefined, effectMs: undefined }); + } else { + entry.droppedRuns++; + } + this.touch(entry); + } + + effect(event: EffectRunEvent): void { + if (event.run === undefined) return; + const entry = this.entryFor(event.interaction, event.at); + const run = entry.runs.find((item) => item.event.run === event.run); + if (!run) return; + run.effectMs = (run.effectMs ?? 0) + event.durationMs; + this.touch(entry); + } + + flush(event: FlushEvent): void { + // A flush with nothing in it says nothing about an action. + if (!event.runs && !event.created) { + if (!event.interaction) this.external = undefined; + return; + } + const entry = this.entryFor(event.interaction, event.at); + entry.flushes++; + entry.flushMs += event.durationMs; + // The re-runs of an outside update end with its flush, so the next ones start a new entry. + if (!event.interaction) this.external = undefined; + this.touch(entry); + } + + interaction(event: InteractionEvent): void { + // A click on something that reads no state, such as the devtools themselves. + const idle = !event.writes && !event.runs && !event.created && !event.holds.length; + if (idle && !this.byOrigin.has(event.origin)) { + this.idle.set(event.origin, event); + return; + } + const entry = this.entryFor(event.origin, event.at); + entry.interaction = event; + entry.at = event.at; + this.touch(entry); + } + + hold(event: HoldEvent): void { + const entry = this.entryFor(event.interaction, event.at); + entry.holds.push(event); + this.touch(entry); + } + + /** + * A server-function call joins the action it ran for. A call that no + * interaction made is left to the server-function panel. + */ + call(event: CallEvent, instance: string | undefined): void { + const origin = callInteraction(event.origin); + if (!origin) return; + const entry = this.entryFor(origin, event.at); + const idle = this.idle.get(origin); + if (idle && !entry.interaction) { + entry.interaction = idle; + entry.at = idle.at; + } + entry.calls.push({ event, instance }); + this.touch(entry); + } + + navigation(event: NavigationEvent): void { + const entry = this.entryFor(event.interaction ?? event.origin, event.at); + entry.navigations.push(event); + this.touch(entry); + } + + /** + * The entries for the panel. An entry that changed since the last call is a + * new object, so the panel redraws only what moved. + */ + snapshot(): TimelineEntry[] { + if (!this.changed.size) return this.entries; + this.entries = this.entries.map((entry) => { + if (!this.changed.has(entry)) return entry; + const copy = { + ...entry, + writes: entry.writes.slice(), + runs: entry.runs.slice(), + holds: entry.holds.slice(), + navigations: entry.navigations.slice(), + calls: entry.calls.slice(), + }; + // Later records find the entry through its origin, so they reach the copy. + if (copy.origin) this.byOrigin.set(copy.origin, copy); + if (this.external === entry) this.external = copy; + return copy; + }); + this.changed.clear(); + return this.entries; + } + + clear(): void { + this.entries = []; + this.byOrigin = new WeakMap(); + this.idle = new WeakMap(); + this.external = undefined; + this.changed.clear(); + } +} + +/** True when the runtime can explain re-runs, which is in dev and observe builds. */ +export function isTimelineAvailable(): boolean { + return !!OBSERVE?.records && typeof attribution?.enable === 'function'; +} + +/** + * Turns on the runtime's attribution engine and feeds its records to a + * builder. The engine's records arrive inside the runtime's own work, where a + * signal write would be part of what it measures, so the panel hears about + * changes on a microtask. Returns the stop. + */ +export function startTimeline(builder: TimelineBuilder, onChange: () => void): () => void { + if (!isTimelineAvailable()) return () => {}; + const records = OBSERVE!.records; + let queued = false; + const changed = () => { + if (queued) return; + queued = true; + queueMicrotask(() => { + queued = false; + onChange(); + }); + }; + const offs = [ + records.subscribe('rerun', (event, node) => { + builder.rerun(event, node as object | undefined); + changed(); + }), + records.subscribe('effect', (event) => { + builder.effect(event); + changed(); + }), + records.subscribe('flush', (event) => { + builder.flush(event); + changed(); + }), + records.subscribe('interaction', (event) => { + builder.interaction(event); + changed(); + }), + records.subscribe('hold', (event) => { + builder.hold(event); + changed(); + }), + records.subscribe('navigation', (event) => { + builder.navigation(event); + changed(); + }), + records.subscribe('call', (event, live) => { + // The server-function panel listens from the moment the package loads, so + // it has given the call its key by now. The live object itself is not + // kept, since nothing else holds it once the call settled. + builder.call(event, instanceOfCall(live)); + changed(); + }), + ]; + // The console log stays off: the panel is where the re-runs are read. + const release = attribution.enable({ log: false, values: 'full' }); + return () => { + release(); + for (const off of offs) off(); + }; +} diff --git a/tests/e2e/devtools.spec.ts b/tests/e2e/devtools.spec.ts index 4e2d559..b40a7d5 100644 --- a/tests/e2e/devtools.spec.ts +++ b/tests/e2e/devtools.spec.ts @@ -489,6 +489,73 @@ test('lists runtime issues', async ({ page }) => { await expect(page.getByRole('tab', { name: 'Issues', exact: true })).not.toContainText('2'); }); +test('shows what each action re-ran and why', async ({ page }) => { + await page.goto('/'); + await page.locator('#increment-count').click(); + + await openTab(page, 'Timeline'); + const rows = page.locator('[data-solid-timeline-row]'); + // Clicks on the toolbar change nothing in the app, so the click on the counter is the only entry. + await expect(rows).toHaveCount(1); + await expect(rows.first()).toContainText('click on button#increment-count'); + await expect(rows.first()).toContainText('1 write · 3 re-runs'); + await expect(rows.first()).toContainText('committed'); + + await rows.first().click(); + const detail = page.locator('[data-solid-timeline-detail]'); + await expect(detail.locator('[data-solid-timeline-write]')).toHaveText(/write\s*count\s*0 → 1/); + await expect(detail.locator('[data-solid-timeline-run-toggle]')).toHaveText([ + /doubled/, + /effect/, + /report-doubled/, + ]); + + // An effect re-ran because the memo it reads changed, which the write caused. + await detail + .locator('[data-solid-timeline-run-toggle]') + .filter({ hasText: 'report-doubled' }) + .click(); + const causes = detail.locator( + '[data-solid-timeline-run][data-open] [data-solid-timeline-causes]', + ); + await expect(causes.first()).toContainText('doubled changed'); + await expect(causes.first()).toContainText('count'); + + // The eye opens the memo in the reactivity graph. + await detail + .locator('[data-solid-timeline-run]') + .filter({ hasText: /^doubled/ }) + .getByRole('button', { name: 'View in graph' }) + .click(); + await expect(page.getByRole('tab', { name: 'Reactivity', exact: true })).toHaveAttribute( + 'aria-selected', + 'true', + ); + await expect(page.locator('[data-solid-reactivity-detail]')).toContainText('doubled'); +}); + +test('lists the server calls of an action and opens them', async ({ page }) => { + await page.goto('/'); + // The handler writes nothing, so the call is all the click did. + await page.locator('#call-server').click(); + + await openTab(page, 'Timeline'); + const row = page.locator('[data-solid-timeline-row]').filter({ hasText: 'button#call-server' }); + await expect(row).toContainText('1 call'); + await row.click(); + + const call = page.locator('[data-solid-timeline-call]'); + await expect(call).toContainText('loadUser'); + await expect(call).toContainText('200'); + await call.click(); + + await expect(page.getByRole('tab', { name: 'Server functions', exact: true })).toHaveAttribute( + 'aria-selected', + 'true', + ); + await expect(page.locator('[data-solid-function-instance-viewer]')).toContainText('loadUser'); +}); + test('copies reports for an agent', async ({ page, context }) => { await context.grantPermissions(['clipboard-read', 'clipboard-write']); const clipboard = () => page.evaluate(() => navigator.clipboard.readText()); diff --git a/tests/fixture/app.tsx b/tests/fixture/app.tsx index a1d86c7..cb4dd4d 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 { createEffect, createMemo, createSignal, Show } from 'solid-js'; +import { createEffect, createMemo, createSignal, OBSERVE, Show } from 'solid-js'; function Broken(): never { throw new Error('client boom'); @@ -32,6 +32,34 @@ function emitServerFunctionResponse() { responseStatus = 500; } +// A call record as the runtime sends it once a call settles. The handler +// writes nothing, so the call is all the click did. +function callServer() { + const origin = OBSERVE?.attribution.currentOrigin(); + const at = performance.now(); + const request = new Request('/_server', { method: 'POST', body: '[1]' }); + setTimeout(() => { + OBSERVE?.records.emit( + 'call', + { + id: 'load-user', + name: 'loadUser', + at, + durationMs: performance.now() - at, + method: 'POST', + outcome: 'ok', + status: 200, + origin, + }, + { + args: [1], + request, + response: new Response('{"id":1}', { headers: { 'content-type': 'application/json' } }), + } as never, + ); + }, 40); +} + // Reads a signal in its body, outside any tracking scope, which the runtime // reports as a warning. function DraftReader() { @@ -78,6 +106,9 @@ function App() { +