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) => ( + + )} + +
+
+ + {`${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) => ( +
+
+ )} +
+ + + {`${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) => ( + + )} + + +
+
+ + + {(record) => { + const node = () => { + const current = record(); + return current.type === 'load' || current.type === 'fallback' + ? current.node?.deref() + : undefined; + }; + return ( + + ); + }} + +
+
+
+
+
+ ); +} + +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 ( + <> + + 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() { + + + +