From 7365853d527f29ca47427a8b74a8f087c7eab8db Mon Sep 17 00:00:00 2001 From: "Alexis H. Munsayac" Date: Sat, 24 Oct 2026 21:28:50 +0800 Subject: [PATCH 1/2] feat: add a Warnings panel for the runtime's diagnostics - A Warnings tab lists the findings Solid reports on its diagnostics channel, such as a signal read outside a tracking scope. - Equal findings are one row with a count. The tab shows the total. - Rows can be filtered by severity, or searched by code, message and owner. - The detail shows the kind, owner path, node and when it was seen. It links to the owner in the ownership tree, the node in the reactivity graph, and the code's section of the repair guide. - The copy button puts a report of the finding on the clipboard for an agent. - The panel listens from the moment the toolbar mounts, so findings from the first render are kept. - Owner paths start at the app's first component, without the toolbar's own owners. Co-Authored-By: Claude Opus 5.5 --- .changeset/warnings-panel.md | 9 + README.md | 17 ++ src/dev-toolbar/icons.tsx | 19 ++ src/dev-toolbar/index.css | 5 + src/dev-toolbar/index.tsx | 46 ++- src/dev-toolbar/report/warning-report.ts | 32 +++ src/dev-toolbar/warnings/index.tsx | 346 +++++++++++++++++++++++ src/dev-toolbar/warnings/styles.css | 300 ++++++++++++++++++++ src/dev-toolbar/warnings/tracker.test.ts | 127 +++++++++ src/dev-toolbar/warnings/tracker.ts | 135 +++++++++ tests/e2e/devtools.spec.ts | 43 ++- tests/fixture/app.tsx | 15 + 12 files changed, 1090 insertions(+), 4 deletions(-) create mode 100644 .changeset/warnings-panel.md create mode 100644 src/dev-toolbar/report/warning-report.ts create mode 100644 src/dev-toolbar/warnings/index.tsx create mode 100644 src/dev-toolbar/warnings/styles.css create mode 100644 src/dev-toolbar/warnings/tracker.test.ts create mode 100644 src/dev-toolbar/warnings/tracker.ts diff --git a/.changeset/warnings-panel.md b/.changeset/warnings-panel.md new file mode 100644 index 0000000..d53ca9f --- /dev/null +++ b/.changeset/warnings-panel.md @@ -0,0 +1,9 @@ +--- +'@solidjs/start-devtools': minor +--- + +Add a Warnings panel that lists the warnings the Solid runtime reports, such as a signal read outside a tracking scope. + +- 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..8b26b97 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 runtime's own warnings, 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 ``` +## Warnings + +The warnings 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. Warnings 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..4c618b5 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 WarningIcon(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..0f3aba4 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, + WarningIcon, +} from './icons.js'; import { excludeOwner, includeOwner } from './ownership/registry.js'; import { excludeReactiveOwner, includeReactiveOwner } from './reactivity/registry.js'; +import { addWarnings, captureWarnings, type WarningEntry } from './warnings/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 WarningsViewer = clientOnly(() => import('./warnings/index.js'), { lazy: true }); -type Tab = 'err' | 'fn' | 'rx' | 'own'; +type Tab = 'err' | 'warn' | 'fn' | 'rx' | 'own'; const TABS: { value: Tab; label: string; icon: typeof ErrorIcon }[] = [ { value: 'err', label: 'Errors', icon: ErrorIcon }, + { value: 'warn', label: 'Warnings', icon: WarningIcon }, { value: 'fn', label: 'Server functions', icon: FunctionIcon }, { value: 'rx', label: 'Reactivity', icon: GraphIcon }, { value: 'own', label: 'Ownership', icon: TreeIcon }, @@ -301,6 +313,16 @@ 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 [warnings, setWarnings] = createSignal([]); + if (!isServer) { + onCleanup(captureWarnings((added) => setWarnings((current) => addWarnings(current, added)))); + } + const warningCount = createMemo(() => + warnings().reduce((total, entry) => total + entry.count, 0), + ); + const [instances, setInstances] = createSignal< Record >({}); @@ -323,6 +345,7 @@ export function DevToolbar(props: DevToolbarProps) { const badge = (value: Tab): number => { if (value === 'err') return errors().length; + if (value === 'warn') return warningCount(); if (value === 'fn') return callCount(); return 0; }; @@ -428,7 +451,11 @@ export function DevToolbar(props: DevToolbarProps) { {count()} @@ -447,6 +474,19 @@ export function DevToolbar(props: DevToolbarProps) { aria-labelledby={`solid-dev-toolbar-tab-${tab()}`} > + setWarnings([])} + onViewOwner={(owner) => { + setOwnershipFocus({ owner }); + show('own'); + }} + onViewInGraph={(node) => { + setGraphFocus({ node }); + show('rx'); + }} + /> '))}`); + 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/src/dev-toolbar/warnings/index.tsx b/src/dev-toolbar/warnings/index.tsx new file mode 100644 index 0000000..3b50fe6 --- /dev/null +++ b/src/dev-toolbar/warnings/index.tsx @@ -0,0 +1,346 @@ +import type { JSX } from '@solidjs/web'; +import { createMemo, createSignal, DEV, For, 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 { CopyReportButton } from '../report/CopyReportButton.js'; +import { pageRoute } from '../report/markdown.js'; +import { warningReport } from '../report/warning-report.js'; +import { + isWarningsAvailable, + warningText, + type WarningEntry, + type WarningEvent, +} from './tracker.js'; +import './styles.css'; + +type Severity = WarningEvent['severity']; + +const SEVERITIES: { value: Severity; label: string }[] = [ + { value: 'error', label: 'Errors' }, + { value: 'warn', label: 'Warnings' }, + { value: 'info', label: 'Info' }, +]; + +const BADGES: Record = { + 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 WarningsViewerProps { + show?: boolean; + entries: WarningEntry[]; + 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 WarningsViewer(props: WarningsViewerProps): 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(); + }} + > + + +
+
+ +
+ + + Warnings need a development build of solid-js. + + + } + > +
+ + + {props.entries.length + ? 'Nothing matches this filter.' + : 'No warnings from the runtime.'} + + + } + > + + {(entry) => ( + + )} + + +
+ + + {(entry) => { + const subject = () => entry().subject?.deref(); + const guide = () => guideUrl(entry().event.code); + return ( + + ); + }} + +
+
+
+
+
+ ); +} diff --git a/src/dev-toolbar/warnings/styles.css b/src/dev-toolbar/warnings/styles.css new file mode 100644 index 0000000..ffc4e0d --- /dev/null +++ b/src/dev-toolbar/warnings/styles.css @@ -0,0 +1,300 @@ +[data-solid-warnings-viewer] { + color: var(--start-dt-text); + + display: flex; + flex-direction: column; + + height: 100%; + min-height: 0; +} + +[data-solid-warnings-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-warnings-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-warnings-search]:focus { + outline: none; + border-color: var(--start-dt-accent); +} + +[data-solid-warnings-filters] { + display: flex; + flex-direction: row; + align-items: center; + + gap: 0.25rem; +} + +[data-solid-warnings-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-warnings-filter]::before { + content: ''; + + width: 0.5rem; + height: 0.5rem; + + border-radius: 9999px; + background: var(--start-dt-danger); +} + +[data-solid-warnings-filter='warn']::before { + background: oklch(0.8 0.15 80); +} + +[data-solid-warnings-filter='info']::before { + background: oklch(0.68 0.13 245); +} + +[data-solid-warnings-filter]:hover { + background: var(--start-dt-surface-hover); +} + +[data-solid-warnings-filter][data-off] { + color: var(--start-dt-text-muted); + opacity: 0.55; +} + +[data-solid-warnings-nav-actions] { + display: flex; + flex-direction: row; + align-items: center; + + gap: 0.375rem; + + margin-left: auto; +} + +[data-solid-warnings-count] { + color: var(--start-dt-text-muted); + white-space: nowrap; +} + +[data-solid-warnings-body] { + display: flex; + flex-direction: row; + + flex: 1; + min-height: 0; +} + +[data-solid-warnings-list] { + display: flex; + flex-direction: column; + + flex: 1; + min-width: 0; + + overflow: auto; +} + +[data-solid-warnings-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-warnings-row]:hover { + background: var(--start-dt-surface-hover); +} + +[data-solid-warnings-row][data-selected] { + background: var(--start-dt-surface-active); +} + +[data-solid-warnings-row]:focus-visible { + outline: 2px solid var(--start-dt-accent); + outline-offset: -2px; +} + +[data-solid-warnings-row-head] { + display: flex; + flex-direction: row; + align-items: center; + + gap: 0.5rem; + + min-width: 0; +} + +[data-solid-warnings-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-warnings-row-owner] { + overflow: hidden; + text-overflow: ellipsis; + + color: var(--start-dt-text-muted); +} + +[data-solid-warnings-row-message] { + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + + color: var(--start-dt-text-muted); +} + +[data-solid-warnings-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-warnings-detail-head] { + display: flex; + flex-direction: row; + align-items: center; + + gap: 0.375rem; + + min-width: 0; +} + +[data-solid-warnings-detail-head] [data-solid-copy-report] { + margin-left: auto; + padding: 0.25rem; + border-radius: 0.25rem; +} + +[data-solid-warnings-detail-head] [data-solid-copy-report] > svg { + width: 0.875rem; + height: 0.875rem; +} + +[data-solid-warnings-detail-message] { + white-space: pre-wrap; +} + +[data-solid-warnings-actions] { + display: flex; + flex-direction: row; + flex-wrap: wrap; + + gap: 0.375rem; +} + +[data-solid-warnings-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-warnings-action]:hover { + background: var(--start-dt-accent-soft); +} + +[data-solid-warnings-action] > svg { + width: 0.875rem; + height: 0.875rem; +} + +[data-solid-warnings-data] { + display: flex; + flex-direction: column; + + gap: 0.25rem; +} + +[data-solid-warnings-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-warnings-detail] > * { + flex-shrink: 0; +} + +[data-solid-warnings-data] > pre > * { + white-space: pre; +} + +[data-solid-warnings-detail] [data-solid-kv-key] { + flex-basis: 4rem; +} diff --git a/src/dev-toolbar/warnings/tracker.test.ts b/src/dev-toolbar/warnings/tracker.test.ts new file mode 100644 index 0000000..5116b5c --- /dev/null +++ b/src/dev-toolbar/warnings/tracker.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it } from 'vitest'; +import { warningReport } from '../report/warning-report.js'; +import { addWarnings, appOwnerPath, warningText, type WarningEvent } from './tracker.js'; + +function finding(over: Partial = {}): WarningEvent { + 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('warningText', () => { + it('drops the code prefix the console needs', () => { + expect(warningText(finding())).toBe('Reactive value "draft" read directly in .'); + expect(warningText(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('addWarnings', () => { + it('counts equal findings as one entry and moves it to the top', () => { + const first = {}; + const second = {}; + let entries = addWarnings([], [{ event: finding(), subject: first, at: 1 }]); + entries = addWarnings(entries, [ + { event: finding({ code: 'NO_OWNER_EFFECT', message: 'other' }), subject: undefined, at: 2 }, + ]); + entries = addWarnings(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 = addWarnings( + [], + [ + { 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(addWarnings([], added, 2).map((entry) => entry.event.message)).toEqual(['m3', 'm2']); + }); +}); + +describe('warningReport', () => { + it('describes the finding with its owner, count, guide and data', () => { + const [entry] = addWarnings( + [], + [ + { + event: finding({ data: { token: 'abc', strictRead: '' } }), + subject: undefined, + at: 1, + }, + { event: finding(), subject: undefined, at: 2 }, + ], + ); + const report = warningReport({ entry: entry!, guideUrl: 'https://guide#x', route: '/' }); + + expect(report).toContain('# Solid 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] = addWarnings( + [], + [ + { + event: finding({ data: { token: 'abc', strictRead: '' } }), + subject: undefined, + at: 1, + }, + ], + ); + const report = warningReport({ 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/warnings/tracker.ts b/src/dev-toolbar/warnings/tracker.ts new file mode 100644 index 0000000..861324b --- /dev/null +++ b/src/dev-toolbar/warnings/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 WarningEvent { + sequence: number; + code: string; + kind: string; + severity: 'info' | 'warn' | 'error'; + message: string; + ownerId?: string; + ownerName?: string; + nodeName?: string; + ownerPath?: string[]; + data?: Record; +} + +/** + * A warning 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 WarningEntry { + key: string; + event: WarningEvent; + /** + * 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_WARNINGS = 200; + +/** The message without the `[CODE]` prefix the runtime puts on it for the console. */ +export function warningText(event: WarningEvent): 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: WarningEvent): 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 addWarnings( + entries: WarningEntry[], + added: { event: WarningEvent; subject: object | undefined; at: number }[], + max = MAX_WARNINGS, +): WarningEntry[] { + 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 isWarningsAvailable(): 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 captureWarnings( + push: (added: { event: WarningEvent; subject: object | undefined; at: number }[]) => void, +): () => void { + const diagnostics = OBSERVE?.diagnostics; + if (!diagnostics) return () => {}; + let pending: { event: WarningEvent; subject: object | undefined; at: number }[] = []; + return diagnostics.subscribe((event, subject) => { + if (!pending.length) { + queueMicrotask(() => { + const batch = pending; + pending = []; + push(batch); + }); + } + const finding = event as WarningEvent; + pending.push({ + event: { ...finding, ownerPath: appOwnerPath(finding.ownerPath) }, + subject: subject ?? undefined, + at: Date.now(), + }); + }); +} diff --git a/tests/e2e/devtools.spec.ts b/tests/e2e/devtools.spec.ts index 53e087c..bb72d36 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 warnings', 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, 'Warnings'); + await expect(page.getByRole('tab', { name: 'Warnings', exact: true })).toContainText('2'); + + const rows = page.locator('[data-solid-warnings-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-warnings-filter="warn"]').click(); + await expect(rows).toHaveCount(0); + await page.locator('[data-solid-warnings-filter="warn"]').click(); + + // The detail links to the component in the ownership tree. + await rows.first().click(); + const detail = page.locator('[data-solid-warnings-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, 'Warnings'); + await page.getByRole('button', { name: 'Clear warnings' }).click(); + await expect(rows).toHaveCount(0); + await expect(page.getByRole('tab', { name: 'Warnings', 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() { + + + + From f900c3c0f6a5ed2950b4501219af35138394d2ed Mon Sep 17 00:00:00 2001 From: "Alexis H. Munsayac" Date: Sat, 24 Oct 2026 21:44:46 +0800 Subject: [PATCH 2/2] refactor: rename the Warnings panel to Issues - The panel lists errors, warnings and info, so a tab named after one of its severities read oddly next to its own filters. - The tab, its empty state, the clear and copy buttons, and the report title say "issue" now. - The code moves from warnings/ to issues/, with the data attributes, the icon and the report renamed to match. - The severity filters still read Errors, Warnings and Info. Co-Authored-By: Claude Opus 5.5 --- .../{warnings-panel.md => issues-panel.md} | 2 +- README.md | 8 +- src/dev-toolbar/icons.tsx | 2 +- src/dev-toolbar/index.tsx | 30 ++++--- .../{warnings => issues}/index.tsx | 79 +++++++++---------- .../{warnings => issues}/styles.css | 74 ++++++++--------- .../{warnings => issues}/tracker.test.ts | 36 ++++----- .../{warnings => issues}/tracker.ts | 34 ++++---- .../{warning-report.ts => issue-report.ts} | 14 ++-- tests/e2e/devtools.spec.ts | 20 ++--- 10 files changed, 145 insertions(+), 154 deletions(-) rename .changeset/{warnings-panel.md => issues-panel.md} (67%) rename src/dev-toolbar/{warnings => issues}/index.tsx (84%) rename src/dev-toolbar/{warnings => issues}/styles.css (73%) rename src/dev-toolbar/{warnings => issues}/tracker.test.ts (72%) rename src/dev-toolbar/{warnings => issues}/tracker.ts (81%) rename src/dev-toolbar/report/{warning-report.ts => issue-report.ts} (69%) diff --git a/.changeset/warnings-panel.md b/.changeset/issues-panel.md similarity index 67% rename from .changeset/warnings-panel.md rename to .changeset/issues-panel.md index d53ca9f..dfae10e 100644 --- a/.changeset/warnings-panel.md +++ b/.changeset/issues-panel.md @@ -2,7 +2,7 @@ '@solidjs/start-devtools': minor --- -Add a Warnings panel that lists the warnings the Solid runtime reports, such as a signal read outside a tracking scope. +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. diff --git a/README.md b/README.md index 8b26b97..8db67d7 100644 --- a/README.md +++ b/README.md @@ -5,7 +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 runtime's own warnings, such as a signal read outside a tracking scope. +- 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. @@ -48,9 +48,9 @@ pnpm demo pnpm demo:explorer ``` -## Warnings +## Issues -The warnings panel lists what the Solid runtime reports on its diagnostics channel, such as +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. @@ -62,7 +62,7 @@ the same findings, mixed with everything else. - **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. Warnings need a development build of `solid-js`. +are there when you open it. Issues need a development build of `solid-js`. ## Reactivity graph diff --git a/src/dev-toolbar/icons.tsx b/src/dev-toolbar/icons.tsx index 4c618b5..172dd1b 100644 --- a/src/dev-toolbar/icons.tsx +++ b/src/dev-toolbar/icons.tsx @@ -722,7 +722,7 @@ export function CheckIcon(props: JSX.IntrinsicElements['svg'] & { title: string ); } -export function WarningIcon(props: JSX.IntrinsicElements['svg'] & { title: string }): JSX.Element { +export function IssueIcon(props: JSX.IntrinsicElements['svg'] & { title: string }): JSX.Element { return ( 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 WarningsViewer = clientOnly(() => import('./warnings/index.js'), { lazy: true }); +const IssuesViewer = clientOnly(() => import('./issues/index.js'), { lazy: true }); -type Tab = 'err' | 'warn' | '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: 'warn', label: 'Warnings', icon: WarningIcon }, + { 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 }, @@ -315,13 +315,11 @@ 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 [warnings, setWarnings] = createSignal([]); + const [issues, setIssues] = createSignal([]); if (!isServer) { - onCleanup(captureWarnings((added) => setWarnings((current) => addWarnings(current, added)))); + onCleanup(captureIssues((added) => setIssues((current) => addIssues(current, added)))); } - const warningCount = createMemo(() => - warnings().reduce((total, entry) => total + entry.count, 0), - ); + const issueCount = createMemo(() => issues().reduce((total, entry) => total + entry.count, 0)); const [instances, setInstances] = createSignal< Record @@ -345,7 +343,7 @@ export function DevToolbar(props: DevToolbarProps) { const badge = (value: Tab): number => { if (value === 'err') return errors().length; - if (value === 'warn') return warningCount(); + if (value === 'issues') return issueCount(); if (value === 'fn') return callCount(); return 0; }; @@ -453,7 +451,7 @@ export function DevToolbar(props: DevToolbarProps) { data-solid-dev-toolbar-tab-badge={ entry.value === 'err' ? 'failure' - : entry.value === 'warn' + : entry.value === 'issues' ? 'warning' : undefined } @@ -474,10 +472,10 @@ export function DevToolbar(props: DevToolbarProps) { aria-labelledby={`solid-dev-toolbar-tab-${tab()}`} > - setWarnings([])} + setIssues([])} onViewOwner={(owner) => { setOwnershipFocus({ owner }); show('own'); diff --git a/src/dev-toolbar/warnings/index.tsx b/src/dev-toolbar/issues/index.tsx similarity index 84% rename from src/dev-toolbar/warnings/index.tsx rename to src/dev-toolbar/issues/index.tsx index 3b50fe6..b2cb33f 100644 --- a/src/dev-toolbar/warnings/index.tsx +++ b/src/dev-toolbar/issues/index.tsx @@ -7,16 +7,11 @@ import { Text } from '../../ui/Text.js'; import { EyeIcon, TrashIcon, TreeIcon } from '../icons.js'; import { CopyReportButton } from '../report/CopyReportButton.js'; import { pageRoute } from '../report/markdown.js'; -import { warningReport } from '../report/warning-report.js'; -import { - isWarningsAvailable, - warningText, - type WarningEntry, - type WarningEvent, -} from './tracker.js'; +import { issueReport } from '../report/issue-report.js'; +import { isIssuesAvailable, issueText, type IssueEntry, type IssueEvent } from './tracker.js'; import './styles.css'; -type Severity = WarningEvent['severity']; +type Severity = IssueEvent['severity']; const SEVERITIES: { value: Severity; label: string }[] = [ { value: 'error', label: 'Errors' }, @@ -64,9 +59,9 @@ function time(at: number): string { return new Date(at).toLocaleTimeString(); } -export interface WarningsViewerProps { +export interface IssuesViewerProps { show?: boolean; - entries: WarningEntry[]; + entries: IssueEntry[]; onClear: () => void; /** Opens the owner of a finding in the ownership tree. */ onViewOwner?: (owner: object) => void; @@ -74,7 +69,7 @@ export interface WarningsViewerProps { onViewInGraph?: (node: object) => void; } -export default function WarningsViewer(props: WarningsViewerProps): JSX.Element { +export default function IssuesViewer(props: IssuesViewerProps): JSX.Element { const [query, setQuery] = createSignal(''); const [hidden, setHidden] = createSignal([]); const [selected, setSelected] = createSignal(); @@ -106,21 +101,21 @@ export default function WarningsViewer(props: WarningsViewerProps): JSX.Element return (
-
-
+
+
setQuery(event.currentTarget.value)} /> -
+
{(severity) => (
-
- +
+ {`${shown().length} of ${props.entries.length}`} - +
-
+
- - Warnings need a development build of solid-js. - + Issues need a development build of solid-js. } > -
+
{props.entries.length ? 'Nothing matches this filter.' - : 'No warnings from the runtime.'} + : 'No issues reported by the runtime.'} } @@ -176,29 +169,29 @@ export default function WarningsViewer(props: WarningsViewerProps): JSX.Element {(entry) => ( )} @@ -211,8 +204,8 @@ export default function WarningsViewer(props: WarningsViewerProps): JSX.Element const subject = () => entry().subject?.deref(); const guide = () => guideUrl(entry().event.code); return ( -