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 (
+
+
+
+
+
+ {props.run.event.nodeName}
+
+
+ no change
+
+
+ {props.run.event.phase}
+
+
+ {props.run.effectMs === undefined
+ ? ms(props.run.event.selfMs)
+ : `${ms(props.run.event.selfMs)} + ${ms(props.run.effectMs)}`}
+
+
+
+ {(current) => (
+ props.onViewInGraph?.(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) => (
+ props.onViewCall?.(current())}
+ >
+ {content}
+
+ )}
+
+ );
+}
+
+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)}
+ />
+
+ setShowUnnamed((current) => !current)}
+ >
+
+ {`${showUnnamed() ? 'Hide' : 'Show'} ${plural(unnamedCount(), 'unnamed update')}`}
+
+
+
+
+
+ {`${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) => (
+ select(entry.id)}
+ >
+
+ {outcomeBadge(entry)}
+
+ {entry.label}
+
+
+
+ {[
+ plural(entry.writes.length, 'write'),
+ plural(runCount(entry), 're-run'),
+ ...(entry.calls.length ? [plural(entry.calls.length, 'call')] : []),
+ ms(duration(entry)),
+ ].join(' · ')}
+
+
+ )}
+
+
+
+
+
+ {(entry) => (
+
+
+ {outcomeBadge(entry())}
+
+ {entry().label}
+
+ timelineReport({ entry: entry(), route: pageRoute() })}
+ />
+
+
+
+ {(interaction) => (
+
+
+
+
+ Input delay
+
+
+ {ms(interaction().inputDelayMs)}
+
+
+
+
+
+ Handler
+
+
+ {ms(interaction().handlerMs)}
+
+
+
+
+ Re-runs
+
+
+ {`${interaction().runs}, ${ms(interaction().runMs)}`}
+
+
+
+
+
+ Settled
+
+
+ {`${ms(interaction().settledMs)} after the event`}
+
+
+
+
+ )}
+
+
+
+
+
+ {`Writes (${entry().writes.length})`}
+
+
+ {(write) => (
+
+
+
+ )}
+
+
+
+
+
+
+ {`Re-runs (${entry().runs.length + entry().droppedRuns})`}
+
+ Nothing re-ran.}
+ >
+ {(run) => (
+ toggleRun(run.event.run)}
+ onViewInGraph={props.onViewInGraph}
+ />
+ )}
+
+
+
+ {`${entry().droppedRuns} more re-runs are not listed.`}
+
+
+
+ Open a re-run to see the changes that caused it.
+
+
+
+
+
+
+ {`Server calls (${entry().calls.length})`}
+
+
+ {(call) => }
+
+
+
+
+
+
+
+ {`Holds (${entry().holds.length})`}
+
+
+ {(hold) => (
+
+ {`${ms(hold.holdMs)}${hold.blockers.length ? `, waiting on ${hold.blockers.join(', ')}` : ''}`}
+
+ )}
+
+
+
+
+ )}
+
+
+
+
+
+
+ );
+}
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() {
emit server function response
+
+ call server
+
setWriter((value) => !value)}>
emit warning