From 98b1b9f4523a3ff2afb92f38482d0e4250fb00da Mon Sep 17 00:00:00 2001 From: erKam Date: Mon, 12 Oct 2026 23:18:03 +0000 Subject: [PATCH 1/5] perf(overlay): push deltas, pause idle pages and window long panel lists The component tree, injectors, change detection and forms collectors now send only what changed after a first full report, at most once a second, and stop pushing while no panel or agent is watching (limits.idleAfterMs). The component tree skips DOM walks when nothing changed. Lists over 300 rows in the panel render only the rows near view. The server rebuilds the exact full reports, so the panel and agent tools see the same data. Refs #342 --- .../__tests__/component-tree-window.test.ts | 117 ++++++ app/src/__tests__/long-lists-window.test.ts | 86 +++++ app/src/__tests__/panel-bench.test.ts | 90 +++++ app/src/pages/component-tree.ts | 81 ++++- app/src/pages/forms-inspector.ts | 8 +- app/src/pages/forms-timeline.ts | 3 + app/src/pages/network-inspector.ts | 53 ++- app/src/pages/store-inspector.ts | 63 +++- app/src/ui/roving-list.ts | 22 ++ app/src/ui/virtual-window.ts | 180 +++++++++ .../content/getting-started/configuration.md | 31 +- .../src/content/getting-started/overlay.md | 2 +- .../devtools/src/__tests__/config.test.ts | 3 + .../src/__tests__/delta-channel.test.ts | 262 ++++++++++++++ .../delta-server-equivalence.test.ts | 224 ++++++++++++ .../src/__tests__/leased-collectors.test.ts | 140 +++++++ .../src/__tests__/overlay-config.test.ts | 3 +- .../src/__tests__/overlay-lease.test.ts | 131 +++++++ .../src/__tests__/page-interest.test.ts | 117 ++++++ .../devtools/src/__tests__/push-bench.test.ts | 179 +++++++++ .../devtools/src/__tests__/push-delta.test.ts | 342 ++++++++++++++++++ packages/devtools/src/cd-overlay.ts | 47 ++- packages/devtools/src/component-tree.ts | 21 +- packages/devtools/src/config.ts | 13 + packages/devtools/src/delta-channel.ts | 108 ++++++ packages/devtools/src/devframe.ts | 102 ++++-- packages/devtools/src/dom-dirty.ts | 81 +++++ packages/devtools/src/forms-collector.ts | 35 +- packages/devtools/src/overlay.ts | 131 ++++--- packages/devtools/src/page-lease.ts | 134 +++++++ packages/devtools/src/push-delta.ts | 302 ++++++++++++++++ packages/devtools/src/rpc/page-interest.ts | 166 +++++++++ packages/devtools/src/rpc/report-deltas.ts | 317 ++++++++++++++++ scripts/panel-axe.mjs | 85 ++++- 34 files changed, 3547 insertions(+), 132 deletions(-) create mode 100644 app/src/__tests__/component-tree-window.test.ts create mode 100644 app/src/__tests__/long-lists-window.test.ts create mode 100644 app/src/__tests__/panel-bench.test.ts create mode 100644 app/src/ui/virtual-window.ts create mode 100644 packages/devtools/src/__tests__/delta-channel.test.ts create mode 100644 packages/devtools/src/__tests__/delta-server-equivalence.test.ts create mode 100644 packages/devtools/src/__tests__/leased-collectors.test.ts create mode 100644 packages/devtools/src/__tests__/overlay-lease.test.ts create mode 100644 packages/devtools/src/__tests__/page-interest.test.ts create mode 100644 packages/devtools/src/__tests__/push-bench.test.ts create mode 100644 packages/devtools/src/__tests__/push-delta.test.ts create mode 100644 packages/devtools/src/delta-channel.ts create mode 100644 packages/devtools/src/dom-dirty.ts create mode 100644 packages/devtools/src/page-lease.ts create mode 100644 packages/devtools/src/push-delta.ts create mode 100644 packages/devtools/src/rpc/page-interest.ts create mode 100644 packages/devtools/src/rpc/report-deltas.ts diff --git a/app/src/__tests__/component-tree-window.test.ts b/app/src/__tests__/component-tree-window.test.ts new file mode 100644 index 00000000..c5fc3cf4 --- /dev/null +++ b/app/src/__tests__/component-tree-window.test.ts @@ -0,0 +1,117 @@ +import { TestBed, type ComponentFixture } from '@angular/core/testing'; +import type { DevframeRpcClient } from 'devframe/client'; +import { describe, expect, it } from 'vitest'; +import { ComponentTree } from '../pages/component-tree'; + +// A tree of 2,000 instances renders only the rows near view, and stays +// navigable by keyboard and described to screen readers. + +const COUNT = 2000; + +// jsdom has no CSS namespace; the page escapes ids with it. +(globalThis as { CSS?: { escape(value: string): string } }).CSS ??= { + escape: (value: string) => value, +}; + +function bigTree() { + const items = Array.from({ length: COUNT - 1 }, (_, i) => ({ + id: `c${i}`, + name: `Item${i}`, + tag: 'app-item', + children: [], + })); + return [{ id: 'app', name: 'App', tag: 'app-root', children: items }]; +} + +function client(roots: unknown[]): DevframeRpcClient { + const state: Record = { + 'component-tree': { + pages: { + a: { pageId: 'a', title: 'a', url: '/', roots, count: COUNT, detail: null, reportedAt: 1 }, + }, + }, + }; + const rpc = { + call: () => Promise.resolve([]), + callEvent: () => Promise.resolve(), + sharedState: (name: string) => + Promise.resolve({ value: () => state[name] ?? null, on: () => () => {} }), + }; + return { connectionMeta: {}, scope: () => ({ rpc }) } as unknown as DevframeRpcClient; +} + +async function settle(fixture: ComponentFixture) { + for (let i = 0; i < 4; i++) { + await new Promise((resolve) => setTimeout(resolve)); + await fixture.whenStable(); + } +} + +async function setup(roots: unknown[]) { + const fixture = TestBed.createComponent(ComponentTree); + fixture.componentRef.setInput('rpc', client(roots)); + document.body.appendChild(fixture.nativeElement); + await settle(fixture); + return fixture; +} + +const rows = (fixture: ComponentFixture) => + Array.from((fixture.nativeElement as HTMLElement).querySelectorAll('.row')); + +describe('ComponentTree with a long tree', () => { + it('renders the rows near view and space for the rest', async () => { + const fixture = await setup(bigTree()); + const shown = rows(fixture); + expect(shown.length).toBeGreaterThan(10); + expect(shown.length).toBeLessThan(200); + const tree = (fixture.nativeElement as HTMLElement).querySelector('.tree')!; + expect(tree.classList).toContain('windowed'); + expect(parseFloat(tree.style.paddingBottom)).toBeGreaterThan(30_000); + expect((fixture.nativeElement as HTMLElement).textContent).toContain( + 'Rows render as you scroll', + ); + fixture.destroy(); + }); + + it('tells screen readers where each row sits among its siblings', async () => { + const fixture = await setup(bigTree()); + const item = rows(fixture).find((row) => row.dataset['id'] === 'c4')!; + expect(item.getAttribute('aria-level')).toBe('2'); + expect(item.getAttribute('aria-posinset')).toBe('5'); + expect(item.getAttribute('aria-setsize')).toBe(String(COUNT - 1)); + // One Tab stop, on a rendered row. + expect(rows(fixture).filter((row) => row.tabIndex === 0)).toHaveLength(1); + fixture.destroy(); + }); + + it('moves to the last row with End, rendering it first', async () => { + const fixture = await setup(bigTree()); + const first = rows(fixture)[0]!; + first.focus(); + first.dispatchEvent( + new KeyboardEvent('keydown', { key: 'End', bubbles: true, cancelable: true }), + ); + await settle(fixture); + const last = rows(fixture).find((row) => row.dataset['id'] === `c${COUNT - 2}`); + expect(last).toBeTruthy(); + expect(document.activeElement).toBe(last); + expect(last!.tabIndex).toBe(0); + expect(rows(fixture).length).toBeLessThan(200); + fixture.destroy(); + }); + + it('renders a short tree whole', async () => { + const fixture = await setup([ + { + id: 'app', + name: 'App', + tag: 'app-root', + children: [{ id: 'x', name: 'X', tag: 'app-x', children: [] }], + }, + ]); + expect(rows(fixture)).toHaveLength(2); + const tree = (fixture.nativeElement as HTMLElement).querySelector('.tree')!; + expect(tree.classList).not.toContain('windowed'); + fixture.destroy(); + }); +}); diff --git a/app/src/__tests__/long-lists-window.test.ts b/app/src/__tests__/long-lists-window.test.ts new file mode 100644 index 00000000..d0f80fe6 --- /dev/null +++ b/app/src/__tests__/long-lists-window.test.ts @@ -0,0 +1,86 @@ +import { TestBed } from '@angular/core/testing'; +import { afterEach, describe, expect, it } from 'vitest'; +import { NetworkInspector } from '../pages/network-inspector'; + +// Long lists render only the rows near view: the HTTP call table here; the +// component tree has its own test. + +const COUNT = 2000; + +const call = (i: number) => ({ + id: `c${i}`, + url: `/api/items/${i}`, + method: 'GET', + status: 200, + durationMs: 12, + side: 'client' as const, + cacheHit: false, + faulted: false, + at: 1000 + i, +}); + +async function settle(fixture: { whenStable(): Promise }) { + for (let i = 0; i < 4; i++) { + await new Promise((resolve) => setTimeout(resolve)); + await fixture.whenStable(); + } +} + +async function network(count: number) { + const fixture = TestBed.createComponent(NetworkInspector); + document.body.appendChild(fixture.nativeElement); + fixture.componentInstance.pages.set([ + { + pageId: 'p1', + url: '/shop', + title: 'Shop', + hydration: null, + reportedAt: 1100, + calls: Array.from({ length: count }, (_, i) => call(i)), + }, + ]); + await settle(fixture); + return fixture; +} + +afterEach(() => TestBed.resetTestingModule()); + +describe('a long HTTP call table', () => { + it('renders the rows near view and keeps the row count for screen readers', async () => { + const fixture = await network(COUNT); + const host = fixture.nativeElement as HTMLElement; + const rows = host.querySelectorAll('tbody tr[data-vindex]'); + expect(rows.length).toBeGreaterThan(10); + expect(rows.length).toBeLessThan(200); + expect(host.querySelector('table')!.getAttribute('aria-rowcount')).toBe(String(COUNT + 1)); + expect(rows[3]!.getAttribute('aria-rowindex')).toBe('5'); + const spacer = host.querySelector('tbody tr.spacer:last-child td')!; + expect(parseFloat(spacer.style.height)).toBeGreaterThan(50_000); + expect(host.querySelector('.filters .total')!.textContent!.trim()).toBe(`${COUNT} of ${COUNT}`); + fixture.destroy(); + }); + + it('goes to the last call with End', async () => { + const fixture = await network(COUNT); + const host = fixture.nativeElement as HTMLElement; + const first = host.querySelector('button[data-call-id]')!; + first.focus(); + first.dispatchEvent( + new KeyboardEvent('keydown', { key: 'End', bubbles: true, cancelable: true }), + ); + await settle(fixture); + const focused = document.activeElement as HTMLElement; + expect(focused.closest('tr')!.getAttribute('aria-rowindex')).toBe(String(COUNT + 1)); + // One Tab stop in the table. + expect(host.querySelectorAll('button[data-call-id][tabindex="0"]')).toHaveLength(1); + fixture.destroy(); + }); + + it('renders a short table whole, without spacers', async () => { + const fixture = await network(20); + const host = fixture.nativeElement as HTMLElement; + expect(host.querySelectorAll('tbody tr[data-vindex]')).toHaveLength(20); + expect(host.querySelector('tbody tr.spacer')).toBeNull(); + fixture.destroy(); + }); +}); diff --git a/app/src/__tests__/panel-bench.test.ts b/app/src/__tests__/panel-bench.test.ts new file mode 100644 index 00000000..18b7a30e --- /dev/null +++ b/app/src/__tests__/panel-bench.test.ts @@ -0,0 +1,90 @@ +import { TestBed } from '@angular/core/testing'; +import type { DevframeRpcClient } from 'devframe/client'; +import { describe, expect, it } from 'vitest'; +import { ComponentTree } from '../pages/component-tree'; + +// How long the component tree takes to render 2,000 instances, and how many +// rows it puts in the DOM. Slow, so it runs only on demand: +// +// PANGULAR_BENCH=1 pnpm vitest run --config app/vitest.config.ts app/src/__tests__/panel-bench.test.ts + +const COUNT = 2000; + +/** Node's process, which the panel's types leave out. */ +const node = ( + globalThis as { + process?: { env: Record; stdout: { write(text: string): void } }; + } +).process; + +function roots() { + const sections = Array.from({ length: 40 }, (_, s) => ({ + id: `s${s}`, + name: 'Section', + tag: 'app-section', + children: [ + ...Array.from({ length: 24 }, (_, r) => ({ + id: `s${s}r${r}`, + name: 'Row', + tag: 'app-row', + children: [{ id: `s${s}r${r}c`, name: 'Cell', tag: 'app-cell', children: [] as unknown[] }], + })), + // One more row in all but the last section: 2,000 instances in all. + ...(s < 39 ? [{ id: `s${s}x`, name: 'Row', tag: 'app-row', children: [] }] : []), + ], + })); + return [{ id: 'app', name: 'App', tag: 'app-root', children: sections }]; +} + +function client(tree: unknown[]): DevframeRpcClient { + const state: Record = { + 'component-tree': { + pages: { + a: { + pageId: 'a', + title: 'a', + url: '/', + roots: tree, + count: COUNT, + detail: null, + reportedAt: 1, + }, + }, + }, + }; + const rpc = { + call: () => Promise.resolve([]), + callEvent: () => Promise.resolve(), + sharedState: (name: string) => + Promise.resolve({ value: () => state[name] ?? null, on: () => () => {} }), + }; + return { connectionMeta: {}, scope: () => ({ rpc }) } as unknown as DevframeRpcClient; +} + +describe.runIf(node?.env['PANGULAR_BENCH'])('panel benchmark', () => { + it('renders a 2,000 component tree', async () => { + const tree = roots(); + const runs: number[] = []; + let rows = 0; + let nodes = 0; + for (let i = 0; i < 5; i++) { + const started = performance.now(); + const fixture = TestBed.createComponent(ComponentTree); + fixture.componentRef.setInput('rpc', client(tree)); + document.body.appendChild(fixture.nativeElement); + for (let k = 0; k < 4; k++) { + await new Promise((resolve) => setTimeout(resolve)); + await fixture.whenStable(); + } + runs.push(performance.now() - started); + const host = fixture.nativeElement as HTMLElement; + rows = host.querySelectorAll('.row').length; + nodes = host.querySelectorAll('*').length; + fixture.destroy(); + } + runs.sort((a, b) => a - b); + const result = { rows, domNodes: nodes, medianRenderMs: Math.round(runs[2]!) }; + node?.stdout.write(`[bench] ${JSON.stringify(result)}\n`); + expect(rows).toBeGreaterThan(0); + }, 120_000); +}); diff --git a/app/src/pages/component-tree.ts b/app/src/pages/component-tree.ts index 4abab9f9..8debaa88 100644 --- a/app/src/pages/component-tree.ts +++ b/app/src/pages/component-tree.ts @@ -45,6 +45,7 @@ import { ValueStore } from './component-value-store'; import { nestedMatches } from './component-value-state'; import { StoreGlobal } from '../ui/store-global'; import { ForBlocksSection, type ForBlock } from './for-blocks-section'; +import { VirtualWindow } from '../ui/virtual-window'; interface SourceComponent { selector: string; @@ -164,8 +165,14 @@ interface Row { depth: number; hasChildren: boolean; expanded: boolean; + /** Position among its siblings, from 1, and how many siblings there are, for screen readers. */ + posinset: number; + setsize: number; } +/** Height of a tree row, fixed while the tree renders only the rows in view. */ +const TREE_ROW_PX = 32; + function bare(name: string): string { return name.replace(/^_(?=[A-Z])/, ''); } @@ -310,21 +317,35 @@ function storedDirectiveHosts(): boolean { } + @if (rowWindow.enabled()) { +

