From 10999d3a8120daae10f85191f4a026788e37ee47 Mon Sep 17 00:00:00 2001 From: "Alexis H. Munsayac" Date: Sat, 24 Oct 2026 21:55:42 +0800 Subject: [PATCH] feat: add an Async panel of loads, waits, fallbacks and chains - An Async tab shows the async memos that are loading now, with how long each has been loading and how many nodes wait on them. The list comes from the reactivity graph while the panel is open. - Its history lists loads, waits, fallbacks and chains of loads that ran one after another, from the attribution engine's flight, hold and fallback records and its waterfall history. - Loads replaced by a newer load, waits with no pending state on screen or a long tail, fallbacks shown for less than 150ms, and chains are flagged. - A record shows the action that started it, links to the reactivity graph and the ownership tree, and has a copy button for an agent report. - Owner paths drop the toolbar's own owners, as the Issues panel does. - The test fixture has a profile with two chained loads inside a Loading boundary. Co-Authored-By: Claude Opus 5.5 --- .changeset/async-panel.md | 9 + README.md | 20 ++ src/dev-toolbar/async/index.tsx | 443 +++++++++++++++++++++++++ src/dev-toolbar/async/styles.css | 364 ++++++++++++++++++++ src/dev-toolbar/async/tracker.test.ts | 146 ++++++++ src/dev-toolbar/async/tracker.ts | 182 ++++++++++ src/dev-toolbar/icons.tsx | 19 ++ src/dev-toolbar/index.tsx | 30 +- src/dev-toolbar/report/async-report.ts | 109 ++++++ tests/e2e/devtools.spec.ts | 42 ++- tests/fixture/app.tsx | 43 ++- 11 files changed, 1404 insertions(+), 3 deletions(-) create mode 100644 .changeset/async-panel.md create mode 100644 src/dev-toolbar/async/index.tsx create mode 100644 src/dev-toolbar/async/styles.css create mode 100644 src/dev-toolbar/async/tracker.test.ts create mode 100644 src/dev-toolbar/async/tracker.ts create mode 100644 src/dev-toolbar/report/async-report.ts 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() { + + + +