Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/timeline-panel.md
Original file line number Diff line number Diff line change
@@ -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.
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 `<Show>` 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,
Expand Down
14 changes: 13 additions & 1 deletion src/dev-toolbar/functions/index.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -345,13 +345,25 @@ function EmptyServerFunctions(): JSX.Element {

export interface ServerFunctionViewerProps {
instances: Record<string, ServerFunctionInstance | undefined>;
/**
* 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;
}

export function ServerFunctionViewer(props: ServerFunctionViewerProps): JSX.Element {
const [currentInstance, setCurrentInstance] = createSignal<string>();

createEffect(
() => props.focus,
(request) => {
if (request) setCurrentInstance(request.instance);
},
);

const keys = createMemo(() => Object.keys(props.instances));

return (
Expand Down
24 changes: 18 additions & 6 deletions src/dev-toolbar/functions/records.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<object, string>();

/** 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
Expand Down Expand Up @@ -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 },
Expand Down
19 changes: 19 additions & 0 deletions src/dev-toolbar/icons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -740,3 +740,22 @@ export function IssueIcon(props: JSX.IntrinsicElements['svg'] & { title: string
</svg>
);
}

export function TimelineIcon(props: JSX.IntrinsicElements['svg'] & { title: string }): JSX.Element {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
{...props}
>
<title>{props.title}</title>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
/>
</svg>
);
}
37 changes: 34 additions & 3 deletions src/dev-toolbar/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down Expand Up @@ -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<unknown[]>([]);

Expand Down Expand Up @@ -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<TimelineEntry[]>([]);
if (!isServer) {
onCleanup(startTimeline(timeline, () => setTimelineEntries(timeline.snapshot())));
}

const [instances, setInstances] = createSignal<
Record<string, ServerFunctionInstance | undefined>
>({});
Expand Down Expand Up @@ -485,6 +499,22 @@ export function DevToolbar(props: DevToolbarProps) {
show('rx');
}}
/>
<TimelineViewer
show={visible('tl')}
entries={timelineEntries()}
onClear={() => {
timeline.clear();
setTimelineEntries([]);
}}
onViewCall={(instance) => {
setFunctionsFocus({ instance });
show('fn');
}}
onViewInGraph={(node) => {
setGraphFocus({ node });
show('rx');
}}
/>
<ReactivityViewer
show={visible('rx')}
focus={graphFocus()}
Expand All @@ -503,6 +533,7 @@ export function DevToolbar(props: DevToolbarProps) {
/>
<ServerFunctionViewer
show={visible('fn')}
focus={functionsFocus()}
instances={instances()}
onDeleteInstance={(value) => {
setInstances((current) => {
Expand Down
134 changes: 134 additions & 0 deletions src/dev-toolbar/report/timeline-report.ts
Original file line number Diff line number Diff line change
@@ -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');
}
Loading
Loading