+ {{ rows().length }} rows. Rows render as you scroll, so the browser's find only sees + the ones near view; the filter above searches them all. +

+ }
- @for (row of rows(); track row.node.id) { + @for (shown of shownRows(); track shown.row.node.id) { + @let row = shown.row;
{ - for (const node of nodes) { + nodes.forEach((node, i) => { const expanded = searching || !collapsed.has(node.id); - rows.push({ node, depth, hasChildren: node.children.length > 0, expanded }); + rows.push({ + node, + depth, + hasChildren: node.children.length > 0, + expanded, + posinset: i + 1, + setsize: nodes.length, + }); if (expanded) walk(node.children, depth + 1); - } + }); }; walk(this.filtered().nodes, 0); return rows; }); + /** A long tree renders only the rows near the visible part of the page. */ + readonly rowWindow = new VirtualWindow({ + count: computed(() => this.rows().length), + estimate: TREE_ROW_PX, + fixed: true, + }); + readonly shownRows = computed(() => { + const { start, end } = this.rowWindow.range(); + return this.rows() + .slice(start, end) + .map((row, i) => ({ row, index: start + i })); + }); + /** The row Tab lands on: the roving one, or the first rendered row when it is out of view. */ + readonly tabStop = computed(() => { + const id = this.rovingId(); + const shown = this.shownRows(); + return shown.some(({ row }) => row.node.id === id) ? id : (shown[0]?.row.node.id ?? null); + }); + private readonly treeEl = viewChild>('treeEl'); + readonly selectedNode = computed(() => { const id = this.selectedId(); return id ? (this.index().map.get(id) ?? null) : null; @@ -1932,6 +1989,10 @@ export class ComponentTree { }); constructor() { + effect(() => { + const tree = this.treeEl()?.nativeElement; + if (tree) this.rowWindow.attach(tree); + }); effect(() => { const detail = this.detail(); const target = { @@ -2328,6 +2389,9 @@ export class ComponentTree { if (!this.rows().some((row) => row.node.id === id)) this.setFilter(''); if (this.selectedId() !== id) this.select(id); if (focus) this.focusId.set(id); + // In a long tree the row may be outside the rendered window. + const index = this.rows().findIndex((row) => row.node.id === id); + if (index >= 0) this.rowWindow.reveal(index); afterNextRender( () => { const row = document.querySelector(`.row[data-id="${CSS.escape(id)}"]`); @@ -2475,11 +2539,14 @@ export class ComponentTree { if (next >= 0) { const id = rows[next].node.id; this.focusId.set(id); - queueMicrotask(() => { + const focus = () => { const el = document.querySelector(`.row[data-id="${CSS.escape(id)}"]`); el?.focus(); el?.scrollIntoView({ block: 'nearest' }); - }); + }; + // A row outside the rendered window is brought in first. + if (this.rowWindow.rendered(next)) queueMicrotask(focus); + else this.rowWindow.reveal(next, focus); } } } diff --git a/app/src/pages/forms-inspector.ts b/app/src/pages/forms-inspector.ts index 17f91f46..19d4347b 100644 --- a/app/src/pages/forms-inspector.ts +++ b/app/src/pages/forms-inspector.ts @@ -646,6 +646,11 @@ function countFields(node: FormFieldNode): number { padding: 0; list-style: none; } + .form-list > li { + /* A page with many forms skips layout and paint for the entries out of view. */ + content-visibility: auto; + contain-intrinsic-size: auto 52px; + } .form-item { width: 100%; display: grid; @@ -678,8 +683,9 @@ function countFields(node: FormFieldNode): number { color: var(--text-strong); } .form-item:focus-visible { + /* Inside the entry: its paint containment would clip a ring drawn outside. */ outline: 2px solid var(--accent); - outline-offset: 2px; + outline-offset: -2px; } .form-item .dot { grid-area: dot; diff --git a/app/src/pages/forms-timeline.ts b/app/src/pages/forms-timeline.ts index cc27c129..5a2430b4 100644 --- a/app/src/pages/forms-timeline.ts +++ b/app/src/pages/forms-timeline.ts @@ -363,6 +363,9 @@ const EVENT_TYPES = [ border-radius: 6px; color: var(--text); transition: background-color 150ms var(--ease); + /* A long timeline skips layout and paint for the events out of view. */ + content-visibility: auto; + contain-intrinsic-size: auto 52px; } .events li:hover { background: var(--surface-3); diff --git a/app/src/pages/network-inspector.ts b/app/src/pages/network-inspector.ts index a62d25b1..10d234af 100644 --- a/app/src/pages/network-inspector.ts +++ b/app/src/pages/network-inspector.ts @@ -11,6 +11,7 @@ import { linkedSignal, signal, untracked, + viewChild, } from '@angular/core'; import { JsonPipe } from '@angular/common'; import type { DevframeRpcClient } from 'devframe/client'; @@ -27,6 +28,7 @@ import { prettyJson } from '../format'; import { LimitNote } from '../ui/limit-note'; import { RovingList } from '../ui/roving-list'; import { Select, type SelectOption } from '../ui/select'; +import { VirtualWindow } from '../ui/virtual-window'; type HttpSide = 'client' | 'server'; @@ -586,10 +588,16 @@ const HTTP_STATUS_OPTIONS: SelectOption[] = [ {{ filterStatus() }}
@if (visibleCalls().length) { + @if (callWindow.enabled()) { +

+ Rows render as you scroll, so the browser's find only sees the ones near view; the + search field searches them all. +

+ }
- +
- + @@ -599,9 +607,17 @@ const HTTP_STATUS_OPTIONS: SelectOption[] = [ - - @for (entry of visibleCalls(); track entry.side + entry.id) { + + @if (callWindow.padTop()) { + + + + } + @for (shown of shownCalls(); track shown.entry.side + shown.entry.id) { + @let entry = shown.entry; @@ -658,6 +674,11 @@ const HTTP_STATUS_OPTIONS: SelectOption[] = [ } + @if (callWindow.padBottom()) { + + + + }
Side Method URLNotes
@@ -1625,6 +1646,13 @@ const HTTP_STATUS_OPTIONS: SelectOption[] = [ tbody tr { cursor: pointer; } + tbody tr.spacer td { + padding: 0; + border: 0; + } + .window-note { + margin: 0 0 6px; + } tbody td { transition: background-color 0.15s var(--ease); } @@ -2185,6 +2213,19 @@ export class NetworkInspector { { value: 'client', label: 'Client' }, ]; + /** A long call list renders only the rows near view in its scroll box. */ + readonly callWindow = new VirtualWindow({ + count: computed(() => this.visibleCalls().length), + estimate: 37, + }); + readonly shownCalls = computed(() => { + const { start, end } = this.callWindow.range(); + return this.visibleCalls() + .slice(start, end) + .map((entry, i) => ({ entry, index: start + i })); + }); + private readonly callsBody = viewChild>('callsBody'); + readonly visibleCalls = computed(() => { const query = this.callQuery().trim().toLowerCase(); const side = this.sideFilter() ?? 'all'; @@ -2309,6 +2350,10 @@ export class NetworkInspector { }); constructor() { + effect(() => { + const body = this.callsBody()?.nativeElement; + if (body) this.callWindow.attach(body); + }); effect(() => { const client = this.rpc(); if (client) this.load(client); diff --git a/app/src/pages/store-inspector.ts b/app/src/pages/store-inspector.ts index 0e5572e3..04848588 100644 --- a/app/src/pages/store-inspector.ts +++ b/app/src/pages/store-inspector.ts @@ -20,6 +20,7 @@ import { actionAllowed, actionBlockedMessage, panelConfig } from '../devtools-co import { LimitNote } from '../ui/limit-note'; import { RovingList } from '../ui/roving-list'; import { Select, type SelectOption } from '../ui/select'; +import { VirtualWindow } from '../ui/virtual-window'; import { changeCountText, moreChangesText, @@ -377,12 +378,23 @@ const CLASSIC_KINDS = new Set([ />