From db6e0c0e80af86859c30fa3db4cb3d65180cb6c7 Mon Sep 17 00:00:00 2001 From: "Alexis H. Munsayac" Date: Wed, 4 Nov 2026 10:38:31 +0800 Subject: [PATCH 01/10] feat(dock): group the panels into Health, Activity and Structure views The dock header switches between three views instead of a sidebar of seven tabs. Health lists thrown errors and runtime issues together, with the selected entry beside the list. Activity holds Timeline, Async and Server functions, and Structure holds Ownership and Reactivity. The header and pull tab show the solid-js version instead of the toolbar's title and version. The error panel lists frames beside the source, folds library frames and opens frames in the editor. The timeline folds runs of the same action. Every list and detail split uses the resize handle. Co-Authored-By: Claude Opus 5.5 --- .changeset/dock-three-views.md | 10 + README.md | 10 +- src/dev-toolbar/async/index.tsx | 6 +- src/dev-toolbar/async/styles.css | 5 +- src/dev-toolbar/error-viewer/frames.test.ts | 81 +++ src/dev-toolbar/error-viewer/frames.ts | 99 ++++ src/dev-toolbar/error-viewer/index.tsx | 317 ++++++++---- src/dev-toolbar/error-viewer/styles.css | 115 ++++- src/dev-toolbar/functions/index.tsx | 9 +- src/dev-toolbar/functions/styles.css | 14 +- src/dev-toolbar/health/index.tsx | 251 ++++++++++ src/dev-toolbar/health/styles.css | 82 ++++ src/dev-toolbar/index.css | 182 ++----- src/dev-toolbar/index.tsx | 516 +++++++++++--------- src/dev-toolbar/issues/index.tsx | 426 ++++++---------- src/dev-toolbar/issues/styles.css | 4 +- src/dev-toolbar/reactivity/index.tsx | 19 + src/dev-toolbar/reactivity/styles.css | 31 ++ src/dev-toolbar/timeline/group.test.ts | 62 +++ src/dev-toolbar/timeline/group.ts | 76 +++ src/dev-toolbar/timeline/index.tsx | 174 +++++-- src/dev-toolbar/timeline/styles.css | 26 +- tests/e2e/devtools.spec.ts | 66 +-- 23 files changed, 1743 insertions(+), 838 deletions(-) create mode 100644 .changeset/dock-three-views.md create mode 100644 src/dev-toolbar/error-viewer/frames.test.ts create mode 100644 src/dev-toolbar/error-viewer/frames.ts create mode 100644 src/dev-toolbar/health/index.tsx create mode 100644 src/dev-toolbar/health/styles.css create mode 100644 src/dev-toolbar/timeline/group.test.ts create mode 100644 src/dev-toolbar/timeline/group.ts diff --git a/.changeset/dock-three-views.md b/.changeset/dock-three-views.md new file mode 100644 index 0000000..e1426f2 --- /dev/null +++ b/.changeset/dock-three-views.md @@ -0,0 +1,10 @@ +--- +'@solidjs/start-devtools': minor +--- + +- The dock groups its panels into three views: Health (errors and runtime issues in one list), Activity (Timeline, Async and Server functions) and Structure (Ownership and Reactivity). +- The header and the pull tab show the `solid-js` version the app runs, in place of the toolbar's title and version. The pull tab also counts issues. +- The error panel lists stack frames beside the source, folds frames from libraries, and opens the selected frame in the editor. +- The Timeline folds runs of the same action into one row with a count. +- The detail panes of Health, Timeline, Async and Server functions, and the split between stack frames and source, are resizable. +- Picking an element with the inspector opens it in the ownership tree. The Reactivity graph shows the component filter the pick sets, with a button to clear it. diff --git a/README.md b/README.md index 41e6258..59b18eb 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,15 @@ pnpm add @solidjs/start-devtools@next The toolbar is a dock at the bottom of the page. Closed, it is a small grip at the bottom center. Click it, drag it up, or press Alt+Shift+D to open it. Drag its top edge to change its height. It remembers whether it was open, its tab and its height across reloads, and an -error opens it on the Errors tab. +error opens it on the Health view. + +The panels are grouped into three views, picked in the header: + +- **Health**: thrown errors and the issues the runtime reports, in one list. +- **Activity**: the Timeline, Async and Server functions panels. +- **Structure**: the Ownership tree and the Reactivity graph. + +The header and the pull tab show the `solid-js` version the app runs. The Vite plugin mounts it automatically. Integrations can use the package directly: diff --git a/src/dev-toolbar/async/index.tsx b/src/dev-toolbar/async/index.tsx index 00053f6..cbad6a7 100644 --- a/src/dev-toolbar/async/index.tsx +++ b/src/dev-toolbar/async/index.tsx @@ -3,7 +3,9 @@ import { createEffect, createMemo, createSignal, For, getOwner, onCleanup, Show import { Badge } from '../../ui/Badge.js'; import IconButton from '../../ui/IconButton.js'; import Placeholder from '../../ui/Placeholder.js'; +import { ResizeHandle } from '../../ui/ResizeHandle.js'; import { Text } from '../../ui/Text.js'; +import { useResizableDetail } from '../../ui/use-resizable-detail.js'; import { EyeIcon, TrashIcon, TreeIcon } from '../icons.js'; import { excludeReactiveOwner, @@ -51,6 +53,7 @@ export interface AsyncViewerProps { } export default function AsyncViewer(props: AsyncViewerProps): JSX.Element { + const detail = useResizableDetail({ storageKey: 'solid-start-devtools:async-detail-width' }); // The live list reads the graph, which must not list the panel's own nodes. excludeReactiveOwner(getOwner()); @@ -292,7 +295,8 @@ export default function AsyncViewer(props: AsyncViewerProps): JSX.Element { : undefined; }; return ( - - )} - - - + + + )} + + + + ); + }} + ); } + +/** `:41` from `src/OrderTable.tsx:41:7`, or nothing when the location has no line. */ +function lineOf(location: string): string { + const parts = location.split(':'); + const last = parts[parts.length - 1]!; + if (parts.length > 2 && /^\d+$/.test(last) && /^\d+$/.test(parts[parts.length - 2]!)) { + return `:${parts[parts.length - 2]}`; + } + if (parts.length === 2 && /^\d+$/.test(last)) return `:${last}`; + return ''; +} diff --git a/src/dev-toolbar/ownership/registry.ts b/src/dev-toolbar/ownership/registry.ts index ad2c76c..ddaa102 100644 --- a/src/dev-toolbar/ownership/registry.ts +++ b/src/dev-toolbar/ownership/registry.ts @@ -1,5 +1,6 @@ import { DEV } from 'solid-js'; import { listenToDevHooks } from '../dev-hooks.js'; +import { isSolidBuiltin } from './builtins.js'; import { buildOwnershipTree, EMPTY_TREE, type OwnershipTree, type RawNode } from './tree.js'; let nextId = 1; @@ -157,6 +158,8 @@ function rootOf(owner: RawNode): RawNode { export interface SnapshotOptions { componentsOnly?: boolean; + /** Fold the components Solid ships into their parents. */ + hideBuiltins?: boolean; includeDisposed?: boolean; } @@ -189,5 +192,7 @@ export function snapshotOwnershipTree(options?: SnapshotOptions): OwnershipTree isIncluded, componentsOnly: options?.componentsOnly ?? true, includeDisposed: options?.includeDisposed, + hideBuiltins: options?.hideBuiltins ?? options?.componentsOnly ?? true, + isBuiltin: isSolidBuiltin, }); } diff --git a/src/dev-toolbar/ownership/runs.test.ts b/src/dev-toolbar/ownership/runs.test.ts new file mode 100644 index 0000000..dce926d --- /dev/null +++ b/src/dev-toolbar/ownership/runs.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest'; +import { groupRuns, type RunItem } from './runs.js'; + +function items(names: string, unfoldable: string[] = []): RunItem[] { + return [...names].map((name, index) => ({ + id: `${index}`, + name, + foldable: !unfoldable.includes(`${index}`), + })); +} + +describe('groupRuns', () => { + it('folds the rows after the first of a run of the same name', () => { + expect(groupRuns(items('aaaa'))).toEqual([ + { kind: 'item', id: '0' }, + { kind: 'run', ids: ['1', '2', '3'] }, + ]); + }); + + it('leaves short runs and mixed names alone', () => { + expect(groupRuns(items('aab'))).toEqual([ + { kind: 'item', id: '0' }, + { kind: 'item', id: '1' }, + { kind: 'item', id: '2' }, + ]); + }); + + it('never folds a row that must stay, and splits the run around it', () => { + expect(groupRuns(items('aaaaaa', ['2']))).toEqual([ + { kind: 'item', id: '0' }, + { kind: 'item', id: '1' }, + { kind: 'item', id: '2' }, + { kind: 'item', id: '3' }, + { kind: 'run', ids: ['4', '5'] }, + ]); + }); + + it('starts a new group at each name', () => { + expect(groupRuns(items('aaabbb'))).toEqual([ + { kind: 'item', id: '0' }, + { kind: 'run', ids: ['1', '2'] }, + { kind: 'item', id: '3' }, + { kind: 'run', ids: ['4', '5'] }, + ]); + }); +}); diff --git a/src/dev-toolbar/ownership/runs.ts b/src/dev-toolbar/ownership/runs.ts new file mode 100644 index 0000000..7b89b00 --- /dev/null +++ b/src/dev-toolbar/ownership/runs.ts @@ -0,0 +1,44 @@ +/** A sibling row the run finder looks at. */ +export interface RunItem { + id: string; + name: string; + /** + * False for a row that must stay on screen: one that is open with children + * below it, or one that is selected. + */ + foldable: boolean; +} + +export type RunSegment = { kind: 'item'; id: string } | { kind: 'run'; ids: string[] }; + +/** The fewest rows a collapsed run may hold. */ +export const MIN_RUN = 2; + +/** + * Groups consecutive siblings that share a name. The first row of a group + * stays, and the rest fold into one run when at least `MIN_RUN` rows remain. + */ +export function groupRuns(items: RunItem[]): RunSegment[] { + const out: RunSegment[] = []; + let index = 0; + while (index < items.length) { + const first = items[index]!; + // Step 1: find how far the group goes. + let end = index + 1; + if (first.foldable) { + while (end < items.length && items[end]!.foldable && items[end]!.name === first.name) end++; + } + + // Step 2: keep the first row, and fold the rest when there are enough. + out.push({ kind: 'item', id: first.id }); + if (end - index - 1 >= MIN_RUN) { + const ids: string[] = []; + for (let at = index + 1; at < end; at++) ids.push(items[at]!.id); + out.push({ kind: 'run', ids }); + } else { + for (let at = index + 1; at < end; at++) out.push({ kind: 'item', id: items[at]!.id }); + } + index = end; + } + return out; +} diff --git a/src/dev-toolbar/ownership/styles.css b/src/dev-toolbar/ownership/styles.css index 4f0ed28..cc02073 100644 --- a/src/dev-toolbar/ownership/styles.css +++ b/src/dev-toolbar/ownership/styles.css @@ -1,136 +1,122 @@ -[data-solid-ownership-viewer] { - --start-dt-kind-component: oklch(0.68 0.13 245); - --start-dt-kind-root: oklch(0.72 0.12 195); - --start-dt-kind-memo: oklch(0.7 0.14 300); - --start-dt-kind-effect: oklch(0.72 0.15 150); - --start-dt-kind-render-effect: oklch(0.78 0.13 80); - --start-dt-kind-tracked-effect: oklch(0.72 0.12 210); - --start-dt-kind-scope: oklch(0.6 0.02 265); - - color: var(--start-dt-text); +/* The Structure view's tree and detail, set to the prototype. */ +[data-solid-ownership-viewer] { display: flex; - flex-direction: column; - - height: 100%; + flex: 1; min-height: 0; -} + min-width: 0; + overflow: hidden; -[data-solid-ownership-nav] { - display: flex; - flex-direction: row; - align-items: center; + color: var(--start-dt-text); + font: + 13px/1.45 system-ui, + -apple-system, + sans-serif; +} - gap: 0.5rem; +/* the tree pane */ - padding: 0.5rem 0.75rem; +[data-solid-ownership-tree] { + flex: 1; + min-width: 240px; + overflow: auto; + padding: 8px; - border-bottom: var(--start-dt-border-soft) 1px solid; + font: 12.5px/1 var(--start-dt-mono); } -[data-solid-ownership-nav-actions] { +[data-solid-ownership-filters] { display: flex; - flex-direction: row; align-items: center; + gap: 6px; + padding: 0 2px 8px; - gap: 0.375rem; - - margin-left: auto; -} - -[data-solid-ownership-count] { - color: var(--start-dt-text-muted); - white-space: nowrap; + font-family: + system-ui, + -apple-system, + sans-serif; } [data-solid-ownership-search] { flex: 1; min-width: 6rem; - max-width: 18rem; - - padding: 0.25rem 0.5rem; + max-width: 14rem; + height: 24px; + padding: 0 8px; - border-radius: 0.5rem; - border: var(--start-dt-border) 1px solid; - background: var(--start-dt-surface); + border: 1px solid var(--start-dt-line); + border-radius: 7px; + background: var(--start-dt-well); color: var(--start-dt-text); - font-family: inherit; - font-size: 0.75rem; - line-height: 1rem; + font: inherit; + font-size: 11.5px; +} + +[data-solid-ownership-search]::placeholder { + color: var(--start-dt-faint); } [data-solid-ownership-search]:focus { outline: none; - border-color: var(--start-dt-accent); + border-color: var(--start-dt-line-2); } -[data-solid-ownership-modes] { - display: flex; - - border-radius: 9999px; - border: var(--start-dt-border) 1px solid; - overflow: hidden; +[data-solid-ownership-count] { + color: var(--start-dt-faint); + font-size: 10.5px; + white-space: nowrap; } -[data-solid-ownership-mode] { - padding: 0.1875rem 0.625rem; +[data-solid-ownership-tool] { + display: grid; + flex: none; + place-items: center; + width: 30px; + height: 30px; + padding: 0; - border: none; - background: var(--start-dt-surface); + border: 0; + border-radius: 7px; + background: none; color: var(--start-dt-text-muted); cursor: pointer; } -[data-solid-ownership-mode]:hover { - background: var(--start-dt-surface-hover); -} - -[data-solid-ownership-mode][data-active] { - background: var(--start-dt-accent-soft); - color: var(--start-dt-accent); -} - -[data-solid-ownership-body] { - display: flex; - flex-direction: row; - - flex: 1; - min-height: 0; +[data-solid-ownership-tool]:hover { + background: oklch(1 0 0 / 0.06); + color: var(--start-dt-text); } -[data-solid-ownership-rows] { - display: flex; - flex-direction: column; - - flex: 1; - min-width: 0; - min-height: 0; - - padding: 0.375rem 0.25rem; - - overflow: auto; -} +/* rows */ [data-solid-ownership-row] { display: flex; - flex-direction: row; align-items: center; + gap: 6px; + height: 26px; + padding-right: 8px; - gap: 0.125rem; - - border-radius: 0.375rem; + border-radius: 6px; + color: var(--start-dt-text-muted); - min-width: 0; + white-space: nowrap; + cursor: pointer; } [data-solid-ownership-row]:hover { - background: var(--start-dt-surface-hover); + background: oklch(1 0 0 / 0.04); +} + +[data-solid-ownership-row]:focus-visible { + outline: 1px solid var(--start-dt-line-2); + outline-offset: -1px; } [data-solid-ownership-row][data-selected] { - background: var(--start-dt-surface-active); + background: var(--start-dt-raise-2); + color: var(--start-dt-text); } [data-solid-ownership-row][data-fresh] { @@ -139,7 +125,7 @@ @keyframes solid-ownership-pulse { 0% { - background: var(--start-dt-accent-soft); + background: var(--start-dt-solid-soft); } 100% { background: transparent; @@ -147,483 +133,300 @@ } [data-solid-ownership-chevron] { - display: inline-flex; - align-items: center; - justify-content: center; - - width: 1rem; - height: 1rem; - flex-shrink: 0; + width: 12px; + flex: none; - /* The browser pads buttons, which leaves the arrow no room in a 1rem box. */ - padding: 0; - - border: none; - background: none; - color: var(--start-dt-text-muted); - cursor: pointer; -} - -[data-solid-ownership-chevron]::before { - content: ''; - - box-sizing: content-box; - width: 0.3125rem; - height: 0.3125rem; - flex-shrink: 0; - - border-right: 1.5px currentColor solid; - border-bottom: 1.5px currentColor solid; - - transform: rotate(-45deg); - transition: transform 150ms cubic-bezier(0.4, 0, 0.2, 1); + color: var(--start-dt-faint); + font-size: 9px; + text-align: center; } -[data-solid-ownership-chevron][data-leaf] { - cursor: default; +[data-solid-ownership-label] { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; } -[data-solid-ownership-chevron][data-leaf]::before { - content: none; +[data-solid-ownership-name] { + color: var(--start-dt-text); } -[data-solid-ownership-chevron][data-expanded]::before { - transform: rotate(45deg); +/* names of scopes between components read as the muted text around them */ +[data-solid-ownership-label]:not([data-kind='component']) [data-solid-ownership-name] { + color: inherit; } -[data-solid-ownership-label] { +[data-solid-ownership-meta] { display: flex; - flex-direction: row; align-items: center; + gap: 5px; + margin-left: auto; - gap: 0.375rem; - - flex: 1; - min-width: 0; - - padding: 0.1875rem 0.25rem; - - border: none; - background: none; - color: inherit; - - text-align: left; - cursor: pointer; -} - -[data-solid-ownership-name] { - overflow: hidden; - text-overflow: ellipsis; + color: var(--start-dt-faint); + font-size: 10.5px; } -[data-solid-ownership-kind] { - width: 0.5rem; - height: 0.5rem; - - flex-shrink: 0; - - border-radius: 9999px; - background: var(--start-dt-kind-scope); +[data-solid-ownership-meta][data-tone='amber'] { + color: var(--start-dt-amber); } -[data-solid-ownership-kind='component'] { - background: var(--start-dt-kind-component); +[data-solid-ownership-meta][data-tone='red'] { + color: var(--start-dt-red); } -[data-solid-ownership-kind='root'] { - background: var(--start-dt-kind-root); -} +[data-solid-ownership-heat] { + width: 6px; + height: 6px; -[data-solid-ownership-kind='memo'] { - background: var(--start-dt-kind-memo); + border-radius: 50%; } -[data-solid-ownership-kind='effect'] { - background: var(--start-dt-kind-effect); +[data-solid-ownership-heat='red'] { + background: var(--start-dt-red); } -[data-solid-ownership-kind='render-effect'] { - background: var(--start-dt-kind-render-effect); +[data-solid-ownership-heat='amber'] { + background: var(--start-dt-amber); } -[data-solid-ownership-kind='tracked-effect'] { - background: var(--start-dt-kind-tracked-effect); -} +/* the detail pane */ [data-solid-ownership-detail] { position: relative; - display: flex; - flex-direction: column; - - /* Keeps the tree usable when the window shrinks below a saved width. */ - max-width: calc(100% - 240px); - flex-shrink: 0; - min-height: 0; - - overflow-y: auto; - - border-left: var(--start-dt-border-soft) 1px solid; -} - -[data-solid-ownership-detail-content] { - display: flex; - flex-direction: column; - - gap: 0.5rem; + flex: none; + overflow: auto; + padding: 14px 18px; - padding: 0.625rem 0.75rem; + border-left: 1px solid var(--start-dt-line); } [data-solid-ownership-detail-head] { - display: flex; - flex-direction: row; - align-items: center; - - gap: 0.375rem; + margin: 0; - min-width: 0; + font-family: var(--start-dt-mono); + font-size: 15px; + font-weight: 700; + letter-spacing: -0.01em; + line-height: 1.45; } -[data-solid-ownership-location] { - align-self: flex-start; - - padding: 0.125rem 0.375rem; +[data-solid-ownership-sub] { + margin: 2px 0 14px; - border: var(--start-dt-border) 1px solid; - border-radius: 0.375rem; - background: var(--start-dt-surface); - color: var(--start-dt-accent); - - cursor: pointer; - - max-width: 100%; - overflow: hidden; + color: var(--start-dt-faint); + font-size: 12px; } -[data-solid-ownership-location]:hover { - background: var(--start-dt-surface-hover); -} - -[data-solid-ownership-detail-block] { +[data-solid-ownership-sect] { display: flex; - flex-direction: column; + justify-content: space-between; + margin: 14px 0 8px; - gap: 0.25rem; - - padding-top: 0.5rem; - - border-top: var(--start-dt-border-soft) 1px solid; + color: var(--start-dt-faint); + font-size: 10.5px; + letter-spacing: 0.08em; + text-transform: uppercase; } -[data-solid-ownership-note] { - color: var(--start-dt-text-muted); +[data-solid-ownership-kv] { + display: grid; + grid-template-columns: minmax(120px, 170px) minmax(0, 1fr); + align-items: center; + gap: 6px 14px; + margin: 0; + + font: 12px var(--start-dt-mono); } -[data-solid-ownership-signals] { +[data-solid-ownership-kv] dt { display: flex; - flex-direction: column; + align-items: center; + gap: 6px; - gap: 0.125rem; + color: var(--start-dt-text-muted); + overflow-wrap: anywhere; } -[data-solid-ownership-signal-block] { +[data-solid-ownership-kv] dd { display: flex; - flex-direction: column; - width: 100%; -} - -[data-solid-ownership-signal-chevron] { - display: inline-flex; align-items: center; - justify-content: center; - - width: 0.875rem; - height: 0.875rem; - flex-shrink: 0; + flex-wrap: wrap; + gap: 0 6px; + margin: 0; - border: none; - background: none; - padding: 0; - color: var(--start-dt-text-muted); - cursor: pointer; + min-width: 0; } -[data-solid-ownership-signal-chevron]::before { - content: ''; +[data-solid-ownership-kind-chip] { + flex: none; + padding: 0 5px; - width: 0.3125rem; - height: 0.3125rem; - - border-right: 1.5px currentColor solid; - border-bottom: 1.5px currentColor solid; + border-radius: 4px; + background: oklch(1 0 0 / 0.07); + color: var(--start-dt-text-muted); - transform: rotate(-45deg); - transition: transform 150ms cubic-bezier(0.4, 0, 0.2, 1); + font-size: 9.5px; + line-height: 15px; } -[data-solid-ownership-signal-chevron][data-expanded]::before { - transform: rotate(45deg); +[data-solid-ownership-kind-chip='signal'] { + background: var(--start-dt-solid-soft); + color: var(--start-dt-solid); } -[data-solid-ownership-signal-tree] { - padding: 0.25rem 0.5rem; - margin-top: 0.25rem; - margin-left: 0.875rem; - border-radius: 0.375rem; - border: 1px solid var(--start-dt-border-soft); - background: var(--start-dt-bg); - max-height: 14rem; - overflow: auto; +[data-solid-ownership-kind-chip='memo'] { + background: var(--start-dt-violet-soft); + color: var(--start-dt-violet); } -[data-solid-ownership-signal] { - display: grid; - grid-template-columns: minmax(4rem, 40%) 1fr auto; - align-items: baseline; - - gap: 0.5rem; +[data-solid-ownership-value] { + padding: 2px 6px; + margin-left: -7px; - padding: 0.125rem 0.25rem; - - border-radius: 0.25rem; + border: 1px solid transparent; + border-radius: 5px; + overflow-wrap: anywhere; } -[data-solid-ownership-signal]:hover { - background: var(--start-dt-surface-hover); +[data-solid-ownership-value]:not([data-editable]) { + margin-left: 0; + padding: 2px 0; } -/* The action stays out of the way until the row is pointed at or focused. */ -[data-solid-ownership-signal-actions] { - display: flex; - align-items: center; - gap: 0.25rem; - margin-left: auto; +[data-solid-ownership-value][data-editable] { + cursor: text; } -[data-solid-ownership-edit-signal] { - display: inline-flex; - align-items: center; - justify-content: center; - - padding: 0.125rem; - - border: none; - border-radius: 0.25rem; - background: none; - color: var(--start-dt-text-muted); +[data-solid-ownership-value][data-editable]:hover { + border-color: var(--start-dt-line-2); + background: oklch(0 0 0 / 0.25); +} - cursor: pointer; - opacity: 0; - transition: - opacity 150ms ease-out, - color 150ms ease-out; +[data-solid-ownership-value][data-tone='str'] { + color: var(--start-dt-green); } -[data-solid-ownership-signal]:hover [data-solid-ownership-edit-signal], -[data-solid-ownership-edit-signal]:focus-visible { - opacity: 1; +[data-solid-ownership-value][data-tone='num'] { + color: var(--start-dt-amber); } -[data-solid-ownership-edit-signal]:hover { - color: var(--start-dt-accent); - background: var(--start-dt-accent-soft); +[data-solid-ownership-value][data-tone='kw'] { + color: var(--start-dt-violet); } -[data-solid-ownership-signal] [data-solid-ownership-edit-signal] svg { - width: 0.875rem; - height: 0.875rem; +[data-solid-ownership-edit-failed] { + border-color: var(--start-dt-red); } [data-solid-ownership-signal-input] { - flex: 1; - min-width: 3.5rem; - max-width: 12rem; - padding: 0.0625rem 0.25rem; - font-family: inherit; - font-size: 0.6875rem; - line-height: 1rem; - border-radius: 0.25rem; - border: 1px solid var(--start-dt-accent); - background: var(--start-dt-bg); + width: 100%; + min-width: 0; + padding: 2px 6px; + margin-left: -7px; + + border: 1px solid var(--start-dt-solid); + border-radius: 5px; + background: oklch(0 0 0 / 0.25); color: var(--start-dt-text); - outline: none; + + font: inherit; } -[data-solid-ownership-toggle-btn] { - display: inline-flex; - align-items: center; - padding: 0.0625rem 0.25rem; - border: 1px solid var(--start-dt-border-soft, transparent); - border-radius: 0.25rem; - background: var(--start-dt-surface, transparent); - color: var(--start-dt-text); - cursor: pointer; - transition: border-color 150ms ease-out; +[data-solid-ownership-signal-input]:focus { + outline: none; } -[data-solid-ownership-toggle-btn]:hover { - border-color: var(--start-dt-accent); +[data-solid-ownership-was] { + color: var(--start-dt-faint); + font-size: 11px; } [data-solid-ownership-view-graph] { - display: inline-flex; - align-items: center; - justify-content: center; - - padding: 0.125rem; + display: inline-grid; + place-items: center; + width: 18px; + height: 18px; + padding: 0; - border: none; - border-radius: 0.25rem; + border: 0; + border-radius: 5px; background: none; - color: var(--start-dt-accent); + color: var(--start-dt-faint); - cursor: pointer; opacity: 0; + cursor: pointer; } -[data-solid-ownership-signal]:hover [data-solid-ownership-view-graph], +[data-solid-ownership-view-graph] svg { + width: 14px; + height: 14px; +} + +[data-solid-ownership-kv] dd:hover [data-solid-ownership-view-graph], [data-solid-ownership-view-graph]:focus-visible { opacity: 1; } [data-solid-ownership-view-graph]:hover { - background: var(--start-dt-accent-soft); + background: oklch(1 0 0 / 0.06); + color: var(--start-dt-text); } -/* The selected owner's own action sits in the heading, so it is always shown. */ -[data-solid-ownership-detail-head] [data-solid-ownership-view-graph] { - margin-left: auto; - opacity: 1; +[data-solid-ownership-inspector] { + flex-basis: 100%; + margin-top: 4px; } -/* The toolbar sizes every svg as a large icon. Row actions need a small one. */ -[data-solid-ownership-signal] [data-solid-ownership-view-graph] svg, -[data-solid-ownership-detail-head] [data-solid-ownership-view-graph] svg { - width: 0.875rem; - height: 0.875rem; +[data-solid-ownership-empty] { + color: var(--start-dt-faint); + font-size: 12px; } -[data-solid-ownership-scope-name], -[data-solid-ownership-signal-name] { - display: inline-flex; +[data-solid-ownership-buttons] { + display: flex; + flex-wrap: wrap; align-items: center; - - gap: 0.375rem; - - min-width: 0; - overflow: hidden; -} - -[data-solid-ownership-scope-name] > [data-solid-text-size], -[data-solid-ownership-signal-name] > [data-solid-text-size] { - overflow: hidden; - text-overflow: ellipsis; -} - -[data-solid-ownership-signal] > [data-solid-text-size]:first-child, -[data-solid-ownership-signal] > [data-solid-ownership-signal-name] { - overflow: hidden; - text-overflow: ellipsis; + gap: 6px; + margin-top: 16px; } -[data-solid-ownership-signal-value] { - color: var(--start-dt-text-muted); - - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} +/* ancestry */ [data-solid-ownership-stack] { display: flex; flex-direction: column; - - gap: 0.125rem; } [data-solid-ownership-frame] { display: flex; - flex-direction: row; - align-items: center; - - gap: 0.375rem; - - padding: 0.1875rem 0.375rem; + align-items: baseline; + gap: 10px; + height: 26px; + padding: 0 6px; - border: none; - border-radius: 0.375rem; + border: 0; + border-radius: 6px; background: none; - color: var(--start-dt-text); + color: var(--start-dt-text-muted); + font: 12px var(--start-dt-mono); text-align: left; + white-space: nowrap; cursor: pointer; - - min-width: 0; + align-items: center; } [data-solid-ownership-frame]:hover { - background: var(--start-dt-surface-hover); + background: oklch(1 0 0 / 0.04); } [data-solid-ownership-frame][data-current] { - background: var(--start-dt-surface-active); -} - -[data-solid-ownership-frame-index] { - width: 1rem; - flex-shrink: 0; - - color: var(--start-dt-text-muted); - - font-family: ui-monospace, SFMono-Regular, Menlo, monospace; - font-size: 0.625rem; - text-align: right; -} - -[data-solid-ownership-frame-name] { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + color: var(--start-dt-text); } [data-solid-ownership-frame-location] { margin-left: auto; - color: var(--start-dt-text-muted); - - direction: rtl; - overflow: hidden; - text-overflow: ellipsis; - max-width: 55%; -} - -[data-solid-ownership-chips] { - display: flex; - flex-wrap: wrap; - - gap: 0.25rem; -} - -[data-solid-ownership-chip] { - display: inline-flex; - align-items: center; - - gap: 0.25rem; - - padding: 0.125rem 0.5rem; - - border-radius: 9999px; - border: var(--start-dt-border) 1px solid; - background: var(--start-dt-surface); - color: var(--start-dt-text); - - cursor: pointer; -} - -button[data-solid-ownership-chip]:hover { - background: var(--start-dt-surface-hover); -} - -[data-solid-ownership-edit-failed] { - text-decoration: wavy underline var(--start-dt-danger); + color: var(--start-dt-faint); + font-size: 10.5px; } diff --git a/src/dev-toolbar/ownership/tree.test.ts b/src/dev-toolbar/ownership/tree.test.ts index 356ce3b..3a5cf23 100644 --- a/src/dev-toolbar/ownership/tree.test.ts +++ b/src/dev-toolbar/ownership/tree.test.ts @@ -287,3 +287,17 @@ describe('ancestorsOf', () => { ]); }); }); + +describe('hideBuiltins', () => { + it('lifts the children of a built-in component to its parent', () => { + const inner = owner({ component: 'Row' }); + const builtin = owner({ component: 'For', children: [inner] }); + const app = owner({ component: 'App', children: [builtin] }); + const tree = build([app], { + hideBuiltins: true, + isBuiltin: (item) => item._component?.name === 'For', + }); + expect(tree.nodes.map((node) => node.name)).toEqual(['', '']); + expect(tree.nodes[1]!.parentId).toBe(tree.nodes[0]!.id); + }); +}); diff --git a/src/dev-toolbar/ownership/tree.ts b/src/dev-toolbar/ownership/tree.ts index 08d5e84..ff63d2a 100644 --- a/src/dev-toolbar/ownership/tree.ts +++ b/src/dev-toolbar/ownership/tree.ts @@ -1,3 +1,5 @@ +import { untrack } from 'solid-js'; + /** Raw owner or signal from the runtime. Only the fields the tree needs are read. */ export type RawNode = Record; @@ -162,6 +164,38 @@ function propNames(owner: RawNode): string[] | undefined { } } +export interface PropValue { + name: string; + value: unknown; +} + +/** + * Reads the props of a component. They are getters, so a read can run app code + * and throw. Each one is read on its own, outside any tracking scope, and a + * getter that throws shows its error as the value. + */ +export function readPropValues(owner: RawNode): PropValue[] { + const props = owner._component?.props; + if (!props || typeof props !== 'object') return []; + let names: string[]; + try { + names = Object.keys(props); + } catch { + return []; + } + const out: PropValue[] = []; + for (const name of names) { + let value: unknown; + try { + value = untrack(() => props[name]); + } catch (error) { + value = error; + } + out.push({ name, value }); + } + return out; +} + /** The runtime clock of a node's last write. Zero for owners that hold no value. */ function timeOf(node: RawNode): number { return typeof node._time === 'number' ? node._time : 0; @@ -188,6 +222,10 @@ export interface BuildOptions { componentsOnly: boolean; /** Keep owners the runtime already disposed. */ includeDisposed?: boolean; + /** Walk through the components Solid ships, so their children join the parent. */ + hideBuiltins?: boolean; + /** True for a component that comes from solid-js or @solidjs/web. */ + isBuiltin?(owner: RawNode): boolean; } /** @@ -269,6 +307,14 @@ export function buildOwnershipTree(roots: RawNode[], options: BuildOptions): Own return; } + // A built-in such as For or Show is nobody's concern. Its children join the + // component above it. + if (options.hideBuiltins && isComponent(owner) && options.isBuiltin?.(owner)) { + if (host) collectSignals(owner, host); + for (const child of options.children(owner)) walk(child, host, depth, false); + return; + } + const shown = !options.componentsOnly || isComponent(owner); if (shown) { diff --git a/src/dev-toolbar/palette/index.tsx b/src/dev-toolbar/palette/index.tsx new file mode 100644 index 0000000..75988ff --- /dev/null +++ b/src/dev-toolbar/palette/index.tsx @@ -0,0 +1,229 @@ +import type { JSX } from '@solidjs/web'; +import { createMemo, createSignal, For, onSettled, Show } from 'solid-js'; +import { snapshotOwnershipTree, startOwnershipTracking } from '../ownership/registry.js'; +import { formatValue } from '../reactivity/format.js'; +import { snapshotReactivityGraph, startReactivityTracking } from '../reactivity/registry.js'; +import type { ServerFunctionInstance } from '../functions/index.js'; +import type { TimelineEntry } from '../timeline/tracker.js'; +import { + searchPalette, + type PaletteItem, + type PaletteMatch, + type PaletteTarget, +} from './search.js'; +import './styles.css'; + +export interface CommandPaletteProps { + entries: TimelineEntry[]; + instances: Record; + onPick: (target: PaletteTarget) => void; + onClose: () => void; +} + +/** How long ago a `performance.now()` time was, in words. */ +function ago(at: number): string { + const seconds = Math.max(0, Math.round((performance.now() - at) / 1000)); + if (seconds < 5) return 'just now'; + if (seconds < 60) return `${seconds}s ago`; + return `${Math.round(seconds / 60)}m ago`; +} + +function shorten(text: string, max = 32): string { + return text.length > max ? `${text.slice(0, max - 1)}…` : text; +} + +/** Everything the palette can jump to, read once when it opens. */ +function collectItems(props: CommandPaletteProps): PaletteItem[] { + const items: PaletteItem[] = []; + + // Components come from the ownership tree, folded down to components. + for (const node of snapshotOwnershipTree({ componentsOnly: true }).nodes) { + if (node.kind !== 'component') continue; + items.push({ + group: 'Components', + label: node.name, + detail: node.location ?? `${node.signals.length} signals`, + target: { kind: 'component', owner: node.owner }, + }); + } + + // Signals and memos come from the reactive graph, with their current value. + for (const node of snapshotReactivityGraph().nodes) { + if (node.kind !== 'signal' && node.kind !== 'memo') continue; + const owner = node.ownerPath[node.ownerPath.length - 1]; + const value = shorten(formatValue(node.value)); + items.push({ + group: node.kind === 'memo' ? 'Memos' : 'Signals', + label: node.name, + detail: owner ? `${value} · ${owner}` : value, + target: { kind: node.kind, node: node.raw }, + }); + } + + // Actions, newest first as the timeline holds them. + for (const entry of props.entries) { + items.push({ + group: 'Actions', + label: entry.label, + detail: ago(entry.at), + target: { kind: 'action', origin: entry.origin, id: entry.id }, + }); + } + + // Server functions by name, with how many calls each made. + const calls = new Map(); + for (const [instance, value] of Object.entries(props.instances)) { + if (!value) continue; + const name = String(value.request.meta?.name ?? value.request.id); + const known = calls.get(name); + if (known) known.count++; + else calls.set(name, { instance, count: 1 }); + } + for (const [name, { instance, count }] of calls) { + items.push({ + group: 'Server functions', + label: name, + detail: count === 1 ? '1 call' : `${count} calls`, + target: { kind: 'call', instance }, + }); + } + return items; +} + +/** The label with its matched characters marked. */ +function Highlighted(props: { match: PaletteMatch }): JSX.Element { + const parts = createMemo(() => { + const marked = new Set(props.match.positions); + const result: { text: string; marked: boolean }[] = []; + for (const [index, char] of [...props.match.item.label].entries()) { + const last = result[result.length - 1]; + const isMarked = marked.has(index); + if (last && last.marked === isMarked) last.text += char; + else result.push({ text: char, marked: isMarked }); + } + return result; + }); + return ( + + {(part) => (part.marked ? {part.text} : part.text)} + + ); +} + +const KIND_CHIPS: Record = { + component: { text: '<>', kind: 'component' }, + memo: { text: 'memo', kind: 'memo' }, + signal: { text: 'signal', kind: 'signal' }, + action: { text: 'A', kind: 'action' }, + call: { text: 'ƒ', kind: 'call' }, +}; + +/** ⌘K: jump to a component, signal, memo, action or server function by name. */ +export default function CommandPalette(props: CommandPaletteProps): JSX.Element { + const [query, setQuery] = createSignal(''); + const [active, setActive] = createSignal(0); + const [items, setItems] = createSignal([]); + let input: HTMLInputElement | undefined; + + // The registries only see nodes while something tracks them, so the palette + // tracks while it is open and reads once the trackers have walked the app. + onSettled(() => { + const stopOwnership = startOwnershipTracking(); + const stopReactivity = startReactivityTracking(); + const frame = requestAnimationFrame(() => setItems(collectItems(props))); + input?.focus(); + return () => { + cancelAnimationFrame(frame); + stopOwnership(); + stopReactivity(); + }; + }); + + const matches = createMemo(() => searchPalette(items(), query())); + + function pick(match: PaletteMatch | undefined): void { + if (!match) return; + props.onPick(match.item.target); + props.onClose(); + } + + function onKeyDown(event: KeyboardEvent): void { + const count = matches().length; + if (event.key === 'ArrowDown') setActive((index) => (count ? (index + 1) % count : 0)); + else if (event.key === 'ArrowUp') + setActive((index) => (count ? (index - 1 + count) % count : 0)); + else if (event.key === 'Enter') pick(matches()[active()]); + else if (event.key === 'Escape') props.onClose(); + else return; + event.preventDefault(); + } + + return ( +
event.target === event.currentTarget && props.onClose()} + > +
+ +
+ + {items().length ? 'No matches.' : 'Reading the app…'} +
+ } + > + + {(match, index) => { + const chip = KIND_CHIPS[match.item.target.kind]; + const heading = () => + index() === 0 || matches()[index() - 1]!.item.group !== match.item.group; + return ( + <> + +
{match.item.group}
+
+
setActive(index())} + onClick={() => pick(match)} + > + {chip.text} + + {match.item.detail} +
+ + ); + }} +
+ +
+
+ + ); +} diff --git a/src/dev-toolbar/palette/search.test.ts b/src/dev-toolbar/palette/search.test.ts new file mode 100644 index 0000000..ab6a454 --- /dev/null +++ b/src/dev-toolbar/palette/search.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; +import { matchLabel, searchPalette, type PaletteItem } from './search.js'; + +function item(label: string, group: PaletteItem['group'] = 'Memos'): PaletteItem { + return { group, label, detail: '', target: { kind: 'call', instance: label } }; +} + +describe('matchLabel', () => { + it('matches a substring and returns its positions', () => { + expect(matchLabel('revenueUsd', 'rev')).toEqual({ positions: [0, 1, 2], score: 150 }); + }); + + it('matches a subsequence in order', () => { + expect(matchLabel('refundOrder', 'rfo')?.positions).toEqual([0, 2, 6]); + }); + + it('rejects letters out of order', () => { + expect(matchLabel('revenue', 'vnu')).toBeDefined(); + expect(matchLabel('revenue', 'ver')).toBeUndefined(); + }); + + it('ignores case', () => { + expect(matchLabel('OrderRow', 'orderrow')?.positions).toHaveLength(8); + }); +}); + +describe('searchPalette', () => { + it('ranks a match at the start above one in the middle', () => { + const result = searchPalette([item('totalRevenue'), item('revenue')], 'rev'); + expect(result.map((match) => match.item.label)).toEqual(['revenue', 'totalRevenue']); + }); + + it('lists groups in a fixed order and caps each group', () => { + const items = [ + item('refund', 'Actions'), + item('revenue', 'Memos'), + item('r1', 'Signals'), + item('r2', 'Signals'), + item('r3', 'Signals'), + ]; + const result = searchPalette(items, 'r', 2); + expect(result.map((match) => match.item.group)).toEqual([ + 'Memos', + 'Signals', + 'Signals', + 'Actions', + ]); + }); + + it('returns everything for an empty query', () => { + expect(searchPalette([item('a'), item('b')], '')).toHaveLength(2); + }); +}); diff --git a/src/dev-toolbar/palette/search.ts b/src/dev-toolbar/palette/search.ts new file mode 100644 index 0000000..cd82a01 --- /dev/null +++ b/src/dev-toolbar/palette/search.ts @@ -0,0 +1,96 @@ +/** What a palette result points at, so the dock can open it in the right view. */ +export type PaletteTarget = + | { kind: 'memo' | 'signal'; node: object } + | { kind: 'component'; owner: object } + | { kind: 'action'; origin: object | undefined; id: number } + | { kind: 'call'; instance: string }; + +export interface PaletteItem { + /** The group heading the item is listed under. */ + group: 'Components' | 'Signals' | 'Memos' | 'Actions' | 'Server functions'; + label: string; + /** Muted text on the right: a value, an owner, a time. */ + detail: string; + target: PaletteTarget; +} + +export interface PaletteMatch { + item: PaletteItem; + /** Indexes of the label's characters that matched, for highlighting. */ + positions: number[]; + score: number; +} + +/** The order groups are listed in. */ +export const GROUP_ORDER: PaletteItem['group'][] = [ + 'Components', + 'Memos', + 'Signals', + 'Actions', + 'Server functions', +]; + +/** + * Matches `query` against `label` as a subsequence, ignoring case. A run of + * adjacent characters and a match at the start or after a separator score + * higher, so `rev` ranks `revenue` above `refundOrderView`. + */ +export function matchLabel( + label: string, + query: string, +): { positions: number[]; score: number } | undefined { + const text = label.toLowerCase(); + const needle = query.trim().toLowerCase(); + if (!needle) return { positions: [], score: 0 }; + + // A plain substring is the strongest match. + const at = text.indexOf(needle); + if (at !== -1) { + const positions: number[] = []; + for (let i = 0; i < needle.length; i++) positions.push(at + i); + const boundary = at === 0 || !/[a-z0-9]/.test(text[at - 1]!); + return { positions, score: 100 + (boundary ? 50 : 0) - at }; + } + + // Otherwise every character must appear in order. + const positions: number[] = []; + let score = 0; + let from = 0; + for (const char of needle) { + const index = text.indexOf(char, from); + if (index === -1) return undefined; + if (positions.length && index === positions[positions.length - 1]! + 1) score += 5; + positions.push(index); + from = index + 1; + } + return { positions, score: score - positions[0]! }; +} + +/** The items that match `query`, best first within each group, groups in `GROUP_ORDER`. */ +export function searchPalette(items: PaletteItem[], query: string, limit = 6): PaletteMatch[] { + // Each group keeps its own best matches, so one large group cannot crowd out the rest. + const byGroup = new Map(); + for (const item of items) { + const match = matchLabel(item.label, query); + if (!match) continue; + let list = byGroup.get(item.group); + if (!list) { + list = []; + byGroup.set(item.group, list); + } + list.push({ item, positions: match.positions, score: match.score }); + } + + const result: PaletteMatch[] = []; + for (const group of GROUP_ORDER) { + const list = byGroup.get(group); + if (!list) continue; + list.sort(compareMatches); + for (const match of list.slice(0, limit)) result.push(match); + } + return result; +} + +function compareMatches(a: PaletteMatch, b: PaletteMatch): number { + return b.score - a.score || a.item.label.length - b.item.label.length; +} diff --git a/src/dev-toolbar/palette/styles.css b/src/dev-toolbar/palette/styles.css new file mode 100644 index 0000000..ed029b6 --- /dev/null +++ b/src/dev-toolbar/palette/styles.css @@ -0,0 +1,182 @@ +[data-solid-palette-backdrop] { + position: fixed; + inset: 0; + z-index: 3; + + pointer-events: auto; + background: oklch(0 0 0 / 0.35); +} + +[data-solid-palette] { + position: absolute; + left: 50%; + top: 18vh; + width: min(560px, calc(100vw - 2rem)); + transform: translateX(-50%); + + overflow: hidden; + + color: var(--start-dt-text); + background: oklch(0.19 0.015 265 / 0.96); + backdrop-filter: blur(24px); + -webkit-backdrop-filter: blur(24px); + border: 1px solid var(--start-dt-line-2); + border-radius: 14px; + box-shadow: 0 30px 80px oklch(0 0 0 / 0.6); + + animation: solid-palette-pop 220ms cubic-bezier(0.2, 0.8, 0.2, 1); +} + +@keyframes solid-palette-pop { + from { + opacity: 0; + transform: translate(-50%, -8px) scale(0.98); + } +} + +@media (prefers-reduced-motion: reduce) { + [data-solid-palette] { + animation: none; + } +} + +[data-solid-palette-query] { + display: flex; + align-items: center; + gap: 10px; + + padding: 14px 16px; + border-bottom: 1px solid var(--start-dt-line); +} + +[data-solid-palette-query] svg { + width: 16px; + height: 16px; + flex-shrink: 0; + + fill: none; + stroke: var(--start-dt-text-muted); + stroke-width: 1.6; + stroke-linecap: round; +} + +[data-solid-palette-query] input { + flex: 1; + min-width: 0; + + padding: 0; + border: none; + outline: none; + background: none; + color: var(--start-dt-text); + + font: + 15px system-ui, + sans-serif; +} + +[data-solid-palette-query] input::placeholder { + color: var(--start-dt-faint); +} + +[data-solid-palette] kbd { + padding: 0 4px; + border: 1px solid var(--start-dt-line-2); + border-radius: 4px; + color: var(--start-dt-faint); + font: 10px var(--start-dt-mono); +} + +[data-solid-palette-results] { + max-height: 50vh; + overflow: auto; + padding: 6px; +} + +[data-solid-palette-group] { + padding: 8px 10px 4px; + + color: var(--start-dt-faint); + font-size: 10.5px; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +[data-solid-palette-item] { + display: flex; + align-items: center; + gap: 10px; + + padding: 8px 10px; + border-radius: 8px; + cursor: pointer; +} + +[data-solid-palette-item][aria-selected='true'] { + background: var(--start-dt-raise-2); +} + +[data-solid-palette-item] code { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + + font: 12.5px var(--start-dt-mono); +} + +[data-solid-palette-item] mark { + background: none; + color: var(--start-dt-solid); + font-weight: 700; +} + +[data-solid-palette-detail] { + margin-left: auto; + flex-shrink: 0; + max-width: 50%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + + color: var(--start-dt-faint); + font-size: 11.5px; +} + +[data-solid-palette-kind] { + flex-shrink: 0; + min-width: 18px; + padding: 0 5px; + + border-radius: 4px; + font: 700 9.5px/16px var(--start-dt-mono); + text-align: center; +} + +[data-solid-palette-kind='memo'] { + background: var(--start-dt-violet-soft); + color: var(--start-dt-violet); +} + +[data-solid-palette-kind='signal'], +[data-solid-palette-kind='action'] { + background: var(--start-dt-solid-soft); + color: var(--start-dt-solid); +} + +[data-solid-palette-kind='call'] { + background: var(--start-dt-violet-soft); + color: var(--start-dt-violet); +} + +[data-solid-palette-kind='component'] { + background: oklch(1 0 0 / 0.07); + color: var(--start-dt-text-muted); +} + +[data-solid-palette-empty] { + padding: 18px 10px; + color: var(--start-dt-faint); + text-align: center; + font-size: 12px; +} diff --git a/src/dev-toolbar/reactivity/ValueInspector.tsx b/src/dev-toolbar/reactivity/ValueInspector.tsx index f12b85c..eadf908 100644 --- a/src/dev-toolbar/reactivity/ValueInspector.tsx +++ b/src/dev-toolbar/reactivity/ValueInspector.tsx @@ -1,5 +1,5 @@ import type { JSX } from '@solidjs/web'; -import { createMemo, createSignal, For, Show } from 'solid-js'; +import { createMemo, For, Show } from 'solid-js'; import { Badge } from '../../ui/Badge.js'; import { TreeBranch, diff --git a/src/dev-toolbar/reactivity/index.tsx b/src/dev-toolbar/reactivity/index.tsx index 49c5807..49b8284 100644 --- a/src/dev-toolbar/reactivity/index.tsx +++ b/src/dev-toolbar/reactivity/index.tsx @@ -147,6 +147,8 @@ export interface ReactivityViewerProps { componentFilter?: string; /** Drops the component filter set by the element inspector. */ onClearComponentFilter?: () => void; + /** Shown first in the toolbar, such as the Structure view's mode chips. */ + lead?: JSX.Element; } function isDescendantOf(owner: object | undefined, target: object): boolean { @@ -490,6 +492,7 @@ export default function ReactivityViewer(props: ReactivityViewerProps): JSX.Elem
+ {props.lead} unknown; private bridgeNode?: Record; + /** The node the bridge forwards to now. */ + private forwarded?: Record; private ensureBridge(): void { if (this.setter && this.bridgeNode) return; @@ -226,6 +230,12 @@ class ReactiveWriter { const [_, set] = createSignal(0); this.setter = set as (value: unknown) => unknown; const owner = getOwner(); + // The bridge is the toolbar's, so the graph, the tree and the runtime's + // records leave it out. Listed as app code, the graph could hand it back + // as the node to edit. + excludeReactiveOwner(owner); + excludeOwner(owner); + if (owner) OBSERVE?.exclude(owner); const signals = DEV?.getSignals ? (DEV.getSignals(owner as never) as Record[]) : []; @@ -239,6 +249,14 @@ class ReactiveWriter { this.ensureBridge(); if (!this.setter || !this.bridgeNode) return false; + // A panel that still holds the bridge means the node it forwards to. + // Forwarding the bridge to itself would recurse in every getter. + if (target === this.bridgeNode) { + if (!this.forwarded) return false; + target = this.forwarded; + } + this.forwarded = target; + if ('_host' in target && target._host) { const key = target._key; if (target._host.v) target._host.v[key] = nextValue; diff --git a/src/dev-toolbar/report/CopyReportButton.tsx b/src/dev-toolbar/report/CopyReportButton.tsx index 5fc8160..902353e 100644 --- a/src/dev-toolbar/report/CopyReportButton.tsx +++ b/src/dev-toolbar/report/CopyReportButton.tsx @@ -1,6 +1,7 @@ import type { JSX } from '@solidjs/web'; import { createSignal, onCleanup, Show } from 'solid-js'; import IconButton from '../../ui/IconButton.js'; +import { HealthButton } from '../health/HealthButton.js'; import { CheckIcon, CopyIcon } from '../icons.js'; import './styles.css'; @@ -36,6 +37,8 @@ export interface CopyReportButtonProps { report: () => string | Promise; /** What the button copies, such as "this error". */ label: string; + /** Shows a button with this text beside the icon, in place of the icon alone. */ + text?: string; } /** Copies a Markdown report for an agent, such as Claude Code, to read. */ @@ -69,6 +72,22 @@ export function CopyReportButton(props: CopyReportButtonProps): JSX.Element { } }; + if (props.text) { + return ( + + + {props.text} + + ); + } + return ( void; + /** A signal, memo or effect another panel asked the graph to show. */ + focus?: { node: object }; + /** An owner another panel asked the tree to show. Pass a new object for every request. */ + ownerFocus?: { owner: object }; + /** Graph: only nodes owned by or under this component instance. */ + ownerFilter?: object; + /** Graph: only nodes owned by or under a component of this name. */ + componentFilter?: string; + onClearComponentFilter?: () => void; + /** Graph: opens the owner of a node in the tree. */ + onViewOwner?: (owner: object) => void; + /** Tree: opens a signal, memo or effect in the graph. */ + onViewInGraph?: (node: object) => void; + /** The last change of a signal, from the timeline. The second argument is the runtime signal. */ + lastChange?: (nodeId: string | number, node?: object) => SignalChange | undefined; + /** Opens the activity of an owner. */ + onShowActivity?: (owner: object, name: string) => void; +} + +const MODES: { value: StructureMode; label: string; name: string }[] = [ + { value: 'owners', label: 'Owners', name: 'Ownership' }, + { value: 'graph', label: 'Graph', name: 'Reactivity' }, +]; + +interface ModeChipsProps { + mode: StructureMode; + onChange: (mode: StructureMode) => void; +} + +/** The Owners and Graph chips. Both sides of the view show them. */ +function ModeChips(props: ModeChipsProps): JSX.Element { + function moveMode(event: KeyboardEvent): void { + if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return; + event.preventDefault(); + props.onChange(props.mode === 'owners' ? 'graph' : 'owners'); + // The chip that takes over is the one that is now selected. + queueMicrotask(() => { + (event.currentTarget as HTMLElement | null)?.parentElement + ?.querySelector('[aria-selected="true"]') + ?.focus(); + }); + } + + return ( +
+ + {(entry) => ( + + )} + +
+ ); +} + +/** + * The Structure view: the ownership tree and the reactivity graph behind one + * pair of chips. Selecting a chip swaps the pane, and each pane keeps its own + * selection while it is hidden. + */ +export default function StructureViewer(props: StructureViewerProps): JSX.Element { + const [ownMode, setOwnMode] = createSignal('owners'); + const mode = () => props.mode ?? ownMode(); + + function changeMode(next: StructureMode): void { + setOwnMode(next); + props.onModeChange?.(next); + } + + const chips = () => ; + + return ( + <> + + + + ); +} diff --git a/src/dev-toolbar/structure/signal-history.test.ts b/src/dev-toolbar/structure/signal-history.test.ts new file mode 100644 index 0000000..f459e2f --- /dev/null +++ b/src/dev-toolbar/structure/signal-history.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest'; +import { SignalHistory } from './signal-history.js'; + +describe('SignalHistory', () => { + it('has no change for a node seen once', () => { + const history = new SignalHistory(); + expect(history.lastChange({ _value: 1 }, 10)).toBeUndefined(); + }); + + it('reports the previous value and when it changed', () => { + const history = new SignalHistory(); + const node = { _value: 'paid' }; + history.lastChange(node, 10); + node._value = 'refunded'; + expect(history.lastChange(node, 20)).toEqual({ prev: '"paid"', at: 20 }); + // Reading again without a change keeps the same answer. + expect(history.lastChange(node, 30)).toEqual({ prev: '"paid"', at: 20 }); + }); +}); diff --git a/src/dev-toolbar/structure/signal-history.ts b/src/dev-toolbar/structure/signal-history.ts new file mode 100644 index 0000000..fd19e4f --- /dev/null +++ b/src/dev-toolbar/structure/signal-history.ts @@ -0,0 +1,35 @@ +import { formatValue } from '../reactivity/format.js'; +import type { SignalChange } from '../ownership/index.js'; + +interface Seen { + value: unknown; + prev?: unknown; + at?: number; +} + +/** + * Remembers each signal's value the last time the tree showed it, and the + * value before that. The runtime does not hand out the id its write records + * use, so the history is kept per node here. + */ +export class SignalHistory { + #seen = new WeakMap(); + + /** Notes the node's current value, and returns its last change if it had one. */ + lastChange(node: object, now = performance.now()): SignalChange | undefined { + const value = (node as { _value?: unknown })._value; + const seen = this.#seen.get(node); + if (!seen) { + this.#seen.set(node, { value }); + return undefined; + } + // A new value moves the old one into `prev` and stamps the time. + if (!Object.is(seen.value, value)) { + seen.prev = seen.value; + seen.at = now; + seen.value = value; + } + if (seen.at === undefined) return undefined; + return { prev: formatValue(seen.prev), at: seen.at }; + } +} diff --git a/src/dev-toolbar/structure/styles.css b/src/dev-toolbar/structure/styles.css new file mode 100644 index 0000000..1b4ea94 --- /dev/null +++ b/src/dev-toolbar/structure/styles.css @@ -0,0 +1,48 @@ +/* The chips of the Structure view, in the tree pane and in the graph toolbar. */ + +[data-solid-structure-chip] { + padding: 3px 9px; + + border: 1px solid var(--start-dt-line-2); + border-radius: 99px; + background: none; + color: var(--start-dt-text-muted); + + font: inherit; + font-family: + system-ui, + -apple-system, + sans-serif; + font-size: 11.5px; + line-height: 1.45; + white-space: nowrap; + cursor: pointer; + flex: none; +} + +[data-solid-structure-chip][aria-selected='true'] { + border-color: transparent; + background: var(--start-dt-raise-2); + color: var(--start-dt-text); +} + +[data-solid-structure-chip][data-ghost] { + margin-left: auto; + + border-style: dashed; + color: var(--start-dt-faint); +} + +[data-solid-structure-chip][data-ghost][aria-pressed='true'] { + background: oklch(1 0 0 / 0.05); + color: var(--start-dt-text-muted); +} + +[data-solid-structure-chip]:focus-visible { + outline: 1px solid var(--start-dt-solid); + outline-offset: 1px; +} + +[data-solid-structure-modes] { + display: contents; +} diff --git a/src/dev-toolbar/timeline/index.tsx b/src/dev-toolbar/timeline/index.tsx deleted file mode 100644 index b315c9a..0000000 --- a/src/dev-toolbar/timeline/index.tsx +++ /dev/null @@ -1,557 +0,0 @@ -import type { JSX } from '@solidjs/web'; -import { createMemo, createSignal, For, Show } from 'solid-js'; -import type { ChangeRecord } from 'solid-js/attribution'; -import { Badge } from '../../ui/Badge.js'; -import IconButton from '../../ui/IconButton.js'; -import Placeholder from '../../ui/Placeholder.js'; -import { ResizeHandle } from '../../ui/ResizeHandle.js'; -import { Text } from '../../ui/Text.js'; -import { useResizableDetail } from '../../ui/use-resizable-detail.js'; -import { EyeIcon, TrashIcon } from '../icons.js'; -import { CopyReportButton } from '../report/CopyReportButton.js'; -import { pageRoute } from '../report/markdown.js'; -import { ms, plural, timelineReport } from '../report/timeline-report.js'; -import { - isTimelineAvailable, - isUnnamedUpdate, - type TimelineCall, - type TimelineEntry, - type TimelineRun, -} from './tracker.js'; -import { entryDuration, entryRunCount, groupEntries, type TimelineGroup } from './group.js'; -import './styles.css'; - -function outcomeBadge(entry: TimelineEntry): JSX.Element { - const outcome = entry.interaction?.outcome; - if (outcome === 'held') return held; - if (outcome === 'committed') return committed; - if (entry.origin && !entry.interaction) return pending; - return {entry.origin ? (outcome ?? 'idle') : 'update'}; -} - -/** `0 → 1` for a write, with the values the runtime previewed. */ -function WriteText(props: { change: ChangeRecord }): JSX.Element { - return ( - <> - {props.change.kind} - - {props.change.name} - - - - {props.change.prev === undefined - ? props.change.value! - : `${props.change.prev} → ${props.change.value}`} - - - - ); -} - -/** Why a run happened: the changes it saw, each with the changes behind it. */ -function CauseTree(props: { causes: ChangeRecord[] }): JSX.Element { - return ( -
    - - {(cause) => ( -
  • - - ← - }> - - {`${cause.name} changed`} - - - - - - -
  • - )} -
    -
- ); -} - -interface RunRowProps { - run: TimelineRun; - open: boolean; - onToggle: () => void; - onViewInGraph?: (node: object) => void; -} - -function RunRow(props: RunRowProps): JSX.Element { - const node = () => props.run.node?.deref(); - return ( -
-
- - - {(current) => ( - - )} - -
- - - No tracked change caused this run, such as a retry after an error. - - } - > - - - -
- ); -} - -function CallRow(props: { - call: TimelineCall; - onViewCall?: (instance: string) => void; -}): JSX.Element { - const status = () => - props.call.event.status === undefined ? 'ERR' : String(props.call.event.status); - const content = ( - <> - {props.call.event.method} - - {props.call.event.name ?? props.call.event.id} - - {status()} - - {ms(props.call.event.durationMs)} - - - ); - return ( - {content}} - > - {(current) => ( - - )} - - ); -} - -interface EntryRowProps { - entry: TimelineEntry; - selected: boolean; - nested?: boolean; - onSelect: (id: number) => void; -} - -function EntryRow(props: EntryRowProps): JSX.Element { - return ( - - ); -} - -interface GroupRowsProps { - group: TimelineGroup; - open: boolean; - selected: number | undefined; - onToggle: () => void; - onSelect: (id: number) => void; -} - -/** A run of entries with one label: a summary row that opens into the entries. */ -function GroupRows(props: GroupRowsProps): JSX.Element { - // A closed group shows the selection of an entry inside it. - const holdsSelection = () => - !props.open && props.group.entries.some((entry) => entry.id === props.selected); - return ( -
- - - - {(entry) => ( - - )} - - -
- ); -} - -export interface TimelineViewerProps { - show?: boolean; - entries: TimelineEntry[]; - onClear: () => void; - /** Opens a memo or an effect in the reactivity graph. */ - onViewInGraph?: (node: object) => void; - /** Opens a server-function call in its panel, by the panel's key for it. */ - onViewCall?: (instance: string) => void; -} - -export default function TimelineViewer(props: TimelineViewerProps): JSX.Element { - const [query, setQuery] = createSignal(''); - const [showUnnamed, setShowUnnamed] = createSignal(false); - const [selected, setSelected] = createSignal(); - const [openRuns, setOpenRuns] = createSignal([]); - const [openGroups, setOpenGroups] = createSignal([]); - const detail = useResizableDetail({ storageKey: 'solid-start-devtools:timeline-detail-width' }); - - const unnamedCount = createMemo(() => props.entries.filter(isUnnamedUpdate).length); - - const shown = createMemo(() => { - const search = query().trim().toLowerCase(); - const entries = showUnnamed() - ? props.entries - : props.entries.filter((entry) => !isUnnamedUpdate(entry)); - if (!search) return entries; - return entries.filter( - (entry) => - entry.label.toLowerCase().includes(search) || - entry.writes.some((write) => write.name.toLowerCase().includes(search)) || - entry.runs.some((run) => run.event.nodeName.toLowerCase().includes(search)), - ); - }); - - // Folding runs after filtering, so the search only sees the entries it matches. - const groups = createMemo(() => groupEntries(shown())); - - const selectedEntry = createMemo(() => { - const id = selected(); - return id === undefined ? undefined : props.entries.find((entry) => entry.id === id); - }); - - function select(id: number): void { - setOpenRuns([]); - setSelected((current) => (current === id ? undefined : id)); - } - - function toggleGroup(key: number): void { - setOpenGroups((current) => - current.includes(key) ? current.filter((item) => item !== key) : [...current, key], - ); - } - - function toggleRun(run: number): void { - setOpenRuns((current) => - current.includes(run) ? current.filter((item) => item !== run) : [...current, run], - ); - } - - return ( - -
-
-
- setQuery(event.currentTarget.value)} - /> - - - -
- - {`${shown().length} of ${props.entries.length}`} - - { - setSelected(undefined); - props.onClear(); - }} - > - - -
-
- -
- - - The timeline needs a development build of solid-js. - - - } - > -
- - - {props.entries.length - ? 'Nothing matches this filter.' - : 'Interact with the app to see what each action re-runs.'} - - - } - > - - {(group) => ( - - } - > - toggleGroup(group.key)} - onSelect={select} - /> - - )} - - -
- - - {(entry) => ( - - )} - -
-
-
-
-
- ); -} diff --git a/src/dev-toolbar/timeline/styles.css b/src/dev-toolbar/timeline/styles.css deleted file mode 100644 index dd47219..0000000 --- a/src/dev-toolbar/timeline/styles.css +++ /dev/null @@ -1,379 +0,0 @@ -[data-solid-timeline-viewer] { - --start-dt-kind-memo: oklch(0.7 0.14 300); - --start-dt-kind-effect: oklch(0.72 0.15 150); - - color: var(--start-dt-text); - - display: flex; - flex-direction: column; - - height: 100%; - min-height: 0; -} - -[data-solid-timeline-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-timeline-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-timeline-search]:focus { - outline: none; - border-color: var(--start-dt-accent); -} - -[data-solid-timeline-unnamed] { - 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-muted); - - cursor: pointer; -} - -[data-solid-timeline-unnamed]:hover { - background: var(--start-dt-surface-hover); -} - -[data-solid-timeline-unnamed][aria-pressed='true'] { - color: var(--start-dt-text); -} - -[data-solid-timeline-nav-actions] { - display: flex; - flex-direction: row; - align-items: center; - - gap: 0.375rem; - - margin-left: auto; -} - -[data-solid-timeline-count] { - color: var(--start-dt-text-muted); - white-space: nowrap; -} - -[data-solid-timeline-body] { - display: flex; - flex-direction: row; - - flex: 1; - min-height: 0; -} - -[data-solid-timeline-list] { - display: flex; - flex-direction: column; - - flex: 1; - min-width: 0; - - overflow: auto; -} - -[data-solid-timeline-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-timeline-row]:hover { - background: var(--start-dt-surface-hover); -} - -[data-solid-timeline-row][data-selected] { - background: var(--start-dt-surface-active); -} - -[data-solid-timeline-row]:focus-visible { - outline: 2px solid var(--start-dt-accent); - outline-offset: -2px; -} - -[data-solid-timeline-row-head] { - display: flex; - flex-direction: row; - align-items: center; - - gap: 0.5rem; - - min-width: 0; -} - -[data-solid-timeline-row-label] { - overflow: hidden; - text-overflow: ellipsis; -} - -[data-solid-timeline-row-meta] { - color: var(--start-dt-text-muted); -} - -[data-solid-timeline-detail] { - display: flex; - flex-direction: column; - - gap: 0.625rem; - - position: relative; - - /* Keeps the list usable when the window shrinks below a saved width. */ - max-width: calc(100% - 240px); - 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-timeline-detail] > * { - flex-shrink: 0; -} - -[data-solid-timeline-detail] [data-solid-kv-key] { - flex-basis: 5.5rem; -} - -[data-solid-timeline-detail-head] { - display: flex; - flex-direction: row; - align-items: center; - - gap: 0.375rem; - - min-width: 0; -} - -[data-solid-timeline-detail-head] [data-solid-copy-report] { - margin-left: auto; - padding: 0.25rem; - border-radius: 0.25rem; -} - -[data-solid-timeline-detail-head] [data-solid-copy-report] > svg { - width: 0.875rem; - height: 0.875rem; -} - -[data-solid-timeline-section] { - display: flex; - flex-direction: column; - - gap: 0.25rem; -} - -[data-solid-timeline-write], -[data-solid-timeline-cause] { - display: flex; - flex-direction: row; - align-items: baseline; - - gap: 0.375rem; - - min-width: 0; -} - -[data-solid-timeline-change-kind] { - color: var(--start-dt-text-muted); - font-family: ui-monospace, SFMono-Regular, Menlo, monospace; - font-size: 0.6875rem; -} - -[data-solid-timeline-change-value] { - color: oklch(0.8 0.12 80); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -[data-solid-timeline-call] { - display: flex; - flex-direction: row; - align-items: center; - - gap: 0.375rem; - - padding: 0.25rem 0.375rem; - - border: none; - border-radius: 0.375rem; - background: none; - color: var(--start-dt-text); - text-align: left; -} - -button[data-solid-timeline-call] { - cursor: pointer; -} - -button[data-solid-timeline-call]:hover { - background: var(--start-dt-surface-hover); -} - -[data-solid-timeline-run] { - display: flex; - flex-direction: column; - - border-radius: 0.375rem; -} - -[data-solid-timeline-run][data-open] { - background: var(--start-dt-surface); - padding-bottom: 0.375rem; -} - -[data-solid-timeline-run-head] { - display: flex; - flex-direction: row; - align-items: center; - - gap: 0.25rem; -} - -[data-solid-timeline-run-toggle] { - display: flex; - flex-direction: row; - align-items: center; - - gap: 0.375rem; - - flex: 1; - min-width: 0; - - padding: 0.25rem 0.375rem; - - border: none; - border-radius: 0.375rem; - background: none; - color: var(--start-dt-text); - text-align: left; - - cursor: pointer; -} - -[data-solid-timeline-run-toggle]:hover { - background: var(--start-dt-surface-hover); -} - -[data-solid-timeline-run-time] { - margin-left: auto; - color: var(--start-dt-text-muted); -} - -[data-solid-timeline-kind] { - width: 0.5rem; - height: 0.5rem; - flex-shrink: 0; - - border-radius: 9999px; - background: var(--start-dt-kind-effect); -} - -[data-solid-timeline-kind='memo'] { - background: var(--start-dt-kind-memo); -} - -[data-solid-timeline-view-graph] { - 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-timeline-view-graph]:hover { - background: var(--start-dt-accent-soft); -} - -[data-solid-timeline-view-graph] svg { - width: 0.875rem; - height: 0.875rem; -} - -[data-solid-timeline-causes] { - margin: 0; - padding: 0 0 0 1.25rem; - - list-style: none; -} - -[data-solid-timeline-causes] [data-solid-timeline-causes] { - padding-left: 1rem; -} - -[data-solid-timeline-arrow] { - color: var(--start-dt-text-muted); -} - -[data-solid-timeline-note] { - color: var(--start-dt-text-muted); -} - -[data-solid-timeline-group] { - display: flex; - flex-direction: column; -} - -[data-solid-timeline-group] > [data-solid-timeline-row] { - flex-shrink: 0; -} - -[data-solid-timeline-row][data-nested] { - padding-left: 1.75rem; -} - -[data-solid-timeline-chevron] { - width: 0.75rem; - flex-shrink: 0; - - color: var(--start-dt-text-muted); - text-align: center; -} diff --git a/src/dev-toolbar/timeline/tracker.test.ts b/src/dev-toolbar/timeline/tracker.test.ts index 4bee355..932775f 100644 --- a/src/dev-toolbar/timeline/tracker.test.ts +++ b/src/dev-toolbar/timeline/tracker.test.ts @@ -8,7 +8,14 @@ import type { } from 'solid-js/attribution'; import { timelineReport } from '../report/timeline-report.js'; import type { CallEvent } from '@solidjs/web'; -import { callInteraction, isUnnamedUpdate, rootChanges, TimelineBuilder } from './tracker.js'; +import { + callInteraction, + evictionIndex, + isUnnamedUpdate, + MAX_SAME_LABEL, + rootChanges, + TimelineBuilder, +} from './tracker.js'; const click: ChangeOrigin = { kind: 'interaction', @@ -301,3 +308,17 @@ describe('timelineReport', () => { expect(report).not.toContain('a → b'); }); }); + +describe('evictionIndex', () => { + it('drops the oldest entry of a label repeated past the limit', () => { + const entries = [{ label: 'refund' }]; + for (let i = 0; i < MAX_SAME_LABEL + 1; i++) entries.push({ label: 'update: tick' }); + entries.push({ label: 'setFilter' }); + // The oldest is `setFilter`, but the tick label is over the limit. + expect(evictionIndex(entries)).toBe(entries.length - 2); + }); + + it('drops the oldest entry when no label is over the limit', () => { + expect(evictionIndex([{ label: 'a' }, { label: 'b' }, { label: 'a' }])).toBe(2); + }); +}); diff --git a/src/dev-toolbar/timeline/tracker.ts b/src/dev-toolbar/timeline/tracker.ts index 464fd96..533a341 100644 --- a/src/dev-toolbar/timeline/tracker.ts +++ b/src/dev-toolbar/timeline/tracker.ts @@ -62,6 +62,24 @@ export interface TimelineEntry { /** Most entries the panel keeps. The oldest go first. */ export const MAX_ENTRIES = 100; +/** How many entries of one label are kept before its oldest go first. */ +export const MAX_SAME_LABEL = 20; + +/** + * The entry to drop when the timeline is full. A loop such as an animation + * frame repeats one label many times a second, so the oldest entry of a label + * that has more than `MAX_SAME_LABEL` entries goes before any other action. + * Otherwise the oldest entry goes. `entries` is newest first. + */ +export function evictionIndex(entries: { label: string }[]): number { + const counts = new Map(); + for (const entry of entries) counts.set(entry.label, (counts.get(entry.label) ?? 0) + 1); + for (let index = entries.length - 1; index >= 0; index--) { + if (counts.get(entries[index]!.label)! > MAX_SAME_LABEL) return index; + } + return entries.length - 1; +} + /** Most re-runs one entry keeps. */ export const MAX_RUNS = 300; @@ -114,6 +132,23 @@ export function isUnnamedUpdate(entry: TimelineEntry): boolean { return !entry.origin && entry.writes.every((write) => DEFAULT_NAMES.has(write.name)); } +/** + * An entry that wrote nothing, re-ran nothing and made no call, hold or + * navigation. A click inside the toolbar ends up here: the runtime counts its + * flush, but the toolbar's own nodes send no records. Lists leave it out, as + * they do an interaction that changed nothing. + */ +export function isIdleEntry(entry: TimelineEntry): boolean { + return ( + !entry.writes.length && + !entry.runs.length && + !entry.droppedRuns && + !entry.calls.length && + !entry.holds.length && + !entry.navigations.length + ); +} + function externalLabel(writes: ChangeRecord[]): string { const origin = writes.find((write) => write.origin)?.origin; const source = origin && origin.kind !== 'external' ? formatOrigin(origin) : 'update'; @@ -157,8 +192,9 @@ export class TimelineBuilder { calls: [], }; this.entries.unshift(entry); - for (const dropped of this.entries.splice(this.max)) { - if (dropped.origin) this.byOrigin.delete(dropped.origin); + while (this.entries.length > this.max) { + const [dropped] = this.entries.splice(evictionIndex(this.entries), 1); + if (dropped!.origin) this.byOrigin.delete(dropped!.origin); if (dropped === this.external) this.external = undefined; } if (origin) this.byOrigin.set(origin, entry); @@ -345,8 +381,9 @@ export function startTimeline(builder: TimelineBuilder, onChange: () => void): ( changed(); }), ]; - // The console log stays off: the panel is where the re-runs are read. - const release = attribution.enable({ log: false, values: 'full' }); + // The console log stays off: the panel is where the re-runs are read. Stacks + // are on so each write names the line of the handler that made it. + const release = attribution.enable({ log: false, values: 'full', stacks: true }); return () => { release(); for (const off of offs) off(); diff --git a/tests/e2e/devtools.spec.ts b/tests/e2e/devtools.spec.ts index 5b4b6a5..233d9a0 100644 --- a/tests/e2e/devtools.spec.ts +++ b/tests/e2e/devtools.spec.ts @@ -23,23 +23,35 @@ async function readJavaScript(directory: string): Promise { return sources.join('\n'); } -// The view each panel belongs to. Health has no panels of its own. +// The view each panel belongs to. Activity is one merged stream, so Timeline, Async +// and Server functions are not tabs of their own. Ownership and Reactivity are +// chips inside Structure. const VIEW_OF: Record = { + Health: 'Health', Timeline: 'Activity', Async: 'Activity', 'Server functions': 'Activity', Ownership: 'Structure', Reactivity: 'Structure', }; +const CHIP_OF = new Set(['Ownership', 'Reactivity']); -// Opens the dock on a view, or on a panel of a view. The dock sits below the +// Opens the dock on a view, or on a chip of the Structure view. The dock sits below the // page, which can scroll past it, so the fixture stays clickable while it is open. async function openTab(page: Page, name: string) { const launcher = page.getByRole('button', { name: 'Open Start Devtools' }); if (await launcher.isVisible()) await launcher.click(); const view = VIEW_OF[name]; - if (view) await page.getByRole('tab', { name: view, exact: true }).click(); - await page.getByRole('tab', { name, exact: true }).click(); + await page.getByRole('tab', { name: view, exact: true }).click(); + if (CHIP_OF.has(name)) await page.getByRole('tab', { name, exact: true }).click(); +} + +// A signal of the selected owner is a term and the definition beside it. +function ownershipSignal(page: Page, name: string) { + const term = page + .locator('dt[data-solid-ownership-signal]') + .filter({ hasText: new RegExp(`${name}$`) }); + return { term, value: term.locator('xpath=following-sibling::dd[1]') }; } test('renders the toolbar around the app', async ({ page }) => { @@ -77,7 +89,7 @@ test('docks at the bottom of the page', async ({ page }) => { await page.reload(); await expect(dock).toBeVisible(); await expect.poll(async () => (await dock.boundingBox())!.height).toBe(box.height + 100); - await expect(page.getByRole('tab', { name: 'Ownership', exact: true })).toHaveAttribute( + await expect(page.getByRole('tab', { name: 'Structure', exact: true })).toHaveAttribute( 'aria-selected', 'true', ); @@ -296,12 +308,11 @@ test('shows server-function calls', async ({ page }) => { }); await page.goto('/'); - const instances = page.locator('[data-solid-functions-instances]'); + const instances = page.locator('[data-solid-activity-row][data-kind="server"]'); await page.locator('#emit-server-function-request').click(); await openTab(page, 'Server functions'); await expect(instances).toContainText('loadUser'); - await expect(page.getByRole('tab', { name: 'Server functions' })).toContainText('1'); await page.locator('#emit-server-function-response').click(); await expect(instances).toContainText('201'); @@ -310,10 +321,12 @@ test('shows server-function calls', async ({ page }) => { await expect(instances).toContainText('500'); // The detail pane starts empty and fills in once a call is selected. - const detail = page.locator('[data-solid-functions-detail]'); - await expect(detail).toContainText('Select a server function call.'); - await page.locator('[data-solid-functions-instances] [data-solid-select-option]').first().click(); - await expect(detail.locator('[data-solid-function-instance-viewer]')).toBeVisible(); + const detail = page.locator('[data-solid-activity-detail]'); + await expect(detail).toContainText('Select an item to see what it did.'); + await instances.first().click(); + await expect( + detail.locator('[data-solid-activity-detail-body][data-kind="server"]'), + ).toBeVisible(); expect(warnings).not.toContainEqual(expect.stringContaining('STRICT_READ_UNTRACKED')); }); @@ -326,12 +339,10 @@ test('shows the arguments and files of an upload call', async ({ page }) => { await page.locator('#emit-upload-request').click(); await openTab(page, 'Server functions'); await page - .locator('[data-solid-functions-instances] [data-solid-select-option]', { - hasText: 'uploadAvatar', - }) + .locator('[data-solid-activity-row][data-kind="server"]', { hasText: 'uploadAvatar' }) .click(); - const detail = page.locator('[data-solid-functions-detail]'); + const detail = page.locator('[data-solid-activity-detail]'); await expect(detail.locator('[data-solid-seroval-viewer]')).toContainText('note'); await expect(detail.locator('[data-solid-form-data-viewer]')).toContainText('file0'); await expect(detail.locator('[data-solid-blob-viewer-name]')).toContainText('avatar.txt'); @@ -420,9 +431,7 @@ test('maps the reactivity graph', async ({ page }) => { page.locator('[data-solid-reactivity-detail] [data-solid-reactivity-owner]'), ).toHaveText(' › '); await page.locator('[data-solid-reactivity-detail] [data-solid-reactivity-owner]').click(); - await expect(page.locator('[data-solid-ownership-row][data-selected]')).toContainText( - '', - ); + await expect(page.locator('[data-solid-ownership-row][data-selected]')).toContainText('Counter'); }); test('maps the ownership tree', async ({ page }) => { @@ -430,40 +439,37 @@ test('maps the ownership tree', async ({ page }) => { const rows = page.locator('[data-solid-ownership-name]'); await openTab(page, 'Ownership'); - await expect(rows).toHaveText(['', '', '', '', '', '']); + // Solid's own components are hidden by default. + await expect(rows).toHaveText(['App', 'Greeting', 'Counter']); - // The expand arrow keeps its full size inside the button. - const arrow = await page - .locator('[data-solid-ownership-chevron]') - .first() - .evaluate((element) => { - const style = getComputedStyle(element, '::before'); - return [style.width, style.height]; - }); - expect(arrow).toEqual(['5px', '5px']); + // The expand arrow is a glyph that keeps its width in the row. + await expect(page.locator('[data-solid-ownership-chevron]').first()).toHaveText('▾'); + expect( + await page + .locator('[data-solid-ownership-chevron]') + .first() + .evaluate((element) => element.getBoundingClientRect().width), + ).toBe(12); // The detail pane only appears once an owner is selected. await expect(page.locator('[data-solid-ownership-detail]')).toHaveCount(0); // A component owns the signals and scopes created inside it. - await page.locator('[data-solid-ownership-label]').filter({ hasText: '' }).click(); + await page.locator('[data-solid-ownership-label]').filter({ hasText: 'Counter' }).click(); const detail = page.locator('[data-solid-ownership-detail]'); - await expect(detail).toContainText('Signals (1)'); + await expect(detail.locator('[data-solid-ownership-detail-head]')).toContainText(' (document.querySelector('#increment-count') as HTMLElement).click()); - await expect( - detail - .locator('[data-solid-ownership-signal]') - .filter({ has: page.getByText('count', { exact: true }) }), - ).toContainText('1'); + await expect(ownershipSignal(page, 'count').value).toContainText('1'); // Props are listed by name, never read. - await page.locator('[data-solid-ownership-label]').filter({ hasText: '' }).click(); - await expect(detail).toContainText('Props (1)'); + await page.locator('[data-solid-ownership-label]').filter({ hasText: 'Greeting' }).click(); + await expect(detail).toContainText('prop'); await expect(detail).toContainText('name'); // The ancestry section lists the owners above the selection, nearest first. @@ -474,28 +480,29 @@ test('maps the ownership tree', async ({ page }) => { // Clicking a frame walks up the tree. await frames.nth(1).click(); - await expect(detail.locator('[data-solid-ownership-detail-head]')).toContainText(''); + await expect(detail.locator('[data-solid-ownership-detail-head]')).toContainText('', '']); + await expect(rows).toHaveText(['App', 'Counter']); // Rows carry no count badges. await expect(page.locator('[data-solid-ownership-row] [data-solid-badge="info"]')).toHaveCount(0); // A signal opens in the reactivity graph with that node selected. await page.locator('[data-solid-ownership-search]').fill(''); - await page.locator('[data-solid-ownership-label]').filter({ hasText: '' }).click(); - const countEntry = detail - .locator('[data-solid-ownership-signal]') - .filter({ has: page.getByText('count', { exact: true }) }); + await page.locator('[data-solid-ownership-label]').filter({ hasText: 'Counter' }).click(); + const countEntry = ownershipSignal(page, 'count').value; await countEntry.hover(); - await countEntry.getByRole('button', { name: 'View in graph' }).click(); + await countEntry.getByRole('button', { name: 'View count in graph' }).click(); await expect( page .locator('[data-solid-reactivity-node]') @@ -529,7 +536,8 @@ test('maps the ownership tree', async ({ page }) => { // In owner mode a memo is a row of its own, and its heading links to the graph. await openTab(page, 'Ownership'); - await page.getByRole('button', { name: 'Owners', exact: true }).click(); + const showInternals = page.locator('[data-solid-ownership-mode]'); + if ((await showInternals.getAttribute('aria-pressed')) === 'true') await showInternals.click(); await page .locator('[data-solid-ownership-label]') .filter({ hasText: /^doubled$/ }) @@ -552,18 +560,14 @@ test('maps the ownership tree', async ({ page }) => { // Double click resets the width await handle.dblclick(); - const resetWidth = (await detail.boundingBox())!.width; - expect(Math.abs(resetWidth - 320)).toBeLessThanOrEqual(2); + await expect(detail).toHaveCSS('width', '480px'); // The left arrow key widens the pane from the keyboard. await handle.focus(); await page.keyboard.press('ArrowLeft'); - await expect(handle).toHaveAttribute('aria-valuenow', '336'); + await expect(handle).toHaveAttribute('aria-valuenow', '496'); - await detail - .locator('[data-solid-ownership-detail-head]') - .getByRole('button', { name: 'View in graph' }) - .click(); + await detail.getByRole('button', { name: 'View in graph', exact: true }).click(); await expect( page .locator('[data-solid-reactivity-node]') @@ -584,18 +588,19 @@ test('lists runtime issues', async ({ page }) => { 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 page.locator('[data-solid-health-filter="error"]').click(); await expect(rows).toHaveCount(0); - await page.locator('[data-solid-issues-filter="warn"]').click(); + await expect(page.locator('[data-solid-health-count]')).toContainText('0'); + await page.locator('[data-solid-health-filter="all"]').click(); // The detail links to the component in the ownership tree. await rows.first().click(); const detail = page.locator('[data-solid-issues-detail]'); + // The toolbar's own owners are not part of the path. + await expect(detail).toContainText(' › › value › '); + await expect(detail).not.toContainText(''); await expect(detail).toContainText('draft'); await expect(detail.getByRole('link', { name: 'Open the repair guide' })).toHaveAttribute( 'href', @@ -603,7 +608,7 @@ test('lists runtime issues', async ({ page }) => { ); await detail.getByRole('button', { name: 'Show in the ownership tree' }).click(); await expect(page.locator('[data-solid-ownership-row][data-selected]')).toContainText( - '', + 'DraftReader', ); // Clearing empties the list and the count. @@ -618,44 +623,44 @@ test('shows what each action re-ran and why', async ({ page }) => { await page.locator('#increment-count').click(); await openTab(page, 'Timeline'); - const rows = page.locator('[data-solid-timeline-row]'); - // Clicks on the toolbar change nothing in the app, so the click on the counter is the only entry. + const actions = page.locator('[data-solid-activity-row][data-kind="action"]'); + const rows = actions.filter({ hasText: 'button#increment-count' }); await expect(rows).toHaveCount(1); - await expect(rows.first()).toContainText('click on button#increment-count'); + await expect(rows.first().locator('[data-solid-activity-what] b')).toContainText( + 'click on button#increment-count', + ); await expect(rows.first()).toContainText('1 write · 3 re-runs'); - await expect(rows.first()).toContainText('committed'); await rows.first().click(); - const detail = page.locator('[data-solid-timeline-detail]'); - await expect(detail.locator('[data-solid-timeline-write]')).toHaveText(/write\s*count\s*0 → 1/); - await expect(detail.locator('[data-solid-timeline-run-toggle]')).toHaveText([ - /doubled/, - /effect/, - /report-doubled/, - ]); + const detail = page.locator('[data-solid-activity-detail]'); + await expect(detail.locator('[data-solid-timeline-outcome]')).toContainText('committed'); + const wrote = detail.locator('[data-solid-activity-node]').first(); + await expect(wrote).toContainText('Wrote'); + await expect(wrote).toContainText('count'); + await expect(wrote).toContainText('0 → 1'); + const runs = detail.locator('[data-solid-activity-wf]'); + await expect(runs).toHaveCount(3); + await expect(runs.nth(0)).toContainText('doubled'); + await expect(runs.nth(1)).toContainText('effect'); + await expect(runs.nth(2)).toContainText('report-doubled'); // An effect re-ran because the memo it reads changed, which the write caused. - await detail - .locator('[data-solid-timeline-run-toggle]') - .filter({ hasText: 'report-doubled' }) - .click(); - const causes = detail.locator( - '[data-solid-timeline-run][data-open] [data-solid-timeline-causes]', - ); + await runs.filter({ hasText: 'report-doubled' }).click(); + const causes = detail.locator('[data-solid-activity-causes]'); await expect(causes.first()).toContainText('doubled changed'); await expect(causes.first()).toContainText('count'); // The eye opens the memo in the reactivity graph. - await detail - .locator('[data-solid-timeline-run]') - .filter({ hasText: /^doubled/ }) - .getByRole('button', { name: 'View in graph' }) - .click(); + await detail.getByRole('button', { name: 'View in graph' }).first().click(); await expect(page.getByRole('tab', { name: 'Reactivity', exact: true })).toHaveAttribute( 'aria-selected', 'true', ); await expect(page.locator('[data-solid-reactivity-detail]')).toContainText('doubled'); + + // Clicks on the toolbar change nothing in the app, so the click on the counter is the only entry. + await openTab(page, 'Timeline'); + await expect(actions).toHaveCount(1); }); test('lists the server calls of an action and opens them', async ({ page }) => { @@ -664,37 +669,44 @@ test('lists the server calls of an action and opens them', async ({ page }) => { await page.locator('#call-server').click(); await openTab(page, 'Timeline'); - const row = page.locator('[data-solid-timeline-row]').filter({ hasText: 'button#call-server' }); - await expect(row).toContainText('1 call'); + const row = page + .locator('[data-solid-activity-row][data-kind="action"]') + .filter({ hasText: 'button#call-server' }); + await expect(row).toContainText('1 server call'); await row.click(); - const call = page.locator('[data-solid-timeline-call]'); - await expect(call).toContainText('loadUser'); - await expect(call).toContainText('200'); + const call = page.locator('[data-solid-activity-wf][data-kind="server"]'); + await expect(call).toContainText('ƒ loadUser'); await call.click(); - await expect(page.getByRole('tab', { name: 'Server functions', exact: true })).toHaveAttribute( + // The call opens beside the list, in the same view. + await expect(page.getByRole('tab', { name: 'Activity', exact: true })).toHaveAttribute( 'aria-selected', 'true', ); - await expect(page.locator('[data-solid-function-instance-viewer]')).toContainText('loadUser'); + const server = page.locator('[data-solid-activity-detail-body][data-kind="server"]'); + await expect(server.locator('h2')).toContainText('loadUser'); + await expect(server.locator('h2')).toContainText('200'); }); 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.'); + const live = page.locator('[data-solid-activity-row][data-kind="async"]', { + hasText: 'user loading', + }); + await expect(live).toHaveCount(0); 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. + // While the next user loads, the list shows what is loading. await page.locator('#next-user').click(); - await expect(page.locator('[data-solid-async-now]')).toContainText('user'); + await expect(live).toContainText('live'); await expect(page.locator('#async-posts')).toHaveText('post of user 2'); - await expect(page.locator('[data-solid-async-now]')).toContainText('Nothing is loading.'); + await expect(live).toHaveCount(0); - const rows = page.locator('[data-solid-async-row]'); + const rows = page.locator('[data-solid-activity-row][data-kind="async"]'); 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. @@ -707,13 +719,14 @@ test('shows async loads, waits, fallbacks and chains', async ({ page }) => { const wait = rows.filter({ hasText: /wait on (user, posts|posts, user)/ }).first(); await expect(wait).toContainText('no pending state shown'); await wait.click(); - const detail = page.locator('[data-solid-async-detail]'); + const detail = page.locator('[data-solid-activity-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); + await page.locator('[data-solid-activity-chip][data-filter="server"]').click(); + await expect(rows).toHaveCount(0); + await page.locator('[data-solid-activity-chip][data-filter="all"]').click(); // A load links to its node in the reactivity graph. await rows.filter({ hasText: 'load user' }).first().click(); @@ -747,22 +760,28 @@ test('hides updates that only touched unnamed signals', async ({ page }) => { await expect(page.locator('#async-posts')).toHaveText('post of user 1'); await openTab(page, 'Timeline'); - const rows = page.locator('[data-solid-timeline-row]'); - const toggle = page.locator('[data-solid-timeline-unnamed]'); - await expect(toggle).toContainText(/Show \d+ unnamed update/); + const rows = page.locator('[data-solid-activity-row][data-kind="action"]'); + const toggle = page.locator('[data-solid-activity-chip][data-ghost]'); + await expect(toggle).toContainText(/\d+ folded/); await expect(rows.filter({ hasText: /^update/ })).toHaveCount(0); await expect(rows.filter({ hasText: 'button#show-async' })).toHaveCount(1); + const folded = await rows.count(); await toggle.click(); - await expect(toggle).toContainText(/Hide \d+ unnamed update/); - expect(await rows.count()).toBeGreaterThan(1); + await expect(toggle).toContainText(/\d+ unfolded/); + expect(await rows.count()).toBeGreaterThan(folded); }); test('copies reports for an agent', async ({ page, context }) => { await context.grantPermissions(['clipboard-read', 'clipboard-write']); const clipboard = () => page.evaluate(() => navigator.clipboard.readText()); + // A button with a visible label carries the sentence in its title, an icon button in its name. const copy = (name: string) => - page.getByRole('button', { name: `Copy ${name} for an agent` }).click(); + page + .locator( + `button[title="Copy ${name} for an agent"], button[aria-label="Copy ${name} for an agent"]`, + ) + .click(); await page.goto('/'); @@ -786,14 +805,14 @@ test('copies reports for an agent', async ({ page, context }) => { await page.locator('#emit-server-function-request').click(); await page.locator('#emit-server-function-response').click(); await openTab(page, 'Server functions'); - await page.locator('[data-solid-functions-instances] [data-solid-select-option]').first().click(); + await page.locator('[data-solid-activity-row][data-kind="server"]').first().click(); await copy('this call'); await expect.poll(clipboard).toContain('# Server function call: `loadUser`'); expect(await clipboard()).toContain('- Response: `201`'); // A component, with its signals and where it sits. await openTab(page, 'Ownership'); - await page.locator('[data-solid-ownership-label]').filter({ hasText: '' }).click(); + await page.locator('[data-solid-ownership-label]').filter({ hasText: 'Counter' }).click(); await copy('this component'); await expect.poll(clipboard).toContain('# Solid component: ``'); expect(await clipboard()).toContain('- `count` = `0`'); @@ -907,6 +926,10 @@ test('inspects elements and funnels components across panels', async ({ page }) await expect(inspectButton).not.toHaveAttribute('data-active', ''); // 4. The pick opens in the Ownership tree, selected. + await expect(page.getByRole('tab', { name: 'Structure', exact: true })).toHaveAttribute( + 'aria-selected', + 'true', + ); await expect(page.getByRole('tab', { name: 'Ownership', exact: true })).toHaveAttribute( 'aria-selected', 'true', @@ -940,16 +963,13 @@ test('allows live editing of signal state and updates DOM', async ({ page }) => // 1. Live edit via Ownership panel await openTab(page, 'Ownership'); - await page.locator('[data-solid-ownership-label]').filter({ hasText: '' }).click(); + await page.locator('[data-solid-ownership-label]').filter({ hasText: 'Counter' }).click(); - const countSignal = page - .locator('[data-solid-ownership-signal]') - .filter({ has: page.getByText('count', { exact: true }) }); + const countSignal = ownershipSignal(page, 'count').value; await expect(countSignal).toContainText('0'); // Trigger inline edit - await countSignal.hover(); - await countSignal.locator('[data-solid-ownership-edit-signal]').click(); + await countSignal.locator('[data-solid-ownership-value]').click(); const ownershipInput = countSignal.locator('[data-solid-ownership-signal-input]'); await expect(ownershipInput).toBeVisible(); await ownershipInput.fill('42'); From 72e7cb71bb7263988dda1326c0cee92e2035a781 Mon Sep 17 00:00:00 2001 From: "Alexis H. Munsayac" Date: Wed, 4 Nov 2026 11:54:58 +0800 Subject: [PATCH 04/10] fix(health): run the age clock only while shown, and open each new error Co-Authored-By: Claude Opus 5.5 --- src/dev-toolbar/health/index.tsx | 30 ++++++++++++++++++------------ 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/src/dev-toolbar/health/index.tsx b/src/dev-toolbar/health/index.tsx index 2c04649..a072551 100644 --- a/src/dev-toolbar/health/index.tsx +++ b/src/dev-toolbar/health/index.tsx @@ -1,6 +1,6 @@ import type { JSX } from '@solidjs/web'; import { clientOnly } from '@solidjs/web'; -import { createEffect, createMemo, createSignal, For, onSettled, Show } from 'solid-js'; +import { createEffect, createMemo, createSignal, For, Show } from 'solid-js'; import { ResizeHandle } from '../../ui/ResizeHandle.js'; import { useResizableDetail } from '../../ui/use-resizable-detail.js'; import type { ErrorRecord } from '../error-record.js'; @@ -64,19 +64,25 @@ export default function HealthViewer(props: HealthViewerProps): JSX.Element { }), ); - // The ages in the rows move on without a change to the lists, so a clock runs. - onSettled(() => { - const timer = setInterval(() => setTick((value) => value + 1), TICK_MS); - return () => clearInterval(timer); - }); + // The ages in the rows move on without a change to the lists, so a clock + // runs while the view is shown. + createEffect( + () => props.show, + (show) => { + if (!show) return; + const timer = setInterval(() => setTick((value) => value + 1), TICK_MS); + return () => clearInterval(timer); + }, + ); - // A new error lands at the top of the list and is opened, as the old Errors panel did. - let seenErrors = 0; + // A new error lands at the top of the list and is opened, as the old Errors + // panel did. The newest record is compared, so a capped list still works. + let newest: ErrorRecord | undefined; createEffect( - () => props.errors.length, - (count) => { - if (count > seenErrors) setSelected({ kind: 'error', index: 0 }); - seenErrors = count; + () => props.errors[0], + (first) => { + if (first && first !== newest) setSelected({ kind: 'error', index: 0 }); + newest = first; }, ); From adf977727b5f9f7273be07eaf7a75679728c9305 Mon Sep 17 00:00:00 2001 From: "Alexis H. Munsayac" Date: Wed, 4 Nov 2026 12:05:24 +0800 Subject: [PATCH 05/10] feat(activity): show the last 30 seconds on a live strip, with pause The lanes strip covered the oldest to the newest record and rescaled as entries were dropped, so actions seemed to vanish. It now covers the last 30 seconds on a fixed axis with -30s, -20s, -10s and now, and items slide left as time passes. The list shows the same window, and a selected item stays until it is deselected. Pause freezes what is shown, even while the stores drop old records. Co-Authored-By: Claude Opus 5.5 --- src/dev-toolbar/activity/index.tsx | 87 +++++++++++-- src/dev-toolbar/activity/model.test.ts | 162 ++++++++++++++++++++++--- src/dev-toolbar/activity/model.ts | 160 ++++++++++++++++++------ src/dev-toolbar/activity/scrub.tsx | 28 ++++- src/dev-toolbar/activity/styles.css | 75 ++++++++++++ 5 files changed, 441 insertions(+), 71 deletions(-) diff --git a/src/dev-toolbar/activity/index.tsx b/src/dev-toolbar/activity/index.tsx index edd343b..70175dc 100644 --- a/src/dev-toolbar/activity/index.tsx +++ b/src/dev-toolbar/activity/index.tsx @@ -1,5 +1,6 @@ import type { JSX } from '@solidjs/web'; import { createEffect, createMemo, createSignal, For, getOwner, onCleanup, Show } from 'solid-js'; +import { PauseIcon, PlayIcon } from '../icons.js'; import Placeholder from '../../ui/Placeholder.js'; import { ResizeHandle } from '../../ui/ResizeHandle.js'; import { Text } from '../../ui/Text.js'; @@ -20,8 +21,12 @@ import { bucketRows, collectRows, GLYPHS, + frozenSources, inWindow, + type ActivitySources, layoutLanes, + viewRange, + WINDOW_MS, matchesFilter, matchesOwner, resolveSelection, @@ -43,7 +48,7 @@ const FILTERS: { value: ActivityFilter; label: string }[] = [ ]; /** How often the group labels and the elapsed time of what loads now are redrawn. */ -const TICK_MS = 1000; +const TICK_MS = 250; export interface ActivityFocus { /** Select the action that began with this interaction or frame. */ @@ -119,6 +124,11 @@ export default function ActivityViewer(props: ActivityViewerProps): JSX.Element const [owner, setOwner] = createSignal(); const [live, setLive] = createSignal([]); const [now, setNow] = createSignal(performance.now()); + const [paused, setPaused] = createSignal(false); + // What the list showed when it was paused. + const [frozenSrc, setFrozenSrc] = createSignal(); + // The clock reads this outside any tracking scope. + let frozen = false; const [observed, setObserved] = createSignal(0); const since = new Map(); const memory = new ValueMemory(); @@ -152,7 +162,9 @@ export default function ActivityViewer(props: ActivityViewerProps): JSX.Element } const stop = startReactivityTracking(); const unsubscribe = subscribeReactivityGraph(read); - const timer = setInterval(() => setNow(performance.now()), TICK_MS); + const timer = setInterval(() => { + if (!frozen) setNow(performance.now()); + }, TICK_MS); read(); return () => { clearInterval(timer); @@ -195,16 +207,59 @@ export default function ActivityViewer(props: ActivityViewerProps): JSX.Element }, ); - const collected = createMemo(() => - collectRows({ + // The strip and the list cover the last `WINDOW_MS`. While paused nothing newer than the pause shows. + const range = createMemo(() => { + const current = viewRange(now()); + return paused() ? current : { ...current, until: Number.POSITIVE_INFINITY }; + }); + + // A picked time range that has slid out of the strip is dropped. + createEffect( + () => { + const picked = timeRange(); + return picked !== undefined && picked.to < now() - WINDOW_MS; + }, + (stale) => { + if (stale) setTimeRange(undefined); + }, + ); + + function togglePause(): void { + const next = !paused(); + frozen = next; + // Pausing keeps what is shown, so the stores can evict without the view losing rows. + setFrozenSrc( + next + ? { + entries: props.entries, + instances: props.instances, + records: props.records, + errors: props.errors, + live: live(), + } + : undefined, + ); + // Resuming jumps back to live. + if (!next) setNow(performance.now()); + setPaused(next); + } + + const collected = createMemo(() => { + const current: ActivitySources = { entries: props.entries, instances: props.instances, records: props.records, errors: props.errors, live: live(), + }; + const frozenNow = frozenSrc(); + return collectRows({ + ...(frozenNow ? frozenSources(frozenNow, current) : current), unfold: unfold(), - }), - ); + range: range(), + pinned: selected(), + }); + }); const shown = createMemo(() => { const rows: ActivityRow[] = []; @@ -221,7 +276,7 @@ export default function ActivityViewer(props: ActivityViewerProps): JSX.Element }); const buckets = createMemo(() => bucketRows(shown(), now())); - const lanes = createMemo(() => layoutLanes(collected().rows)); + const lanes = createMemo(() => layoutLanes(collected().rows, now())); const selection = createMemo(() => resolveSelection(collected().rows, selected())); // Each detail reads its own memo, which holds the same object while the list @@ -338,13 +393,27 @@ export default function ActivityViewer(props: ActivityViewerProps): JSX.Element type="button" data-solid-activity-chip data-clear - disabled={!collected().rows.length} + disabled={!props.entries.length && !props.records.length} title="Clear the timeline and the async history" onClick={clear} > Clear +
{collected().rows.length ? 'Nothing matches this filter.' - : 'Interact with the app to see what each action re-ran.'} + : 'Nothing happened in the last 30 seconds.'}
} > diff --git a/src/dev-toolbar/activity/model.test.ts b/src/dev-toolbar/activity/model.test.ts index c8580be..45c67a0 100644 --- a/src/dev-toolbar/activity/model.test.ts +++ b/src/dev-toolbar/activity/model.test.ts @@ -4,6 +4,7 @@ import type { ErrorRecord } from '../error-record.js'; import type { ServerFunctionInstance } from '../functions/index.js'; import type { TimelineEntry, TimelineRun } from '../timeline/tracker.js'; import { + axisMarks, bucketLabel, bucketRows, callBars, @@ -11,6 +12,7 @@ import { countNames, entryLocation, firstMemoDelta, + frozenSources, handlerLocation, inWindow, layoutLanes, @@ -24,6 +26,8 @@ import { shiftWindow, summarizeChain, ValueMemory, + viewRange, + WINDOW_MS, type ActivityRow, } from './model.js'; @@ -271,43 +275,165 @@ describe('time groups', () => { }); describe('lanes', () => { - it('places ticks by time and marks the actions with many re-runs', () => { + const NOW = 100_000; + + it('places ticks and bars along the last 30 seconds', () => { const many = Array.from({ length: 9 }, (_, i) => run(i, 'm', 'memo', 0, 1)); const { rows } = collect({ entries: [ - entry(2, 'big', 1000, { runs: many, flushMs: 0 }), - entry(1, 'small', 0, { flushMs: 0 }), + entry(2, 'big', NOW - 15_000, { runs: many, flushMs: 0 }), + entry(1, 'small', NOW, { flushMs: 0 }), ], - instances: { 'fn#1': server(500, 250) }, + instances: { 'fn#1': server(NOW - 24_000, 3000) }, records: [ { type: 'wait', id: 1, - at: 250, - event: { blockers: [], holdMs: 500 }, + at: NOW - 15_000, + event: { blockers: [], holdMs: 6000 }, } as unknown as AsyncRecord, ], }); - const lanes = layoutLanes(rows); - expect(lanes.start).toBe(0); - expect(lanes.end).toBe(1000); + const lanes = layoutLanes(rows, NOW); + expect(lanes.start).toBe(NOW - WINDOW_MS); + expect(lanes.end).toBe(NOW); expect(lanes.actions.map((tick) => [tick.left, tick.big])).toEqual([ - [100, true], - [0, false], + [100, false], + [50, true], ]); - expect(lanes.server).toEqual([{ left: 50, width: 25, big: false, member: 's:fn#1' }]); - expect(lanes.async).toEqual([{ left: 25, width: 50, big: false, member: 'w:1' }]); + expect(lanes.server).toEqual([{ left: 20, width: 10, big: false, member: 's:fn#1' }]); + expect(lanes.async).toEqual([{ left: 50, width: 20, big: false, member: 'w:1' }]); + }); + + it('drops ticks before the start and clips bars that cross it', () => { + const { rows } = collect({ + entries: [entry(1, 'old', NOW - 40_000, { flushMs: 0 })], + instances: { + 'fn#1': server(NOW - 35_000, 10_000), + 'fn#2': server(NOW - 50_000, 5000), + }, + }); + const lanes = layoutLanes(rows, NOW); + expect(lanes.actions).toEqual([]); + expect(lanes.server).toHaveLength(1); + expect(lanes.server[0]).toMatchObject({ left: 0 }); + expect(lanes.server[0]!.width).toBeCloseTo((5000 / WINDOW_MS) * 100); + }); + + it('runs a pending call and a load in flight up to now', () => { + const { rows } = collect({ + instances: { 'fn#1': server(NOW - 3000) }, + live: [{ id: 'n', name: 'user', ownerPath: [], since: NOW - 6000, raw: {} }], + }); + const lanes = layoutLanes(rows, NOW); + expect(lanes.server[0]).toMatchObject({ left: 90, width: 10 }); + expect(lanes.async[0]).toMatchObject({ left: 80, width: 20 }); }); - it('gives an empty session a range to draw', () => { - const lanes = layoutLanes([]); - expect(lanes.end - lanes.start).toBeGreaterThan(0); + it('gives an empty session an empty strip', () => { + const lanes = layoutLanes([], NOW); + expect(lanes.end - lanes.start).toBe(WINDOW_MS); expect(lanes.actions).toEqual([]); }); it('ticks every entry of a fold', () => { - const { rows } = collect({ entries: [entry(2, 'tick', 200), entry(1, 'tick', 100)] }); - expect(layoutLanes(rows).actions).toHaveLength(2); + const { rows } = collect({ + entries: [entry(2, 'tick', NOW - 100), entry(1, 'tick', NOW - 200)], + }); + expect(layoutLanes(rows, NOW).actions).toHaveLength(2); + }); +}); + +describe('axis', () => { + it('labels every 10 seconds and ends with now', () => { + expect(axisMarks()).toEqual([ + { label: '\u221230s', left: 0 }, + { label: '\u221220s', left: (10 / 30) * 100 }, + { label: '\u221210s', left: (20 / 30) * 100 }, + { label: 'now', left: 100 }, + ]); + }); +}); + +describe('a paused view', () => { + const NOW = 100_000; + const range = { since: NOW - WINDOW_MS, until: NOW }; + + it('keeps the rows the stores evicted and the pending call that answered', () => { + const empty = { entries: [], instances: {}, records: [], errors: [], live: [] }; + const frozen = { + ...empty, + entries: [entry(2, 'tick', NOW - 100), entry(1, 'tick', NOW - 200)], + instances: { 'fn#1': server(NOW - 1000) }, + }; + // The store evicted both entries and a response arrived for the call. + const current = { + ...empty, + entries: [entry(9, 'new', NOW + 500)], + instances: { 'fn#1': server(NOW - 1000, 40) }, + }; + const sources = frozenSources(frozen, current); + const { rows, folded } = collectRows({ ...sources, unfold: false, range }); + expect(rows.map((row) => row.title).sort()).toEqual(['refundOrder', 'tick']); + expect(rows.find((row) => row.kind === 'server')?.time).toBe('40.0ms'); + expect(rows.find((row) => row.kind === 'action')?.count).toBe(2); + expect(folded).toBe(1); + }); +}); + +describe('the last 30 seconds', () => { + const NOW = 100_000; + const range = viewRange(NOW); + const entries = [ + entry(3, 'new', NOW - 1000), + entry(2, 'edge', NOW - 29_000), + entry(1, 'old', NOW - 60_000), + ]; + + it('lists only what ended within the window', () => { + const { rows } = collect({ entries, range }); + expect(rows.map((row) => row.title)).toEqual(['new', 'edge']); + }); + + it('keeps a thing that began earlier but ended inside', () => { + const { rows } = collect({ + entries: [entry(1, 'long', NOW - 40_000, { flushMs: 20_000 })], + range, + }); + expect(rows).toHaveLength(1); + }); + + it('keeps the pinned row however old it is', () => { + const { rows } = collect({ entries, range, pinned: 'a:1' }); + expect(rows.map((row) => row.title)).toEqual(['new', 'edge', 'old']); + const call = collect({ + instances: { 'fn#1': server(NOW - 90_000, 5) }, + range, + pinned: 's:fn#1', + }); + expect(call.rows).toHaveLength(1); + }); + + it('counts only the folded rows in the window', () => { + const repeats = [ + entry(4, 'tick', NOW - 100), + entry(3, 'tick', NOW - 200), + entry(2, 'tick', NOW - 50_000), + entry(1, 'tick', NOW - 60_000), + ]; + expect(collect({ entries: repeats, range }).folded).toBe(1); + expect(collect({ entries: repeats }).folded).toBe(3); + }); + + it('counts a running call as now', () => { + const { rows } = collect({ instances: { 'fn#1': server(NOW - 90_000) }, range }); + expect(rows).toHaveLength(1); + }); + + it('shows nothing newer than the pause', () => { + const paused = { since: NOW - WINDOW_MS, until: NOW - 5000 }; + const { rows } = collect({ entries, range: paused }); + expect(rows.map((row) => row.title)).toEqual(['edge']); }); }); diff --git a/src/dev-toolbar/activity/model.ts b/src/dev-toolbar/activity/model.ts index 6ebeced..0080771 100644 --- a/src/dev-toolbar/activity/model.ts +++ b/src/dev-toolbar/activity/model.ts @@ -57,11 +57,33 @@ export interface ActivityRow { subtitle: string; /** The right-aligned time or duration. */ time: string; + /** Still going: a load in flight or a call with no answer yet. Its end is now. */ + running?: boolean; /** How many entries the row folds. `1` for a plain row. */ count: number; source: ActivitySource; } +/** How far back the view looks. */ +export const WINDOW_MS = 30_000; + +/** The span the view shows: what ended since `since` and began by `until`. */ +export interface ViewRange { + since: number; + until: number; +} + +/** The last `WINDOW_MS` up to `now`. */ +export function viewRange(now: number): ViewRange { + return { since: now - WINDOW_MS, until: now }; +} + +/** Whether a thing that began at `at` and ended at `end` belongs to the range. */ +export function inRange(at: number, end: number, range: ViewRange | undefined): boolean { + if (!range) return true; + return at <= range.until && end >= range.since; +} + export const GLYPHS = { act: 'A', srv: 'ƒ', wait: '⧗', err: '✕' } as const; export function actionKey(id: number): string { @@ -162,6 +184,34 @@ function singleGroup(entry: TimelineEntry): TimelineGroup { }; } +/** What the rows are built from. */ +export interface ActivitySources { + entries: readonly TimelineEntry[]; + instances: Record; + records: readonly AsyncRecord[]; + errors: readonly ErrorRecord[]; + live: readonly LiveLoad[]; +} + +/** + * The sources of a paused view: everything the pause took, with the newest + * state of what is still in the stores. Nothing the stores evicted since + * disappears, and nothing new appears. Only a row already shown updates, such + * as a pending call that got its response. + */ +export function frozenSources(frozen: ActivitySources, current: ActivitySources): ActivitySources { + const entries: TimelineEntry[] = []; + const latest = new Map(); + for (const entry of current.entries) latest.set(entry.id, entry); + for (const entry of frozen.entries) entries.push(latest.get(entry.id) ?? entry); + + const instances: Record = {}; + for (const key of Object.keys(frozen.instances)) { + instances[key] = current.instances[key] ?? frozen.instances[key]; + } + return { ...frozen, entries, instances }; +} + export interface CollectInput { entries: readonly TimelineEntry[]; instances: Record; @@ -170,6 +220,10 @@ export interface CollectInput { live: readonly LiveLoad[]; /** Show every entry as its own row, unnamed updates included. */ unfold: boolean; + /** Keeps only what falls in the range. Everything when absent. */ + range?: ViewRange; + /** The selection key of a row that stays listed whatever its age. */ + pinned?: string; } export interface Collected { @@ -223,6 +277,7 @@ function serverRow( title: callName(value), subtitle: owner ? `${status} · from ${owner}` : `${status} · ${value.request.source.method}`, time: duration === undefined ? (status === '…' ? 'live' : '') : ms(duration), + running: !value.response && status === '…', count: 1, source: { type: 'server', instance, value }, }; @@ -279,6 +334,7 @@ function liveRow(load: LiveLoad): ActivityRow { title: `${load.name} loading`, subtitle: load.ownerPath.length ? load.ownerPath.join(' › ') : 'loading now', time: 'live', + running: true, count: 1, source: { type: 'live', load }, }; @@ -294,6 +350,12 @@ export function collectRows(input: CollectInput): Collected { for (const entry of input.entries) { // A click that changed nothing, such as one on the toolbar itself, is never listed. if (isIdleEntry(entry)) continue; + if ( + !inRange(entry.at, entry.at + entryDuration(entry), input.range) && + actionKey(entry.id) !== input.pinned + ) { + continue; + } if (isUnnamedUpdate(entry)) { folded++; if (input.unfold) named.push(entry); @@ -333,6 +395,22 @@ export function collectRows(input: CollectInput): Collected { rows.push(errorRow(input.errors[index]!, index, input.entries)); } + // Only the actions fold, so the rest are cut to the range here. + if (input.range) { + const kept: ActivityRow[] = []; + for (const row of rows) { + if (row.source.type === 'action') kept.push(row); + else if ( + row.members.includes(input.pinned ?? '') || + inRange(row.at, row.running ? input.range.until : row.end, input.range) + ) { + kept.push(row); + } + } + rows.length = 0; + rows.push(...kept); + } + // Newest first. The sort is stable, so ties keep their source order. rows.sort((a, b) => b.at - a.at); return { rows, folded }; @@ -482,63 +560,67 @@ export interface Lanes { async: LaneTick[]; } -/** The shortest span the strip covers, so one early entry does not fill it. */ -export const MIN_SPAN_MS = 1000; - -function percent(value: number, start: number, span: number): number { - return ((value - start) / span) * 100; +/** A bar from `at` to `end`, clipped to the strip, or `undefined` when it lies outside. */ +function clipBar( + at: number, + end: number, + lanes: Pick, +): { left: number; width: number } | undefined { + if (end < lanes.start || at > lanes.end) return undefined; + const span = lanes.end - lanes.start; + const from = Math.max(at, lanes.start); + const to = Math.min(Math.max(end, at), lanes.end); + return { left: ((from - lanes.start) / span) * 100, width: ((to - from) / span) * 100 }; } -/** Positions every item of the three lanes along the time range they cover. */ -export function layoutLanes(rows: readonly ActivityRow[]): Lanes { - const lanes: Lanes = { start: 0, end: MIN_SPAN_MS, actions: [], server: [], async: [] }; - if (!rows.length) return lanes; - - // The range runs from the oldest start to the latest end. - let start = Infinity; - let end = -Infinity; - for (const row of rows) { - if (row.kind === 'error') continue; - start = Math.min(start, row.at); - end = Math.max(end, row.end); - } - if (!Number.isFinite(start)) return lanes; - end = Math.max(end, start + MIN_SPAN_MS); - const span = end - start; - lanes.start = start; - lanes.end = end; +/** Positions the items of the three lanes along the last `WINDOW_MS` up to `now`. */ +export function layoutLanes(rows: readonly ActivityRow[], now: number): Lanes { + const lanes: Lanes = { start: now - WINDOW_MS, end: now, actions: [], server: [], async: [] }; for (const row of rows) { const { source } = row; if (source.type === 'action') { - // Every entry of a fold is its own tick. + // Every entry of a fold is its own tick, and a tick before the start is dropped. for (const entry of source.entries) { + const at = clipBar(entry.at, entry.at, lanes); + if (!at) continue; lanes.actions.push({ - left: percent(entry.at, start, span), + left: at.left, width: 0, big: entryRunCount(entry) >= BIG_RUNS, member: actionKey(entry.id), }); } - } else if (row.kind === 'server') { - lanes.server.push({ - left: percent(row.at, start, span), - width: ((row.end - row.at) / span) * 100, - big: false, - member: row.members[0]!, - }); - } else if (row.kind === 'async') { - lanes.async.push({ - left: percent(row.at, start, span), - width: ((row.end - row.at) / span) * 100, - big: false, - member: row.members[0]!, - }); + } else if (row.kind === 'server' || row.kind === 'async') { + const bar = clipBar(row.at, row.running ? now : row.end, lanes); + if (!bar) continue; + const tick = { left: bar.left, width: bar.width, big: false, member: row.members[0]! }; + if (row.kind === 'server') lanes.server.push(tick); + else lanes.async.push(tick); } } return lanes; } +export interface AxisMark { + label: string; + /** Percent of the strip. */ + left: number; +} + +/** The labels under the strip, one every `stepMs`, ending with `now`. */ +export function axisMarks(windowMs = WINDOW_MS, stepMs = 10_000): AxisMark[] { + const marks: AxisMark[] = []; + for (let back = windowMs; back > 0; back -= stepMs) { + marks.push({ + label: `\u2212${Math.round(back / 1000)}s`, + left: ((windowMs - back) / windowMs) * 100, + }); + } + marks.push({ label: 'now', left: 100 }); + return marks; +} + /** Percent of the strip to a time. */ export function timeAt(lanes: Pick, fraction: number): number { return lanes.start + Math.min(1, Math.max(0, fraction)) * (lanes.end - lanes.start); diff --git a/src/dev-toolbar/activity/scrub.tsx b/src/dev-toolbar/activity/scrub.tsx index 99bdab5..58e71a2 100644 --- a/src/dev-toolbar/activity/scrub.tsx +++ b/src/dev-toolbar/activity/scrub.tsx @@ -1,6 +1,7 @@ import type { JSX } from '@solidjs/web'; import { For, Show } from 'solid-js'; import { + axisMarks, makeWindow, shiftWindow, timeAt, @@ -16,6 +17,8 @@ const KEY_STEP = 0.02; /** Pixels a drag has to travel before it counts as a drag and not a click. */ const DRAG_SLOP = 3; +const MARKS = axisMarks(); + type DragMode = 'new' | 'move' | 'from' | 'to'; export interface ScrubProps { @@ -27,15 +30,15 @@ export interface ScrubProps { function Ticks(props: { ticks: LaneTick[]; selected: string | undefined }): JSX.Element { return ( - + tick.member}> {(tick) => ( )} @@ -123,6 +126,21 @@ export function Scrub(props: ScrubProps): JSX.Element {
+ +
Date: Wed, 4 Nov 2026 12:05:25 +0800 Subject: [PATCH 06/10] docs(changeset): describe the 30-second Activity window Co-Authored-By: Claude Opus 5.5 --- .changeset/dock-three-views.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/dock-three-views.md b/.changeset/dock-three-views.md index 0a4a6d4..a003d28 100644 --- a/.changeset/dock-three-views.md +++ b/.changeset/dock-three-views.md @@ -4,7 +4,7 @@ - The dock has three views in its header: Health, Activity and Structure. The header shows the `solid-js` version the app runs, in place of the toolbar's title and version. - Health lists thrown errors and runtime issues together, with search and severity chips. An error shows the function it was thrown in, when it was caught, the action it was caught during with a link to it, its stack frames beside the source, and "Open in editor". -- Activity merges actions, server-function calls, async loads and waits, and errors into one stream with a lanes strip, filter chips and time groups. Repeated actions fold into one row. An action shows what it wrote, what re-ran and which effects ran, then waterfalls of its re-runs and server calls. +- Activity merges actions, server-function calls, async loads and waits, and errors into one stream over the last 30 seconds, with a live lanes strip on a time axis, filter chips, time groups and a pause button. Repeated actions fold into one row. An action shows what it wrote, what re-ran and which effects ran, then waterfalls of its re-runs and server calls. - Structure shows the ownership tree or the reactivity graph. Tree rows show signal and memo counts and how recently and how often their computations update. "Hide Solid internals" folds Solid's own components away. The detail lists props, signals and memos by kind, with a signal's previous value. - ⌘K (Ctrl+K) opens a search across components, signals, memos, actions and server functions while the dock is open. - The timeline keeps distinct actions when one action repeats many times, such as an animation loop. From 17eda44dc2450ae419b4511e14ad3932e8f026bf Mon Sep 17 00:00:00 2001 From: "Alexis H. Munsayac" Date: Wed, 4 Nov 2026 12:21:41 +0800 Subject: [PATCH 07/10] fix(activity): keep every action from the last 30 seconds The timeline kept 100 entries and the async history its own count, so a loop such as an animation frame pushed actions out of the window within a second. Both now keep everything from the last 30 seconds, with a large safety cap. A fast run of one action draws as one bar on the strip, and the view redraws from a sample at most every 250ms. Co-Authored-By: Claude Opus 5.5 --- src/dev-toolbar/activity/index.tsx | 114 ++++++++++++++++------- src/dev-toolbar/activity/model.test.ts | 39 +++++++- src/dev-toolbar/activity/model.ts | 80 ++++++++++++---- src/dev-toolbar/activity/scrub.tsx | 8 +- src/dev-toolbar/activity/styles.css | 4 + src/dev-toolbar/async/tracker.test.ts | 18 ++++ src/dev-toolbar/async/tracker.ts | 23 ++++- src/dev-toolbar/timeline/tracker.test.ts | 62 +++++++----- src/dev-toolbar/timeline/tracker.ts | 56 +++++------ 9 files changed, 299 insertions(+), 105 deletions(-) diff --git a/src/dev-toolbar/activity/index.tsx b/src/dev-toolbar/activity/index.tsx index 70175dc..0f474cf 100644 --- a/src/dev-toolbar/activity/index.tsx +++ b/src/dev-toolbar/activity/index.tsx @@ -49,6 +49,8 @@ const FILTERS: { value: ActivityFilter; label: string }[] = [ /** How often the group labels and the elapsed time of what loads now are redrawn. */ const TICK_MS = 250; +/** Longest the view waits to read the graph after it changed. */ +const GRAPH_READ_MS = 100; export interface ActivityFocus { /** Select the action that began with this interaction or frame. */ @@ -123,12 +125,44 @@ export default function ActivityViewer(props: ActivityViewerProps): JSX.Element const [timeRange, setTimeRange] = createSignal(); const [owner, setOwner] = createSignal(); const [live, setLive] = createSignal([]); - const [now, setNow] = createSignal(performance.now()); const [paused, setPaused] = createSignal(false); // What the list showed when it was paused. - const [frozenSrc, setFrozenSrc] = createSignal(); - // The clock reads this outside any tracking scope. + // Read by the sampler, outside any tracking scope. + let frozenSrc: ActivitySources | undefined; + // The clock reads these outside any tracking scope. let frozen = false; + let pausedAt = 0; + let lastSample = 0; + let graphChanged = true; + let liveSignature = ''; + // The list and the strip redraw from a sample taken at most every `TICK_MS`, + // however often the stores change. A loop that updates 60 times a second + // would otherwise rebuild every row 60 times a second. + function currentSources(): ActivitySources { + return { + entries: props.entries, + instances: props.instances, + records: props.records, + errors: props.errors, + live: live(), + }; + } + const [view, setView] = createSignal<{ sources: ActivitySources; now: number }>({ + sources: { entries: [], instances: {}, records: [], errors: [], live: [] }, + now: performance.now(), + }); + const now = () => view().now; + + function sample(taken?: ActivitySources): void { + lastSample = performance.now(); + const frozenNow = frozenSrc; + const current = taken ?? currentSources(); + setView({ + sources: frozenNow ? frozenSources(frozenNow, current) : current, + now: frozen ? pausedAt : lastSample, + }); + } + const [observed, setObserved] = createSignal(0); const since = new Map(); const memory = new ValueMemory(); @@ -159,15 +193,40 @@ export default function ActivityViewer(props: ActivityViewerProps): JSX.Element // Step 2: forget what stopped loading. for (const id of since.keys()) if (!ids.has(id)) since.delete(id); setLive(loads); + // A load that starts or lands shows at once, not at the next tick. + const signature = [...ids].join('|'); + if (signature !== liveSignature) { + liveSignature = signature; + queueMicrotask(() => sample()); + } } const stop = startReactivityTracking(); - const unsubscribe = subscribeReactivityGraph(read); + // The graph is read at the tick and only if it changed, since reading all of it costs. + let readTimer: ReturnType | undefined; + const unsubscribe = subscribeReactivityGraph(() => { + graphChanged = true; + // Reads are spaced out, since reading the whole graph costs. + readTimer ??= setTimeout(() => { + readTimer = undefined; + if (!graphChanged) return; + graphChanged = false; + read(); + }, GRAPH_READ_MS); + }); const timer = setInterval(() => { - if (!frozen) setNow(performance.now()); + if (graphChanged) { + graphChanged = false; + read(); + } + sample(); }, TICK_MS); + graphChanged = false; read(); + // Outside the effect, since reading the stores here would be an untracked read. + queueMicrotask(() => sample()); return () => { clearInterval(timer); + clearTimeout(readTimer); unsubscribe(); stop(); }; @@ -175,6 +234,14 @@ export default function ActivityViewer(props: ActivityViewerProps): JSX.Element ); onCleanup(() => since.clear()); + // A change in the stores shows at once when the last sample is old enough, else at the next tick. + createEffect( + () => ({ show: !!props.show, sources: currentSources() }), + ({ show, sources }) => { + if (show && performance.now() - lastSample >= TICK_MS) sample(sources); + }, + ); + // The values of the memos that re-ran are read as the runs arrive, so the next run can show what it changed from. createEffect( () => props.entries, @@ -209,7 +276,7 @@ export default function ActivityViewer(props: ActivityViewerProps): JSX.Element // The strip and the list cover the last `WINDOW_MS`. While paused nothing newer than the pause shows. const range = createMemo(() => { - const current = viewRange(now()); + const current = viewRange(view().now); return paused() ? current : { ...current, until: Number.POSITIVE_INFINITY }; }); @@ -228,38 +295,21 @@ export default function ActivityViewer(props: ActivityViewerProps): JSX.Element const next = !paused(); frozen = next; // Pausing keeps what is shown, so the stores can evict without the view losing rows. - setFrozenSrc( - next - ? { - entries: props.entries, - instances: props.instances, - records: props.records, - errors: props.errors, - live: live(), - } - : undefined, - ); - // Resuming jumps back to live. - if (!next) setNow(performance.now()); + pausedAt = view().now; + frozenSrc = next ? view().sources : undefined; setPaused(next); + // Resuming jumps back to live. + sample(); } - const collected = createMemo(() => { - const current: ActivitySources = { - entries: props.entries, - instances: props.instances, - records: props.records, - errors: props.errors, - live: live(), - }; - const frozenNow = frozenSrc(); - return collectRows({ - ...(frozenNow ? frozenSources(frozenNow, current) : current), + const collected = createMemo(() => + collectRows({ + ...view().sources, unfold: unfold(), range: range(), pinned: selected(), - }); - }); + }), + ); const shown = createMemo(() => { const rows: ActivityRow[] = []; diff --git a/src/dev-toolbar/activity/model.test.ts b/src/dev-toolbar/activity/model.test.ts index 45c67a0..ac02ecf 100644 --- a/src/dev-toolbar/activity/model.test.ts +++ b/src/dev-toolbar/activity/model.test.ts @@ -336,11 +336,46 @@ describe('lanes', () => { expect(lanes.actions).toEqual([]); }); - it('ticks every entry of a fold', () => { + it('draws a close fold as one bar of all its entries', () => { const { rows } = collect({ entries: [entry(2, 'tick', NOW - 100), entry(1, 'tick', NOW - 200)], }); - expect(layoutLanes(rows, NOW).actions).toHaveLength(2); + // Two entries 100ms apart read as one bar standing for both. + expect(layoutLanes(rows, NOW).actions[0]!.members).toHaveLength(2); + }); +}); + +describe('run bars', () => { + const NOW = 100_000; + + it('joins entries of a folded action less than 250ms apart into one bar', () => { + const entries = []; + for (let i = 0; i < 60; i++) + entries.push(entry(60 - i, 'frame', NOW - 5000 + (59 - i) * 16, { flushMs: 1 })); + const { rows } = collect({ entries }); + const lanes = layoutLanes(rows, NOW); + expect(lanes.actions).toHaveLength(1); + const bar = lanes.actions[0]!; + expect(bar.members).toHaveLength(60); + const span = (59 * 16 + 1) / WINDOW_MS; + expect(bar.width).toBeCloseTo(span * 100); + expect(bar.left).toBeCloseTo(((WINDOW_MS - 5000) / WINDOW_MS) * 100); + }); + + it('keeps sparse entries as ticks and splits runs at a long gap', () => { + const at = [NOW - 20_000, NOW - 19_900, NOW - 19_800, NOW - 10_000]; + const entries = []; + for (let i = 0; i < at.length; i++) + entries.push(entry(at.length - i, 'tick', at[at.length - 1 - i]!)); + const lanes = layoutLanes(collect({ entries }).rows, NOW); + expect(lanes.actions).toHaveLength(2); + expect(lanes.actions.map((tick) => tick.members?.length ?? 1).sort()).toEqual([1, 3]); + }); + + it('keeps single actions as ticks and unfolded entries apart', () => { + const entries = [entry(2, 'x', NOW - 100), entry(1, 'x', NOW - 150)]; + const unfolded = layoutLanes(collect({ entries, unfold: true }).rows, NOW); + expect(unfolded.actions.every((tick) => tick.width === 0)).toBe(true); }); }); diff --git a/src/dev-toolbar/activity/model.ts b/src/dev-toolbar/activity/model.ts index 0080771..09f2c59 100644 --- a/src/dev-toolbar/activity/model.ts +++ b/src/dev-toolbar/activity/model.ts @@ -14,6 +14,7 @@ import { import { isIdleEntry, isUnnamedUpdate, + WINDOW_MS, type TimelineEntry, type TimelineRun, } from '../timeline/tracker.js'; @@ -64,8 +65,8 @@ export interface ActivityRow { source: ActivitySource; } -/** How far back the view looks. */ -export const WINDOW_MS = 30_000; +/** How far back the view looks. The stores keep the same span. */ +export { WINDOW_MS }; /** The span the view shows: what ended since `since` and began by `until`. */ export interface ViewRange { @@ -412,7 +413,7 @@ export function collectRows(input: CollectInput): Collected { } // Newest first. The sort is stable, so ties keep their source order. - rows.sort((a, b) => b.at - a.at); + rows.sort((a, b) => b.end - a.end); return { rows, folded }; } @@ -530,7 +531,7 @@ export function bucketLabel(ageMs: number): string { export function bucketRows(rows: readonly ActivityRow[], now: number): RowBucket[] { const buckets: RowBucket[] = []; for (const row of rows) { - const label = bucketLabel(now - row.at); + const label = bucketLabel(now - row.end); const last = buckets[buckets.length - 1]; if (last && last.label === label) last.rows.push(row); else buckets.push({ label, rows: [row] }); @@ -550,8 +551,13 @@ export interface LaneTick { big: boolean; /** The row the tick stands for, so a click can select it. */ member: string; + /** Every entry a run bar stands for. */ + members?: string[]; } +/** Entries of one action closer than this draw as one bar. */ +export const RUN_GAP_MS = 250; + export interface Lanes { start: number; end: number; @@ -573,6 +579,46 @@ function clipBar( return { left: ((from - lanes.start) / span) * 100, width: ((to - from) / span) * 100 }; } +/** + * The ticks of a folded action, whose entries are newest first. Entries less + * than `RUN_GAP_MS` apart join into one bar from the start of the first to the + * end of the last, so a loop of 60 updates a second reads as one band. A lone + * entry stays a tick. A plain row's one entry is a tick. + */ +function pushActionTicks(lanes: Lanes, entries: readonly TimelineEntry[], folded: boolean): void { + let run: TimelineEntry[] = []; + + function closeRun(): void { + if (!run.length) return; + const first = run[0]!; + const last = run[run.length - 1]!; + const big = run.some((entry) => entryRunCount(entry) >= BIG_RUNS); + if (run.length === 1) { + const tick = clipBar(first.at, first.at, lanes); + if (tick) { + lanes.actions.push({ left: tick.left, width: 0, big, member: actionKey(first.id) }); + } + } else { + const bar = clipBar(first.at, last.at + entryDuration(last), lanes); + if (bar) { + const members: string[] = []; + for (const entry of run) members.push(actionKey(entry.id)); + lanes.actions.push({ left: bar.left, width: bar.width, big, member: members[0]!, members }); + } + } + run = []; + } + + // Walk oldest to newest. + for (let i = entries.length - 1; i >= 0; i--) { + const entry = entries[i]!; + const previous = run[run.length - 1]; + if (previous && (!folded || entry.at - previous.at >= RUN_GAP_MS)) closeRun(); + run.push(entry); + } + closeRun(); +} + /** Positions the items of the three lanes along the last `WINDOW_MS` up to `now`. */ export function layoutLanes(rows: readonly ActivityRow[], now: number): Lanes { const lanes: Lanes = { start: now - WINDOW_MS, end: now, actions: [], server: [], async: [] }; @@ -580,17 +626,7 @@ export function layoutLanes(rows: readonly ActivityRow[], now: number): Lanes { for (const row of rows) { const { source } = row; if (source.type === 'action') { - // Every entry of a fold is its own tick, and a tick before the start is dropped. - for (const entry of source.entries) { - const at = clipBar(entry.at, entry.at, lanes); - if (!at) continue; - lanes.actions.push({ - left: at.left, - width: 0, - big: entryRunCount(entry) >= BIG_RUNS, - member: actionKey(entry.id), - }); - } + pushActionTicks(lanes, source.entries, row.count > 1); } else if (row.kind === 'server' || row.kind === 'async') { const bar = clipBar(row.at, row.running ? now : row.end, lanes); if (!bar) continue; @@ -801,10 +837,20 @@ export class ValueMemory { private deltas = new WeakMap(); private seen = new WeakSet(); + /** Entries already read. A builder snapshot reuses the object of an entry that did not change. */ + private done = new WeakSet(); + /** Takes the entries newest first, as the builder keeps them. */ observe(entries: readonly TimelineEntry[]): void { - for (let i = entries.length - 1; i >= 0; i--) { - for (const run of entries[i]!.runs) { + // Only entries that are new or changed since the last call, oldest first. + const fresh: TimelineEntry[] = []; + for (const entry of entries) { + if (!this.done.has(entry)) fresh.push(entry); + } + for (let i = fresh.length - 1; i >= 0; i--) { + const entry = fresh[i]!; + this.done.add(entry); + for (const run of entry.runs) { if (this.seen.has(run)) continue; this.seen.add(run); if (run.event.nodeKind !== 'memo') continue; diff --git a/src/dev-toolbar/activity/scrub.tsx b/src/dev-toolbar/activity/scrub.tsx index 58e71a2..4ba37bd 100644 --- a/src/dev-toolbar/activity/scrub.tsx +++ b/src/dev-toolbar/activity/scrub.tsx @@ -28,6 +28,11 @@ export interface ScrubProps { onWindow: (window: TimeWindow | undefined) => void; } +function isSelected(tick: LaneTick, selected: string | undefined): boolean { + if (selected === undefined) return false; + return tick.member === selected || !!tick.members?.includes(selected); +} + function Ticks(props: { ticks: LaneTick[]; selected: string | undefined }): JSX.Element { return ( tick.member}> @@ -35,7 +40,8 @@ function Ticks(props: { ticks: LaneTick[]; selected: string | undefined }): JSX. { }); }); +describe('retention by time', () => { + it('keeps the records of the last 30 seconds and drops older ones', () => { + const history = new AsyncHistory(); + for (const at of [1000, 20_000, 40_000]) + history.flight(flight({ at, nodeName: `n${at}` }), undefined); + history.flight(flight({ at: 45_000, nodeName: 'n45000' }), undefined); + expect(history.records.map((record) => record.at)).toEqual([45_000, 40_000, 20_000]); + expect(history.prune(80_000)).toBe(true); + expect(history.records).toEqual([]); + }); + + it('caps a runaway at the safety limit', () => { + const history = new AsyncHistory(3); + for (let at = 1; at <= 5; at++) history.flight(flight({ at }), undefined); + expect(history.records.map((record) => record.at)).toEqual([5, 4, 3]); + }); +}); + describe('concernOf', () => { it('flags replaced loads, silent and long waits, flashes and chains', () => { const history = new AsyncHistory(); diff --git a/src/dev-toolbar/async/tracker.ts b/src/dev-toolbar/async/tracker.ts index ec04963..7158b44 100644 --- a/src/dev-toolbar/async/tracker.ts +++ b/src/dev-toolbar/async/tracker.ts @@ -7,6 +7,7 @@ import { type WaterfallRecord, } from 'solid-js/attribution'; import { appOwnerPath } from '../issues/tracker.js'; +import { WINDOW_MS } from '../timeline/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; @@ -25,8 +26,8 @@ function isToolbarNode(node: object | undefined): boolean { } } -/** Most records the panel keeps. The oldest go first. */ -export const MAX_RECORDS = 200; +/** A safety cap that only applies in a runaway. */ +export const MAX_RECORDS = 1000; /** One thing that happened while the app waited on async work. */ export type AsyncRecord = @@ -87,10 +88,24 @@ export class AsyncHistory { private sources = new WeakSet(); private seenChains = new WeakSet(); - constructor(private readonly max = MAX_RECORDS) {} + constructor( + private readonly max = MAX_RECORDS, + private readonly windowMs = WINDOW_MS, + ) {} private add(record: AsyncRecord): void { - this.records = [record, ...this.records].slice(0, this.max); + this.records = [record, ...this.records]; + this.prune(record.at); + } + + /** Drops the records that began before the window ending at `now`, and the oldest past the safety cap. */ + prune(now: number): boolean { + const cutoff = now - this.windowMs; + let keep = this.records.length; + while (keep > 0 && (keep > this.max || this.records[keep - 1]!.at < cutoff)) keep--; + if (keep === this.records.length) return false; + this.records = this.records.slice(0, keep); + return true; } flight(event: FlightEvent, node: object | undefined): void { diff --git a/src/dev-toolbar/timeline/tracker.test.ts b/src/dev-toolbar/timeline/tracker.test.ts index 932775f..e4232e8 100644 --- a/src/dev-toolbar/timeline/tracker.test.ts +++ b/src/dev-toolbar/timeline/tracker.test.ts @@ -8,14 +8,7 @@ import type { } from 'solid-js/attribution'; import { timelineReport } from '../report/timeline-report.js'; import type { CallEvent } from '@solidjs/web'; -import { - callInteraction, - evictionIndex, - isUnnamedUpdate, - MAX_SAME_LABEL, - rootChanges, - TimelineBuilder, -} from './tracker.js'; +import { callInteraction, isUnnamedUpdate, rootChanges, TimelineBuilder } from './tracker.js'; const click: ChangeOrigin = { kind: 'interaction', @@ -176,6 +169,45 @@ describe('TimelineBuilder', () => { } expect(builder.snapshot().map((entry) => entry.origin?.name)).toEqual(['e3', 'e2']); }); + + function act(builder: TimelineBuilder, at: number): void { + builder.rerun( + rerun({ run: at, at, interaction: { kind: 'interaction', name: `e${at}`, at } }), + undefined, + ); + } + + it('keeps every entry of the last 30 seconds and drops older ones', () => { + const builder = new TimelineBuilder(); + for (let at = 1000; at <= 61_000; at += 1000) act(builder, at); + const names = builder.snapshot(61_000).map((entry) => entry.origin?.at); + // 31 entries from 31000 to 61000, newest first. + expect(names).toHaveLength(31); + expect(names[0]).toBe(61_000); + expect(names[30]).toBe(31_000); + }); + + it('does not thin out a repeated label inside the window', () => { + const builder = new TimelineBuilder(); + for (let i = 0; i < 1800; i++) act(builder, 1000 + i * 16); + expect(builder.snapshot(1000 + 1799 * 16)).toHaveLength(1800); + }); + + it('ages entries out of a snapshot even when nothing new arrived', () => { + const builder = new TimelineBuilder(); + act(builder, 1000); + const first = builder.snapshot(2000); + expect(first).toHaveLength(1); + const later = builder.snapshot(40_000); + expect(later).toHaveLength(0); + expect(later).not.toBe(first); + }); + + it('caps a runaway at the safety limit, dropping the oldest', () => { + const builder = new TimelineBuilder(3); + for (const at of [1, 2, 3, 4, 5]) act(builder, at); + expect(builder.snapshot(5).map((entry) => entry.origin?.at)).toEqual([5, 4, 3]); + }); }); function call(over: Partial = {}): CallEvent { @@ -308,17 +340,3 @@ describe('timelineReport', () => { expect(report).not.toContain('a → b'); }); }); - -describe('evictionIndex', () => { - it('drops the oldest entry of a label repeated past the limit', () => { - const entries = [{ label: 'refund' }]; - for (let i = 0; i < MAX_SAME_LABEL + 1; i++) entries.push({ label: 'update: tick' }); - entries.push({ label: 'setFilter' }); - // The oldest is `setFilter`, but the tick label is over the limit. - expect(evictionIndex(entries)).toBe(entries.length - 2); - }); - - it('drops the oldest entry when no label is over the limit', () => { - expect(evictionIndex([{ label: 'a' }, { label: 'b' }, { label: 'a' }])).toBe(2); - }); -}); diff --git a/src/dev-toolbar/timeline/tracker.ts b/src/dev-toolbar/timeline/tracker.ts index 533a341..e989982 100644 --- a/src/dev-toolbar/timeline/tracker.ts +++ b/src/dev-toolbar/timeline/tracker.ts @@ -60,25 +60,10 @@ export interface TimelineEntry { calls: TimelineCall[]; } -/** Most entries the panel keeps. The oldest go first. */ -export const MAX_ENTRIES = 100; -/** How many entries of one label are kept before its oldest go first. */ -export const MAX_SAME_LABEL = 20; - -/** - * The entry to drop when the timeline is full. A loop such as an animation - * frame repeats one label many times a second, so the oldest entry of a label - * that has more than `MAX_SAME_LABEL` entries goes before any other action. - * Otherwise the oldest entry goes. `entries` is newest first. - */ -export function evictionIndex(entries: { label: string }[]): number { - const counts = new Map(); - for (const entry of entries) counts.set(entry.label, (counts.get(entry.label) ?? 0) + 1); - for (let index = entries.length - 1; index >= 0; index--) { - if (counts.get(entries[index]!.label)! > MAX_SAME_LABEL) return index; - } - return entries.length - 1; -} +/** How far back the timeline and the Activity view look. */ +export const WINDOW_MS = 30_000; +/** A safety cap that only applies in a runaway, such as a loop that makes thousands of actions a second. */ +export const MAX_ENTRIES = 5000; /** Most re-runs one entry keeps. */ export const MAX_RUNS = 300; @@ -173,7 +158,10 @@ export class TimelineBuilder { */ private idle = new WeakMap(); - constructor(private readonly max = MAX_ENTRIES) {} + constructor( + private readonly max = MAX_ENTRIES, + private readonly windowMs = WINDOW_MS, + ) {} private create(origin: ChangeOrigin | undefined, at: number): TimelineEntry { const entry: TimelineEntry = { @@ -192,15 +180,28 @@ export class TimelineBuilder { calls: [], }; this.entries.unshift(entry); - while (this.entries.length > this.max) { - const [dropped] = this.entries.splice(evictionIndex(this.entries), 1); - if (dropped!.origin) this.byOrigin.delete(dropped!.origin); - if (dropped === this.external) this.external = undefined; - } if (origin) this.byOrigin.set(origin, entry); + this.prune(entry.at); return entry; } + /** + * Drops the entries that began before the window ending at `now`, and the + * oldest ones past the safety cap. Returns whether any went. The list is + * newest first, so the oldest are at the end. + */ + private prune(now: number): boolean { + const cutoff = now - this.windowMs; + let keep = this.entries.length; + while (keep > 0 && (keep > this.max || this.entries[keep - 1]!.at < cutoff)) keep--; + if (keep === this.entries.length) return false; + for (const dropped of this.entries.splice(keep)) { + if (dropped.origin) this.byOrigin.delete(dropped.origin); + if (dropped === this.external) this.external = undefined; + } + return true; + } + private entryFor(origin: ChangeOrigin | undefined, at: number): TimelineEntry { if (origin) return this.byOrigin.get(origin) ?? this.create(origin, at); this.external ??= this.create(undefined, at); @@ -295,8 +296,9 @@ export class TimelineBuilder { * The entries for the panel. An entry that changed since the last call is a * new object, so the panel redraws only what moved. */ - snapshot(): TimelineEntry[] { - if (!this.changed.size) return this.entries; + snapshot(now = performance.now()): TimelineEntry[] { + const pruned = this.prune(now); + if (!this.changed.size) return pruned ? this.entries.slice() : this.entries; this.entries = this.entries.map((entry) => { if (!this.changed.has(entry)) return entry; const copy = { From d53155a209ad60494840aff29e4aa4f18220828f Mon Sep 17 00:00:00 2001 From: "Alexis H. Munsayac" Date: Wed, 4 Nov 2026 12:21:43 +0800 Subject: [PATCH 08/10] docs(changeset): describe time-based retention Co-Authored-By: Claude Opus 5.5 --- .changeset/dock-three-views.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/dock-three-views.md b/.changeset/dock-three-views.md index a003d28..a0b34cc 100644 --- a/.changeset/dock-three-views.md +++ b/.changeset/dock-three-views.md @@ -7,5 +7,5 @@ - Activity merges actions, server-function calls, async loads and waits, and errors into one stream over the last 30 seconds, with a live lanes strip on a time axis, filter chips, time groups and a pause button. Repeated actions fold into one row. An action shows what it wrote, what re-ran and which effects ran, then waterfalls of its re-runs and server calls. - Structure shows the ownership tree or the reactivity graph. Tree rows show signal and memo counts and how recently and how often their computations update. "Hide Solid internals" folds Solid's own components away. The detail lists props, signals and memos by kind, with a signal's previous value. - ⌘K (Ctrl+K) opens a search across components, signals, memos, actions and server functions while the dock is open. -- The timeline keeps distinct actions when one action repeats many times, such as an animation loop. +- The timeline and the async history keep everything from the last 30 seconds instead of a fixed number of entries, so a loop such as an animation frame no longer pushes other actions out. A fast run of one action draws as one bar. - Every list and detail split is resizable. From f63820a1616c144591f585e2803ca760cce7f67b Mon Sep 17 00:00:00 2001 From: "Alexis H. Munsayac" Date: Wed, 4 Nov 2026 12:43:11 +0800 Subject: [PATCH 09/10] fix(health): mark the error with the highlighter's squiggle, not a caret The source view drew a ^ under the column. The highlighter's err marker already wraps the exact expression, so it is drawn as an error squiggle and the lines around it step back. Co-Authored-By: Claude Opus 5.5 --- src/dev-toolbar/error-viewer/CodeView.tsx | 24 +++-------------------- src/dev-toolbar/error-viewer/styles.css | 24 +++++++++++------------ 2 files changed, 15 insertions(+), 33 deletions(-) diff --git a/src/dev-toolbar/error-viewer/CodeView.tsx b/src/dev-toolbar/error-viewer/CodeView.tsx index b4de301..68e78d9 100644 --- a/src/dev-toolbar/error-viewer/CodeView.tsx +++ b/src/dev-toolbar/error-viewer/CodeView.tsx @@ -8,7 +8,7 @@ export interface CodeViewProps { fileName: string; content: string; line: number; - /** Column of the error, so the marker can point at one word instead of the line. */ + /** Column of the error, so the `err` marker wraps one expression instead of the line. */ column?: number; } @@ -20,23 +20,6 @@ export interface CodeViewProps { */ const RANGE = 25; -/** - * Puts a `^` under the column of the hit line. - * - * The line sits after a gutter of 58px, and the code is monospace, so the - * caret is indented by that gutter and one character per column before it. - */ -function placeCaret(hit: HTMLElement, column: number | undefined): void { - hit.parentElement?.querySelector('[data-solid-error-viewer-caret]')?.remove(); - if (!column || column < 1) return; - const caret = document.createElement('span'); - caret.setAttribute('data-solid-error-viewer-caret', ''); - caret.setAttribute('aria-hidden', 'true'); - caret.style.paddingLeft = `calc(58px + ${column - 1}ch)`; - caret.textContent = '^'; - hit.after(caret); -} - export function CodeView(props: CodeViewProps): JSX.Element | null { const [element, setElement] = createSignal(); @@ -53,12 +36,11 @@ export function CodeView(props: CodeViewProps): JSX.Element | null { // The frame can sit anywhere in the file, so the view opens on it. Scrolling // the box itself leaves the page where it is. createEffect( - () => ({ view: element(), code: html(), column: props.column }), - ({ view, column }) => { + () => ({ view: element(), code: html() }), + ({ view }) => { if (!view) return; const focused = view.querySelector('.focus') as HTMLElement | null; if (!focused) return; - placeCaret(focused, column); view.scrollTop = Math.max(focused.offsetTop - view.clientHeight / 2, 0); }, ); diff --git a/src/dev-toolbar/error-viewer/styles.css b/src/dev-toolbar/error-viewer/styles.css index 79e8278..5bb662f 100644 --- a/src/dev-toolbar/error-viewer/styles.css +++ b/src/dev-toolbar/error-viewer/styles.css @@ -358,19 +358,19 @@ color: var(--start-dt-red); } -/* The `^` under the column of the hit line. */ -[data-solid-error-viewer-caret] { - display: block; - box-sizing: border-box; - - color: var(--start-dt-red); - font-weight: 700; - line-height: 1.1; - white-space: pre; +/* The other lines step back. The highlighter tags the block when a line is + in focus, so this needs no tracking of which line that is. */ +[data-solid-error-viewer-code-view] .has-focus .l:not(.focus) { + opacity: 0.6; } -/* The `err` directive marks the word; the caret already points at it. */ +/* The `err` directive wraps the exact expression at the frame's column, drawn + as an editor's error squiggle. */ [data-solid-error-viewer-code-view] .tok.error { - background: none; - box-shadow: none; + border-radius: 2px; + background-color: var(--start-dt-red-soft); + text-decoration: underline wavy var(--start-dt-red); + text-decoration-thickness: 1px; + text-underline-offset: 3px; + text-decoration-skip-ink: none; } From e3ab7bf68ca339fd3cd32052a0ebb8c2a290b22c Mon Sep 17 00:00:00 2001 From: "Alexis H. Munsayac" Date: Wed, 4 Nov 2026 13:20:46 +0800 Subject: [PATCH 10/10] test(e2e): open the Health view in the error tests from main Co-Authored-By: Claude Opus 5.5 --- tests/e2e/devtools.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/e2e/devtools.spec.ts b/tests/e2e/devtools.spec.ts index 233d9a0..0e623a0 100644 --- a/tests/e2e/devtools.spec.ts +++ b/tests/e2e/devtools.spec.ts @@ -227,7 +227,7 @@ test('leaves out errors thrown by browser extensions', async ({ page }) => { // An error of the app still counts. await page.locator('#throw-client-error').click(); - await expect(page.getByRole('tab', { name: 'Errors', exact: true })).toHaveAttribute( + await expect(page.getByRole('tab', { name: 'Health', exact: true })).toHaveAttribute( 'aria-selected', 'true', ); @@ -247,7 +247,7 @@ test('keeps the newest errors when an error repeats in a loop', async ({ page }) } }); - const tab = page.getByRole('tab', { name: 'Errors', exact: true }); + const tab = page.getByRole('tab', { name: 'Health', exact: true }); await expect(tab).toHaveAttribute('aria-selected', 'true'); await expect(tab).toContainText('200'); // The newest is shown first, and the oldest five are gone.