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/__tests__/virtual-window.test.ts b/app/src/__tests__/virtual-window.test.ts new file mode 100644 index 00000000..bdf2b8cb --- /dev/null +++ b/app/src/__tests__/virtual-window.test.ts @@ -0,0 +1,65 @@ +import { ApplicationRef, signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { afterEach, describe, expect, it } from 'vitest'; +import { VirtualWindow } from '../ui/virtual-window'; + +// A windowed list that gets shorter while scrolled near its end, such as a +// filter taking 2000 rows down to 301. + +const ROW = 20; + +function setup() { + const count = signal(2000); + const win = TestBed.runInInjectionContext( + () => new VirtualWindow({ count, estimate: ROW, fixed: true }), + ); + const host = document.createElement('div'); + host.style.overflowY = 'auto'; + let scrollTop = 0; + Object.defineProperty(host, 'scrollTop', { + get: () => scrollTop, + set: (v: number) => (scrollTop = v), + }); + Object.defineProperty(host, 'clientHeight', { get: () => 600 }); + document.body.appendChild(host); + win.attach(host); + const scrollTo = (top: number) => { + scrollTop = top; + host.dispatchEvent(new Event('scroll')); + }; + const render = () => TestBed.inject(ApplicationRef).tick(); + return { count, win, host, scrollTo, setScroll: (top: number) => (scrollTop = top), render }; +} + +afterEach(() => { + TestBed.resetTestingModule(); + document.body.innerHTML = ''; +}); + +describe('virtual window', () => { + it('still renders rows when the list shrinks below the scroll position', () => { + const { count, win, scrollTo } = setup(); + scrollTo(1990 * ROW); + expect(win.range().end).toBe(2000); + count.set(301); + const { start, end } = win.range(); + expect(end).toBe(301); + expect(start).toBeLessThan(301); + // The rows that fill the viewport at the end of the list, plus the overscan. + expect(end - start).toBeGreaterThanOrEqual(600 / ROW); + expect(win.padTop() + (end - start) * ROW + win.padBottom()).toBe(301 * ROW); + }); + + it('reads the scroll position again when the row count changes', () => { + const { count, win, scrollTo, setScroll, render } = setup(); + scrollTo(1990 * ROW); + render(); + count.set(301); + // The browser clamps the scroll position to the shorter list, with no scroll event. + setScroll(301 * ROW - 600); + render(); + // The list grows back before the next scroll: the window follows where the list really is. + count.set(2000); + expect(win.range().start).toBe((301 * ROW - 600) / ROW - 12); + }); +}); 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([ />
    - @for (entry of storeLog(); track entry.seq) { -
  • + @for (shown of shownLog(); track shown.entry.seq) { + @let entry = shown.entry; +