diff --git a/.changeset/async-panel.md b/.changeset/async-panel.md
new file mode 100644
index 0000000..babd29e
--- /dev/null
+++ b/.changeset/async-panel.md
@@ -0,0 +1,9 @@
+---
+'@solidjs/start-devtools': minor
+---
+
+Add an Async panel that shows what the app is loading now, and how its async loads, waits and fallbacks went.
+
+- Loads that a newer load replaced, waits with no pending state on screen, fallbacks that flashed, and loads that ran one after another are flagged.
+- A record shows the action that started it and links to the reactivity graph and the ownership tree.
+- The copy button puts a report of the record on the clipboard for an agent.
diff --git a/README.md b/README.md
index e562bd0..e08cc6e 100644
--- a/README.md
+++ b/README.md
@@ -7,6 +7,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.
+- What the app is loading now, and how its async loads, waits and fallbacks went.
- 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.
@@ -90,6 +91,25 @@ toolbar mounts, so the first action is recorded even with the dock closed. Click
toolbar itself change nothing in the app, so they are left out. The timeline needs a
development build of `solid-js`.
+## Async
+
+The async panel shows what the app waits on.
+
+- **Loading now** lists the async memos that are loading while the panel is open, with how
+ long each has been loading and how many nodes wait on them.
+- **Loads** are async memos that landed, or that a newer load replaced.
+- **Waits** are writes the runtime held back until the async work they depend on settled,
+ such as a click whose new screen needed data. A wait is flagged when the screen showed no
+ pending state, or when it took long.
+- **Fallbacks** are `` fallbacks that showed. One shorter than 150ms is flagged as a
+ flash.
+- **Chains** are loads that ran one after another, each starting only when the one before
+ it landed, which could often run at the same time.
+
+Select a record for its details, the action that started it, and links to the reactivity
+graph and the ownership tree. Records come from the attribution engine of `solid-js`, so a
+record shows up once it has finished. The panel 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/async/index.tsx b/src/dev-toolbar/async/index.tsx
new file mode 100644
index 0000000..00053f6
--- /dev/null
+++ b/src/dev-toolbar/async/index.tsx
@@ -0,0 +1,443 @@
+import type { JSX } from '@solidjs/web';
+import { createEffect, createMemo, createSignal, For, getOwner, onCleanup, Show } from 'solid-js';
+import { Badge } from '../../ui/Badge.js';
+import IconButton from '../../ui/IconButton.js';
+import Placeholder from '../../ui/Placeholder.js';
+import { Text } from '../../ui/Text.js';
+import { EyeIcon, TrashIcon, TreeIcon } from '../icons.js';
+import {
+ excludeReactiveOwner,
+ snapshotReactivityGraph,
+ startReactivityTracking,
+ subscribeReactivityGraph,
+ type ReactiveNode,
+} from '../reactivity/registry.js';
+import { asyncDuration, asyncOrigin, asyncReport, asyncTitle } from '../report/async-report.js';
+import { CopyReportButton } from '../report/CopyReportButton.js';
+import { pageRoute } from '../report/markdown.js';
+import { ms } from '../report/timeline-report.js';
+import {
+ concernOf,
+ isAsyncAvailable,
+ type AsyncHistory,
+ type AsyncRecord,
+ type AsyncRecordType,
+} from './tracker.js';
+import './styles.css';
+
+const TYPES: { value: AsyncRecordType; label: string }[] = [
+ { value: 'load', label: 'Loads' },
+ { value: 'wait', label: 'Waits' },
+ { value: 'fallback', label: 'Fallbacks' },
+ { value: 'chain', label: 'Chains' },
+];
+
+/** How often the elapsed time of what is loading now is redrawn. */
+const TICK_MS = 250;
+
+interface LoadingNow {
+ node: ReactiveNode;
+ /** When the panel first saw it pending, on the `performance.now()` clock. */
+ since: number;
+}
+
+export interface AsyncViewerProps {
+ show?: boolean;
+ history: AsyncHistory;
+ records: AsyncRecord[];
+ onClear: () => void;
+ onViewInGraph?: (node: object) => void;
+ onViewOwner?: (owner: object) => void;
+}
+
+export default function AsyncViewer(props: AsyncViewerProps): JSX.Element {
+ // The live list reads the graph, which must not list the panel's own nodes.
+ excludeReactiveOwner(getOwner());
+
+ const [hidden, setHidden] = createSignal([]);
+ const [selected, setSelected] = createSignal();
+ const [pending, setPending] = createSignal([]);
+ const [now, setNow] = createSignal(performance.now());
+ const since = new Map();
+
+ // What is loading now comes from the graph, so it is read only while the panel shows.
+ createEffect(
+ () => !!props.show,
+ (visible) => {
+ if (!visible) return;
+ const read = () => {
+ const nodes = snapshotReactivityGraph().nodes.filter(
+ (node) => node.pending && node.kind !== 'signal',
+ );
+ const ids = new Set(nodes.map((node) => node.id));
+ for (const id of since.keys()) if (!ids.has(id)) since.delete(id);
+ for (const node of nodes) if (!since.has(node.id)) since.set(node.id, performance.now());
+ setPending(nodes);
+ };
+ const stop = startReactivityTracking();
+ const unsubscribe = subscribeReactivityGraph(read);
+ const timer = setInterval(() => setNow(performance.now()), TICK_MS);
+ read();
+ return () => {
+ clearInterval(timer);
+ unsubscribe();
+ stop();
+ };
+ },
+ );
+
+ const loadingNow = createMemo<{ sources: LoadingNow[]; waiting: number }>(() => {
+ const sources: LoadingNow[] = [];
+ let waiting = 0;
+ for (const node of pending()) {
+ if (props.history.isSource(node.raw)) {
+ sources.push({ node, since: since.get(node.id) ?? now() });
+ } else {
+ waiting++;
+ }
+ }
+ return { sources, waiting };
+ });
+
+ const shown = createMemo(() => {
+ const off = hidden();
+ return props.records.filter((record) => !off.includes(record.type));
+ });
+
+ const selectedRecord = createMemo(() => {
+ const id = selected();
+ return id === undefined ? undefined : props.records.find((record) => record.id === id);
+ });
+
+ function toggleType(value: AsyncRecordType): void {
+ setHidden((current) =>
+ current.includes(value) ? current.filter((item) => item !== value) : [...current, value],
+ );
+ }
+
+ onCleanup(() => since.clear());
+
+ return (
+
+
+
+
+
+
+ {(type) => (
+ toggleType(type.value)}
+ >
+
+ {type.label}
+
+
+ )}
+
+
+
+
+ {`${shown().length} of ${props.records.length}`}
+
+ {
+ setSelected(undefined);
+ props.onClear();
+ }}
+ >
+
+
+
+
+
+
+
+
+ The async panel needs a development build of solid-js.
+
+
+ }
+ >
+
+
+ Loading now
+
+ {loadingNow().waiting
+ ? `${loadingNow().waiting} nodes are waiting on async work that has not loaded before.`
+ : 'Nothing is loading.'}
+
+ }
+ >
+
+ {(item) => (
+
+
+
+ {item.node.name}
+
+
+ {item.node.ownerPath.join(' › ')}
+
+
+ {ms(now() - item.since)}
+
+
+ props.onViewInGraph?.(item.node.raw)}
+ >
+
+
+
+
+ )}
+
+
+
+ {`${loadingNow().waiting} more nodes are waiting on these.`}
+
+
+
+
+
+
+
+
+ {props.records.length
+ ? 'Nothing matches this filter.'
+ : 'Async loads, waits and fallbacks show up here once they finish.'}
+
+
+ }
+ >
+
+ {(record) => (
+
+ setSelected((current) =>
+ current === record.id ? undefined : record.id,
+ )
+ }
+ >
+
+ {record.type}
+
+ {asyncTitle(record)}
+
+
+ {ms(asyncDuration(record))}
+
+
+
+ {(concern) => {concern()} }
+
+
+ )}
+
+
+
+
+
+
+ {(record) => {
+ const node = () => {
+ const current = record();
+ return current.type === 'load' || current.type === 'fallback'
+ ? current.node?.deref()
+ : undefined;
+ };
+ return (
+
+
+ {record().type}
+
+ {asyncTitle(record())}
+
+ asyncReport({ record: record(), route: pageRoute() })}
+ />
+
+
+ {(concern) => {concern()} }
+
+
+
+
+ Took
+
+
+ {ms(asyncDuration(record()))}
+
+
+
+ {(origin) => (
+
+
+ Started by
+
+
+ {origin()}
+
+
+ )}
+
+
+
+
+ {(current) => (
+
+
+ props.onViewInGraph?.(current())}
+ >
+
+
+ Show in the reactivity graph
+
+
+
+
+ props.onViewOwner?.(current())}
+ >
+
+
+ Show in the ownership tree
+
+
+
+
+ )}
+
+
+ );
+ }}
+
+
+
+
+
+
+ );
+}
+
+function Row(props: { label: string; children: JSX.Element }): JSX.Element {
+ return (
+
+
+ {props.label}
+
+
+ {props.children}
+
+
+ );
+}
+
+/** The fields a record has beyond its title and time. */
+function RecordRows(props: { record: AsyncRecord }): JSX.Element {
+ return (
+ <>
+ {(() => {
+ const record = props.record;
+ switch (record.type) {
+ case 'load':
+ return (
+ <>
+
+ {record.event.outcome === 'landed' ? 'landed' : 'replaced by a newer load'}
+
+
+ {record.event.ownerPath!.join(' › ')}
+
+ >
+ );
+ case 'wait':
+ return (
+ <>
+ {record.event.blockers.join(', ') || 'unknown'}
+
+ {record.event.heldWrites
+ .map((write) =>
+ write.value === undefined
+ ? write.name
+ : `${write.name} ${write.prev ?? ''} → ${write.value}`,
+ )
+ .join(', ') || 'none'}
+
+
+ {record.event.acknowledgements.length
+ ? record.event.acknowledgements
+ .map((ack) => `${ack.kind}(${ack.source})`)
+ .join(', ')
+ : 'none shown'}
+
+ >
+ );
+ case 'fallback':
+ return (
+
+ {record.event.ownerPath!.join(' › ')}
+
+ );
+ case 'chain':
+ return (
+
+ {record.event.chain.map((link) => `${link.name} ${ms(link.ms)}`).join(' → ')}
+
+ );
+ }
+ })()}
+ >
+ );
+}
diff --git a/src/dev-toolbar/async/styles.css b/src/dev-toolbar/async/styles.css
new file mode 100644
index 0000000..329319f
--- /dev/null
+++ b/src/dev-toolbar/async/styles.css
@@ -0,0 +1,364 @@
+[data-solid-async-viewer] {
+ --start-dt-async-load: oklch(0.68 0.13 245);
+ --start-dt-async-wait: oklch(0.8 0.15 80);
+ --start-dt-async-fallback: oklch(0.7 0.14 300);
+ --start-dt-async-chain: oklch(0.72 0.15 20);
+
+ color: var(--start-dt-text);
+
+ display: flex;
+ flex-direction: column;
+
+ height: 100%;
+ min-height: 0;
+}
+
+[data-solid-async-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-async-filters] {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+
+ gap: 0.25rem;
+}
+
+[data-solid-async-filter] {
+ display: flex;
+ align-items: center;
+
+ gap: 0.25rem;
+
+ padding: 0.1875rem 0.5rem;
+
+ border-radius: 9999px;
+ border: var(--start-dt-border) 1px solid;
+ background: var(--start-dt-surface);
+ color: var(--start-dt-text);
+
+ cursor: pointer;
+}
+
+[data-solid-async-filter]::before {
+ content: '';
+
+ width: 0.5rem;
+ height: 0.5rem;
+
+ border-radius: 9999px;
+ background: var(--start-dt-async-load);
+}
+
+[data-solid-async-filter='wait']::before {
+ background: var(--start-dt-async-wait);
+}
+
+[data-solid-async-filter='fallback']::before {
+ background: var(--start-dt-async-fallback);
+}
+
+[data-solid-async-filter='chain']::before {
+ background: var(--start-dt-async-chain);
+}
+
+[data-solid-async-filter]:hover {
+ background: var(--start-dt-surface-hover);
+}
+
+[data-solid-async-filter][data-off] {
+ color: var(--start-dt-text-muted);
+ opacity: 0.55;
+}
+
+[data-solid-async-nav-actions] {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+
+ gap: 0.375rem;
+
+ margin-left: auto;
+}
+
+[data-solid-async-count],
+[data-solid-async-muted],
+[data-solid-async-note],
+[data-solid-async-elapsed] {
+ color: var(--start-dt-text-muted);
+}
+
+[data-solid-async-body] {
+ display: flex;
+ flex-direction: row;
+
+ flex: 1;
+ min-height: 0;
+}
+
+[data-solid-async-main] {
+ display: flex;
+ flex-direction: column;
+
+ flex: 1;
+ min-width: 0;
+}
+
+[data-solid-async-now] {
+ display: flex;
+ flex-direction: column;
+
+ gap: 0.25rem;
+
+ padding: 0.5rem 0.75rem;
+
+ border-bottom: var(--start-dt-border-soft) 1px solid;
+}
+
+[data-solid-async-now-row] {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+
+ gap: 0.5rem;
+
+ min-width: 0;
+}
+
+[data-solid-async-now-row] [data-solid-async-muted] {
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+[data-solid-async-now-row] [data-solid-async-elapsed],
+[data-solid-async-row-head] [data-solid-async-elapsed] {
+ margin-left: auto;
+}
+
+[data-solid-async-spinner] {
+ width: 0.625rem;
+ height: 0.625rem;
+ flex-shrink: 0;
+
+ border-radius: 9999px;
+ border: 2px solid var(--start-dt-async-load);
+ border-right-color: transparent;
+
+ animation: solid-async-spin 0.8s linear infinite;
+}
+
+@keyframes solid-async-spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ [data-solid-async-spinner] {
+ animation: none;
+ }
+}
+
+[data-solid-async-icon] {
+ 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-async-icon]:hover {
+ background: var(--start-dt-accent-soft);
+}
+
+[data-solid-async-icon] svg {
+ width: 0.875rem;
+ height: 0.875rem;
+}
+
+[data-solid-async-list] {
+ display: flex;
+ flex-direction: column;
+
+ flex: 1;
+ min-height: 0;
+
+ overflow: auto;
+}
+
+[data-solid-async-row] {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+
+ 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-async-row]:hover {
+ background: var(--start-dt-surface-hover);
+}
+
+[data-solid-async-row][data-selected] {
+ background: var(--start-dt-surface-active);
+}
+
+[data-solid-async-row]:focus-visible {
+ outline: 2px solid var(--start-dt-accent);
+ outline-offset: -2px;
+}
+
+[data-solid-async-row-head] {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+
+ gap: 0.5rem;
+
+ width: 100%;
+ min-width: 0;
+}
+
+[data-solid-async-row-title] {
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+[data-solid-async-type] {
+ flex-shrink: 0;
+
+ padding: 0 0.375rem;
+
+ border-radius: 9999px;
+ background: oklch(0.68 0.13 245 / 0.18);
+ color: var(--start-dt-async-load);
+
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+ font-size: 0.6875rem;
+ line-height: 1.125rem;
+}
+
+[data-solid-async-type='wait'] {
+ background: oklch(0.8 0.15 80 / 0.18);
+ color: var(--start-dt-async-wait);
+}
+
+[data-solid-async-type='fallback'] {
+ background: oklch(0.7 0.14 300 / 0.18);
+ color: var(--start-dt-async-fallback);
+}
+
+[data-solid-async-type='chain'] {
+ background: oklch(0.72 0.15 20 / 0.18);
+ color: var(--start-dt-async-chain);
+}
+
+[data-solid-async-detail] {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+
+ gap: 0.625rem;
+
+ width: 24rem;
+ 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-async-detail] > * {
+ flex-shrink: 0;
+}
+
+[data-solid-async-detail] > [data-solid-kv-table] {
+ align-self: stretch;
+}
+
+[data-solid-async-detail] [data-solid-kv-key] {
+ flex-basis: 5.5rem;
+}
+
+[data-solid-async-detail-head] {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+
+ gap: 0.375rem;
+
+ align-self: stretch;
+ min-width: 0;
+}
+
+[data-solid-async-detail-head] [data-solid-copy-report] {
+ margin-left: auto;
+ padding: 0.25rem;
+ border-radius: 0.25rem;
+}
+
+[data-solid-async-detail-head] [data-solid-copy-report] > svg {
+ width: 0.875rem;
+ height: 0.875rem;
+}
+
+[data-solid-async-actions] {
+ display: flex;
+ flex-direction: row;
+ flex-wrap: wrap;
+
+ gap: 0.375rem;
+}
+
+[data-solid-async-action] {
+ display: inline-flex;
+ align-items: center;
+
+ gap: 0.375rem;
+
+ padding: 0.25rem 0.5rem;
+
+ border-radius: 0.375rem;
+ border: var(--start-dt-border) 1px solid;
+ background: var(--start-dt-surface);
+ color: var(--start-dt-accent);
+
+ cursor: pointer;
+}
+
+[data-solid-async-action]:hover {
+ background: var(--start-dt-accent-soft);
+}
+
+[data-solid-async-action] > svg {
+ width: 0.875rem;
+ height: 0.875rem;
+}
diff --git a/src/dev-toolbar/async/tracker.test.ts b/src/dev-toolbar/async/tracker.test.ts
new file mode 100644
index 0000000..61e71af
--- /dev/null
+++ b/src/dev-toolbar/async/tracker.test.ts
@@ -0,0 +1,146 @@
+import { describe, expect, it } from 'vitest';
+import type { ChangeOrigin, FlightEvent, HoldEvent, WaterfallRecord } from 'solid-js/attribution';
+import { asyncReport, asyncTitle } from '../report/async-report.js';
+import { AsyncHistory, concernOf } from './tracker.js';
+
+const click: ChangeOrigin = {
+ kind: 'interaction',
+ name: 'click',
+ target: 'button#next "next"',
+ at: 1,
+};
+
+function flight(over: Partial = {}): FlightEvent {
+ return {
+ nodeId: 2,
+ nodeName: 'user',
+ ownerPath: ['', '', 'computed', '', 'user'],
+ at: 10,
+ durationMs: 120,
+ outcome: 'landed',
+ ...over,
+ };
+}
+
+function hold(over: Partial = {}): HoldEvent {
+ return {
+ at: 5,
+ holdMs: 250,
+ tailMs: 240,
+ flushes: 3,
+ heldWrites: [{ name: 'userId', prev: '1', value: '2', origin: click }],
+ blockers: ['user', 'posts'],
+ acknowledgements: [],
+ paintedDuringHold: 0,
+ action: false,
+ silent: true,
+ long: false,
+ interaction: click,
+ ...over,
+ } as HoldEvent;
+}
+
+describe('AsyncHistory', () => {
+ it('keeps records newest first and remembers which nodes loaded', () => {
+ const history = new AsyncHistory();
+ const node = {};
+ history.flight(flight(), node);
+ history.hold(hold());
+
+ expect(history.records.map((record) => record.type)).toEqual(['wait', 'load']);
+ expect(history.isSource(node)).toBe(true);
+ expect(history.isSource({})).toBe(false);
+ });
+
+ it('drops the toolbar owners from paths', () => {
+ const history = new AsyncHistory();
+ history.flight(flight(), undefined);
+ const [record] = history.records;
+ expect(record!.type === 'load' && record!.event.ownerPath).toEqual(['', 'user']);
+ });
+
+ it('adds each chain once', () => {
+ const history = new AsyncHistory();
+ const chain: WaterfallRecord = {
+ chain: [
+ { name: 'user', ms: 120 },
+ { name: 'posts', ms: 121 },
+ ],
+ sequentialMs: 241,
+ };
+ expect(history.chains([chain], 1)).toBe(true);
+ expect(history.chains([chain], 2)).toBe(false);
+ expect(history.records).toHaveLength(1);
+ });
+
+ it('keeps the newest records up to the limit', () => {
+ const history = new AsyncHistory(2);
+ for (const name of ['a', 'b', 'c']) history.flight(flight({ nodeName: name }), undefined);
+ expect(history.records.map((record) => asyncTitle(record))).toEqual(['load c', 'load b']);
+ });
+});
+
+describe('concernOf', () => {
+ it('flags replaced loads, silent and long waits, flashes and chains', () => {
+ const history = new AsyncHistory();
+ history.flight(flight({ outcome: 'abandoned' }), undefined);
+ history.hold(hold());
+ history.hold(hold({ silent: false, long: true }));
+ history.hold(hold({ silent: false }));
+ history.fallback({ at: 1, shownMs: 90 }, undefined);
+ history.fallback({ at: 1, shownMs: 400 }, undefined);
+ history.chains([{ chain: [], sequentialMs: 0 }], 1);
+
+ expect(history.records.map(concernOf).reverse()).toEqual([
+ 'replaced by a newer load',
+ 'no pending state shown',
+ 'long wait',
+ undefined,
+ 'flash',
+ undefined,
+ 'loads ran one after another',
+ ]);
+ });
+});
+
+describe('asyncReport', () => {
+ it('describes a wait with what it held back and what the screen showed', () => {
+ const history = new AsyncHistory();
+ history.hold(hold());
+ const report = asyncReport({ record: history.records[0]!, route: '/' });
+
+ expect(report).toContain('# Solid async: `wait on user, posts`');
+ expect(report).toContain('- Took: 250.0ms');
+ expect(report).toContain('- Started by: `click on button#next "next"`');
+ expect(report).toContain('- Concern: no pending state shown');
+ expect(report).toContain(' - `userId` 1 → 2');
+ expect(report).toContain('- Pending state shown by: nothing');
+ });
+
+ it('lists the loads of a chain in order', () => {
+ const history = new AsyncHistory();
+ history.chains(
+ [
+ {
+ chain: [
+ { name: 'user', ms: 120 },
+ { name: 'posts', ms: 121 },
+ ],
+ sequentialMs: 241,
+ },
+ ],
+ 1,
+ );
+ const report = asyncReport({ record: history.records[0]!, route: '/' });
+ expect(report).toContain('# Solid async: `chain user → posts`');
+ expect(report).toContain(' - `user` 120.0ms\n - `posts` 121.0ms');
+ });
+
+ it('hides held values with sensitive names', () => {
+ const history = new AsyncHistory();
+ history.hold(hold({ heldWrites: [{ name: 'password', prev: 'a', value: 'b' }] }));
+ const report = asyncReport({ record: history.records[0]!, route: '/' });
+ expect(report).toContain('`password` [redacted]');
+ expect(report).not.toContain('a → b');
+ });
+});
diff --git a/src/dev-toolbar/async/tracker.ts b/src/dev-toolbar/async/tracker.ts
new file mode 100644
index 0000000..251f442
--- /dev/null
+++ b/src/dev-toolbar/async/tracker.ts
@@ -0,0 +1,182 @@
+import { OBSERVE } from 'solid-js';
+import {
+ attribution,
+ type FallbackEvent,
+ type FlightEvent,
+ type HoldEvent,
+ type WaterfallRecord,
+} from 'solid-js/attribution';
+import { appOwnerPath } from '../issues/tracker.js';
+
+/** A fallback shown for less than this reads as a flash, as the runtime's `FALLBACK_FLASH` finding counts it. */
+export const FLASH_MS = 150;
+
+/** Most records the panel keeps. The oldest go first. */
+export const MAX_RECORDS = 200;
+
+/** One thing that happened while the app waited on async work. */
+export type AsyncRecord =
+ | {
+ /** An async computation loaded, or was replaced by a newer load. */
+ type: 'load';
+ id: number;
+ at: number;
+ event: FlightEvent;
+ node: WeakRef | undefined;
+ }
+ | {
+ /** Writes were held back until async work they depend on settled. */
+ type: 'wait';
+ id: number;
+ at: number;
+ event: HoldEvent;
+ }
+ | {
+ /** A `` boundary showed its fallback. */
+ type: 'fallback';
+ id: number;
+ at: number;
+ event: FallbackEvent;
+ node: WeakRef | undefined;
+ }
+ | {
+ /** Loads that ran one after another, each starting when the one before landed. */
+ type: 'chain';
+ id: number;
+ at: number;
+ event: WaterfallRecord;
+ };
+
+export type AsyncRecordType = AsyncRecord['type'];
+
+/** What makes a record worth a look, if anything. */
+export function concernOf(record: AsyncRecord): string | undefined {
+ switch (record.type) {
+ case 'load':
+ return record.event.outcome === 'abandoned' ? 'replaced by a newer load' : undefined;
+ case 'wait':
+ if (record.event.silent) return 'no pending state shown';
+ if (record.event.long) return 'long wait';
+ return undefined;
+ case 'fallback':
+ return record.event.shownMs < FLASH_MS ? 'flash' : undefined;
+ case 'chain':
+ return 'loads ran one after another';
+ }
+}
+
+/** Collects the async records, newest first. */
+export class AsyncHistory {
+ records: AsyncRecord[] = [];
+ private nextId = 1;
+ /** The async nodes that loaded at least once, so the live list can tell sources from waiters. */
+ private sources = new WeakSet();
+ private seenChains = new WeakSet();
+
+ constructor(private readonly max = MAX_RECORDS) {}
+
+ private add(record: AsyncRecord): void {
+ this.records = [record, ...this.records].slice(0, this.max);
+ }
+
+ flight(event: FlightEvent, node: object | undefined): void {
+ if (node) this.sources.add(node);
+ this.add({
+ type: 'load',
+ id: this.nextId++,
+ at: event.at,
+ event: { ...event, ownerPath: appOwnerPath(event.ownerPath) },
+ node: node ? new WeakRef(node) : undefined,
+ });
+ }
+
+ hold(event: HoldEvent): void {
+ const acknowledgements = event.acknowledgements.map((ack) => ({
+ ...ack,
+ reader: appOwnerPath(ack.reader),
+ }));
+ this.add({
+ type: 'wait',
+ id: this.nextId++,
+ at: event.at,
+ event: { ...event, acknowledgements },
+ });
+ }
+
+ fallback(event: FallbackEvent, node: object | undefined): void {
+ this.add({
+ type: 'fallback',
+ id: this.nextId++,
+ at: event.at,
+ event: { ...event, ownerPath: appOwnerPath(event.ownerPath) },
+ node: node ? new WeakRef(node) : undefined,
+ });
+ }
+
+ /** Adds the chains the runtime found that this history has not seen yet. */
+ chains(found: readonly WaterfallRecord[], at: number): boolean {
+ let added = false;
+ for (const chain of found) {
+ if (this.seenChains.has(chain)) continue;
+ this.seenChains.add(chain);
+ this.add({ type: 'chain', id: this.nextId++, at, event: chain });
+ added = true;
+ }
+ return added;
+ }
+
+ isSource(node: object): boolean {
+ return this.sources.has(node);
+ }
+
+ clear(): void {
+ this.records = [];
+ }
+}
+
+/** True when the runtime reports async work, which is in dev and observe builds. */
+export function isAsyncAvailable(): boolean {
+ return !!OBSERVE?.records && typeof attribution?.enable === 'function';
+}
+
+/**
+ * Feeds the runtime's async records to a history. The attribution engine
+ * makes them, so this takes a hold on it. The records arrive inside the
+ * runtime's own work, so the panel hears about them on a microtask. Returns
+ * the stop.
+ */
+export function startAsyncCapture(history: AsyncHistory, onChange: () => void): () => void {
+ if (!isAsyncAvailable()) return () => {};
+ const records = OBSERVE!.records;
+ let queued = false;
+ const changed = () => {
+ if (queued) return;
+ queued = true;
+ queueMicrotask(() => {
+ queued = false;
+ // The runtime stores a chain after it sends the record of the load that ended it,
+ // and only its history buffer holds chains, so the buffer is read here.
+ history.chains(attribution.history('waterfall'), performance.now());
+ onChange();
+ });
+ };
+ const offs = [
+ records.subscribe('flight', (event, node) => {
+ history.flight(event, node as object | undefined);
+ changed();
+ }),
+ records.subscribe('hold', (event) => {
+ history.hold(event);
+ changed();
+ }),
+ records.subscribe('fallback', (event, node) => {
+ history.fallback(event, node as object | undefined);
+ changed();
+ }),
+ ];
+ const release = attribution.enable({ log: false, values: 'full' });
+ return () => {
+ release();
+ for (const off of offs) off();
+ };
+}
diff --git a/src/dev-toolbar/icons.tsx b/src/dev-toolbar/icons.tsx
index 76b1a77..c7be788 100644
--- a/src/dev-toolbar/icons.tsx
+++ b/src/dev-toolbar/icons.tsx
@@ -759,3 +759,22 @@ export function TimelineIcon(props: JSX.IntrinsicElements['svg'] & { title: stri
);
}
+
+export function AsyncIcon(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 bfe9d41..18454c6 100644
--- a/src/dev-toolbar/index.tsx
+++ b/src/dev-toolbar/index.tsx
@@ -18,6 +18,7 @@ import IconButton from '../ui/IconButton.js';
import { type ServerFunctionInstance, ServerFunctionViewer } from './functions/index.js';
import { captureServerFunctionCall } from './functions/tracker.js';
import {
+ AsyncIcon,
ErrorIcon,
FunctionIcon,
GraphIcon,
@@ -27,6 +28,7 @@ import {
TimelineIcon,
TreeIcon,
} from './icons.js';
+import { AsyncHistory, startAsyncCapture, type AsyncRecord } from './async/tracker.js';
import { addIssues, captureIssues, type IssueEntry } from './issues/tracker.js';
import { excludeOwner, includeOwner } from './ownership/registry.js';
import { excludeReactiveOwner, includeReactiveOwner } from './reactivity/registry.js';
@@ -38,13 +40,15 @@ const ReactivityViewer = clientOnly(() => import('./reactivity/index.js'), { laz
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 });
+const AsyncViewer = clientOnly(() => import('./async/index.js'), { lazy: true });
-type Tab = 'err' | 'issues' | 'tl' | 'fn' | 'rx' | 'own';
+type Tab = 'err' | 'issues' | 'tl' | 'async' | '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: 'async', label: 'Async', icon: AsyncIcon },
{ value: 'fn', label: 'Server functions', icon: FunctionIcon },
{ value: 'rx', label: 'Reactivity', icon: GraphIcon },
{ value: 'own', label: 'Ownership', icon: TreeIcon },
@@ -335,6 +339,13 @@ export function DevToolbar(props: DevToolbarProps) {
onCleanup(startTimeline(timeline, () => setTimelineEntries(timeline.snapshot())));
}
+ // Loads that finish before the toolbar settles are kept too.
+ const asyncHistory = new AsyncHistory();
+ const [asyncRecords, setAsyncRecords] = createSignal([]);
+ if (!isServer) {
+ onCleanup(startAsyncCapture(asyncHistory, () => setAsyncRecords(asyncHistory.records)));
+ }
+
const [instances, setInstances] = createSignal<
Record
>({});
@@ -515,6 +526,23 @@ export function DevToolbar(props: DevToolbarProps) {
show('rx');
}}
/>
+ {
+ asyncHistory.clear();
+ setAsyncRecords([]);
+ }}
+ onViewInGraph={(node) => {
+ setGraphFocus({ node });
+ show('rx');
+ }}
+ onViewOwner={(owner) => {
+ setOwnershipFocus({ owner });
+ show('own');
+ }}
+ />
link.name).join(' → ')}`;
+ }
+}
+
+/** How long a record took, as the panel lists it. */
+export function asyncDuration(record: AsyncRecord): number {
+ switch (record.type) {
+ case 'load':
+ return record.event.durationMs;
+ case 'wait':
+ return record.event.holdMs;
+ case 'fallback':
+ return record.event.shownMs;
+ case 'chain':
+ return record.event.sequentialMs;
+ }
+}
+
+function details(record: AsyncRecord): string[] {
+ const out: string[] = [];
+ switch (record.type) {
+ case 'load': {
+ const { event } = record;
+ out.push(`- Outcome: ${event.outcome === 'landed' ? 'landed' : 'replaced by a newer load'}`);
+ if (event.ownerPath?.length) out.push(`- Owner path: ${code(event.ownerPath.join(' > '))}`);
+ break;
+ }
+ case 'wait': {
+ const { event } = record;
+ out.push(`- Waited on: ${event.blockers.map(code).join(', ') || 'unknown'}`);
+ out.push(`- Quiet tail: ${ms(event.tailMs)} after the last write that joined`);
+ if (event.heldWrites.length) {
+ out.push('- Held writes:');
+ for (const write of event.heldWrites) {
+ const values = isSensitiveName(write.name)
+ ? ` ${REDACTED}`
+ : write.value === undefined
+ ? ''
+ : ` ${write.prev === undefined ? '' : `${redactText(write.prev)} → `}${redactText(write.value)}`;
+ out.push(` - ${code(write.name)}${values}`);
+ }
+ }
+ if (event.acknowledgements.length) {
+ out.push('- Pending state shown by:');
+ for (const ack of event.acknowledgements) {
+ const reader = ack.reader?.length ? ` in ${code(ack.reader.join(' > '))}` : '';
+ out.push(` - ${ack.kind} of ${code(ack.source)}${reader}`);
+ }
+ } else {
+ out.push('- Pending state shown by: nothing, the screen did not change while it waited');
+ }
+ break;
+ }
+ case 'fallback': {
+ const { event } = record;
+ if (event.ownerPath?.length) out.push(`- Boundary: ${code(event.ownerPath.join(' > '))}`);
+ break;
+ }
+ case 'chain': {
+ out.push('- Loads, in order:');
+ for (const link of record.event.chain) out.push(` - ${code(link.name)} ${ms(link.ms)}`);
+ out.push('- Each load started only after the one before it landed.');
+ break;
+ }
+ }
+ return out;
+}
+
+/** The action a record traces back to, if the runtime knows it. */
+export function asyncOrigin(record: AsyncRecord): string | undefined {
+ const origin = record.type === 'chain' ? undefined : record.event.interaction;
+ return origin ? formatOrigin(origin) : undefined;
+}
+
+/** A Markdown report of one async record, for an agent to read. */
+export function asyncReport(input: AsyncReportInput): string {
+ const { record } = input;
+ const out: string[] = [`# Solid async: ${code(asyncTitle(record))}`, ''];
+ out.push(`- Page: ${code(redactUrl(input.route))}`);
+ out.push(`- Took: ${ms(asyncDuration(record))}`);
+ const origin = asyncOrigin(record);
+ if (origin) out.push(`- Started by: ${code(origin)}`);
+ const concern = concernOf(record);
+ if (concern) out.push(`- Concern: ${concern}`);
+ out.push(...details(record));
+ return out.join('\n');
+}
diff --git a/tests/e2e/devtools.spec.ts b/tests/e2e/devtools.spec.ts
index b40a7d5..c80b28d 100644
--- a/tests/e2e/devtools.spec.ts
+++ b/tests/e2e/devtools.spec.ts
@@ -332,7 +332,7 @@ test('maps the ownership tree', async ({ page }) => {
const rows = page.locator('[data-solid-ownership-name]');
await openTab(page, 'Ownership');
- await expect(rows).toHaveText(['', '', '', '', '']);
+ await expect(rows).toHaveText(['', '', '', '', '', '']);
// The expand arrow keeps its full size inside the button.
const arrow = await page
@@ -556,6 +556,46 @@ test('lists the server calls of an action and opens them', async ({ page }) => {
await expect(page.locator('[data-solid-function-instance-viewer]')).toContainText('loadUser');
});
+test('shows async loads, waits, fallbacks and chains', async ({ page }) => {
+ await page.goto('/');
+ await openTab(page, 'Async');
+ await expect(page.locator('[data-solid-async-now]')).toContainText('Nothing is loading.');
+
+ await page.locator('#show-async').click();
+ await expect(page.locator('#async-posts')).toHaveText('post of user 1');
+
+ // While the next user loads, the panel lists what is loading.
+ await page.locator('#next-user').click();
+ await expect(page.locator('[data-solid-async-now]')).toContainText('user');
+ await expect(page.locator('#async-posts')).toHaveText('post of user 2');
+ await expect(page.locator('[data-solid-async-now]')).toContainText('Nothing is loading.');
+
+ const rows = page.locator('[data-solid-async-row]');
+ await expect(rows.filter({ hasText: 'load user' }).first()).toBeVisible();
+ await expect(rows.filter({ hasText: 'fallback at ' }).first()).toBeVisible();
+ // posts starts only once user has landed.
+ await expect(rows.filter({ hasText: 'chain user → posts' }).first()).toContainText(
+ 'loads ran one after another',
+ );
+
+ // The click held the write until both loads landed, and nothing on screen said so.
+ const wait = rows.filter({ hasText: 'wait on user, posts' }).first();
+ await expect(wait).toContainText('no pending state shown');
+ await wait.click();
+ const detail = page.locator('[data-solid-async-detail]');
+ await expect(detail).toContainText('click on button#next-user');
+ await expect(detail).toContainText('userId 1 → 2');
+
+ // The filter hides a kind of record.
+ await page.locator('[data-solid-async-filter="chain"]').click();
+ await expect(rows.filter({ hasText: 'chain user → posts' })).toHaveCount(0);
+
+ // A load links to its node in the reactivity graph.
+ await rows.filter({ hasText: 'load user' }).first().click();
+ await detail.getByRole('button', { name: 'Show in the reactivity graph' }).click();
+ await expect(page.locator('[data-solid-reactivity-detail]')).toContainText('user');
+});
+
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 cb4dd4d..c4b8c10 100644
--- a/tests/fixture/app.tsx
+++ b/tests/fixture/app.tsx
@@ -1,4 +1,4 @@
-import { render } from '@solidjs/web';
+import { Loading, render } from '@solidjs/web';
import { DevToolbar, mountDevToolbar, pushServerFunctionCall } from '@solidjs/start-devtools';
import { createEffect, createMemo, createSignal, OBSERVE, Show } from 'solid-js';
@@ -68,6 +68,40 @@ function DraftReader() {
return {text}
;
}
+const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
+
+// Two loads where the second waits for the first, inside a Loading boundary.
+function AsyncProfile() {
+ const [id, setId] = createSignal(1, { name: 'userId' });
+ const user = createMemo(
+ async () => {
+ const value = id();
+ await wait(300);
+ return { id: value, name: `user ${value}` };
+ },
+ { name: 'user' },
+ );
+ const posts = createMemo(
+ async () => {
+ const current = user();
+ await wait(300);
+ return [`post of ${current.name}`];
+ },
+ { name: 'posts' },
+ );
+ return (
+ <>
+ setId((value) => value + 1)}>
+ next user
+
+ loading
}>
+ {user().name}
+ {posts()[0]}
+
+ >
+ );
+}
+
function Greeting(props: { name: string }) {
return {`hello ${props.name}`}
;
}
@@ -94,6 +128,7 @@ function Counter() {
function App() {
const [broken, setBroken] = createSignal(false);
const [writer, setWriter] = createSignal(false);
+ const [profile, setProfile] = createSignal(false);
return (
@@ -115,6 +150,12 @@ function App() {
+ setProfile(true)}>
+ show async
+
+
+
+
setBroken(true)}>
throw client error