diff --git a/.changeset/issues-panel.md b/.changeset/issues-panel.md new file mode 100644 index 0000000..dfae10e --- /dev/null +++ b/.changeset/issues-panel.md @@ -0,0 +1,9 @@ +--- +'@solidjs/start-devtools': minor +--- + +Add an Issues panel that lists the issues the Solid runtime reports, such as a signal read outside a tracking scope. Each one is an error, a warning or info. + +- Repeated findings are grouped with a count, and the tab shows the total. +- A finding links to its owner in the ownership tree, its node in the reactivity graph, and its section of the repair guide. +- The copy button puts a report of the finding on the clipboard for an agent. diff --git a/README.md b/README.md index b6c225f..8db67d7 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ Development error and server-function tooling for Solid Start mode. `@solidjs/start-devtools` provides the toolbar used by the Solid Vite plugin in development. - Runtime error inspection with source-mapped stack frames. +- The issues the runtime reports, such as a signal read outside a tracking scope. - 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. @@ -47,6 +48,22 @@ pnpm demo pnpm demo:explorer ``` +## Issues + +The issues panel lists what the Solid runtime reports on its diagnostics channel, such as +a signal read outside a tracking scope or a write inside a component. The console prints +the same findings, mixed with everything else. + +- A finding that repeats, such as one in each row of a list, is one row with a count. +- Filter by severity (errors, warnings, info) or by code, message and owner. +- Select a row to see its kind, owner path, node and when it was seen. +- **Show in the ownership tree** and **Show in the reactivity graph** select the owner or node + the finding is about. +- **Open the repair guide** opens the section for its code. + +The panel listens from the moment the toolbar mounts, so findings from the first render +are there when you open it. Issues need a development build of `solid-js`. + ## Reactivity graph The graph panel maps the running reactive graph. Signals, memos and effects are nodes, diff --git a/src/dev-toolbar/icons.tsx b/src/dev-toolbar/icons.tsx index 0ded254..172dd1b 100644 --- a/src/dev-toolbar/icons.tsx +++ b/src/dev-toolbar/icons.tsx @@ -721,3 +721,22 @@ export function CheckIcon(props: JSX.IntrinsicElements['svg'] & { title: string ); } + +export function IssueIcon(props: JSX.IntrinsicElements['svg'] & { title: string }): JSX.Element { + return ( + + {props.title} + + + ); +} diff --git a/src/dev-toolbar/index.css b/src/dev-toolbar/index.css index b3d1c2f..4f8bcac 100644 --- a/src/dev-toolbar/index.css +++ b/src/dev-toolbar/index.css @@ -350,6 +350,11 @@ color: oklch(0.78 0.14 25); } +[data-solid-dev-toolbar-tab-badge='warning'] { + background: oklch(0.8 0.15 80 / 0.18); + color: oklch(0.85 0.12 80); +} + [data-solid-dev-toolbar-body] { flex: 1; min-width: 0; diff --git a/src/dev-toolbar/index.tsx b/src/dev-toolbar/index.tsx index b63114d..334e4be 100644 --- a/src/dev-toolbar/index.tsx +++ b/src/dev-toolbar/index.tsx @@ -9,6 +9,7 @@ import { For, getOwner, OBSERVE, + onCleanup, onSettled, Show, } from 'solid-js'; @@ -16,19 +17,30 @@ import version from '../version.js'; import IconButton from '../ui/IconButton.js'; import { type ServerFunctionInstance, ServerFunctionViewer } from './functions/index.js'; import { captureServerFunctionCall } from './functions/tracker.js'; -import { ErrorIcon, FunctionIcon, GraphIcon, MinimizeIcon, SolidIcon, TreeIcon } from './icons.js'; +import { + ErrorIcon, + FunctionIcon, + GraphIcon, + MinimizeIcon, + SolidIcon, + TreeIcon, + IssueIcon, +} from './icons.js'; import { excludeOwner, includeOwner } from './ownership/registry.js'; import { excludeReactiveOwner, includeReactiveOwner } from './reactivity/registry.js'; +import { addIssues, captureIssues, type IssueEntry } from './issues/tracker.js'; import './index.css'; const ErrorViewer = clientOnly(() => import('./error-viewer/index.js'), { lazy: true }); const ReactivityViewer = clientOnly(() => import('./reactivity/index.js'), { lazy: true }); const OwnershipViewer = clientOnly(() => import('./ownership/index.js'), { lazy: true }); +const IssuesViewer = clientOnly(() => import('./issues/index.js'), { lazy: true }); -type Tab = 'err' | 'fn' | 'rx' | 'own'; +type Tab = 'err' | 'issues' | '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: 'fn', label: 'Server functions', icon: FunctionIcon }, { value: 'rx', label: 'Reactivity', icon: GraphIcon }, { value: 'own', label: 'Ownership', icon: TreeIcon }, @@ -301,6 +313,14 @@ export function DevToolbar(props: DevToolbarProps) { }; }); + // Findings arrive while the app renders, before the toolbar settles, so the + // channel is joined during setup instead of in `onSettled`. + const [issues, setIssues] = createSignal([]); + if (!isServer) { + onCleanup(captureIssues((added) => setIssues((current) => addIssues(current, added)))); + } + const issueCount = createMemo(() => issues().reduce((total, entry) => total + entry.count, 0)); + const [instances, setInstances] = createSignal< Record >({}); @@ -323,6 +343,7 @@ export function DevToolbar(props: DevToolbarProps) { const badge = (value: Tab): number => { if (value === 'err') return errors().length; + if (value === 'issues') return issueCount(); if (value === 'fn') return callCount(); return 0; }; @@ -428,7 +449,11 @@ export function DevToolbar(props: DevToolbarProps) { {count()} @@ -447,6 +472,19 @@ export function DevToolbar(props: DevToolbarProps) { aria-labelledby={`solid-dev-toolbar-tab-${tab()}`} > + setIssues([])} + onViewOwner={(owner) => { + setOwnershipFocus({ owner }); + show('own'); + }} + onViewInGraph={(node) => { + setGraphFocus({ node }); + show('rx'); + }} + /> = { + error: 'failure', + warn: 'warning', + info: 'info', +}; + +/** The section of the repair guide for a code. Only dev builds of the runtime know it. */ +function guideUrl(code: string): string | undefined { + try { + return DEV?.guideUrl(code as never); + } catch { + return undefined; + } +} + +/** A signal is not an owner, so the tree shows the owner it was created under. */ +function ownerOf(subject: object): object { + const node = subject as { _owner?: object; _parent?: object }; + return '_parent' in node || !node._owner ? subject : node._owner; +} + +/** Computations and signals are in the graph. Components and roots are not. */ +function isInGraph(subject: object): boolean { + const node = subject as { _fn?: unknown; _parent?: unknown; _component?: unknown }; + if (node._component) return false; + return typeof node._fn === 'function' || !('_parent' in node); +} + +function dataText(data: Record): string { + try { + return JSON.stringify(data, null, 2); + } catch { + return String(data); + } +} + +function time(at: number): string { + return new Date(at).toLocaleTimeString(); +} + +export interface IssuesViewerProps { + show?: boolean; + entries: IssueEntry[]; + onClear: () => void; + /** Opens the owner of a finding in the ownership tree. */ + onViewOwner?: (owner: object) => void; + /** Opens the node of a finding in the reactivity graph. */ + onViewInGraph?: (node: object) => void; +} + +export default function IssuesViewer(props: IssuesViewerProps): JSX.Element { + const [query, setQuery] = createSignal(''); + const [hidden, setHidden] = createSignal([]); + const [selected, setSelected] = createSignal(); + + const shown = createMemo(() => { + const search = query().trim().toLowerCase(); + const off = hidden(); + return props.entries.filter((entry) => { + if (off.includes(entry.event.severity)) return false; + if (!search) return true; + const { event } = entry; + return [event.code, event.message, event.nodeName ?? '', ...(event.ownerPath ?? [])].some( + (text) => text.toLowerCase().includes(search), + ); + }); + }); + + const selectedEntry = createMemo(() => { + const key = selected(); + return key ? props.entries.find((entry) => entry.key === key) : undefined; + }); + + function toggleSeverity(value: Severity): void { + setHidden((current) => + current.includes(value) ? current.filter((item) => item !== value) : [...current, value], + ); + } + + return ( + +
+
+
+ setQuery(event.currentTarget.value)} + /> +
+ + {(severity) => ( + + )} + +
+
+ + {`${shown().length} of ${props.entries.length}`} + + { + setSelected(undefined); + props.onClear(); + }} + > + + +
+
+ +
+ + Issues need a development build of solid-js. + + } + > +
+ + + {props.entries.length + ? 'Nothing matches this filter.' + : 'No issues reported by the runtime.'} + + + } + > + + {(entry) => ( + + )} + + +
+ + + {(entry) => { + const subject = () => entry().subject?.deref(); + const guide = () => guideUrl(entry().event.code); + return ( + + ); + }} + +
+
+
+
+
+ ); +} diff --git a/src/dev-toolbar/issues/styles.css b/src/dev-toolbar/issues/styles.css new file mode 100644 index 0000000..b355d52 --- /dev/null +++ b/src/dev-toolbar/issues/styles.css @@ -0,0 +1,300 @@ +[data-solid-issues-viewer] { + color: var(--start-dt-text); + + display: flex; + flex-direction: column; + + height: 100%; + min-height: 0; +} + +[data-solid-issues-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-issues-search] { + flex: 1; + min-width: 6rem; + max-width: 18rem; + + padding: 0.25rem 0.5rem; + + border-radius: 0.5rem; + border: var(--start-dt-border) 1px solid; + background: var(--start-dt-surface); + color: var(--start-dt-text); + + font-family: inherit; + font-size: 0.75rem; + line-height: 1rem; +} + +[data-solid-issues-search]:focus { + outline: none; + border-color: var(--start-dt-accent); +} + +[data-solid-issues-filters] { + display: flex; + flex-direction: row; + align-items: center; + + gap: 0.25rem; +} + +[data-solid-issues-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-issues-filter]::before { + content: ''; + + width: 0.5rem; + height: 0.5rem; + + border-radius: 9999px; + background: var(--start-dt-danger); +} + +[data-solid-issues-filter='warn']::before { + background: oklch(0.8 0.15 80); +} + +[data-solid-issues-filter='info']::before { + background: oklch(0.68 0.13 245); +} + +[data-solid-issues-filter]:hover { + background: var(--start-dt-surface-hover); +} + +[data-solid-issues-filter][data-off] { + color: var(--start-dt-text-muted); + opacity: 0.55; +} + +[data-solid-issues-nav-actions] { + display: flex; + flex-direction: row; + align-items: center; + + gap: 0.375rem; + + margin-left: auto; +} + +[data-solid-issues-count] { + color: var(--start-dt-text-muted); + white-space: nowrap; +} + +[data-solid-issues-body] { + display: flex; + flex-direction: row; + + flex: 1; + min-height: 0; +} + +[data-solid-issues-list] { + display: flex; + flex-direction: column; + + flex: 1; + min-width: 0; + + overflow: auto; +} + +[data-solid-issues-row] { + display: flex; + flex-direction: column; + align-items: stretch; + + gap: 0.25rem; + + padding: 0.5rem 0.75rem; + + border: none; + border-bottom: var(--start-dt-border-soft) 1px solid; + background: none; + color: var(--start-dt-text); + text-align: left; + + cursor: pointer; +} + +[data-solid-issues-row]:hover { + background: var(--start-dt-surface-hover); +} + +[data-solid-issues-row][data-selected] { + background: var(--start-dt-surface-active); +} + +[data-solid-issues-row]:focus-visible { + outline: 2px solid var(--start-dt-accent); + outline-offset: -2px; +} + +[data-solid-issues-row-head] { + display: flex; + flex-direction: row; + align-items: center; + + gap: 0.5rem; + + min-width: 0; +} + +[data-solid-issues-row-count] { + padding: 0 0.375rem; + + border-radius: 9999px; + background: var(--start-dt-surface-hover); + color: var(--start-dt-text-muted); + + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.6875rem; + line-height: 1.125rem; +} + +[data-solid-issues-row-owner] { + overflow: hidden; + text-overflow: ellipsis; + + color: var(--start-dt-text-muted); +} + +[data-solid-issues-row-message] { + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + + color: var(--start-dt-text-muted); +} + +[data-solid-issues-detail] { + display: flex; + flex-direction: column; + + 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; +} + +[data-solid-issues-detail-head] { + display: flex; + flex-direction: row; + align-items: center; + + gap: 0.375rem; + + min-width: 0; +} + +[data-solid-issues-detail-head] [data-solid-copy-report] { + margin-left: auto; + padding: 0.25rem; + border-radius: 0.25rem; +} + +[data-solid-issues-detail-head] [data-solid-copy-report] > svg { + width: 0.875rem; + height: 0.875rem; +} + +[data-solid-issues-detail-message] { + white-space: pre-wrap; +} + +[data-solid-issues-actions] { + display: flex; + flex-direction: row; + flex-wrap: wrap; + + gap: 0.375rem; +} + +[data-solid-issues-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); + text-decoration: none; + + cursor: pointer; +} + +[data-solid-issues-action]:hover { + background: var(--start-dt-accent-soft); +} + +[data-solid-issues-action] > svg { + width: 0.875rem; + height: 0.875rem; +} + +[data-solid-issues-data] { + display: flex; + flex-direction: column; + + gap: 0.25rem; +} + +[data-solid-issues-data] > pre { + margin: 0; + padding: 0.5rem; + + border-radius: 0.375rem; + background: var(--start-dt-surface); + overflow: auto; +} + +/* The pane scrolls, so its blocks keep their height instead of shrinking to fit. */ +[data-solid-issues-detail] > * { + flex-shrink: 0; +} + +[data-solid-issues-data] > pre > * { + white-space: pre; +} + +[data-solid-issues-detail] [data-solid-kv-key] { + flex-basis: 4rem; +} diff --git a/src/dev-toolbar/issues/tracker.test.ts b/src/dev-toolbar/issues/tracker.test.ts new file mode 100644 index 0000000..1852ab7 --- /dev/null +++ b/src/dev-toolbar/issues/tracker.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it } from 'vitest'; +import { issueReport } from '../report/issue-report.js'; +import { addIssues, appOwnerPath, issueText, type IssueEvent } from './tracker.js'; + +function finding(over: Partial = {}): IssueEvent { + return { + sequence: 1, + code: 'STRICT_READ_UNTRACKED', + kind: 'strict-read', + severity: 'warn', + message: '[STRICT_READ_UNTRACKED] Reactive value "draft" read directly in .', + nodeName: 'draft', + ownerPath: ['', ''], + ...over, + }; +} + +describe('issueText', () => { + it('drops the code prefix the console needs', () => { + expect(issueText(finding())).toBe('Reactive value "draft" read directly in .'); + expect(issueText(finding({ message: 'plain' }))).toBe('plain'); + }); +}); + +describe('appOwnerPath', () => { + it('drops the toolbar owners and the scopes before the first app component', () => { + expect( + appOwnerPath([ + '', + '', + 'children', + '', + 'computed', + '', + 'memo', + ]), + ).toEqual(['', 'memo']); + }); + + it('keeps a path outside the toolbar as it is', () => { + expect(appOwnerPath(['', 'effect'])).toEqual(['', 'effect']); + expect(appOwnerPath(undefined)).toBeUndefined(); + }); +}); + +describe('addIssues', () => { + it('counts equal findings as one entry and moves it to the top', () => { + const first = {}; + const second = {}; + let entries = addIssues([], [{ event: finding(), subject: first, at: 1 }]); + entries = addIssues(entries, [ + { event: finding({ code: 'NO_OWNER_EFFECT', message: 'other' }), subject: undefined, at: 2 }, + ]); + entries = addIssues(entries, [{ event: finding({ sequence: 3 }), subject: second, at: 3 }]); + + expect(entries.map((entry) => entry.event.code)).toEqual([ + 'STRICT_READ_UNTRACKED', + 'NO_OWNER_EFFECT', + ]); + expect(entries[0]!.count).toBe(2); + expect(entries[0]!.firstAt).toBe(1); + expect(entries[0]!.lastAt).toBe(3); + // The latest node, since the first one can be disposed. + expect(entries[0]!.subject?.deref()).toBe(second); + }); + + it('keeps findings in different owners apart', () => { + const entries = addIssues( + [], + [ + { event: finding(), subject: undefined, at: 1 }, + { event: finding({ ownerPath: ['', ''] }), subject: undefined, at: 2 }, + ], + ); + expect(entries).toHaveLength(2); + }); + + it('keeps the newest entries up to the limit', () => { + const added = [1, 2, 3].map((n) => ({ + event: finding({ message: `m${n}` }), + subject: undefined, + at: n, + })); + expect(addIssues([], added, 2).map((entry) => entry.event.message)).toEqual(['m3', 'm2']); + }); +}); + +describe('issueReport', () => { + it('describes the finding with its owner, count, guide and data', () => { + const [entry] = addIssues( + [], + [ + { + event: finding({ data: { token: 'abc', strictRead: '' } }), + subject: undefined, + at: 1, + }, + { event: finding(), subject: undefined, at: 2 }, + ], + ); + const report = issueReport({ entry: entry!, guideUrl: 'https://guide#x', route: '/' }); + + expect(report).toContain('# Solid issue (warn): `STRICT_READ_UNTRACKED`'); + expect(report).toContain('Reactive value "draft" read directly in .'); + expect(report).toContain('- Owner path: ` > `'); + expect(report).toContain('- Node: `draft`'); + expect(report).toContain('- Reported 2 times'); + expect(report).toContain('- Repair guide: https://guide#x'); + }); + + it('redacts sensitive data', () => { + const [entry] = addIssues( + [], + [ + { + event: finding({ data: { token: 'abc', strictRead: '' } }), + subject: undefined, + at: 1, + }, + ], + ); + const report = issueReport({ entry: entry!, guideUrl: undefined, route: '/' }); + expect(report).toContain('"token": "[redacted]"'); + expect(report).toContain('"strictRead": ""'); + expect(report).not.toContain('Repair guide'); + }); +}); diff --git a/src/dev-toolbar/issues/tracker.ts b/src/dev-toolbar/issues/tracker.ts new file mode 100644 index 0000000..99eef82 --- /dev/null +++ b/src/dev-toolbar/issues/tracker.ts @@ -0,0 +1,135 @@ +import { OBSERVE } from 'solid-js'; + +/** One finding of the runtime's diagnostics channel, as `OBSERVE.diagnostics` delivers it. */ +export interface IssueEvent { + sequence: number; + code: string; + kind: string; + severity: 'info' | 'warn' | 'error'; + message: string; + ownerId?: string; + ownerName?: string; + nodeName?: string; + ownerPath?: string[]; + data?: Record; +} + +/** + * An issue in the panel. The runtime can report the same finding many times, + * such as a read in a list row, so equal findings are one entry with a count. + */ +export interface IssueEntry { + key: string; + event: IssueEvent; + /** + * The owner or signal the finding is about. Held weakly, so the panel does + * not keep a disposed part of the app alive. + */ + subject: WeakRef | undefined; + count: number; + firstAt: number; + lastAt: number; +} + +/** Most entries the panel keeps. The oldest go first. */ +export const MAX_ISSUES = 200; + +/** The message without the `[CODE]` prefix the runtime puts on it for the console. */ +export function issueText(event: IssueEvent): string { + const prefix = `[${event.code}] `; + return event.message.startsWith(prefix) ? event.message.slice(prefix.length) : event.message; +} + +/** The component the toolbar wraps the app in. Owners above it are the toolbar's. */ +const APP_SCOPE = ''; + +/** + * The owner path from the app's first component down. The toolbar's own + * owners, and the scopes between them and the app, are dropped. + */ +export function appOwnerPath(path: string[] | undefined): string[] | undefined { + if (!path) return path; + const scope = path.indexOf(APP_SCOPE); + if (scope === -1) return path; + const rest = path.slice(scope + 1); + const first = rest.findIndex((name) => name.startsWith('<')); + return first === -1 ? rest : rest.slice(first); +} + +function keyOf(event: IssueEvent): string { + return [event.code, event.ownerPath?.join('>') ?? '', event.nodeName ?? '', event.message].join( + '\n', + ); +} + +/** + * Adds findings to the entries, newest first. A finding equal to an entry + * moves that entry to the top and counts it again. + */ +export function addIssues( + entries: IssueEntry[], + added: { event: IssueEvent; subject: object | undefined; at: number }[], + max = MAX_ISSUES, +): IssueEntry[] { + const byKey = new Map(entries.map((entry) => [entry.key, entry])); + for (const { event, subject, at } of added) { + const key = keyOf(event); + const current = byKey.get(key); + byKey.delete(key); + byKey.set( + key, + current + ? { + ...current, + event, + // The latest node, since an earlier one can be disposed by now. + subject: subject ? new WeakRef(subject) : current.subject, + count: current.count + 1, + lastAt: at, + } + : { + key, + event, + subject: subject ? new WeakRef(subject) : undefined, + count: 1, + firstAt: at, + lastAt: at, + }, + ); + } + // The map keeps insertion order, so the latest findings are at its end. + return Array.from(byKey.values()).reverse().slice(0, max); +} + +/** True when the runtime has a diagnostics channel, which is in dev and observe builds. */ +export function isIssuesAvailable(): boolean { + return !!OBSERVE?.diagnostics; +} + +/** + * Listens to the diagnostics channel. The runtime calls the listener inside + * the scope that misbehaved, where writing a signal is itself a finding, so + * findings are collected and handed over on a microtask. + */ +export function captureIssues( + push: (added: { event: IssueEvent; subject: object | undefined; at: number }[]) => void, +): () => void { + const diagnostics = OBSERVE?.diagnostics; + if (!diagnostics) return () => {}; + let pending: { event: IssueEvent; subject: object | undefined; at: number }[] = []; + return diagnostics.subscribe((event, subject) => { + if (!pending.length) { + queueMicrotask(() => { + const batch = pending; + pending = []; + push(batch); + }); + } + const finding = event as IssueEvent; + pending.push({ + event: { ...finding, ownerPath: appOwnerPath(finding.ownerPath) }, + subject: subject ?? undefined, + at: Date.now(), + }); + }); +} diff --git a/src/dev-toolbar/report/issue-report.ts b/src/dev-toolbar/report/issue-report.ts new file mode 100644 index 0000000..88b893d --- /dev/null +++ b/src/dev-toolbar/report/issue-report.ts @@ -0,0 +1,32 @@ +import { issueText, type IssueEntry } from '../issues/tracker.js'; +import { code, fence } from './markdown.js'; +import { redactJson, redactText, redactUrl, truncateText } from './redact.js'; + +export interface IssueReportInput { + entry: IssueEntry; + /** The section of the repair guide for the code, when the runtime knows it. */ + guideUrl: string | undefined; + /** The path and query of the page. */ + route: string; +} + +/** A Markdown report of a runtime issue, for an agent to read. */ +export function issueReport(input: IssueReportInput): string { + const { event, count } = input.entry; + const out: string[] = [`# Solid issue (${event.severity}): ${code(event.code)}`, '']; + + out.push(fence(truncateText(redactText(issueText(event)))), ''); + out.push(`- Page: ${code(redactUrl(input.route))}`); + out.push(`- Kind: ${event.kind}`); + if (event.ownerPath?.length) out.push(`- Owner path: ${code(event.ownerPath.join(' > '))}`); + if (event.nodeName) out.push(`- Node: ${code(event.nodeName)}`); + if (count > 1) out.push(`- Reported ${count} times`); + if (input.guideUrl) out.push(`- Repair guide: ${input.guideUrl}`); + + if (event.data && Object.keys(event.data).length) { + const data = JSON.stringify(redactJson(event.data), null, 2); + out.push('', '## Data', '', fence(truncateText(data), 'json')); + } + + return out.join('\n'); +} diff --git a/tests/e2e/devtools.spec.ts b/tests/e2e/devtools.spec.ts index 53e087c..4e2d559 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 @@ -448,6 +448,47 @@ test('maps the ownership tree', async ({ page }) => { ).toHaveAttribute('data-solid-reactivity-node', 'selected'); }); +test('lists runtime issues', async ({ page }) => { + await page.goto('/'); + + // Each render of the reader reports the same untracked read. + for (let i = 0; i < 3; i++) await page.locator('#emit-warning').click(); + await openTab(page, 'Issues'); + await expect(page.getByRole('tab', { name: 'Issues', exact: true })).toContainText('2'); + + const rows = page.locator('[data-solid-issues-row]'); + await expect(rows).toHaveCount(1); + await expect(rows.first()).toContainText('STRICT_READ_UNTRACKED'); + await expect(rows.first()).toContainText('×2'); + // The toolbar's own owners are not part of the path. + await expect(rows.first()).toContainText(' › › value › '); + await expect(rows.first()).not.toContainText(''); + + // The severity filter hides it. + await page.locator('[data-solid-issues-filter="warn"]').click(); + await expect(rows).toHaveCount(0); + await page.locator('[data-solid-issues-filter="warn"]').click(); + + // The detail links to the component in the ownership tree. + await rows.first().click(); + const detail = page.locator('[data-solid-issues-detail]'); + await expect(detail).toContainText('draft'); + await expect(detail.getByRole('link', { name: 'Open the repair guide' })).toHaveAttribute( + 'href', + /reactivity-diagnostics.*#strict_read_untracked/, + ); + await detail.getByRole('button', { name: 'Show in the ownership tree' }).click(); + await expect(page.locator('[data-solid-ownership-row][data-selected]')).toContainText( + '', + ); + + // Clearing empties the list and the count. + await openTab(page, 'Issues'); + await page.getByRole('button', { name: 'Clear issues' }).click(); + await expect(rows).toHaveCount(0); + await expect(page.getByRole('tab', { name: 'Issues', exact: true })).not.toContainText('2'); +}); + 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 272abab..a1d86c7 100644 --- a/tests/fixture/app.tsx +++ b/tests/fixture/app.tsx @@ -32,6 +32,14 @@ function emitServerFunctionResponse() { responseStatus = 500; } +// Reads a signal in its body, outside any tracking scope, which the runtime +// reports as a warning. +function DraftReader() { + const [draft] = createSignal('draft text', { name: 'draft' }); + const text = draft(); + return

{text}

; +} + function Greeting(props: { name: string }) { return

{`hello ${props.name}`}

; } @@ -57,6 +65,7 @@ function Counter() { function App() { const [broken, setBroken] = createSignal(false); + const [writer, setWriter] = createSignal(false); return (
@@ -69,6 +78,12 @@ function App() { + + + +