diff --git a/README.md b/README.md index a402894..6a792a9 100644 --- a/README.md +++ b/README.md @@ -1,35 +1,37 @@ # Angular Render Scan -Angular Render Scan is a visual debugging overlay for Angular change detection. It shows which components update, how often they update, and which checks are slow or wasted. +Angular Render Scan turns a named user interaction into ranked Angular change-detection findings, a before/after comparison, and a portable report for developers and CI. ![Angular Render Scan in Action](docs/assets/angular-render-scan-demo.gif) [Live Demo](https://edisonaugusthy.github.io/angular-render-scan/) | [npm](https://www.npmjs.com/package/angular-render-scan) -## Versions +## Compatibility | Package | Version | | ------------------------- | --------- | -| Angular | `^22.0.0` | -| `angular-render-scan` | `0.1.8` | -| `angular-render-scan-cli` | `0.1.5` | +| Angular provider mode | `17+` | +| Script-tag overlay | `9+` | +| `angular-render-scan` | `0.1.13` | +| `angular-render-scan-cli` | `0.1.13` | ## What it shows - Component render outlines and heatmap colors. - Slow render and budget violation alerts. - Change detection trigger labels such as `zone:click`, `signal:write`, and `router:navigation`. -- OnPush candidates, referentially unstable inputs, and suspected Zone pollution. -- A copyable AI performance prompt for slow/error components. -- Session export JSON for deeper debugging. +- Ranked, evidence-backed findings for a named interaction. +- Before/after verification with regression guardrails. +- Markdown, HTML, and JSON reports for local review and pull requests. +- OnPush experiments, referentially unstable inputs, and Zone.js-only pollution leads. ## Install ```sh -npm install angular-render-scan +npm install -D angular-render-scan ``` -Angular Render Scan expects Angular 9+. +Provider mode supports Angular 17+ (including signals and zoneless applications). For Angular 9–16, use the framework-independent script-tag build; automatic component instrumentation requires provider mode. ## Setup with the CLI @@ -112,12 +114,25 @@ import { getOptions, getReferentialInstability, getZonePollutionEvents, + beginInteraction, + endInteraction, + compareInteractionReports, + formatInteractionReportMarkdown, scan, setOptions, stop, } from "angular-render-scan"; scan(); +beginInteraction("Add product to cart"); +// Perform the interaction. +const baseline = endInteraction(); + +beginInteraction("Add product to cart"); +// Perform the interaction after a candidate fix. +const candidate = endInteraction(); +console.log(compareInteractionReports(baseline, candidate)); +console.log(formatInteractionReportMarkdown(candidate)); setOptions({ enabled: false }); setOptions({ enabled: true, log: true }); @@ -133,9 +148,11 @@ console.log(getCdGraph()); stop(); ``` -## Toolbar +## Interaction workflow -The toolbar shows render count, FPS, latest cycle time, slowest component, trigger source, OnPush candidates, Zone pollution events, alerts, and copy/export actions. +Click `Capture`, name the interaction, perform it, then click `Finish`. The diagnosis panel ranks findings by impact and gives a concrete next action. Click `Use as baseline`, apply a fix, and capture the same interaction again to see an improved, unchanged, or regressed result. Markdown and standalone HTML exports are available from the panel. + +FPS is shown only as environmental context. A disconnected component host is not labeled a memory leak without heap evidence, and an OnPush opportunity percentage is an observed no-mutation check share—not a predicted saving. Zone pollution guidance applies only when the app uses Zone.js. CD Graph and Waterfall remain advanced runtime APIs rather than primary diagnosis surfaces. Useful shortcuts: @@ -148,11 +165,11 @@ Useful shortcuts: | `Alt+Shift+T` | Toggle toolbar | | `Escape` | Close open panels | -## Details and AI Prompt +## Details and developer handoff Enable `Details` in the toolbar, hover a captured component, then click it to pin a recommendation panel. The panel shows timing, render count, reason, selector, changed inputs, recent cycles, and local Angular recommendations. -Use `Copy Slow Issues Prompt` to copy a focused prompt for an AI coding assistant. It includes recent cycle history, thresholds, and slow/error component evidence without copying DOM nodes, component instances, or source code. +The ranked interaction report is the primary output. For a secondary AI handoff, call `copyAIPrompt()` or use `Alt+Shift+C`; it copies recent telemetry evidence without DOM nodes, component instances, or source code. ## Playwright Audit @@ -163,7 +180,7 @@ import { startRenderAudit } from "angular-render-scan"; test("no render regression", async ({ page }) => { await page.goto("/"); - const audit = await startRenderAudit(page); + const audit = await startRenderAudit(page, "Add product to cart"); await page.click("button.expensive-operation"); const report = await audit.stop(); @@ -175,6 +192,16 @@ test("no render regression", async ({ page }) => { }); ``` +Persist `await report.interactionReport()` as JSON in your test artifact directory. A single CLI command can then create a PR summary and fail on a material regression: + +```sh +npx angular-render-scan-cli report \ + --input artifacts/candidate.json \ + --baseline performance-baseline.json \ + --github-summary \ + --fail-on-regression +``` + ## Production Behavior Angular Render Scan is intended for development and demo debugging. Provider mode checks Angular `isDevMode()` and does not run in production unless explicitly enabled. diff --git a/demo/src/index.html b/demo/src/index.html index 999cb3a..e3f1e5f 100644 --- a/demo/src/index.html +++ b/demo/src/index.html @@ -5,13 +5,14 @@ Angular Render Scan · v22 Demo - + + diff --git a/demo/src/main.ts b/demo/src/main.ts index 24682f9..eba7551 100644 --- a/demo/src/main.ts +++ b/demo/src/main.ts @@ -2,831 +2,212 @@ import { bootstrapApplication } from '@angular/platform-browser'; import { ChangeDetectionStrategy, Component, - computed, - input, OnDestroy, OnInit, - output, + computed, + input, signal, } from '@angular/core'; import { AngularRenderScanMarkDirective, - getCdGraph, - getOnPushCandidates, - getReferentialInstability, - getZonePollutionEvents, + getWastedStats, provideAngularRenderScan, - getSignalDependencyGraph, - getComponentCostAnalysis, } from 'angular-render-scan'; -// ── Types ────────────────────────────────────────────────────── -interface Product { id: number; name: string; price: number; cat: string; icon: string } -interface LogEntry { time: string; msg: string; type: 'warn' | 'info' | 'success' } -interface PollutionEntry { time: string; trigger: string; comps: number } -interface TriggerEntry { time: string; source: string; kind: 'zone' | 'signal' | 'unknown' } - -const PRODUCTS: Product[] = [ - { id: 1, name: 'TypeScript Handbook', price: 29, cat: 'Books', icon: '📘' }, - { id: 2, name: 'Mechanical Keyboard', price: 149, cat: 'Hardware', icon: '⌨️' }, - { id: 3, name: 'Developer Mug', price: 19, cat: 'Merch', icon: '☕' }, - { id: 4, name: 'Rubber Duck', price: 9, cat: 'Debug', icon: '🦆' }, - { id: 5, name: 'Monitor Stand', price: 89, cat: 'Hardware', icon: '🖥️' }, - { id: 6, name: 'SSH Key Ring', price: 14, cat: 'Merch', icon: '🔑' }, -]; - -// ── ProductCard (OnPush — scanner marks it green) ────────────── -@Component({ - selector: 'app-product', - standalone: true, - imports: [AngularRenderScanMarkDirective], - template: ` -
- {{ p().icon }} -
-
{{ p().name }}
-
{{ p().cat }}
-
- \${{ p().price }} - -
- `, - changeDetection: ChangeDetectionStrategy.OnPush, -}) -class ProductComponent { - readonly p = input.required(); - readonly add = output(); -} - -// ── CartItem (Default CD — scanner shows it rerenders often) ─── -@Component({ - selector: 'app-cart-item', - standalone: true, - imports: [AngularRenderScanMarkDirective], - template: ` -
- {{ item().icon }} - {{ item().name }} - ×{{ qty() }} - -
- `, - // intentionally Default CD to be surfaced as OnPush candidate -}) -class CartItemComponent { - readonly item = input.required(); - readonly qty = input.required(); - readonly remove = output(); +interface RenderEvent { + name: string; + duration: number; + changed: boolean; + reason: string; } -// ── SlowComponent (Default CD — intentional OnPush candidate) ─ @Component({ - selector: 'app-slow', + selector: 'demo-preview-card', standalone: true, imports: [AngularRenderScanMarkDirective], + changeDetection: ChangeDetectionStrategy.OnPush, template: ` -
-
- Computed result - {{ compute() }} -
-
- Ran {{ renders }} times · Last counter: {{ counter() }} +
+
{{ title().slice(0, 1) }}
+
+ {{ title() }} + {{ subtitle() }}
-
+ {{ score() }} + `, - // no OnPush — will rerender on every parent CD cycle }) -class SlowComponent { - readonly counter = input.required(); - renders = 0; - - compute(): string { - this.renders += 1; - const start = Date.now(); - let n = 0.5; - while (Date.now() - start < 20) { - n = Math.sin(n) + 0.1; - } - const elapsed = Date.now() - start; - console.log('COMPUTE ELAPSED:', elapsed); - return n.toFixed(2); - } +class PreviewCardComponent { + readonly title = input.required(); + readonly subtitle = input.required(); + readonly score = input.required(); } -// ── RefDemo (OnPush — but receives new object refs) ──────────── @Component({ - selector: 'app-ref-demo', + selector: 'demo-expensive-result', standalone: true, imports: [AngularRenderScanMarkDirective], template: ` -
- config @Input - - {{ '{' }} theme: "{{ cfg().theme }}", size: {{ cfg().size }} {{ '}' }} - +
+ Calculated value + {{ calculate() }}
`, - changeDetection: ChangeDetectionStrategy.OnPush, }) -class RefDemoComponent { - readonly cfg = input.required<{ theme: string; size: number }>(); +class ExpensiveResultComponent { + readonly seed = input.required(); + + calculate(): string { + const start = performance.now(); + let value = this.seed() + 0.5; + while (performance.now() - start < 18) value = Math.sin(value) + 0.5; + return value.toFixed(4); + } } -// ── Root ─────────────────────────────────────────────────────── @Component({ selector: 'app-root', standalone: true, - imports: [ProductComponent, CartItemComponent, SlowComponent, RefDemoComponent], + imports: [PreviewCardComponent, ExpensiveResultComponent], template: ` -
- - -
- -
-
- Products - {{ products.length }} items -
-
- @for (p of products; track p.id) { - - } -
-
- -
-
- Cart - @if (cartTotal() > 0) { - \${{ cartTotal() }} - } -
-
- @if (cartKeys().length === 0) { -
🛒
Empty
- } @else { - @for (k of cartKeys(); track k) { - - } -
- \${{ cartTotal() }} - -
- } -
-
- -
-
- Render log - -
-
- @if (log().length === 0) { -
📋
Nothing yet
- } @else { -
- @for (e of log(); track e.time + e.msg + $index) { -
- {{ e.time }} - {{ e.msg }} -
- } -
- } -
-
- -
- - -
- - -
- - - - - - - -
- - -
- - - @if (activeTab() === 'trigger') { -
-
-
-
CD cycles
-
{{ totalCycles() }}
-
this session
-
-
-
Last trigger
-
{{ lastTrigger() }}
-
what caused the latest CD cycle
-
-
-
-
Trigger types
-
-
-
-
Zone — click
- -
-
-
Zone — setTimeout
- -
-
-
Signal write
- -
-
-
Zone — setInterval
- -
-
-
-
- @if (triggers().length > 0) { -
-
- History - -
-
- - - - @for (t of triggers(); track t.time + t.source + $index) { - - - - - - } - -
TimeSourceType
{{ t.time }}{{ t.source }}{{ t.kind }}
-
-
- } -
- } - - - @if (activeTab() === 'onpush') { -
- -
- -
- @if (onPushList().length === 0) { -
No candidates yet — interact with the shop, then Refresh
- } @else { -
-
{{ onPushList().length }} component{{ onPushList().length > 1 ? 's' : '' }} to optimise
-
- - - - @for (c of onPushList(); track c.name) { - - - - - - - } - -
ComponentWastedChecksConfidence
{{ c.selector }} -
-
- {{ c.wastedPercentage.toFixed(0) }}% -
-
{{ c.totalChecks }}{{ c.confidence }}
-
-
- } -
-
-
-
- } - - - @if (activeTab() === 'zone') { -
-
-
-
Pollution events
-
{{ pollutionEvents().length }}
-
this session
-
-
-
Stream
-
{{ stream() ? '🔴 Live' : '⚪ Off' }}
-
setInterval source
-
-
-
- - - -
- @if (pollutionEvents().length === 0) { -
No pollution yet — use the buttons above
- } @else { -
-
{{ pollutionEvents().length }} pollution events
-
- - - - @for (e of pollutionEvents(); track e.time + e.trigger + $index) { - - - - - - } - -
TimeTaskSource
{{ e.time }}{{ e.trigger }}{{ e.comps }} components
-
-
- } -
-
-
-
- } - - - @if (activeTab() === 'ref') { -
- -
-
- Fired {{ refFireCount() }} times with identical data but new object references -
+
+
+
Angular Render Scan
+
Profiling locally
+ GitHub ↗ +
+ +
+
+
+ Interactive performance lab +

See exactly why Angular checked a component.

+

Run a focused scenario, then enable the picker in the bottom-right panel and hover the highlighted component.

-
- - -
- @if (refList().length === 0) { -
No instability yet — fire a few references, then Refresh
- } @else { -
-
Instability report
-
- - - - @for (r of refList(); track r.componentName + r.inputName) { - - - - - - - } - -
ComponentInputUnstable refsWaste
{{ r.selector }}{{ r.inputName }}{{ r.unstableRefCount }} -
-
- {{ (r.unstableRefCount / (r.totalRenders || 1) * 100).toFixed(0) }}% -
-
-
+
    +
  1. 1Trigger a scenario below
  2. +
  3. 2Turn on component inspect
  4. +
  5. 3Hover a highlighted element
  6. +
+
+ +
+
+
Scenarios

Generate useful evidence

+ +
+
+
01
+

Signal update

A normal local update with a visible DOM change.

+ +
+ +
+
02
+

Stable OnPush input

Updates only when its scalar inputs actually change.

+ +
+ +
+
03
+

Reference churn

Passes a new object-shaped value with identical content.

+ +
+ +
+
04
+

Blocking template work

Runs synchronous work during change detection.

+ +
- } -
-
-
- } - - @if (activeTab() === 'graph') { -
-
- - Interact with the shop first -
- @if (graphNodes().length === 0) { -
📊
No data yet — interact with the shop, then Refresh
- } @else { -
-
- {{ graphNodes().length }} components tracked -
- {{ onPushNodeCount() }} OnPush - {{ defaultNodeCount() }} Default -
-
-
- - - - @for (n of graphNodes(); track n.id) { - - - - - - - - } - -
ComponentStrategyRendersWastedLast trigger
{{ n.name }} - - {{ n.cdStrategy }} - - {{ n.renderCount }} - @if (n.wastedChecks > 0) { - {{ n.wastedChecks }} - } @else { - - } - - @if (n.lastTrigger) { - {{ n.lastTrigger }} - } @else { - - } -
-
+
- } - - @if (activeTab() === 'signals') { -
-
- - Shows dependencies between signals -
- @if (signalGraph().nodes.length === 0) { -
📶
No signals detected yet — trigger signal writes/reads, then Refresh
- } @else { -
-
- Signal Nodes ({{ signalGraph().nodes.length }}) -
-
- - - - @for (n of signalGraph().nodes; track n.id) { - - - - - - - - } - -
NameKindUpdatesWastedValue
{{ n.name }} - - {{ n.kind }} - - {{ n.updateCount }} - @if (n.wastedCount > 0) { - {{ n.wastedCount }} - } @else { - 0 - } - {{ n.value ?? '—' }}
-
+
+
Checks{{ totalChecks() }}
+
No DOM change{{ wastedPercentage() }}%
+
Latest{{ latestDuration() }}
- - @if (signalGraph().edges.length > 0) { -
-
- Dependency Edges ({{ signalGraph().edges.length }}) -
-
- - - - @for (e of signalGraph().edges; track e.fromId + '->' + e.toId) { - - - - - } - -
FromTo
{{ e.fromId }}➔ {{ e.toId }}
-
-
- } - } -
- } - - @if (activeTab() === 'cost') { -
-
- - Rank components by render cost -
- @if (costAnalysis().length === 0) { -
💰
No cost analysis data yet — interact with the shop, then Refresh
- } @else { -
-
- Component Performance Costs -
-
- - - - @for (c of costAnalysis(); track c.name) { - - - - - - - - } - -
ComponentRendersTotal durationAvg durationCost share
{{ c.name }}{{ c.renderCount }}{{ c.totalDuration.toFixed(2) }}ms{{ c.averageDuration.toFixed(2) }}ms -
-
- {{ c.costPercentage.toFixed(1) }}% -
-
-
+
+
Recent checksNewest first
+ @if (events().length === 0) { +
Interact with a scenario to capture component evidence.
+ } @else { + @for (event of events().slice(0, 6); track $index) { +
{{ event.name }}{{ event.reason }}{{ event.duration.toFixed(2) }} ms
+ } + }
- } -
- } - -
-
- -
- + +
+
+
`, }) class AppComponent implements OnInit, OnDestroy { - - // ── Shared reactive counter ────────────────────────────────── - readonly activeTab = signal<'trigger'|'onpush'|'zone'|'ref'|'graph'|'signals'|'cost'>('trigger'); - - readonly counter = signal(0); - readonly signalTick = signal(0); - readonly stream = signal(false); - - - // ── Cart ───────────────────────────────────────────────────── - readonly products = PRODUCTS; - readonly cart = signal(new Map()); - readonly cartKeys = computed(() => Array.from(this.cart().keys())); - readonly cartTotal = computed(() => { let t = 0; this.cart().forEach(v => t += v.product.price * v.qty); return t; }); - - // ── Trigger attribution ────────────────────────────────────── - readonly totalCycles = signal(0); - readonly lastTrigger = signal('—'); - readonly triggers = signal([]); - readonly triggerKind = computed((): string => { - const t = this.lastTrigger(); - if (t.startsWith('zone:')) return 'zone'; - if (t.startsWith('signal:')) return 'signal'; - return 'unknown'; - }); - - // ── Log ────────────────────────────────────────────────────── - readonly log = signal([]); - - // ── Zone pollution ─────────────────────────────────────────── - readonly pollutionEvents = signal([]); - - // ── OnPush candidates ──────────────────────────────────────── - readonly onPushList = signal>([]); - - // ── Ref instability ────────────────────────────────────────── - readonly refCfg = signal({ theme: 'dark', size: 12 }); - readonly refFireCount = signal(0); - readonly refList = signal>([]); - - // ── CD graph ───────────────────────────────────────────────── - readonly graphNodes = signal['nodes']>([]); - readonly onPushNodeCount = computed(() => this.graphNodes().filter(n => n.cdStrategy === 'OnPush').length); - readonly defaultNodeCount = computed(() => this.graphNodes().filter(n => n.cdStrategy === 'Default').length); - - // ── Code snippets ──────────────────────────────────────────── - readonly codeOnPush = `// Before (Default — re-checks every cycle) -@Component({ changeDetection: ChangeDetectionStrategy.Default }) - -// After (OnPush — only re-checks when @Input reference changes or events fire) -@Component({ changeDetection: ChangeDetectionStrategy.OnPush })`; - - readonly codeZone = `// ❌ Problem — setInterval inside Angular triggers CD every second -constructor(private data: DataService) { - setInterval(() => this.data.refresh(), 1000); // Zone.js sees this! -} - -// ✅ Fix — run outside Angular's zone -constructor(private ngZone: NgZone, private data: DataService) { - ngZone.runOutsideAngular(() => { - setInterval(() => { - // only pull Angular back in when you need a real UI update - if (dataChanged) ngZone.run(() => this.data.refresh()); - }, 1000); - }); -}`; - - readonly codeRef = `// ❌ Problem — new object created each render (same values, new reference) -@Component({ template: '' }) -getConfig() { return { theme: 'dark', size: 12 }; } // called every cycle! - -// ✅ Fix 1 — lift to a class field (never re-created) -readonly config = { theme: 'dark', size: 12 }; - -// ✅ Fix 2 — use a signal -readonly config = signal({ theme: 'dark', size: 12 }); - -// ✅ Fix 3 — use computed() so it only changes when dependencies change -readonly config = computed(() => ({ theme: this.theme(), size: this.size() }));`; - - // ── Internals ──────────────────────────────────────────────── - private streamId: ReturnType | null = null; - - private readonly onRender = (e: Event) => { - const d = (e as CustomEvent).detail; - if (d.name === 'AppRoot') { - return; - } - - const trigger = d.renderCause?.trigger || d.reason; - const source = d.renderCause?.source; - - // Ignore render events triggered by updating demo debug/stats signals - if (source && ( - source.includes('totalCycles') || - source.includes('lastTrigger') || - source.includes('triggers') || - source.includes('log') || - source.includes('pollutionEvents') || - source.includes('onPushList') || - source.includes('refList') || - source.includes('graphNodes') || - source.includes('signalGraph') || - source.includes('costAnalysis') - )) { - return; - } - - const t = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }); - const duration = d.latestDuration ?? d.duration ?? 0; - - setTimeout(() => { - this.totalCycles.update(c => c + 1); - if (trigger) { - this.lastTrigger.set(trigger); - this.triggers.update(h => [{ - time: t, - source: trigger, - kind: trigger.startsWith('zone') ? 'zone' : trigger.startsWith('signal') ? 'signal' : 'unknown', - }, ...h.slice(0, 49)]); - } - this.log.update(l => [{ - time: t, - msg: `[${d.name}] ${duration.toFixed(2)}ms${trigger ? ` ← ${trigger}` : ''}`, - type: duration > 15 ? 'warn' : 'info', - }, ...l.slice(0, 24)]); - }); - }; - - private readonly onPollution = (e: Event) => { - const d = (e as CustomEvent).detail; - const t = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }); - setTimeout(() => { - this.pollutionEvents.update(l => [{ - time: t, - trigger: d?.suspectedTrigger ?? 'unknown zone task', - comps: d?.componentCount ?? 0, - }, ...l.slice(0, 49)]); + readonly count = signal(0); + readonly profileVersion = signal(1); + readonly refCount = signal(0); + readonly config = signal({ density: 'comfortable', theme: 'dark' }); + readonly showExpensive = signal(false); + readonly events = signal([]); + readonly profileSubtitle = computed(() => `Profile revision ${this.profileVersion()}`); + readonly totalChecks = signal(0); + readonly wastedPercentage = signal(0); + readonly latestDuration = computed(() => this.events()[0] ? `${this.events()[0].duration.toFixed(2)} ms` : '—'); + + private readonly renderListener = (event: Event) => { + const entry = (event as CustomEvent).detail; + if (!entry) return; + if (entry.name !== 'PreviewCard' && entry.name !== 'ExpensiveResult') return; + queueMicrotask(() => { + this.events.update(items => [{ + name: entry.name, + duration: entry.latestDuration, + changed: entry.mutationType !== 'none', + reason: entry.renderCause?.trigger ?? entry.reason ?? 'unknown', + }, ...items].slice(0, 20)); + const wasted = getWastedStats(); + this.totalChecks.set(wasted.totalChecks); + this.wastedPercentage.set(wasted.wastedPercentage); }); }; - ngOnInit() { - window.addEventListener('angular-render-scan:render', this.onRender); - window.addEventListener('angular-render-scan:zone-pollution', this.onPollution); - } - - ngOnDestroy() { - window.removeEventListener('angular-render-scan:render', this.onRender); - window.removeEventListener('angular-render-scan:zone-pollution', this.onPollution); - if (this.streamId) clearInterval(this.streamId); - } + ngOnInit(): void { window.addEventListener('angular-render-scan:render', this.renderListener); } + ngOnDestroy(): void { window.removeEventListener('angular-render-scan:render', this.renderListener); } - // ── Cart actions ───────────────────────────────────────────── - addToCart(product: Product) { - const m = new Map(this.cart()); - const e = m.get(product.id); - m.set(product.id, e ? { ...e, qty: e.qty + 1 } : { product, qty: 1 }); - this.cart.set(m); - } - removeFromCart(id: number) { - const m = new Map(this.cart()); - const e = m.get(id); - if (!e) return; - if (e.qty > 1) m.set(id, { ...e, qty: e.qty - 1 }); else m.delete(id); - this.cart.set(m); + increment(): void { this.showExpensive.set(false); this.count.update(value => value + 1); } + advanceProfile(): void { this.showExpensive.set(false); this.profileVersion.update(value => value + 1); } + newReference(): void { + this.showExpensive.set(false); + this.config.set({ density: 'comfortable', theme: 'dark' }); + this.refCount.update(value => value + 1); } - checkout() { - this.cart.set(new Map()); - const t = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }); - this.log.update(l => [{ time: t, msg: 'Checkout complete — cart cleared', type: 'success' }, ...l.slice(0, 24)]); + runExpensive(): void { this.showExpensive.set(true); this.count.update(value => value + 1); } + reset(): void { + this.count.set(0); this.profileVersion.set(1); this.refCount.set(0); + this.showExpensive.set(false); this.events.set([]); this.totalChecks.set(0); this.wastedPercentage.set(0); } - - // ── Trigger generators ─────────────────────────────────────── - fireMacroTask() { setTimeout(() => this.counter.update(c => c + 1), 300); } - - toggleStream() { - this.stream.update(v => !v); - if (this.stream()) { - this.streamId = setInterval(() => this.counter.update(c => c + 1), 500); - } else { - if (this.streamId) { clearInterval(this.streamId); this.streamId = null; } - } - } - - // ── Zone pollution ─────────────────────────────────────────── - fireSinglePollution() { setTimeout(() => this.counter.update(c => c + 1), 100); } - - // ── Ref instability ────────────────────────────────────────── - fireRefInstab() { - // Always a new object reference, same values - this.refCfg.set({ theme: 'dark', size: 12 }); - this.refFireCount.update(c => c + 1); - } - fireNewRef() { this.fireRefInstab(); } - refreshRefInstab() { this.refList.set(getReferentialInstability()); } - - // ── OnPush candidates ──────────────────────────────────────── - refreshOnPush() { this.onPushList.set(getOnPushCandidates()); } - - // ── CD graph ───────────────────────────────────────────────── - refreshGraph() { - const g = getCdGraph(); - this.graphNodes.set(g.nodes); - } - - // ── Signals graph ───────────────────────────────────────────── - readonly signalGraph = signal>({ nodes: [], edges: [] }); - refreshSignalGraph() { - this.signalGraph.set(getSignalDependencyGraph()); - } - - // ── Cost Analysis ───────────────────────────────────────────── - readonly costAnalysis = signal>([]); - refreshCostAnalysis() { - this.costAnalysis.set(getComponentCostAnalysis()); - } - - } -// ── Bootstrap ───────────────────────────────────────────────── bootstrapApplication(AppComponent, { - providers: [ - provideAngularRenderScan({ - enabled: true, - showToolbar: true, - animationSpeed: 'fast', - showFPS: true, - log: false, - trackReferentialStability: true, - referentialStabilityDepth: 4, - onPushCandidateThreshold: 40, - maxZonePollutionEvents: 50, - showCdGraph: true, - maxRecordedCycles: 30, - showCopyPrompt: true, - promptContext: 'Angular 22 demo — showcase of all Angular Render Scan features', - onZonePollution: (ev) => { - window.dispatchEvent(new CustomEvent('angular-render-scan:zone-pollution', { detail: ev })); - }, - }), - ], + providers: [provideAngularRenderScan({ + enabled: true, + showToolbar: true, + darkMode: 'dark', + animationSpeed: 'fast', + showFPS: true, + budgets: { warnMs: 8, errorMs: 16, maxRendersPerSecond: 20 }, + maxRecordedCycles: 30, + showCopyPrompt: true, + promptContext: 'Angular Render Scan focused performance lab', + })], }); diff --git a/demo/src/styles.css b/demo/src/styles.css index d37861e..35e3015 100644 --- a/demo/src/styles.css +++ b/demo/src/styles.css @@ -1,288 +1,8 @@ -/* ── Fonts ─────────────────────────────────────────────────── */ -@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap'); - -/* ── Tokens ────────────────────────────────────────────────── */ -:root { - --font: 'Inter', system-ui, sans-serif; - --mono: 'JetBrains Mono', monospace; - - --bg: #f8fafc; - --surface: #ffffff; - --border: #e2e8f0; - --text: #0f172a; - --muted: #64748b; - - --indigo: #6366f1; - --indigo-l: #eef2ff; - --green: #10b981; - --green-l: #d1fae5; - --amber: #f59e0b; - --amber-l: #fef3c7; - --red: #ef4444; - --red-l: #fee2e2; - --purple: #8b5cf6; - --purple-l: #ede9fe; - --cyan: #06b6d4; - --cyan-l: #cffafe; - - --r: 8px; - --r2: 12px; - --sh: 0 1px 3px rgba(0,0,0,.06), 0 1px 2px rgba(0,0,0,.04); - --sh2:0 4px 16px rgba(0,0,0,.08); -} - -:root.dark { - --bg: #0f172a; - --surface: #1e293b; - --border: rgba(255,255,255,.08); - --text: #f1f5f9; - --muted: #94a3b8; - - --indigo-l: rgba(99,102,241,.15); - --green-l: rgba(16,185,129,.12); - --amber-l: rgba(245,158,11,.12); - --red-l: rgba(239,68,68,.12); - --purple-l: rgba(139,92,246,.12); - --cyan-l: rgba(6,182,212,.12); -} - -/* ── Reset ─────────────────────────────────────────────────── */ -*,*::before,*::after{box-sizing:border-box;margin:0;padding:0} -html,body{font-family:var(--font);font-size:14px;line-height:1.6;background:var(--bg);color:var(--text)} -button{font-family:inherit;cursor:pointer;border:none} -code,pre{font-family:var(--mono)} - -/* ── Shell (full-viewport two-column layout) ───────────────── */ -html,body{height:100%;overflow:hidden} -.shell{ - display:grid; - grid-template-columns:340px 1fr; - height:100vh; - gap:0; - background:var(--bg); -} -/* ── Left panel — Shop ─────────────────────────────────────── */ -.panel-left{ - display:flex;flex-direction:column;gap:8px; - padding:12px;overflow-y:auto; - border-right:1px solid var(--border); - background:var(--surface); -} -.log-card{flex:1;min-height:0;display:flex;flex-direction:column} -.log-card .card-body{flex:1;min-height:0} -.log-scroll{overflow-y:auto} -/* ── Right panel — Tabs ────────────────────────────────────── */ -.panel-right{ - display:flex;flex-direction:column; - min-height:0;overflow:hidden; -} -.tab-bar{ - display:flex;gap:0; - border-bottom:1px solid var(--border); - background:var(--surface); - padding:0 12px; - flex-shrink:0; -} -.tab-btn{ - padding:10px 14px;font-size:12px;font-weight:600; - background:none;border:none;border-bottom:2px solid transparent; - color:var(--muted);cursor:pointer;white-space:nowrap; - transition:.15s;margin-bottom:-1px; -} -.tab-btn:hover{color:var(--text)} -.tab-btn.active{color:var(--indigo);border-bottom-color:var(--indigo)} -.tab-content{flex:1;overflow-y:auto;padding:12px} -.tab-panel{display:flex;flex-direction:column;gap:8px} -/* ── Trigger boxes ─────────────────────────────────────────── */ -.trig-box{ - background:rgba(0,0,0,.03);border-radius:var(--r); - padding:10px;display:flex;flex-direction:column;gap:6px; -} -.trig-title{font-size:11px;font-weight:600} - -/* ── Card ──────────────────────────────────────────────────── */ -.card{ - background:var(--surface);border:1px solid var(--border); - border-radius:var(--r2);box-shadow:var(--sh);overflow:hidden; -} -.card + .card{margin-top:12px} -.card-head{ - display:flex;align-items:center;justify-content:space-between; - padding:10px 14px;border-bottom:1px solid var(--border); -} -.card-label{font-size:12px;font-weight:600;color:var(--muted)} -.card-body{padding:14px} -.card-body.flush{padding:0} - -/* ── Buttons ───────────────────────────────────────────────── */ -.btn{ - display:inline-flex;align-items:center;gap:6px; - padding:8px 16px;border-radius:var(--r);font-size:13px;font-weight:500; - border:1px solid transparent;transition:.15s; -} -.btn-primary{background:var(--indigo);color:#fff;border-color:var(--indigo)} -.btn-primary:hover{background:#4f46e5} -.btn-outline{background:transparent;color:var(--text);border-color:var(--border)} -.btn-outline:hover{border-color:var(--indigo);color:var(--indigo)} -.btn-danger{background:var(--red-l);color:var(--red);border-color:rgba(239,68,68,.2)} -.btn-danger:hover{background:var(--red);color:#fff} -.btn-sm{padding:5px 11px;font-size:12px} -.btn:disabled{opacity:.4;cursor:not-allowed;pointer-events:none} -.btn-row{display:flex;gap:8px;flex-wrap:wrap;align-items:center} - -/* ── Badges ────────────────────────────────────────────────── */ -.badge{ - display:inline-flex;align-items:center;gap:4px; - padding:2px 8px;border-radius:20px;font-size:11px;font-weight:600; -} -.badge-green {background:var(--green-l);color:var(--green)} -.badge-amber {background:var(--amber-l);color:var(--amber)} -.badge-red {background:var(--red-l); color:var(--red)} -.badge-indigo{background:var(--indigo-l);color:var(--indigo)} -.badge-purple{background:var(--purple-l);color:var(--purple)} -.badge-cyan {background:var(--cyan-l); color:var(--cyan)} -.badge-gray {background:rgba(0,0,0,.06);color:var(--muted)} - -/* ── Stat row ──────────────────────────────────────────────── */ -.stat-row{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:8px} -.stat{ - flex:1;min-width:100px; - background:var(--surface);border:1px solid var(--border); - border-radius:var(--r);padding:10px 12px;box-shadow:var(--sh); -} -.stat-label{font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.4px;color:var(--muted)} -.stat-value{font-size:20px;font-weight:700;letter-spacing:-1px;line-height:1;margin-top:3px} -.stat-sub{font-size:11px;color:var(--muted);margin-top:2px} -.stat.green .stat-value{color:var(--green)} -.stat.amber .stat-value{color:var(--amber)} -.stat.red .stat-value{color:var(--red)} -.stat.indigo.stat-value{color:var(--indigo)} - -/* ── Trigger pill ──────────────────────────────────────────── */ -.tpill{ - display:inline-flex;align-items:center;gap:4px; - padding:3px 9px;border-radius:20px;font-size:11px;font-weight:600;font-family:var(--mono); -} -.tpill.zone {background:var(--amber-l);color:var(--amber)} -.tpill.signal{background:var(--purple-l);color:var(--purple)} -.tpill.router{background:var(--cyan-l);color:var(--cyan)} -.tpill.manual{background:var(--red-l);color:var(--red)} -.tpill.unknown{background:rgba(0,0,0,.05);color:var(--muted)} - -/* ── Progress bar ──────────────────────────────────────────── */ -.bar{height:6px;border-radius:3px;background:rgba(0,0,0,.06);overflow:hidden;flex:1} -.bar-fill{height:100%;border-radius:3px;transition:width .4s} -.bar-fill.green {background:var(--green)} -.bar-fill.amber {background:var(--amber)} -.bar-fill.red {background:var(--red)} -.bar-fill.indigo{background:var(--indigo)} - -/* ── Live log ──────────────────────────────────────────────── */ -.log{display:flex;flex-direction:column;gap:1px;max-height:220px;overflow-y:auto} -.log-row{ - display:flex;gap:10px;padding:6px 14px;border-radius:4px; - font-family:var(--mono);font-size:12px; -} -.log-row:hover{background:rgba(0,0,0,.03)} -.log-time{color:var(--muted);flex-shrink:0} -.log-msg{flex:1;color:var(--text)} -.log-row.warn .log-msg{color:var(--amber)} -.log-row.error .log-msg{color:var(--red)} -.log-row.success .log-msg{color:var(--green)} -.log-row.info .log-msg{color:var(--cyan)} - -/* ── Table ─────────────────────────────────────────────────── */ -.tbl{width:100%;border-collapse:collapse} -.tbl th{ - text-align:left;padding:7px 12px; - font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.4px; - color:var(--muted);border-bottom:1px solid var(--border);background:rgba(0,0,0,.02); -} -.tbl td{padding:7px 12px;font-size:12px;border-bottom:1px solid rgba(0,0,0,.04);vertical-align:middle} -.tbl tr:last-child td{border-bottom:none} -.tbl tr:hover td{background:rgba(0,0,0,.02)} -:root.dark .tbl th{background:rgba(255,255,255,.02)} -:root.dark .tbl tr:hover td{background:rgba(255,255,255,.02)} - -/* ── Code block ────────────────────────────────────────────── */ -.code{ - background:#1e2030;color:#a9b1d6; - border-radius:var(--r);padding:16px; - font-family:var(--mono);font-size:12px;line-height:1.7; - overflow-x:auto;white-space:pre; -} - -/* ── Product list ──────────────────────────────────────────── */ -.product-row{ - display:flex;align-items:center;gap:12px; - padding:10px 16px;border-bottom:1px solid rgba(0,0,0,.04); -} -.product-row:last-child{border-bottom:none} -.product-row:hover{background:rgba(0,0,0,.02)} -.p-emoji{font-size:20px;flex-shrink:0} -.p-name{font-size:13px;font-weight:500;flex:1} -.p-cat{font-size:11px;color:var(--muted)} -.p-price{font-size:14px;font-weight:700;color:var(--indigo);flex-shrink:0} - -/* ── Cart ──────────────────────────────────────────────────── */ -.cart-row{ - display:flex;align-items:center;gap:10px; - padding:8px 16px;border-bottom:1px solid rgba(0,0,0,.04); -} -.cart-row:last-child{border-bottom:none} -.c-emoji{font-size:16px} -.c-name{flex:1;font-size:12px} -.c-qty{font-size:12px;font-weight:600;color:var(--muted)} - - -.demo-zone-label{ - font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.5px; - color:var(--indigo);margin-bottom:12px;display:flex;align-items:center;gap:6px; -} - -/* ── Section divider ───────────────────────────────────────── */ -.section-divider{ - display:flex;align-items:center;gap:12px;margin:48px 0 32px; -} -.section-divider-line{flex:1;height:1px;background:var(--border)} -.section-divider-text{ - font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:1px;color:var(--muted); - white-space:nowrap; -} - -/* ── Empty ─────────────────────────────────────────────────── */ -.empty{ - display:flex;flex-direction:column;align-items:center;justify-content:center; - gap:6px;padding:20px;color:var(--muted);text-align:center;font-size:12px; -} -.empty-icon{font-size:22px;opacity:.35} - -/* ── Slow component callout ────────────────────────────────── */ -.callout{ - display:flex;align-items:flex-start;gap:12px; - padding:14px 16px;border-radius:var(--r); - background:var(--amber-l);border:1px solid rgba(245,158,11,.25); -} -.callout.red{background:var(--red-l);border-color:rgba(239,68,68,.2)} -.callout-icon{font-size:20px;flex-shrink:0} -.callout-title{font-size:13px;font-weight:600;color:var(--amber)} -.callout.red .callout-title{color:var(--red)} -.callout-body{font-size:12px;color:var(--muted);margin-top:2px} - -/* ── Result box (shows live computed value) ────────────────── */ -.result-box{ - background:rgba(0,0,0,.04);border-radius:var(--r); - padding:10px 14px;font-family:var(--mono);font-size:13px; - display:flex;justify-content:space-between;align-items:center; -} -:root.dark .result-box{background:rgba(255,255,255,.05)} - -/* ── Scrollbar ─────────────────────────────────────────────── */ -::-webkit-scrollbar{width:5px;height:5px} -::-webkit-scrollbar-track{background:transparent} -::-webkit-scrollbar-thumb{background:rgba(0,0,0,.1);border-radius:3px} -:root.dark ::-webkit-scrollbar-thumb{background:rgba(255,255,255,.1)} - -/* ── Anim ──────────────────────────────────────────────────── */ -@keyframes fadeUp{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}} -.fade-up{animation:fadeUp .25s ease} +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@500;600&display=swap'); + +:root{color-scheme:dark;--bg:#09090b;--panel:#121216;--raised:#18181d;--line:rgba(255,255,255,.09);--text:#f4f4f5;--muted:#96969f;--red:#f6375b;--red-soft:rgba(246,55,91,.12);--green:#34d399;--amber:#fbbf24;--mono:'JetBrains Mono',monospace;font-family:Inter,system-ui,sans-serif;background:var(--bg);color:var(--text)} +*{box-sizing:border-box}html,body{margin:0;min-height:100%;background:radial-gradient(circle at 75% -20%,rgba(246,55,91,.09),transparent 35%),var(--bg)}button,a{font:inherit}.app-shell{min-height:100vh}.topbar{height:58px;display:flex;align-items:center;gap:24px;padding:0 28px;border-bottom:1px solid var(--line);background:rgba(9,9,11,.88);backdrop-filter:blur(18px);position:sticky;top:0;z-index:5}.brand{display:flex;align-items:center;gap:10px;font-size:13px;font-weight:700}.topbar-status{margin-left:auto;display:flex;align-items:center;gap:7px;color:var(--muted);font-size:11px}.topbar-status i{width:7px;height:7px;border-radius:50%;background:var(--green);box-shadow:0 0 0 4px rgba(52,211,153,.1)}.topbar a{color:var(--muted);font-size:11px;text-decoration:none}.topbar a:hover{color:var(--text)} +main{width:min(1180px,calc(100% - 40px));margin:0 auto;padding:52px 0 100px}.intro{display:grid;grid-template-columns:minmax(0,1.6fr) minmax(290px,.7fr);gap:80px;align-items:end;padding-bottom:42px;border-bottom:1px solid var(--line)}.eyebrow{color:#ff7892;font-size:9px;font-weight:700;letter-spacing:.12em;text-transform:uppercase}.intro h1{max-width:720px;margin:10px 0 14px;font-size:clamp(34px,5vw,58px);line-height:1.02;letter-spacing:-.055em}.intro p{max-width:650px;margin:0;color:var(--muted);font-size:15px;line-height:1.7}.steps{list-style:none;margin:0;padding:0;border:1px solid var(--line);border-radius:12px;overflow:hidden;background:rgba(18,18,22,.65)}.steps li{display:flex;align-items:center;gap:12px;padding:12px 14px;border-bottom:1px solid var(--line);color:var(--muted);font-size:11px}.steps li:last-child{border:0}.steps b{display:grid;place-items:center;width:20px;height:20px;border-radius:6px;background:var(--red-soft);color:#ff7892;font:600 9px var(--mono)} +.workspace{display:grid;grid-template-columns:minmax(0,1.12fr) minmax(360px,.88fr);gap:34px;padding-top:36px}.section-heading{display:flex;align-items:end;justify-content:space-between;gap:20px;margin-bottom:15px}.section-heading h2{margin:4px 0 0;font-size:19px;letter-spacing:-.025em}.quiet{padding:7px 10px;border:1px solid var(--line);border-radius:7px;background:transparent;color:var(--muted);font-size:10px;cursor:pointer}.quiet:hover{color:var(--text);background:rgba(255,255,255,.04)}.scenario-grid{display:grid;gap:9px}.scenario{display:grid;grid-template-columns:34px 1fr auto;align-items:center;gap:14px;min-height:96px;padding:14px 14px 14px 12px;border:1px solid var(--line);border-radius:11px;background:linear-gradient(110deg,rgba(255,255,255,.025),transparent);transition:.18s}.scenario:hover{transform:translateY(-1px);border-color:rgba(255,255,255,.16);background:rgba(255,255,255,.035)}.scenario-number{align-self:start;color:#666670;font:600 9px var(--mono)}.scenario h3{margin:0 0 5px;font-size:13px}.scenario p{margin:0;color:var(--muted);font-size:11px;line-height:1.45}.scenario button{padding:8px 10px;border:1px solid rgba(246,55,91,.25);border-radius:7px;background:var(--red-soft);color:#ff8ba1;font-size:10px;font-weight:600;cursor:pointer;white-space:nowrap}.scenario button:hover{background:rgba(246,55,91,.2)}.scenario.warning button{border-color:rgba(251,191,36,.22);background:rgba(251,191,36,.08);color:var(--amber)}.scenario.danger button{background:var(--red);color:white}.scenario kbd{margin-left:5px;font:600 9px var(--mono)} +.cycle-count{padding:4px 7px;border:1px solid var(--line);border-radius:999px;color:var(--muted);font-size:9px}.preview-surface{min-height:244px;padding:18px;border:1px solid var(--line);border-radius:12px;background:linear-gradient(145deg,#17171c,#101014);box-shadow:0 20px 60px rgba(0,0,0,.22)}.preview-card{display:flex;align-items:center;gap:12px;padding:14px;border:1px solid var(--line);border-radius:10px;background:rgba(255,255,255,.035)}.avatar{display:grid;place-items:center;width:38px;height:38px;border-radius:9px;background:linear-gradient(145deg,var(--red),#a80028);font-weight:700}.preview-card div:nth-child(2){display:grid;gap:3px;flex:1}.preview-card strong{font-size:12px}.preview-card span{color:var(--muted);font-size:10px}.preview-card small{font:600 18px var(--mono)}.expensive-result,.reference-readout{display:flex;align-items:center;justify-content:space-between;margin-top:10px;padding:12px 14px;border:1px solid var(--line);border-radius:9px;background:rgba(255,255,255,.025);font-size:10px}.expensive-result strong{color:var(--amber);font-family:var(--mono)}.reference-readout span{color:var(--muted)}.reference-readout code{color:#ff8ba1;font:500 9px var(--mono)}.session-summary{display:grid;grid-template-columns:repeat(3,1fr);margin-top:10px;border:1px solid var(--line);border-radius:10px;background:var(--panel);overflow:hidden}.session-summary div{display:grid;gap:4px;padding:11px 13px;border-right:1px solid var(--line)}.session-summary div:last-child{border:0}.session-summary span{color:var(--muted);font-size:8px;text-transform:uppercase;letter-spacing:.06em}.session-summary strong{font:600 12px var(--mono)}.event-feed{margin-top:10px;border:1px solid var(--line);border-radius:10px;background:var(--panel);overflow:hidden}.feed-head{display:flex;justify-content:space-between;padding:10px 12px;border-bottom:1px solid var(--line);font-size:10px;font-weight:600}.feed-head small{color:var(--muted);font-weight:400}.feed-empty{padding:24px 14px;color:var(--muted);font-size:10px;text-align:center}.event-row{display:grid;grid-template-columns:7px 1fr 80px 62px;align-items:center;gap:8px;padding:8px 12px;border-bottom:1px solid rgba(255,255,255,.05);font-size:9px}.event-row:last-child{border:0}.event-row i{width:6px;height:6px;border-radius:50%;background:var(--amber)}.event-row i.changed{background:var(--green)}.event-row strong{overflow:hidden;text-overflow:ellipsis}.event-row span{color:var(--muted);overflow:hidden;text-overflow:ellipsis}.event-row code{text-align:right;color:#c8c8ce;font:500 8px var(--mono)} +@media(max-width:900px){.intro{grid-template-columns:1fr;gap:28px}.workspace{grid-template-columns:1fr}.evidence-column{order:-1}}@media(max-width:620px){.topbar{padding:0 16px}.topbar-status{display:none}main{width:min(100% - 24px,1180px);padding-top:34px}.intro h1{font-size:38px}.scenario{grid-template-columns:28px 1fr}.scenario button{grid-column:2;width:max-content}.event-row{grid-template-columns:7px 1fr 60px}.event-row span{display:none}} diff --git a/e2e/demo.spec.ts b/e2e/demo.spec.ts index efe59b0..0ce9ffa 100644 --- a/e2e/demo.spec.ts +++ b/e2e/demo.spec.ts @@ -2,319 +2,79 @@ import { expect, test } from '@playwright/test'; const overlaySelector = 'angular-render-scan-overlay'; -async function expandToolbar(page: import('@playwright/test').Page) { - const overlay = page.locator(overlaySelector); - await overlay.evaluate((host) => { - const root = host.shadowRoot; - if (root?.querySelector('.toolbar.compact')) { - root.querySelector('.toolbar-size-toggle')?.click(); - } - }); - await expect.poll(async () => overlay.evaluate((host) => { - return host.shadowRoot?.querySelector('.toolbar')?.classList.contains('expanded') ?? false; - })).toBe(true); -} - -test('toolbar appears and toggle controls scanner state', async ({ page }) => { +test('compact toolbar appears and toggles scanner state', async ({ page }) => { await page.goto('/'); - const overlay = page.locator(overlaySelector); await expect(overlay).toBeAttached(); - await expect(page.locator('.card-label').first()).toContainText('Products'); - const angularVersion = await page.locator('[ng-version]').first().getAttribute('ng-version'); - await expect.poll(async () => overlay.evaluate((host) => { - return host.shadowRoot?.querySelector('.angular-version-chip')?.textContent?.trim() ?? ''; - })).toBe(angularVersion ? `ng ${angularVersion}` : ''); - - await page.locator('app-product').filter({ hasText: 'Developer Mug' }).getByRole('button', { name: 'Add' }).click(); - await expandToolbar(page); - await expect.poll(async () => overlay.evaluate((host) => { - const toolbar = host.shadowRoot?.textContent ?? ''; - return toolbar.includes('ProductCard') || toolbar.includes('CartItem') || toolbar.includes('AppRoot') || toolbar.includes('AppComponent'); - })).toBe(true); - - await overlay.evaluate((host) => { - const input = host.shadowRoot?.querySelector('input') as HTMLInputElement | null; - input?.click(); - }); - await expect.poll(async () => overlay.evaluate((host) => host.shadowRoot?.querySelector('.switch-text')?.textContent?.trim())).toBe('Off'); + await expect(page.getByRole('heading', { name: 'Generate useful evidence' })).toBeVisible(); - await overlay.evaluate((host) => { - const input = host.shadowRoot?.querySelector('input') as HTMLInputElement | null; - input?.click(); - }); - await expect.poll(async () => overlay.evaluate((host) => host.shadowRoot?.querySelector('.switch-text')?.textContent?.trim())).toBe('On'); + await expect.poll(() => overlay.evaluate((host) => host.shadowRoot?.querySelector('.power-label')?.textContent)).toBe('On'); + await overlay.evaluate((host) => host.shadowRoot?.querySelector('input')?.click()); + await expect.poll(() => overlay.evaluate((host) => host.shadowRoot?.querySelector('.power-label')?.textContent)).toBe('Off'); + await overlay.evaluate((host) => host.shadowRoot?.querySelector('input')?.click()); + await expect.poll(() => overlay.evaluate((host) => host.shadowRoot?.querySelector('.power-label')?.textContent)).toBe('On'); }); -test('toolbar size toggle persists across refresh', async ({ page }) => { +test('capture ranks an interaction and compares the next run with its baseline', async ({ page }) => { await page.goto('/'); - await page.evaluate(() => { - localStorage.removeItem('angular-render-scan:toolbar-compact'); - }); - await page.reload(); - const overlay = page.locator(overlaySelector); await expect(overlay).toBeAttached(); - await expect.poll(async () => overlay.evaluate((host) => { - return host.shadowRoot?.querySelector('.toolbar')?.classList.contains('compact') ?? false; - })).toBe(true); + page.on('dialog', (dialog) => dialog.accept('Add product to cart')); - await overlay.evaluate((host) => { - host.shadowRoot?.querySelector('.toolbar-size-toggle')?.click(); - }); - await expect.poll(async () => overlay.evaluate((host) => { - return host.shadowRoot?.querySelector('.toolbar')?.classList.contains('expanded') ?? false; - })).toBe(true); + await overlay.evaluate((host) => host.shadowRoot?.querySelector('.interaction-capture-btn')?.click()); + await expect.poll(() => overlay.evaluate((host) => host.shadowRoot?.querySelector('.interaction-capture-btn')?.textContent)).toBe('Finish'); + await page.getByRole('button', { name: /Increment signal/ }).click(); + await overlay.evaluate((host) => host.shadowRoot?.querySelector('.interaction-capture-btn')?.click()); - await page.reload(); - await expect(overlay).toBeAttached(); - await expect.poll(async () => overlay.evaluate((host) => { - return host.shadowRoot?.querySelector('.toolbar')?.classList.contains('expanded') ?? false; - })).toBe(true); + await expect.poll(() => overlay.evaluate((host) => host.shadowRoot?.querySelector('.diagnosis-panel')?.textContent ?? '')).toContain('Add product to cart'); + await expect.poll(() => overlay.evaluate((host) => host.shadowRoot?.querySelector('.diagnosis-panel')?.textContent ?? '')).toMatch(/Next:|No actionable finding/); + await overlay.evaluate((host) => host.shadowRoot?.querySelector('.diagnosis-baseline-btn')?.click()); - await overlay.evaluate((host) => { - host.shadowRoot?.querySelector('.toolbar-size-toggle')?.click(); - }); - await expect.poll(async () => overlay.evaluate((host) => { - return host.shadowRoot?.querySelector('.toolbar')?.classList.contains('compact') ?? false; - })).toBe(true); - - await page.reload(); - await expect(overlay).toBeAttached(); - await expect.poll(async () => overlay.evaluate((host) => { - return host.shadowRoot?.querySelector('.toolbar')?.classList.contains('compact') ?? false; - })).toBe(true); + await overlay.evaluate((host) => host.shadowRoot?.querySelector('.interaction-capture-btn')?.click()); + await page.getByRole('button', { name: /Update profile/ }).click(); + await overlay.evaluate((host) => host.shadowRoot?.querySelector('.interaction-capture-btn')?.click()); + await expect.poll(() => overlay.evaluate((host) => host.shadowRoot?.querySelector('.diagnosis-panel')?.textContent ?? '')).toMatch(/Candidate (improved|unchanged|regressed)/i); }); -test('slow recommendations component can be inspected from details mode', async ({ page }) => { +test('details mode shows evidence on hover without blocking the app', async ({ page }) => { await page.goto('/'); const overlay = page.locator(overlaySelector); await expect(overlay).toBeAttached(); - await expect(page.locator('.card-label').first()).toContainText('Products'); - await expandToolbar(page); - - // Activate OnPush tab so app-slow is rendered - await page.getByRole('button', { name: '🚀 OnPush' }).click(); - await page.getByRole('button', { name: 'Refresh candidates' }).click(); - await expect(page.locator('app-slow')).toBeVisible(); - - await overlay.evaluate((host) => { - host.shadowRoot?.querySelector('.toolbar-picker-toggle')?.click(); - }); - await page.locator('app-slow').hover({ force: true }); - await expect.poll(async () => page.evaluate(() => { - const overlay = document.querySelector('angular-render-scan-overlay'); - const text = overlay?.shadowRoot?.querySelector('.inspect-panel')?.textContent ?? ''; - return text.includes('ExpensiveRecommendation') || text.includes('SlowComponent'); - })).toBe(true); + await page.getByRole('button', { name: /Run 18 ms calculation/ }).click(); + await overlay.evaluate((host) => host.shadowRoot?.querySelector('.toolbar-picker-toggle')?.click()); + await page.locator('demo-expensive-result').hover({ force: true }); + await expect.poll(() => overlay.evaluate((host) => host.shadowRoot?.querySelector('.inspect-panel')?.textContent ?? '')).toContain('ExpensiveResult'); }); -test('cart updates emit component render events', async ({ page }) => { +test('global API returns portable interaction reports', async ({ page }) => { await page.goto('/'); - const overlay = page.locator(overlaySelector); - await expect(overlay).toBeAttached(); - await expect(page.locator('.card-label').first()).toContainText('Products'); - - await expect.poll(async () => page.evaluate(() => { - return new Promise((resolve) => { - const timeout = window.setTimeout(() => { - window.removeEventListener('angular-render-scan:render', onRender); - resolve(''); - }, 700); - const onRender = (event: Event) => { - const detail = (event as CustomEvent<{ name: string }>).detail; - if (detail.name === 'CartItem' || detail.name === 'AppRoot' || detail.name === 'ProductCard') { - window.clearTimeout(timeout); - window.removeEventListener('angular-render-scan:render', onRender); - resolve(detail.name); - } - }; - window.addEventListener('angular-render-scan:render', onRender); - document.querySelector('app-product button')?.click(); - }); - })).not.toBe(''); + const result = await page.evaluate(async () => { + const scan = (window as any).AngularRenderScan; + scan.beginInteraction('Counter click'); + document.querySelector('.scenario button')?.click(); + await new Promise((resolve) => setTimeout(resolve, 30)); + const report = scan.endInteraction(); + return { report, markdown: scan.formatInteractionReportMarkdown(report), html: scan.formatInteractionReportHtml(report) }; + }); + expect(result.report.schemaVersion).toBe(1); + expect(result.report.name).toBe('Counter click'); + expect(result.markdown).toContain('# Angular Render Scan: Counter click'); + expect(result.html).toContain(''); }); -test('manual package rename surfaces in browser integration', async ({ page }) => { +test('cart updates emit component render events', async ({ page }) => { await page.goto('/'); - const overlay = page.locator(overlaySelector); - await expect(overlay).toBeAttached(); - await expect(page.locator('.card-label').first()).toContainText('Products'); - - await expect(page.evaluate(() => 'AngularRenderScan' in window)).resolves.toBe(true); -}); - -test('toolbar can copy an AI performance prompt', async ({ page }) => { - await page.addInitScript(() => { - Object.defineProperty(navigator, 'clipboard', { - configurable: true, - value: { - writeText: async (text: string) => { - (window as any).__angularRenderScanCopiedPrompt = text; - } + await expect.poll(async () => page.evaluate(() => new Promise((resolve) => { + const timeout = window.setTimeout(() => resolve(''), 700); + const onRender = (event: Event) => { + const name = (event as CustomEvent<{ name: string }>).detail.name; + if (['PreviewCard', 'ExpensiveResult'].includes(name)) { + clearTimeout(timeout); + window.removeEventListener('angular-render-scan:render', onRender); + resolve(name); } - }); - }); - await page.goto('/'); - const overlay = page.locator(overlaySelector); - await expect(overlay).toBeAttached(); - await expect(page.locator('.card-label').first()).toContainText('Products'); - await expandToolbar(page); - - // Activate OnPush tab so app-slow is rendered - await page.getByRole('button', { name: '🚀 OnPush' }).click(); - - await page.getByRole('button', { name: 'Refresh candidates' }).click(); - await expect.poll(async () => overlay.evaluate((host) => host.shadowRoot?.textContent ?? '')).toContain('waste'); - await overlay.evaluate((host) => { - host.shadowRoot?.querySelector('.copy-prompt-btn')?.click(); - }); - - await expect.poll(async () => page.evaluate(() => (window as any).__angularRenderScanCopiedPrompt ?? '')).toContain('Angular change-detection'); - await expect.poll(async () => page.evaluate(() => (window as any).__angularRenderScanCopiedPrompt ?? '')).toContain('Environment:'); - await expect.poll(async () => page.evaluate(() => (window as any).__angularRenderScanCopiedPrompt ?? '')).toContain('Recent cycle history:'); - await expect.poll(async () => page.evaluate(() => (window as any).__angularRenderScanCopiedPrompt ?? '')).toContain('Slow/error component issues to fix:'); - await expect.poll(async () => page.evaluate(() => (window as any).__angularRenderScanCopiedPrompt ?? '')).toContain('Cost:'); - await expect.poll(async () => overlay.evaluate((host) => host.shadowRoot?.textContent ?? '')).toContain('Copied'); -}); - -test('toolbar displays and triggers export session controls', async ({ page }) => { - await page.goto('/'); - const overlay = page.locator(overlaySelector); - await expect(overlay).toBeAttached(); - await expect(page.locator('.card-label').first()).toContainText('Products'); - await expandToolbar(page); - - await expect.poll(async () => overlay.evaluate((host) => { - return host.shadowRoot?.querySelector('.export-btn') !== null; - })).toBe(true); - - await page.getByRole('button', { name: 'Click (0)' }).click(); - - const downloadPromise = page.waitForEvent('download'); - await overlay.evaluate((host) => { - host.shadowRoot?.querySelector('.export-btn')?.click(); - }); - const download = await downloadPromise; - expect(download.suggestedFilename()).toContain('angular-render-scan-session-'); -}); - -test('details mode shows hover-positioned recommendation panel without manual actions', async ({ page }) => { - await page.goto('/'); - const overlay = page.locator(overlaySelector); - await expect(overlay).toBeAttached(); - await expect(page.locator('.card-label').first()).toContainText('Products'); - - // Activate OnPush tab so app-slow is rendered - await page.getByRole('button', { name: '🚀 OnPush' }).click(); - - await page.evaluate(() => (window as any).AngularRenderScan.setOptions({ animationSpeed: 'slow' })); - - await page.getByRole('button', { name: 'Refresh candidates' }).click(); - - await overlay.evaluate((host) => { - host.shadowRoot?.querySelector('.toolbar-picker-toggle')?.click(); - }); - await expect.poll(async () => overlay.evaluate((host) => { - return host.shadowRoot?.querySelector('.toolbar-picker-toggle')?.getAttribute('aria-pressed'); - })).toBe('true'); - await expect.poll(async () => overlay.evaluate((host) => { - return host.shadowRoot?.querySelector('.details-toggle')?.getAttribute('data-tooltip') ?? ''; - })).toContain('hover'); - - await page.locator('app-slow').hover({ force: true }); - - await expect.poll(async () => { - const text = await overlay.evaluate((host) => host.shadowRoot?.querySelector('.inspect-panel')?.textContent ?? ''); - return text.includes('ExpensiveRecommendation') || text.includes('SlowComponent'); - }).toBe(true); - await expect.poll(async () => overlay.evaluate((host) => { - const root = host.shadowRoot; - return { - copy: root?.querySelector('.panel-copy-btn') !== null, - close: root?.querySelector('.panel-close') !== null }; - })).toEqual({ copy: false, close: false }); - const distance = await overlay.evaluate(() => { - const host = document.querySelector('angular-render-scan-overlay'); - const panel = host?.shadowRoot?.querySelector('.inspect-panel'); - const target = document.querySelector('app-slow'); - const panelRect = panel?.getBoundingClientRect(); - const targetRect = target?.getBoundingClientRect(); - if (!panelRect || !targetRect) return Number.POSITIVE_INFINITY; - const dx = Math.max(targetRect.left - panelRect.right, panelRect.left - targetRect.right, 0); - const dy = Math.max(targetRect.top - panelRect.bottom, panelRect.top - targetRect.bottom, 0); - return Math.hypot(dx, dy); - }); - expect(distance).toBeLessThan(180); - await page.mouse.move(8, 8); - await expect.poll(async () => overlay.evaluate((host) => { - return host.shadowRoot?.querySelector('.inspect-panel'); - })).toBeNull(); -}); - -test('toolbar can toggle live CPU details panel', async ({ page }) => { - await page.goto('/'); - const overlay = page.locator(overlaySelector); - await expect(overlay).toBeAttached(); - await expect(page.locator('.card-label').first()).toContainText('Products'); - - await expect.poll(async () => overlay.evaluate((host) => { - return host.shadowRoot?.querySelector('.cpu-interactive')?.textContent ?? ''; - })).toContain('CPU'); - - await expect.poll(async () => overlay.evaluate((host) => { - return host.shadowRoot?.querySelector('.cpu-details-panel'); - })).toBeNull(); - - await overlay.evaluate((host) => { - host.shadowRoot?.querySelector('.cpu-interactive')?.click(); - }); - - await expect.poll(async () => overlay.evaluate((host) => { - return host.shadowRoot?.querySelector('.cpu-details-panel')?.textContent ?? ''; - })).toContain('CPU Usage'); - - await overlay.evaluate((host) => { - host.shadowRoot?.querySelector('.cpu-interactive')?.click(); - }); - - await expect.poll(async () => overlay.evaluate((host) => { - return host.shadowRoot?.querySelector('.cpu-details-panel'); - })).toBeNull(); -}); - -test('mutation details appear in toolbar and details panel', async ({ page }) => { - await page.goto('/'); - const overlay = page.locator(overlaySelector); - await expect(overlay).toBeAttached(); - await expect(page.locator('.card-label').first()).toContainText('Products'); - - await page.locator('app-product').first().getByRole('button', { name: 'Add' }).click(); - - await overlay.evaluate((host) => { - host.shadowRoot?.querySelector('.toolbar-picker-toggle')?.click(); - }); - - await page.locator('app-product').first().hover({ force: true }); - - await expect.poll(async () => overlay.evaluate((host) => host.shadowRoot?.querySelector('.inspect-panel')?.textContent ?? '')).toContain('DOM'); -}); - -test('waterfall panel can be toggled via sparkline', async ({ page }) => { - await page.goto('/'); - const overlay = page.locator(overlaySelector); - await expect(overlay).toBeAttached(); - await expect(page.locator('.card-label').first()).toContainText('Products'); - - await page.getByRole('button', { name: 'Click (0)' }).click(); - - await expect.poll(async () => overlay.evaluate((host) => host.shadowRoot?.querySelector('.sparkline-toggle') !== null)).toBe(true); - await expect.poll(async () => overlay.evaluate((host) => host.shadowRoot?.querySelector('.waterfall-panel') !== null)).toBe(false); - - await overlay.evaluate((host) => { - host.shadowRoot?.querySelector('.sparkline-toggle')?.click(); - }); - - await expect.poll(async () => overlay.evaluate((host) => host.shadowRoot?.querySelector('.waterfall-panel')?.textContent ?? '')).toContain('Waterfall'); + window.addEventListener('angular-render-scan:render', onRender); + document.querySelector('.scenario button')?.click(); + }))).not.toBe(''); }); diff --git a/packages/angular-render-scan/README.md b/packages/angular-render-scan/README.md index 92428f3..239c4c9 100644 --- a/packages/angular-render-scan/README.md +++ b/packages/angular-render-scan/README.md @@ -1,18 +1,19 @@ # Angular Render Scan -Angular Render Scan is a visual debugging overlay for Angular change detection. It shows which components update, how often they update, and which checks are slow or wasted. +Angular Render Scan turns a named user interaction into ranked Angular change-detection findings, a before/after comparison, and portable reports. ![Angular Render Scan in Action](https://raw.githubusercontent.com/edisonaugusthy/angular-render-scan/main/docs/assets/angular-render-scan-demo.gif) [Live Demo](https://edisonaugusthy.github.io/angular-render-scan/) | [npm](https://www.npmjs.com/package/angular-render-scan) -## Versions +## Compatibility | Package | Version | |---|---| -| Angular | `^22.0.0` | -| `angular-render-scan` | `0.1.8` | -| `angular-render-scan-cli` | `0.1.5` | +| Angular provider mode | `17+` | +| Script-tag overlay | `9+` | +| `angular-render-scan` | `0.1.13` | +| `angular-render-scan-cli` | `0.1.13` | ## What it shows @@ -26,10 +27,10 @@ Angular Render Scan is a visual debugging overlay for Angular change detection. ## Install ```sh -npm install angular-render-scan +npm install -D angular-render-scan ``` -Angular Render Scan expects Angular 9+. +Provider mode supports Angular 17+ (including signals and zoneless applications). For Angular 9–16, use the framework-independent script-tag build; automatic component instrumentation requires provider mode. ## Setup with the CLI @@ -112,12 +113,25 @@ import { getOptions, getReferentialInstability, getZonePollutionEvents, + beginInteraction, + endInteraction, + compareInteractionReports, + formatInteractionReportMarkdown, scan, setOptions, stop } from 'angular-render-scan'; scan(); +beginInteraction('Add product to cart'); +// Perform the interaction. +const baseline = endInteraction(); + +beginInteraction('Add product to cart'); +// Perform the interaction after a candidate fix. +const candidate = endInteraction(); +console.log(compareInteractionReports(baseline, candidate)); +console.log(formatInteractionReportMarkdown(candidate)); setOptions({ enabled: false }); setOptions({ enabled: true, log: true }); @@ -133,9 +147,11 @@ console.log(getCdGraph()); stop(); ``` -## Toolbar +## Interaction workflow -The toolbar shows render count, FPS, latest cycle time, slowest component, trigger source, OnPush candidates, Zone pollution events, alerts, and copy/export actions. +Click `Capture`, name and perform an interaction, then click `Finish`. Save it as the baseline, apply a fix, and capture the same interaction again. The panel ranks findings and exports Markdown or standalone HTML. + +FPS is context only. Disconnected hosts are not proof of memory leaks, OnPush opportunity share is not a predicted saving, and Zone pollution guidance applies only to Zone.js applications. CD Graph and Waterfall are advanced APIs, not headline diagnosis. Useful shortcuts: @@ -148,11 +164,11 @@ Useful shortcuts: | `Alt+Shift+T` | Toggle toolbar | | `Escape` | Close open panels | -## Details and AI Prompt +## Details and developer handoff Enable `Details` in the toolbar, hover a captured component, then click it to pin a recommendation panel. The panel shows timing, render count, reason, selector, changed inputs, recent cycles, and local Angular recommendations. -Use `Copy Slow Issues Prompt` to copy a focused prompt for an AI coding assistant. It includes recent cycle history, thresholds, and slow/error component evidence without copying DOM nodes, component instances, or source code. +The ranked interaction report is the primary output. For a secondary AI handoff, call `copyAIPrompt()` or use `Alt+Shift+C`; it copies recent telemetry evidence without DOM nodes, component instances, or source code. ## Playwright Audit @@ -163,7 +179,7 @@ import { startRenderAudit } from 'angular-render-scan'; test('no render regression', async ({ page }) => { await page.goto('/'); - const audit = await startRenderAudit(page); + const audit = await startRenderAudit(page, 'Add product to cart'); await page.click('button.expensive-operation'); const report = await audit.stop(); @@ -173,6 +189,12 @@ test('no render regression', async ({ page }) => { }); ``` +Write `await report.interactionReport()` to JSON, then compare it in CI: + +```sh +npx angular-render-scan-cli report --input candidate.json --baseline baseline.json --github-summary --fail-on-regression +``` + ## Production Behavior Angular Render Scan is intended for development and demo debugging. Provider mode checks Angular `isDevMode()` and does not run in production unless explicitly enabled. diff --git a/packages/angular-render-scan/package.json b/packages/angular-render-scan/package.json index 6ef9c59..5c40108 100644 --- a/packages/angular-render-scan/package.json +++ b/packages/angular-render-scan/package.json @@ -49,7 +49,7 @@ "./auto.global": "./dist/auto.global.js" }, "peerDependencies": { - "@angular/core": ">=9.0.0", - "@angular/common": ">=9.0.0" + "@angular/core": ">=17.0.0", + "@angular/common": ">=17.0.0" } } diff --git a/packages/angular-render-scan/src/application/interaction.spec.ts b/packages/angular-render-scan/src/application/interaction.spec.ts new file mode 100644 index 0000000..adf0acf --- /dev/null +++ b/packages/angular-render-scan/src/application/interaction.spec.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest'; +import type { SessionExportData } from '../domain/entities'; +import { + compareInteractionReports, + createInteractionReport, + formatInteractionReportHtml, + formatInteractionReportMarkdown +} from './interaction'; + +function session(duration = 12, wasted = false): SessionExportData { + return { + exportedAt: '2026-07-13T12:00:01.000Z', url: 'https://example.test/products', viewport: '1280x720 @1x', userAgent: 'test', options: {}, + cycles: [{ + id: 1, startedAt: 0, finishedAt: duration, duration, renderedCount: 1, waterfall: [], + entries: [{ id: 'product', name: 'ProductCard', count: 4, latestDuration: duration, averageDuration: duration, latestCycleId: 1, wastedChecks: wasted ? 4 : 0, wastedPercentage: wasted ? 100 : 0, mutationType: wasted ? 'none' : 'text', cdStrategy: 'Default' }] + }], + wastedStats: { totalChecks: 4, wastedChecks: wasted ? 4 : 0, wastedPercentage: wasted ? 100 : 0 }, + budgetViolations: [], detachedComponents: ['OldDialog'], leakedComponents: ['OldDialog'], + onPushCandidates: wasted ? [{ name: 'ProductCard', selector: 'app-product', totalChecks: 10, wastedChecks: 8, wastedPercentage: 80, opportunityPercentage: 80, estimatedSavingPct: 80, confidence: 'high', reason: 'Observed opportunity.' }] : [], + zonePollutionEvents: [], referentialInstabilityReports: [] + }; +} + +describe('interaction reports', () => { + it('ranks evidence and uses cautious detached and OnPush language', () => { + const report = createInteractionReport('Add to cart', session(18, true)); + expect(report.metrics.cycleCount).toBe(1); + expect(report.metrics.componentCheckCount).toBe(1); + expect(report.findings[0].kind).toBe('slow-component'); + expect(report.findings.find((item) => item.kind === 'detached-component')?.summary).toContain('not proof'); + expect(report.findings.find((item) => item.kind === 'onpush-opportunity')?.evidence[0]).toContain('observed'); + }); + + it('flags material before/after regressions', () => { + const baseline = createInteractionReport('Checkout', session(10)); + const candidate = createInteractionReport('Checkout', session(14)); + const comparison = compareInteractionReports(baseline, candidate); + expect(comparison.outcome).toBe('regressed'); + expect(comparison.regressions[0]).toContain('Total cycle time increased'); + }); + + it('formats portable Markdown and self-contained HTML', () => { + const report = createInteractionReport('Search', session()); + expect(formatInteractionReportMarkdown(report)).toContain('# Angular Render Scan: Search'); + expect(formatInteractionReportHtml(report)).toContain(''); + expect(formatInteractionReportHtml(report)).toContain('CPU and FPS are context only'); + }); +}); diff --git a/packages/angular-render-scan/src/application/interaction.ts b/packages/angular-render-scan/src/application/interaction.ts new file mode 100644 index 0000000..fa90cbf --- /dev/null +++ b/packages/angular-render-scan/src/application/interaction.ts @@ -0,0 +1,166 @@ +import type { + InteractionComparison, + InteractionFinding, + InteractionMetricDelta, + InteractionMetrics, + InteractionReport, + SessionCycleData, + SessionExportData +} from '../domain/entities'; + +const severityScore = { critical: 4, high: 3, medium: 2, low: 1 } as const; + +function round(value: number): number { + return Math.round(value * 100) / 100; +} + +function metricsFor(session: SessionExportData): InteractionMetrics { + const entries = session.cycles.flatMap((cycle) => cycle.entries); + // Session entry counters are cumulative; one entry represents one observed check in a cycle. + const totalChecks = entries.length; + const wastedChecks = entries.filter((entry) => entry.mutationType === 'none').length; + const totalCycleDuration = session.cycles.reduce((sum, cycle) => sum + cycle.duration, 0); + return { + cycleCount: session.cycles.length, + componentCheckCount: totalChecks, + totalCycleDuration: round(totalCycleDuration), + maxCycleDuration: round(Math.max(0, ...session.cycles.map((cycle) => cycle.duration))), + wastedChecks, + wastedPercentage: totalChecks === 0 ? 0 : Math.round((wastedChecks / totalChecks) * 100), + budgetViolationCount: session.budgetViolations.length + }; +} + +function finding(input: Omit): InteractionFinding { + const confidence = { high: 3, medium: 2, low: 1 }[input.confidence]; + return { ...input, score: severityScore[input.severity] * 100 + confidence * 10 }; +} + +function rankedFindings(session: SessionExportData, metrics: InteractionMetrics): InteractionFinding[] { + const findings: InteractionFinding[] = []; + for (const violation of session.budgetViolations) { + findings.push(finding({ + kind: 'budget-violation', + severity: violation.type === 'error' || violation.type === 'render-rate' ? 'critical' : 'high', + confidence: 'high', + title: `${violation.componentName} exceeded a configured budget`, + summary: violation.message, + componentName: violation.componentName, + evidence: [`Observed ${round(violation.actual)}; budget ${round(violation.budget)}`, `Selector: ${violation.selector || 'not captured'}`], + action: 'Profile this component in the captured interaction and reduce the measured work or revise the explicit budget.' + })); + } + + const latestByComponent = new Map(); + for (const entry of session.cycles.flatMap((cycle) => cycle.entries)) { + const current = latestByComponent.get(entry.name); + if (!current || entry.latestDuration > current.latestDuration) latestByComponent.set(entry.name, entry); + } + for (const entry of [...latestByComponent.values()].sort((a, b) => b.latestDuration - a.latestDuration).slice(0, 3)) { + if (entry.latestDuration < 8) continue; + findings.push(finding({ + kind: 'slow-component', severity: entry.latestDuration >= 16 ? 'high' : 'medium', confidence: 'high', + title: `${entry.name} was expensive in this interaction`, + summary: `Its slowest observed check took ${round(entry.latestDuration)}ms.`, componentName: entry.name, + evidence: [`Average ${round(entry.averageDuration)}ms`, `Observed count ${entry.count}`, `Trigger: ${entry.reason ?? 'unknown'}`], + action: 'Measure the component template and synchronous work, apply one change, then recapture the same interaction.' + })); + } + + if (metrics.wastedPercentage >= 30) { + findings.push(finding({ + kind: 'wasted-checks', severity: metrics.wastedPercentage >= 70 ? 'high' : 'medium', confidence: 'medium', + title: 'Many observed checks produced no DOM mutation', + summary: `${metrics.wastedPercentage}% of checks in this capture had no observed DOM mutation.`, + evidence: [`${metrics.wastedChecks} checks classified as no-mutation`, `${metrics.componentCheckCount} component checks observed`], + action: 'Rank the affected components, then verify whether stable inputs, signals, or OnPush reduce checks without changing behavior.' + })); + } + + for (const candidate of session.onPushCandidates.slice(0, 3)) { + findings.push(finding({ + kind: 'onpush-opportunity', severity: candidate.opportunityPercentage >= 70 ? 'medium' : 'low', confidence: candidate.confidence, + title: `${candidate.name} is an OnPush experiment candidate`, summary: candidate.reason, componentName: candidate.name, + evidence: [`${candidate.opportunityPercentage}% observed no-mutation check share`, `${candidate.totalChecks} checks sampled`], + action: 'Try OnPush in a candidate branch and use a before/after interaction comparison to verify the result.' + })); + } + + for (const report of session.referentialInstabilityReports.slice(0, 3)) { + findings.push(finding({ + kind: 'referential-instability', severity: report.unstableRefPct >= 60 ? 'high' : 'medium', confidence: 'medium', + title: `${report.componentName}.${report.inputName} repeatedly changed reference`, + summary: 'A new reference was observed with a deeply equal sampled value.', componentName: report.componentName, + evidence: [`${report.unstableRefCount} unstable references`, `${report.unstableRefPct}% of sampled renders`], + action: 'Stabilize the input value at its producer and recapture to confirm fewer checks.' + })); + } + + if (session.zonePollutionEvents.length) { + findings.push(finding({ + kind: 'zone-pollution', severity: 'medium', confidence: 'medium', title: 'Async work triggered change detection without a nearby user event', + summary: 'This applies to Zone-based Angular applications; modern zoneless applications should ignore this finding.', + evidence: [`${session.zonePollutionEvents.length} suspected cycles`, ...session.zonePollutionEvents.slice(0, 2).map((event) => `${event.source}: ${round(event.cycleDuration)}ms`)], + action: 'If this app uses Zone.js, move noisy async work outside Angular or reduce its frequency, then compare the interaction again.' + })); + } + + for (const name of session.detachedComponents ?? session.leakedComponents ?? []) { + findings.push(finding({ + kind: 'detached-component', severity: 'low', confidence: 'low', title: `${name} had a disconnected host element`, + summary: 'A disconnected element was observed. This is not proof that the component is retained or leaking memory.', componentName: name, + evidence: ['Host element was disconnected when sampled'], + action: 'Confirm retention with heap snapshots or allocation profiling before treating this as a memory leak.' + })); + } + return findings.sort((a, b) => b.score - a.score || a.title.localeCompare(b.title)); +} + +export function createInteractionReport(name: string, session: SessionExportData, startedAt?: string): InteractionReport { + const metrics = metricsFor(session); + return { + schemaVersion: 1, name: name.trim() || 'Captured interaction', + startedAt: startedAt ?? session.exportedAt, finishedAt: session.exportedAt, + url: session.url, viewport: session.viewport, metrics, + findings: rankedFindings(session, metrics), session + }; +} + +function delta(baseline: number, candidate: number): InteractionMetricDelta { + return { baseline, candidate, absolute: round(candidate - baseline), percentage: baseline === 0 ? null : round(((candidate - baseline) / baseline) * 100) }; +} + +export function compareInteractionReports(baseline: InteractionReport, candidate: InteractionReport): InteractionComparison { + const deltas = { + totalCycleDuration: delta(baseline.metrics.totalCycleDuration, candidate.metrics.totalCycleDuration), + maxCycleDuration: delta(baseline.metrics.maxCycleDuration, candidate.metrics.maxCycleDuration), + wastedPercentage: delta(baseline.metrics.wastedPercentage, candidate.metrics.wastedPercentage), + budgetViolationCount: delta(baseline.metrics.budgetViolationCount, candidate.metrics.budgetViolationCount) + }; + const regressions: string[] = []; + if (deltas.totalCycleDuration.percentage !== null && deltas.totalCycleDuration.percentage > 10) regressions.push(`Total cycle time increased ${deltas.totalCycleDuration.percentage}%.`); + if (deltas.maxCycleDuration.percentage !== null && deltas.maxCycleDuration.percentage > 10) regressions.push(`Maximum cycle time increased ${deltas.maxCycleDuration.percentage}%.`); + if (deltas.wastedPercentage.absolute > 5) regressions.push(`No-mutation check share increased ${deltas.wastedPercentage.absolute} points.`); + if (deltas.budgetViolationCount.absolute > 0) regressions.push(`${deltas.budgetViolationCount.absolute} additional budget violation(s).`); + const improvements = [deltas.totalCycleDuration.percentage, deltas.maxCycleDuration.percentage].filter((value) => value !== null && value < -10).length; + return { schemaVersion: 1, name: `${baseline.name}: baseline vs candidate`, outcome: regressions.length ? 'regressed' : improvements ? 'improved' : 'unchanged', baseline, candidate, deltas, regressions }; +} + +function escapeHtml(value: string): string { + return value.replace(/[&<>"']/g, (character) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[character] ?? character); +} + +export function formatInteractionReportMarkdown(report: InteractionReport): string { + const lines = [`# Angular Render Scan: ${report.name}`, '', `**Result:** ${report.findings.length ? `${report.findings.length} ranked finding(s)` : 'No actionable finding'}`, '', + `- Cycles: ${report.metrics.cycleCount}`, `- Total cycle time: ${report.metrics.totalCycleDuration}ms`, `- Maximum cycle: ${report.metrics.maxCycleDuration}ms`, + `- No-mutation check share: ${report.metrics.wastedPercentage}%`, `- Budget violations: ${report.metrics.budgetViolationCount}`, '', '## Ranked findings', '']; + if (!report.findings.length) lines.push('No actionable findings were observed in this interaction.'); + report.findings.forEach((item, index) => lines.push(`### ${index + 1}. ${item.title}`, '', `${item.summary}`, '', `**Evidence:** ${item.evidence.join('; ')}`, '', `**Next action:** ${item.action}`, '')); + lines.push('> CPU and FPS are environmental context only. Disconnected elements and OnPush opportunity shares are signals to verify, not proven leaks or savings.'); + return lines.join('\n'); +} + +export function formatInteractionReportHtml(report: InteractionReport): string { + const findings = report.findings.map((item, index) => `

${index + 1}. ${escapeHtml(item.title)}

${escapeHtml(item.summary)}

Evidence: ${escapeHtml(item.evidence.join('; '))}

Next action: ${escapeHtml(item.action)}

`).join(''); + return `${escapeHtml(report.name)}

${escapeHtml(report.name)}

Cycles
${report.metrics.cycleCount}
Total cycle time
${report.metrics.totalCycleDuration}ms
Maximum cycle
${report.metrics.maxCycleDuration}ms
No-mutation share
${report.metrics.wastedPercentage}%
${findings || '

No actionable findings

'}CPU and FPS are context only. Disconnected elements and OnPush opportunity shares require verification.`; +} diff --git a/packages/angular-render-scan/src/application/onpush-analyzer.ts b/packages/angular-render-scan/src/application/onpush-analyzer.ts index addc3b0..8794cd5 100644 --- a/packages/angular-render-scan/src/application/onpush-analyzer.ts +++ b/packages/angular-render-scan/src/application/onpush-analyzer.ts @@ -48,19 +48,18 @@ export function analyzeOnPushCandidates( if (wastedPct < wastedThresholdPct) continue; - // Estimate savings: if OnPush, only renders when inputs change - // So saving is approximately (wasted checks / total checks) - const estimatedSavingPct = wastedPct; + // This is an observed opportunity share, not a prediction of OnPush savings. + const opportunityPercentage = wastedPct; let confidence: 'high' | 'medium' | 'low'; let reason: string; if (wastedPct >= 80) { confidence = 'high'; - reason = `${wastedPct}% of renders produced no DOM changes — component only needs to update when its @Input() props change.`; + reason = `${wastedPct}% of observed checks produced no DOM mutation. Validate inputs, events, and side effects before trying OnPush.`; } else if (wastedPct >= 60) { confidence = 'medium'; - reason = `${wastedPct}% of renders were wasted no-ops. OnPush would significantly reduce unnecessary checks.`; + reason = `${wastedPct}% of observed checks produced no DOM mutation. OnPush is worth testing, but the saving is not yet verified.`; } else { confidence = 'low'; reason = `${wastedPct}% wasted renders detected. Review whether this component has internal side effects before switching to OnPush.`; @@ -72,7 +71,8 @@ export function analyzeOnPushCandidates( totalChecks: comp.totalChecks, wastedChecks: comp.wastedChecks, wastedPercentage: wastedPct, - estimatedSavingPct, + opportunityPercentage, + estimatedSavingPct: opportunityPercentage, confidence, reason }); @@ -83,6 +83,6 @@ export function analyzeOnPushCandidates( const confScore = { high: 3, medium: 2, low: 1 }; const byConf = confScore[b.confidence] - confScore[a.confidence]; if (byConf !== 0) return byConf; - return b.estimatedSavingPct - a.estimatedSavingPct; + return b.opportunityPercentage - a.opportunityPercentage; }); } diff --git a/packages/angular-render-scan/src/application/runtime.ts b/packages/angular-render-scan/src/application/runtime.ts index 08911e3..30fa2f6 100644 --- a/packages/angular-render-scan/src/application/runtime.ts +++ b/packages/angular-render-scan/src/application/runtime.ts @@ -6,6 +6,7 @@ import { startCycle, getWastedStats as statsGetWastedStats, getLeakedComponents as statsGetLeakedComponents, + getDetachedComponents as statsGetDetachedComponents, getOnPushCandidates as statsGetOnPushCandidates, getReferentialInstability as statsGetReferentialInstability, getCdGraph as statsGetCdGraph, @@ -29,9 +30,17 @@ import type { RenderCause, SignalDependencyGraph, ComponentCostEntry, + InteractionComparison, + InteractionReport, SignalDependencyNode, SignalDependencyEdge } from '../domain/entities'; +import { + compareInteractionReports, + createInteractionReport, + formatInteractionReportHtml, + formatInteractionReportMarkdown +} from './interaction'; // Register callback for stats to query render cause to avoid circular imports registerGetRenderCauseCallback((name) => { @@ -46,6 +55,9 @@ let implicitCycleScheduled = false; let recentCycles: AngularRenderCycle[] = []; let activeSessionBudgetViolations: BudgetViolation[] = []; let activeZonePollutionEvents: ZonePollutionEvent[] = []; +let activeInteraction: { name: string; startedAt: string; startedAtMs: number; afterCycleId: number } | undefined; +let latestInteractionReport: InteractionReport | undefined; +let interactionBaseline: InteractionReport | undefined; // v0.2 state tracking let activeCycleTrigger: CdTriggerAttribution | undefined; @@ -112,6 +124,9 @@ export function stop(): void { implicitCycleScheduled = false; activeSessionBudgetViolations = []; activeZonePollutionEvents = []; + activeInteraction = undefined; + latestInteractionReport = undefined; + interactionBaseline = undefined; } export function beginCycle(): number { @@ -137,7 +152,8 @@ export function beginCycle(): number { } function getRendersInLastSecond(id: string, now: number): number { - let count = 0; + // The cycle being finalized is not in recentCycles yet, so include it here. + let count = 1; for (let i = recentCycles.length - 1; i >= 0; i--) { const cycle = recentCycles[i]; if (now - cycle.finishedAt > 1000) { @@ -621,15 +637,15 @@ function buildAIPrompt(cycles: AngularRenderCycle[], options = getResolvedOption '', onPushCandidates.length > 0 ? '## ⚡️ OnPush Migration Candidates:' : '', onPushCandidates.length > 0 - ? 'These components use Default CD and have high wasted render rates. Switching to OnPush should significantly reduce unnecessary checks.' + ? 'These components have a high observed no-mutation check share. Treat OnPush as an experiment and verify it with the same interaction.' : '', ...onPushCandidates.slice(0, 5).map((c, i) => - `${i + 1}. **${c.name}** (${c.selector}) — ${c.wastedPercentage}% wasted, ~${c.estimatedSavingPct}% estimated saving [${c.confidence} confidence] — ${c.reason}` + `${i + 1}. **${c.name}** (${c.selector}) — ${c.opportunityPercentage}% observed no-mutation share [${c.confidence} confidence] — ${c.reason}` ), '', pollutionEvents.length > 0 ? '## ⚠️ Zone Pollution Events (last 5):' : '', pollutionEvents.length > 0 - ? 'These CD cycles were triggered by async operations with no user interaction.' + ? 'For Zone.js applications only: these cycles followed async operations without a nearby user interaction.' : '', ...pollutionEvents.map(e => `- ${e.source}${e.detail ? ` (${e.detail})` : ''} — ${e.componentCount} components ran, ${formatMs(e.cycleDuration)}` @@ -742,7 +758,7 @@ function formatMs(value: number): string { export function getSessionData(): SessionExportData { const options = getResolvedOptions(); const wasted = statsGetWastedStats(); - const leaks = statsGetLeakedComponents().map((c) => c.name); + const detached = statsGetDetachedComponents().map((c) => c.name); const onPushCandidates = statsGetOnPushCandidates(options.onPushCandidateThreshold); const refInstability = statsGetReferentialInstability(); @@ -789,15 +805,69 @@ export function getSessionData(): SessionExportData { cycles: mappedCycles, wastedStats: wasted, budgetViolations: activeSessionBudgetViolations, - leakedComponents: leaks, + detachedComponents: detached, + leakedComponents: detached, onPushCandidates, zonePollutionEvents: [...activeZonePollutionEvents], referentialInstabilityReports: refInstability }; } +/** Start a named, user-visible interaction capture. Only subsequent cycles are included. */ +export function beginInteraction(name: string): void { + activeInteraction = { + name: name.trim() || 'Captured interaction', + startedAt: new Date().toISOString(), + startedAtMs: Date.now(), + afterCycleId: recentCycles.at(-1)?.id ?? activeCycleId + }; +} + +/** Finish the active interaction and return ranked, portable evidence. */ +export function endInteraction(): InteractionReport { + if (!activeInteraction) { + throw new Error('[angular-render-scan] No interaction capture is active. Call beginInteraction(name) first.'); + } + const capture = activeInteraction; + activeInteraction = undefined; + const session = getSessionData(); + const cycles = session.cycles.filter((cycle) => cycle.id > capture.afterCycleId); + const componentNames = new Set(cycles.flatMap((cycle) => cycle.entries.map((entry) => entry.name))); + const scoped: SessionExportData = { + ...session, + cycles, + budgetViolations: session.budgetViolations.filter((violation) => violation.timestamp >= capture.startedAtMs), + onPushCandidates: session.onPushCandidates.filter((candidate) => componentNames.has(candidate.name)), + referentialInstabilityReports: session.referentialInstabilityReports.filter((report) => componentNames.has(report.componentName)), + zonePollutionEvents: session.zonePollutionEvents.filter((event) => event.timestamp >= capture.startedAtMs) + }; + latestInteractionReport = createInteractionReport(capture.name, scoped, capture.startedAt); + return latestInteractionReport; +} + +export function getInteractionReport(): InteractionReport | undefined { + return latestInteractionReport; +} + +export function setInteractionBaseline(report: InteractionReport): void { + interactionBaseline = report; +} + +export function compareWithInteractionBaseline(report = latestInteractionReport): InteractionComparison { + if (!interactionBaseline) throw new Error('[angular-render-scan] No baseline report is set.'); + if (!report) throw new Error('[angular-render-scan] No candidate interaction report is available.'); + return compareInteractionReports(interactionBaseline, report); +} + +export { compareInteractionReports, createInteractionReport, formatInteractionReportHtml, formatInteractionReportMarkdown }; + export function setResolvedTriggerForTest(trigger: CdTriggerAttribution | undefined): void { activeCycleTrigger = trigger; } -export { statsGetWastedStats as getWastedStats, statsGetLeakedComponents as getLeakedComponents, getComponentCostEntries as getComponentCostAnalysis }; +export { + statsGetWastedStats as getWastedStats, + statsGetLeakedComponents as getLeakedComponents, + statsGetDetachedComponents as getDetachedComponents, + getComponentCostEntries as getComponentCostAnalysis +}; diff --git a/packages/angular-render-scan/src/application/stats.spec.ts b/packages/angular-render-scan/src/application/stats.spec.ts index 3f3a8cc..f02b77d 100644 --- a/packages/angular-render-scan/src/application/stats.spec.ts +++ b/packages/angular-render-scan/src/application/stats.spec.ts @@ -159,4 +159,21 @@ describe('stats', () => { expect(cycle.wastedCdStats?.changed).toBe(1); expect(cycle.wastedCdStats?.wasteScore).toBe(50); }); + + it('keeps cycle waste telemetry independent from display filters', () => { + const visible = document.createElement('div'); + const filtered = document.createElement('div'); + document.body.append(visible, filtered); + registerComponent({ id: 'visible', name: 'VisibleComponent', element: visible }); + registerComponent({ id: 'filtered', name: 'FilteredComponent', element: filtered }); + const cycleId = startCycle(); + + recordComponentCheck('visible', 12, cycleId, { mutationType: 'text' }); + recordComponentCheck('filtered', 1, cycleId, { mutationType: 'none' }); + setResolvedOptions({ minDurationMs: 5 }); + const cycle = finishCycle(cycleId, 10, 20, getResolvedOptions()); + + expect(cycle.entries.map(entry => entry.name)).toEqual(['VisibleComponent']); + expect(cycle.wastedCdStats).toEqual({ checked: 2, changed: 1, wasteScore: 50 }); + }); }); diff --git a/packages/angular-render-scan/src/application/stats.ts b/packages/angular-render-scan/src/application/stats.ts index 89636ab..54c5c6d 100644 --- a/packages/angular-render-scan/src/application/stats.ts +++ b/packages/angular-render-scan/src/application/stats.ts @@ -195,16 +195,19 @@ export function finishCycle( finishedAt: number, options?: AngularRenderScanResolvedOptions ): AngularRenderCycle { - const entries = [...components.values()] + const cycleEntries = [...components.values()] .filter((component) => component.latestCycleId === id && component.element.isConnected) .map(toEntry) + .sort((a, b) => b.latestDuration - a.latestDuration); + const entries = cycleEntries .filter((entry) => shouldIncludeEntry(entry, options)) .sort((a, b) => b.latestDuration - a.latestDuration); const waterfall = [...activeCycleWaterfall]; - const checked = entries.length; - const changed = entries.filter((e) => e.mutationType !== 'none').length; + // Filters control presentation, not the accuracy of cycle-level telemetry. + const checked = cycleEntries.length; + const changed = cycleEntries.filter((e) => e.mutationType !== 'none').length; const wasteScore = checked === 0 ? 0 : Math.round(((checked - changed) / checked) * 100); return { @@ -269,6 +272,11 @@ export function getWastedStats(): { totalChecks: number; wastedChecks: number; w } export function getLeakedComponents(): AngularRenderEntry[] { + return getDetachedComponents(); +} + +/** Components whose host element is currently disconnected. This alone does not prove retention. */ +export function getDetachedComponents(): AngularRenderEntry[] { return [...components.values()] .filter((stats) => !stats.element.isConnected) .map(toEntry); diff --git a/packages/angular-render-scan/src/domain/entities.ts b/packages/angular-render-scan/src/domain/entities.ts index 214a384..ee6a241 100644 --- a/packages/angular-render-scan/src/domain/entities.ts +++ b/packages/angular-render-scan/src/domain/entities.ts @@ -50,7 +50,9 @@ export interface OnPushCandidate { totalChecks: number; wastedChecks: number; wastedPercentage: number; - /** Estimated % of total CD time saved by switching to OnPush */ + /** Share of observed checks that produced no DOM mutation. This is not a verified saving. */ + opportunityPercentage: number; + /** @deprecated Use opportunityPercentage. Kept for backwards compatibility. */ estimatedSavingPct: number; confidence: 'high' | 'medium' | 'low'; reason: string; @@ -246,6 +248,9 @@ export interface SessionExportData { cycles: SessionCycleData[]; wastedStats: WastedStats; budgetViolations: BudgetViolation[]; + /** Components whose host element was disconnected when sampled. This does not prove a memory leak. */ + detachedComponents?: string[]; + /** @deprecated Use detachedComponents. */ leakedComponents: string[]; /** NEW */ onPushCandidates: OnPushCandidate[]; @@ -253,6 +258,73 @@ export interface SessionExportData { referentialInstabilityReports: ReferentialInstabilityReport[]; } +export type InteractionFindingKind = + | 'budget-violation' + | 'slow-component' + | 'wasted-checks' + | 'onpush-opportunity' + | 'referential-instability' + | 'zone-pollution' + | 'detached-component'; + +export type InteractionFindingSeverity = 'critical' | 'high' | 'medium' | 'low'; + +export interface InteractionFinding { + kind: InteractionFindingKind; + severity: InteractionFindingSeverity; + confidence: 'high' | 'medium' | 'low'; + score: number; + title: string; + summary: string; + componentName?: string; + evidence: string[]; + action: string; +} + +export interface InteractionMetrics { + cycleCount: number; + componentCheckCount: number; + totalCycleDuration: number; + maxCycleDuration: number; + wastedChecks: number; + wastedPercentage: number; + budgetViolationCount: number; +} + +export interface InteractionReport { + schemaVersion: 1; + name: string; + startedAt: string; + finishedAt: string; + url: string; + viewport: string; + metrics: InteractionMetrics; + findings: InteractionFinding[]; + session: SessionExportData; +} + +export interface InteractionMetricDelta { + baseline: number; + candidate: number; + absolute: number; + percentage: number | null; +} + +export interface InteractionComparison { + schemaVersion: 1; + name: string; + outcome: 'improved' | 'regressed' | 'unchanged'; + baseline: InteractionReport; + candidate: InteractionReport; + deltas: { + totalCycleDuration: InteractionMetricDelta; + maxCycleDuration: InteractionMetricDelta; + wastedPercentage: InteractionMetricDelta; + budgetViolationCount: InteractionMetricDelta; + }; + regressions: string[]; +} + export interface SessionCycleData { id: number; startedAt: number; @@ -304,6 +376,7 @@ export interface AngularRenderScanOptions { showCopyPrompt?: boolean; promptContext?: string; theme?: Partial; + budgets?: AngularRenderScanBudgets; editorProtocol?: 'vscode' | 'webstorm' | 'cursor' | string; darkMode?: AngularRenderScanDarkMode; onCycleStart?: () => void; diff --git a/packages/angular-render-scan/src/domain/options.spec.ts b/packages/angular-render-scan/src/domain/options.spec.ts index 0afd478..0a7e128 100644 --- a/packages/angular-render-scan/src/domain/options.spec.ts +++ b/packages/angular-render-scan/src/domain/options.spec.ts @@ -66,14 +66,15 @@ describe('options', () => { it('validates budget, editorProtocol and darkMode options', () => { setResolvedOptions({ + budgets: { warnMs: 6, errorMs: 18 }, editorProtocol: 'cursor', darkMode: 'dark' }); const resolved = getResolvedOptions(); expect(resolved.budgets).toMatchObject({ - warnMs: 10, - errorMs: 30, + warnMs: 6, + errorMs: 18, maxRendersPerSecond: 20 }); expect(resolved.editorProtocol).toBe('cursor'); diff --git a/packages/angular-render-scan/src/domain/options.ts b/packages/angular-render-scan/src/domain/options.ts index 4bd4518..6309250 100644 --- a/packages/angular-render-scan/src/domain/options.ts +++ b/packages/angular-render-scan/src/domain/options.ts @@ -80,7 +80,11 @@ export function resolveOptions(next?: AngularRenderScanOptions, config: ResolveO merged.showCdGraph = typeof next?.showCdGraph === 'boolean' ? next.showCdGraph : options.showCdGraph; merged.trackReferentialStability = typeof next?.trackReferentialStability === 'boolean' ? next.trackReferentialStability : options.trackReferentialStability; merged.theme = { ...options.theme, ...(next?.theme || {}) }; - merged.budgets = defaultBudgets; + merged.budgets = { + ...defaultBudgets, + ...options.budgets, + ...(next?.budgets || {}), + }; merged.editorProtocol = typeof next?.editorProtocol === 'string' ? next.editorProtocol : options.editorProtocol; merged.darkMode = ['auto', 'dark', 'light'].includes(next?.darkMode as string) ? next!.darkMode! : options.darkMode; merged.onPushCandidateThreshold = normalizeNonNegative(merged.onPushCandidateThreshold, defaultOptions.onPushCandidateThreshold); diff --git a/packages/angular-render-scan/src/infrastructure/angular/angular.ts b/packages/angular-render-scan/src/infrastructure/angular/angular.ts index 3c7bf09..04ab7e3 100644 --- a/packages/angular-render-scan/src/infrastructure/angular/angular.ts +++ b/packages/angular-render-scan/src/infrastructure/angular/angular.ts @@ -30,7 +30,14 @@ import { getLeakedComponents, getOnPushCandidates, getReferentialInstability, - getCdGraph + getCdGraph, + beginInteraction, + endInteraction, + getInteractionReport, + setInteractionBaseline, + compareWithInteractionBaseline, + formatInteractionReportMarkdown, + formatInteractionReportHtml } from '../../application/runtime'; import { recordComponentCheck, registerComponent, unregisterComponent } from '../../application/stats'; import type { AngularRenderScanOptions } from '../../domain/entities'; @@ -215,6 +222,13 @@ function registerGlobalApplicationRef(appRef: ApplicationRef): void { getOnPushCandidates, getReferentialInstability, getCdGraph, + beginInteraction, + endInteraction, + getInteractionReport, + setInteractionBaseline, + compareWithInteractionBaseline, + formatInteractionReportMarkdown, + formatInteractionReportHtml, stop: () => { import('../../application/runtime').then(m => m.stop()); restoreApplicationRef(appRef); diff --git a/packages/angular-render-scan/src/infrastructure/angular/auto-instrumentation.ts b/packages/angular-render-scan/src/infrastructure/angular/auto-instrumentation.ts index dbea6ca..af7d86e 100644 --- a/packages/angular-render-scan/src/infrastructure/angular/auto-instrumentation.ts +++ b/packages/angular-render-scan/src/infrastructure/angular/auto-instrumentation.ts @@ -260,6 +260,9 @@ function summarizeValue(value: unknown): string { function readCdStrategy(instance: object): 'OnPush' | 'Default' | 'unknown' { const cmp = (instance as any)?.constructor?.ɵcmp; if (!cmp) return 'unknown'; + // Modern Angular exposes the strategy as an explicit boolean. + if (cmp.onPush === true) return 'OnPush'; + if (cmp.onPush === false) return 'Default'; // changeDetection: 0 = OnPush, 2 = Default const cd = cmp.changeDetection; if (cd === 0) return 'OnPush'; @@ -333,7 +336,8 @@ export function setupAutoInstrumentation(): void { try { const element = globalNg.getHostElement(instance); if (element && element instanceof Element && !element.hasAttribute('angularRenderScanMark')) { - const name = (instance as any).constructor?.name || 'AnonymousComponent'; + const rawName = (instance as any).constructor?.name || 'AnonymousComponent'; + const name = rawName.replace(/^_+/, '').replace(/Component$/, '') || rawName; const id = `ng-scan-auto-${++nextAutoComponentId}`; const cdStrategy = readCdStrategy(instance); diff --git a/packages/angular-render-scan/src/infrastructure/angular/auto.ts b/packages/angular-render-scan/src/infrastructure/angular/auto.ts index c0c8cb0..de8e83f 100644 --- a/packages/angular-render-scan/src/infrastructure/angular/auto.ts +++ b/packages/angular-render-scan/src/infrastructure/angular/auto.ts @@ -5,6 +5,14 @@ export { scan, setOptions, stop, + getSessionData, + beginInteraction, + endInteraction, + getInteractionReport, + setInteractionBaseline, + compareWithInteractionBaseline, + formatInteractionReportMarkdown, + formatInteractionReportHtml, } from '../../application/runtime'; import { scan } from '../../application/runtime'; diff --git a/packages/angular-render-scan/src/infrastructure/ui/overlay.ts b/packages/angular-render-scan/src/infrastructure/ui/overlay.ts index 6a95736..98c5885 100644 --- a/packages/angular-render-scan/src/infrastructure/ui/overlay.ts +++ b/packages/angular-render-scan/src/infrastructure/ui/overlay.ts @@ -8,12 +8,18 @@ import { getRecording, getSessionData, getWastedStats, - getLeakedComponents, + getDetachedComponents, getRegisteredComponents, getOnPushCandidates, getReferentialInstability, getZonePollutionEvents, getCdGraph, + beginInteraction, + endInteraction, + setInteractionBaseline, + compareInteractionReports, + formatInteractionReportMarkdown, + formatInteractionReportHtml, } from "../../application/runtime"; import type { AngularRenderCycle, @@ -23,6 +29,8 @@ import type { OnPushCandidate, ZonePollutionEvent, CdTriggerAttribution, + InteractionComparison, + InteractionReport, } from "../../domain/entities"; interface ActiveHighlight { @@ -808,6 +816,303 @@ const TOOLBAR_CSS = ` margin: 0; } + /* Compact product shell inspired by React Scan, using Angular's visual language. */ + :host { + --ars-angular: #f6375b; + --ars-angular-soft: rgba(246, 55, 91, 0.14); + --ars-bg: rgba(18, 18, 22, 0.96); + --ars-panel-bg: rgba(18, 18, 22, 0.98); + --ars-card-bg: rgba(255, 255, 255, 0.045); + --ars-chip-bg: rgba(255, 255, 255, 0.055); + --ars-chip-border: rgba(255, 255, 255, 0.09); + --ars-border: rgba(255, 255, 255, 0.11); + --ars-color: #f7f7f8; + --ars-label: #9898a3; + --ars-shadow: 0 20px 55px rgba(0, 0, 0, 0.46), 0 2px 8px rgba(0, 0, 0, 0.3); + } + :host(:not(.dark)) { + --ars-bg: rgba(255, 255, 255, 0.97); + --ars-panel-bg: rgba(255, 255, 255, 0.98); + --ars-card-bg: #f7f7f9; + --ars-chip-bg: #f6f6f8; + --ars-chip-border: rgba(20, 20, 25, 0.09); + --ars-border: rgba(20, 20, 25, 0.12); + --ars-color: #18181c; + --ars-label: #71717b; + --ars-shadow: 0 20px 55px rgba(20, 20, 25, 0.16), 0 2px 8px rgba(20, 20, 25, 0.08); + } + .toolbar, :host(.dark) .toolbar { + gap: 4px; padding: 5px; border-color: var(--ars-border); border-radius: 10px; + background: var(--ars-bg); box-shadow: var(--ars-shadow); color: var(--ars-color); + } + .toolbar:hover { border-color: rgba(246,55,91,.38); box-shadow: var(--ars-shadow); } + .toolbar-switch, :host(.dark) .toolbar-switch { + flex-direction: row; gap: 5px; min-height: 30px; padding: 4px 7px; + background: var(--ars-angular-soft); box-shadow: inset 0 0 0 1px rgba(246,55,91,.2); + } + .angular-version-chip, :host(.dark) .angular-version-chip { + max-width: 48px; padding: 3px 5px; border: 0; background: transparent; + color: #ff8ba1; font-size: 8px; + } + input:checked + .track { background: var(--ars-angular); } + .toolbar-main, .toolbar-extended { gap: 4px; flex-wrap: nowrap; } + .metric, :host(.dark) .metric { + min-width: 54px; gap: 2px; padding: 5px 7px; border-color: var(--ars-chip-border); + border-radius: 7px; background: var(--ars-chip-bg); box-shadow: none; + } + .metric:hover, :host(.dark) .metric:hover { + border-color: rgba(246,55,91,.25); background: rgba(255,255,255,.08); box-shadow: none; + } + .label { color: var(--ars-label); font-size: 7px; letter-spacing: .08em; } + .value { color: var(--ars-color); font-size: 11px; } + .toolbar-actions { gap: 3px; border-left-color: var(--ars-border); } + .action-btn, .clear-btn, .details-toggle { + width: 28px; min-width: 28px; height: 28px; padding: 0; justify-content: center; + border-color: transparent; border-radius: 7px; background: transparent; color: #a9a9b2; + } + .action-btn:hover, .clear-btn:hover, .details-toggle:hover { + border-color: rgba(255,255,255,.08); background: rgba(255,255,255,.08); color: #fff; + } + .details-toggle.active, .action-btn.active { + border-color: rgba(246,55,91,.34); background: var(--ars-angular-soft); + color: #ff7892; box-shadow: none; + } + .inspect-panel, .cpu-details-panel, .waterfall-panel, .alerts-panel, + .onpush-panel, .zone-pollution-panel, .cd-graph-panel { + border-color: var(--ars-border) !important; background: var(--ars-panel-bg) !important; + box-shadow: var(--ars-shadow) !important; color: var(--ars-color) !important; + } + @media (max-width: 720px) { + .toolbar { width: auto; max-width: calc(100vw - 16px); right: 8px !important; } + .toolbar-main .cpu-interactive { display: none; } + .sparkline-toggle { min-width: 72px !important; } + .sparkline-toggle svg { width: 64px; } + } + .toolbar-extended, .toolbar-size-toggle { display: none !important; } + .inspect-panel.streamlined { + width: min(360px, calc(100vw - 24px)); padding: 0; overflow: hidden; + border-radius: 12px; font: 500 11px/1.4 ui-sans-serif, system-ui, sans-serif; + } + .stream-inspect-head { display:flex; align-items:flex-start; justify-content:space-between; gap:16px; padding:14px 15px 12px; border-bottom:1px solid var(--ars-border); } + .stream-eyebrow, .stream-section-label { color:var(--ars-label); font-size:8px; font-weight:800; letter-spacing:.09em; text-transform:uppercase; } + .stream-title { margin-top:2px; color:var(--ars-color); font-size:15px; font-weight:750; letter-spacing:-.02em; } + .stream-selector { margin-top:1px; color:var(--ars-label); font:500 9px ui-monospace, SFMono-Regular, Menlo, monospace; } + .stream-status { padding:4px 7px; border-radius:999px; font-size:8px; font-weight:800; white-space:nowrap; } + .stream-status.changed { color:#34d399; background:rgba(52,211,153,.11); } + .stream-status.no-change { color:#fbbf24; background:rgba(251,191,36,.11); } + .stream-metrics { display:grid; grid-template-columns:repeat(3,1fr); border-bottom:1px solid var(--ars-border); } + .stream-metrics > div { display:grid; gap:3px; padding:10px 14px; border-right:1px solid var(--ars-border); } + .stream-metrics > div:last-child { border-right:0; } + .stream-metrics span, .stream-row > span { color:var(--ars-label); font-size:8px; font-weight:700; text-transform:uppercase; letter-spacing:.05em; } + .stream-metrics strong { color:var(--ars-color); font:750 12px ui-monospace, SFMono-Regular, Menlo, monospace; } + .stream-row { display:flex; align-items:center; justify-content:space-between; gap:16px; padding:9px 14px; border-bottom:1px solid var(--ars-border); } + .stream-row strong { color:var(--ars-color); font:600 9px ui-monospace, SFMono-Regular, Menlo, monospace; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } + .stream-changes { display:grid; gap:6px; padding:11px 14px; border-bottom:1px solid var(--ars-border); } + .stream-change { display:grid; grid-template-columns:minmax(58px,auto) 1fr auto 1fr; align-items:center; gap:6px; min-width:0; } + .stream-change code { color:#ff7892; font:650 9px ui-monospace, SFMono-Regular, Menlo, monospace; } + .stream-change span { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:var(--ars-label); font:500 9px ui-monospace, SFMono-Regular, Menlo, monospace; } + .stream-change b { color:var(--ars-label); } + .stream-change em { grid-column:2/-1; color:#fbbf24; font-size:8px; font-style:normal; } + .stream-empty-change { padding:10px 14px; border-bottom:1px solid var(--ars-border); color:var(--ars-label); font-size:9px; } + .stream-action { margin:10px 12px; padding:9px 10px; border:1px solid var(--ars-border); border-left:2px solid #f6375b; border-radius:7px; background:var(--ars-card-bg); } + .stream-action > span { color:#ff7892; font-size:8px; font-weight:800; letter-spacing:.06em; text-transform:uppercase; } + .stream-action.fast { border-left-color:#34d399; } + .stream-action.fast > span { color:#34d399; } + .stream-action p { margin:3px 0 0; color:var(--ars-color); font-size:10px; line-height:1.45; } + .stream-footer { display:flex; align-items:center; justify-content:space-between; gap:12px; padding:9px 12px; border-top:1px solid var(--ars-border); color:var(--ars-label); font-size:9px; } + .component-copy-btn { padding:5px 8px; border:1px solid var(--ars-border); border-radius:6px; background:transparent; color:var(--ars-color); font:650 9px inherit; cursor:pointer; } + .component-copy-btn:hover { border-color:rgba(246,55,91,.35); background:var(--ars-angular-soft); } + + /* Inspector console: docked to the scanner so hovering never chases a tooltip. */ + .inspect-panel.inspector-console { + padding:0; overflow:hidden; border:1px solid rgba(255,255,255,.12) !important; + border-radius:14px; background:#111116 !important; + box-shadow:0 28px 80px rgba(0,0,0,.58), 0 0 0 1px rgba(0,0,0,.35) !important; + color:#f7f7f8 !important; font:500 11px/1.45 ui-sans-serif,system-ui,-apple-system,sans-serif; + transform-origin:bottom right; animation:inspector-enter 140ms cubic-bezier(.2,.8,.2,1); + } + @keyframes inspector-enter { from { opacity:0; transform:translateY(7px) scale(.985); } to { opacity:1; transform:none; } } + .inspector-header { display:flex; align-items:center; justify-content:space-between; gap:16px; min-height:62px; padding:11px 13px; border-bottom:1px solid rgba(255,255,255,.08); background:linear-gradient(180deg,rgba(255,255,255,.035),transparent); } + .inspector-identity { display:flex; align-items:center; gap:10px; min-width:0; } + .inspector-identity > div { display:grid; min-width:0; } + .inspector-identity strong { overflow:hidden; color:#f7f7f8; font-size:13px; font-weight:720; letter-spacing:-.015em; text-overflow:ellipsis; white-space:nowrap; } + .inspector-identity span:last-child { overflow:hidden; color:#777782; font:500 9px ui-monospace,SFMono-Regular,Menlo,monospace; text-overflow:ellipsis; white-space:nowrap; } + .inspector-status { display:inline-flex; align-items:center; gap:6px; flex:0 0 auto; padding:5px 8px; border:1px solid rgba(255,255,255,.08); border-radius:999px; color:#a4a4ad; background:rgba(255,255,255,.035); font-size:8px; font-weight:700; } + .inspector-status i { width:6px; height:6px; border-radius:50%; } + .inspector-status.changed i { background:#34d399; box-shadow:0 0 0 3px rgba(52,211,153,.1); } + .inspector-status.no-change i { background:#fbbf24; box-shadow:0 0 0 3px rgba(251,191,36,.1); } + .inspector-body { display:grid; grid-template-columns:190px minmax(0,1fr); min-height:192px; } + .inspector-timing { padding:15px; border-right:1px solid rgba(255,255,255,.08); background:rgba(255,255,255,.018); } + .inspector-label { display:block; color:#74747e; font-size:8px; font-weight:750; letter-spacing:.09em; text-transform:uppercase; } + .inspector-duration { display:flex; align-items:baseline; gap:5px; margin-top:6px; } + .inspector-duration strong { color:#fff; font:650 30px/1 ui-monospace,SFMono-Regular,Menlo,monospace; letter-spacing:-.07em; } + .inspector-duration span { color:#777782; font:600 9px ui-monospace,SFMono-Regular,Menlo,monospace; } + .inspector-duration-track { height:4px; margin-top:13px; overflow:hidden; border-radius:999px; background:rgba(255,255,255,.07); } + .inspector-duration-track i { display:block; height:100%; min-width:2px; border-radius:inherit; background:#34d399; } + .inspector-console.medium .inspector-duration-track i { background:#fbbf24; } + .inspector-console.slow .inspector-duration-track i { background:#f6375b; } + .inspector-threshold { display:flex; justify-content:space-between; margin-top:4px; color:#606069; font:500 7px ui-monospace,SFMono-Regular,Menlo,monospace; } + .inspector-mini-stats { display:grid; gap:0; margin-top:14px; border:1px solid rgba(255,255,255,.07); border-radius:8px; overflow:hidden; } + .inspector-mini-stats > div { display:flex; align-items:center; justify-content:space-between; gap:8px; padding:6px 8px; border-bottom:1px solid rgba(255,255,255,.06); } + .inspector-mini-stats > div:last-child { border:0; } + .inspector-mini-stats span { color:#6e6e78; font-size:8px; } + .inspector-mini-stats strong { color:#c9c9cf; font:600 8px ui-monospace,SFMono-Regular,Menlo,monospace; } + .inspector-evidence { display:grid; align-content:start; min-width:0; } + .inspector-block { padding:14px 15px; border-bottom:1px solid rgba(255,255,255,.07); } + .inspector-block:last-child { border-bottom:0; } + .inspector-cause { display:block; overflow:hidden; margin-top:7px; padding:7px 8px; border:1px solid rgba(246,55,91,.18); border-radius:6px; background:rgba(246,55,91,.07); color:#ff8ba1; font:600 9px ui-monospace,SFMono-Regular,Menlo,monospace; text-overflow:ellipsis; white-space:nowrap; } + .inspector-diff-block { display:grid; gap:7px; } + .inspector-diff { display:grid; gap:5px; padding-top:2px; } + .inspector-diff > code { color:#e7e7ea; font:650 9px ui-monospace,SFMono-Regular,Menlo,monospace; } + .inspector-diff > div { display:grid; grid-template-columns:minmax(0,1fr) auto minmax(0,1fr); align-items:center; gap:6px; } + .inspector-diff del,.inspector-diff ins { overflow:hidden; padding:5px 6px; border-radius:5px; font:500 8px ui-monospace,SFMono-Regular,Menlo,monospace; text-decoration:none; text-overflow:ellipsis; white-space:nowrap; } + .inspector-diff del { color:#a1a1aa; background:rgba(255,255,255,.04); } + .inspector-diff ins { color:#b8f7dc; background:rgba(52,211,153,.07); } + .inspector-diff > div span { color:#5f5f68; } + .inspector-diff em { color:#fbbf24; font-size:8px; font-style:normal; } + .inspector-none { margin:7px 0 0; color:#777782; font-size:9px; } + .inspector-footer { display:flex; align-items:center; justify-content:space-between; gap:18px; min-height:66px; padding:10px 12px 10px 15px; border-top:1px solid rgba(255,255,255,.08); background:#0e0e12; } + .inspector-footer > div { min-width:0; } + .inspector-footer > div > span { color:#ff7892; font-size:8px; font-weight:750; letter-spacing:.08em; text-transform:uppercase; } + .inspector-footer p { display:-webkit-box; overflow:hidden; margin:2px 0 0; color:#a0a0a8; font-size:9px; line-height:1.35; -webkit-box-orient:vertical; -webkit-line-clamp:2; } + .inspector-footer .component-copy-btn { display:inline-flex; align-items:center; gap:6px; flex:0 0 auto; padding:7px 10px; border-color:rgba(255,255,255,.11); border-radius:7px; background:rgba(255,255,255,.045); color:#e7e7ea; font-size:9px; } + .inspector-footer .component-copy-btn:hover { border-color:rgba(246,55,91,.35); background:rgba(246,55,91,.1); color:#fff; } + @media (max-width:560px) { + .inspect-panel.inspector-console { overflow:auto; } + .inspector-body { grid-template-columns:1fr; } + .inspector-timing { border-right:0; border-bottom:1px solid rgba(255,255,255,.08); } + .inspector-mini-stats { grid-template-columns:repeat(3,1fr); } + .inspector-mini-stats > div { display:grid; gap:2px; border-right:1px solid rgba(255,255,255,.06); border-bottom:0; } + } + + /* Draggable scanner control: one quiet command bar, not a row of cards. */ + .toolbar, :host(.dark) .toolbar { + flex-wrap:nowrap; gap:0; min-height:46px; padding:5px 6px 5px 5px; + border:1px solid rgba(255,255,255,.12); border-radius:13px; + background:rgba(16,16,20,.97); box-shadow:0 18px 50px rgba(0,0,0,.5),0 0 0 1px rgba(0,0,0,.3); + backdrop-filter:blur(20px); cursor:grab; + } + :host(:not(.dark)) .toolbar { + border-color:rgba(20,20,25,.13); background:rgba(255,255,255,.98); + box-shadow:0 18px 50px rgba(20,20,25,.18),0 0 0 1px rgba(255,255,255,.8); + } + .toolbar:hover { border-color:rgba(255,255,255,.18); box-shadow:0 20px 56px rgba(0,0,0,.54); } + .toolbar-grip { display:grid; grid-template-columns:repeat(2,2px); grid-auto-rows:2px; gap:3px; padding:8px 7px 8px 5px; opacity:.38; } + .toolbar-grip i { width:2px; height:2px; border-radius:50%; background:currentColor; } + .scanner-brand { display:flex; align-items:center; gap:8px; padding-right:11px; } + .scanner-mark { display:grid; place-items:center; width:27px; height:27px; flex:0 0 27px; clip-path:polygon(50% 0,96% 18%,88% 78%,50% 100%,12% 78%,4% 18%); background:linear-gradient(145deg,#ff5d79,#d80031); color:#fff; font-size:10px; font-weight:800; } + .scanner-name { display:grid; color:var(--ars-color); font-size:10px; font-weight:720; line-height:1.15; white-space:nowrap; } + .scanner-name small { margin-top:2px; color:var(--ars-label); font:500 7px ui-monospace,SFMono-Regular,Menlo,monospace; } + .scanner-power { display:grid; place-items:center; min-width:27px; width:27px; height:27px; margin-right:5px; border:1px solid var(--ars-border); border-radius:8px; background:rgba(255,255,255,.035); } + .power-control { position:relative; display:block; width:12px; height:12px; border:1.5px solid #71717b; border-radius:50%; } + .power-control::before { content:""; position:absolute; left:50%; top:-3px; width:1.5px; height:6px; transform:translateX(-50%); border-radius:2px; background:#71717b; box-shadow:0 0 0 2px #111116; } + .scanner-power input:checked + .power-control { border-color:#34d399; box-shadow:0 0 0 3px rgba(52,211,153,.08); } + .scanner-power input:checked + .power-control::before { background:#34d399; } + .toolbar-main, .toolbar.compact .toolbar-main { + display:flex; align-items:stretch; flex-wrap:nowrap; gap:0; min-width:0; max-width:none; + padding:0 4px; border-left:1px solid var(--ars-border); border-right:1px solid var(--ars-border); + } + .toolbar .metric, .toolbar.compact .metric, .toolbar.expanded .metric, :host(.dark) .toolbar .metric { + display:grid; align-content:center; gap:1px; min-width:64px; padding:2px 10px; + border:0; border-right:1px solid var(--ars-border); border-radius:0; background:transparent; box-shadow:none; + } + .toolbar .metric:last-child { border-right:0; } + .toolbar .metric:hover, :host(.dark) .toolbar .metric:hover { border-color:var(--ars-border); background:rgba(255,255,255,.025); box-shadow:none; } + .toolbar .metric .label { color:var(--ars-label); font-size:6.5px; letter-spacing:.09em; } + .toolbar .metric .value { color:var(--ars-color); font-size:10px; font-weight:680; } + .toolbar-actions { gap:2px; padding-left:5px; border-left:0; } + .toolbar .action-btn, .toolbar .clear-btn, .toolbar .details-toggle { + display:grid; place-items:center; width:29px; min-width:29px; height:29px; padding:0; + border:1px solid transparent; border-radius:8px; background:transparent; color:#85858f; + } + .toolbar .action-btn:hover, .toolbar .clear-btn:hover, .toolbar .details-toggle:hover { border-color:var(--ars-border); background:rgba(255,255,255,.055); color:var(--ars-color); } + .toolbar .toolbar-picker-toggle { display:flex; width:auto; min-width:auto; gap:6px; padding:0 9px; } + .toolbar-picker-toggle > span { font-size:9px; font-weight:650; } + .toolbar .toolbar-picker-toggle.active { border-color:rgba(246,55,91,.35); background:rgba(246,55,91,.13); color:#ff7892; box-shadow:none; } + .toolbar .action-btn svg, .toolbar .clear-btn svg, .toolbar .details-toggle svg { width:13px; height:13px; } + .toolbar.disabled { gap:0; min-height:42px; padding:4px 5px; border-radius:12px; opacity:1; } + .toolbar.disabled .scanner-brand { padding-right:8px; } + .toolbar.disabled .scanner-name { color:var(--ars-label); } + .toolbar.disabled .scanner-power { margin-right:0; } + @media (max-width:640px) { + .toolbar, :host(.dark) .toolbar { right:8px !important; max-width:calc(100vw - 16px); } + .scanner-name { display:none; } + .scanner-brand { padding-right:6px; } + .toolbar .metric { min-width:52px; padding-inline:7px; } + .toolbar .metric:last-child { display:none; } + .toolbar-picker-toggle > span { display:none; } + .toolbar .toolbar-picker-toggle { width:29px; padding:0; } + } + + /* Final low-profile mode: intentionally easy to ignore until needed. */ + .toolbar, :host(.dark) .toolbar { + min-height:30px; padding:2px 3px; gap:0; border-radius:8px; + border-color:rgba(255,255,255,.09); background:rgba(15,15,18,.9); + box-shadow:0 8px 24px rgba(0,0,0,.28); backdrop-filter:blur(14px); + } + .toolbar:hover { border-color:rgba(255,255,255,.14); box-shadow:0 10px 28px rgba(0,0,0,.34); } + .scanner-power { + display:flex; place-items:unset; align-items:center; gap:4px; width:auto; min-width:auto; height:24px; + margin:0; padding:0 6px 0 5px; border:0; border-radius:5px; background:transparent; + } + .power-control, .scanner-power input:checked + .power-control { + width:6px; height:6px; border:0; border-radius:50%; background:#71717b; box-shadow:none; + } + .scanner-power input:checked + .power-control { background:#34d399; } + .power-control::before { display:none; } + .power-label { color:#85858e; font-size:8px; font-weight:600; } + .minimal-version { padding:0 7px 0 2px; color:#686871; font:500 7px ui-monospace,SFMono-Regular,Menlo,monospace; white-space:nowrap; } + .toolbar-main, .toolbar.compact .toolbar-main { + height:20px; padding:0 2px; border-color:rgba(255,255,255,.07); + } + .toolbar .metric, .toolbar.compact .metric, .toolbar.expanded .metric, :host(.dark) .toolbar .metric { + display:flex; align-items:center; min-width:auto; padding:0 7px; border-color:rgba(255,255,255,.07); + } + .toolbar .metric .label { display:none; } + .toolbar .metric .value { color:#a6a6ae; font-size:8px; font-weight:600; } + .toolbar-actions { gap:0; padding-left:2px; } + .toolbar .action-btn, .toolbar .clear-btn, .toolbar .details-toggle, + .toolbar .toolbar-picker-toggle { + display:flex; width:auto; min-width:auto; height:24px; padding:0 7px; + border:0; border-radius:5px; background:transparent; color:#777780; + font:600 8px ui-sans-serif,system-ui,sans-serif; + } + .toolbar .action-btn:hover, .toolbar .clear-btn:hover, .toolbar .details-toggle:hover { + border:0; background:rgba(255,255,255,.05); color:#c8c8ce; + } + .toolbar .toolbar-picker-toggle { gap:0; } + .toolbar .toolbar-picker-toggle.active { + border:0; background:rgba(246,55,91,.1); color:#ff7892; box-shadow:none; + } + .toolbar.disabled { min-height:28px; padding:2px 3px; border-radius:8px; } + .toolbar.disabled .minimal-version { padding-right:3px; } + @media (max-width:480px) { + .minimal-version { display:none; } + .toolbar .metric { padding-inline:5px; } + .toolbar .clear-btn { display:none; } + .toolbar-picker-toggle > span { display:inline; } + } + + /* Compact inspector sizing. */ + .inspector-header { min-height:48px; padding:8px 10px; } + .inspector-identity { gap:0; } + .inspector-identity strong { font-size:11px; } + .inspector-status { padding:3px 6px; font-size:7px; } + .inspector-body { grid-template-columns:124px minmax(0,1fr); min-height:126px; } + .inspector-timing { padding:10px; } + .inspector-duration { margin-top:3px; } + .inspector-duration strong { font-size:20px; } + .inspector-duration-track { margin-top:8px; } + .inspector-mini-stats { margin-top:8px; } + .inspector-mini-stats > div { padding:4px 6px; } + .inspector-block { padding:9px 10px; } + .inspector-cause { margin-top:5px; padding:5px 6px; } + .inspector-diff-block { gap:4px; } + .inspector-none { margin-top:5px; } + .inspector-footer { min-height:48px; gap:10px; padding:7px 8px 7px 10px; } + .inspector-footer p { -webkit-line-clamp:1; } + .inspector-footer .component-copy-btn { padding:5px 7px; } + `; @@ -838,6 +1143,11 @@ export class AngularRenderScanOverlay { private detailsMode = false; private copyStatus = ""; private copyStatusTimer = 0; + private interactionCapturing = false; + private interactionReport?: InteractionReport; + private interactionBaseline?: InteractionReport; + private interactionComparison?: InteractionComparison; + private showDiagnosisPanel = false; private toolbarX = 16; private toolbarY = 16; @@ -1009,10 +1319,7 @@ export class AngularRenderScanOverlay { this.hoveredRect = hovered?.rect; this.hoverPointer = hovered ? { x: e.clientX, y: e.clientY } : undefined; this.setDetailsHoverCursor(Boolean(hovered)); - if ( - previousHoveredId !== this.hoveredEntry?.id || - this.hoveredEntry - ) { + if (previousHoveredId !== this.hoveredEntry?.id) { this.renderToolbar(); } }; @@ -1208,12 +1515,7 @@ export class AngularRenderScanOverlay { } private restoreToolbarCompact(): void { - try { - const raw = globalThis.localStorage?.getItem(TOOLBAR_COMPACT_KEY); - this.compactToolbar = raw === null ? true : raw !== "false"; - } catch { - this.compactToolbar = true; - } + this.compactToolbar = true; } private saveToolbarCompact(): void { @@ -1385,14 +1687,6 @@ export class AngularRenderScanOverlay { }) .sort((a, b) => area(a.rect) - area(b.rect)); - if ( - matches.length === 1 && - matches[0].entry.parentId === null && - hitElement !== matches[0].entry.element - ) { - return undefined; - } - return matches[0] ?? this.findClickedEntry(x, y); } @@ -1606,10 +1900,9 @@ export class AngularRenderScanOverlay { const compactToolbar = this.compactToolbar || !this.options.enabled; const cpuVal = this.cpu.value; const cpuClass = cpuVal > 50 ? "cpu-high" : cpuVal > 20 ? "cpu-medium" : ""; - const angularVersion = this.angularVersionFromDom(); const wasted = getWastedStats(); - const leaks = getLeakedComponents(); + const detached = getDetachedComponents(); const onPushCandidates = getOnPushCandidates(); const pollutionEvents = getZonePollutionEvents(); @@ -1647,12 +1940,12 @@ export class AngularRenderScanOverlay { sparklineSvg = `Timeline-`; } - // Leaks metric chip - const leaksChip = - leaks.length > 0 - ? ` - Leaks - ${leaks.length} + // Disconnected hosts are a lead for heap analysis, not proof of a leak. + const detachedChip = + detached.length > 0 + ? ` + Detached + ${detached.length} ` : ""; @@ -1678,7 +1971,7 @@ export class AngularRenderScanOverlay { // OnPush candidates chip const onPushChip = onPushCandidates.length > 0 - ? ` + ? ` OnPush ${onPushCandidates.length} ` @@ -1698,57 +1991,35 @@ export class AngularRenderScanOverlay { const htmlChanged = this.replaceToolbarHtml( container, ` - ${this.options.enabled ? this.inspectPanelHtml() : ""} - ${this.options.enabled ? this.cpuDetailsHtml() : ""} - ${this.options.enabled ? this.waterfallPanelHtml() : ""} - ${this.options.enabled ? this.alertsPanelHtml() : ""} - ${this.options.enabled ? this.onPushPanelHtml(onPushCandidates) : ""} - ${this.options.enabled ? this.zonePollutionPanelHtml(pollutionEvents) : ""} - ${this.options.enabled ? this.cdGraphPanelHtml() : ""} + ${this.options.enabled ? this.streamlinedInspectPanelHtml() : ""} + ${this.options.enabled ? this.diagnosisPanelHtml() : ""}
-
- ${angularVersion ? `ng ${escapeHtml(angularVersion)}` : ""} - -
+ ${this.options.enabled ? `
- ${this.metric("FPS", this.options.showFPS ? String(displayedFps) + " fps" : "-", this.getFpsClass(displayedFps))} - - CPU - ${cpuVal}% - - ${sparklineSvg} + ${this.metric("Cycle", cycle ? `${cycle.duration.toFixed(1)}ms` : "-")}
${compactToolbar ? "" : `
${this.metric("Last cycle", cycle ? `${cycle.duration.toFixed(1)}ms${cycleWasteText}` : "-")} - - CD Graph - Graph - ${this.metric("Slowest", cycle?.slowest ? cycle.slowest.name : "-", "", "slowest-metric")} ${triggerBadge} - ${leaksChip} + ${detachedChip} ${alertsChip} ${onPushChip} ${pollutionChip} + ${this.metric("Context FPS", this.options.showFPS ? String(displayedFps) : "-", this.getFpsClass(displayedFps))}
`} - ${compactToolbar ? "" : ` - ${this.options.showCopyPrompt ? '' : ""} - - - `} + + - ` : ""} @@ -1835,18 +2106,68 @@ export class AngularRenderScanOverlay { { once: true }, ); - toolbarEl?.querySelector(".leak-toggle")?.addEventListener( + toolbarEl?.querySelector(".detached-toggle")?.addEventListener( "click", () => { this.detailsMode = true; - if (leaks.length > 0) { - this.selectedEntry = leaks[0]; + if (detached.length > 0) { + this.selectedEntry = detached[0]; } this.renderToolbar(); }, { once: true }, ); + toolbarEl?.querySelector(".interaction-capture-btn")?.addEventListener( + "click", + () => { + if (!this.interactionCapturing) { + const name = window.prompt("Name the interaction to measure", "Primary interaction")?.trim(); + if (!name) return; + beginInteraction(name); + this.interactionCapturing = true; + this.interactionComparison = undefined; + this.showDiagnosisPanel = false; + this.setCopyStatus("Capture started"); + } else { + this.interactionReport = endInteraction(); + this.interactionCapturing = false; + this.showDiagnosisPanel = true; + if (this.interactionBaseline) { + this.interactionComparison = compareInteractionReports(this.interactionBaseline, this.interactionReport); + } + this.renderToolbar(); + } + }, + { once: true }, + ); + + container.querySelector(".diagnosis-close-btn")?.addEventListener("click", () => { this.showDiagnosisPanel = false; this.renderToolbar(); }, { once: true }); + container.querySelector(".diagnosis-baseline-btn")?.addEventListener("click", () => { + if (!this.interactionReport) return; + this.interactionBaseline = this.interactionReport; + setInteractionBaseline(this.interactionReport); + this.interactionComparison = undefined; + this.setCopyStatus("Baseline saved"); + this.renderToolbar(); + }, { once: true }); + container.querySelector(".diagnosis-copy-btn")?.addEventListener("click", async () => { + if (!this.interactionReport) return; + const copied = await navigator.clipboard.writeText(formatInteractionReportMarkdown(this.interactionReport)).then(() => true, () => false); + this.setCopyStatus(copied ? "Report copied" : "Copy failed"); + }, { once: true }); + container.querySelector(".diagnosis-html-btn")?.addEventListener("click", () => { + if (!this.interactionReport) return; + const blob = new Blob([formatInteractionReportHtml(this.interactionReport)], { type: "text/html" }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = `angular-render-scan-${this.interactionReport.name.toLowerCase().replace(/[^a-z0-9]+/g, "-")}.html`; + anchor.click(); + URL.revokeObjectURL(url); + this.setCopyStatus("HTML report exported"); + }, { once: true }); + toolbarEl?.querySelector(".clear-btn")?.addEventListener( "click", () => { @@ -1912,6 +2233,17 @@ export class AngularRenderScanOverlay { { once: true }, ); + container.querySelector(".component-copy-btn")?.addEventListener( + "click", + async () => { + const entry = this.currentDetailsEntry(); + if (!entry) return; + const copied = await this.copyComponentPrompt(entry, this.latestFps || this.fps.value); + this.setCopyStatus(copied ? "Copied evidence" : "Copy failed"); + }, + { once: true }, + ); + container.querySelector(".open-editor-btn")?.addEventListener( "click", async () => { @@ -2067,6 +2399,68 @@ export class AngularRenderScanOverlay { return true; } + private streamlinedInspectPanelHtml(): string { + const entry = this.currentDetailsEntry(); + if (!entry) return ""; + + const cause = entry.renderCause + ? `${entry.renderCause.trigger}${entry.renderCause.source ? ` → ${entry.renderCause.source}` : ""}` + : entry.reason ?? "unknown"; + const changedInputs = entry.changedInputs?.slice(0, 3) ?? []; + const recommendation = this.recommendationsFor(entry)[0]; + const didChangeDom = entry.mutationType !== "none"; + const status = didChangeDom ? "Changed" : "No visual change"; + const statusClass = didChangeDom ? "changed" : "no-change"; + const durationRatio = Math.min(100, (entry.latestDuration / Math.max(this.slowThresholdMs, 0.1)) * 100); + const durationClass = entry.latestDuration >= this.slowThresholdMs + ? "slow" + : entry.latestDuration >= this.fastThresholdMs ? "medium" : "fast"; + + return ` +
+
+
+
+ ${escapeHtml(entry.name)} + ${escapeHtml(entry.selector ?? entry.element.tagName.toLowerCase())} +
+
+ ${status} +
+
+
+ Last component check +
${entry.latestDuration.toFixed(2)}ms
+
+
0slow at ${this.slowThresholdMs.toFixed(0)} ms
+
+
Checks${entry.count}
+
Strategy${escapeHtml(entry.cdStrategy ?? "unknown")}
+
+
+
+
+ Why Angular checked it + ${escapeHtml(cause)} +
+
+ Input evidence + ${changedInputs.length ? changedInputs.map(input => `
+ ${escapeHtml(input.name)} +
${escapeHtml(input.previous)}${escapeHtml(input.current)}
+ ${input.isReferentiallyUnstable ? 'Equal value, different reference' : ''} +
`).join("") : '

No input change captured in this check.

'} +
+
+
+
+
${recommendation ? escapeHtml(recommendation.category) : "Assessment"}

${recommendation ? escapeHtml(recommendation.action) : "This sample is within the configured performance budget."}

+ +
+
`; + } + private inspectPanelHtml(): string { const entry = this.currentDetailsEntry(); if (!entry) { @@ -2113,9 +2507,9 @@ export class AngularRenderScanOverlay { : ""; const leakWarningHtml = !entry.element?.isConnected - ? `
+ ? `
⚠️ - Memory Leak Warning: Element is disconnected from the DOM but was not destroyed! + Detached host observed. Confirm retention with a heap snapshot before calling this a memory leak.
` : ""; @@ -2228,66 +2622,12 @@ export class AngularRenderScanOverlay { return this.hoveredEntry ?? this.selectedEntry; } - private inspectPanelPositionStyle(entry: AngularRenderEntry): string { + private inspectPanelPositionStyle(_entry: AngularRenderEntry): string { const margin = 12; - const viewportWidth = window.innerWidth; - const viewportHeight = window.innerHeight; - const panelWidth = Math.min(280, Math.max(220, viewportWidth - margin * 2)); - const panelHeightEstimate = Math.min( - 300, - Math.max(180, viewportHeight - margin * 2), - ); - const rect = - this.hoveredEntry?.id === entry.id - ? this.hoveredRect - : entry.element?.getBoundingClientRect(); - const pointer = - this.hoveredEntry?.id === entry.id ? this.hoverPointer : undefined; - - let left = viewportWidth - this.toolbarX - panelWidth; - let top = viewportHeight - this.toolbarY - panelHeightEstimate - 60; - - if (rect) { - const anchorX = pointer?.x ?? rect.left + rect.width / 2; - const anchorY = pointer?.y ?? rect.top + rect.height / 2; - const isLargeTarget = - rect.width > panelWidth + 48 || rect.height > panelHeightEstimate; - - if (isLargeTarget && pointer) { - left = - anchorX + margin + panelWidth <= viewportWidth - ? anchorX + margin - : anchorX - panelWidth - margin; - top = - anchorY + margin + panelHeightEstimate <= viewportHeight - ? anchorY + margin - : anchorY - panelHeightEstimate - margin; - } else { - if (rect.right + margin + panelWidth <= viewportWidth) { - left = rect.right + margin; - } else if (rect.left - margin - panelWidth >= 0) { - left = rect.left - panelWidth - margin; - } else { - left = - anchorX + margin + panelWidth <= viewportWidth - ? anchorX + margin - : anchorX - panelWidth - margin; - } - - top = rect.top; - if (rect.height > panelHeightEstimate / 2) { - top = anchorY - panelHeightEstimate / 2; - } - } - } - - left = Math.max(margin, Math.min(left, viewportWidth - panelWidth - margin)); - top = Math.max( - margin, - Math.min(top, viewportHeight - panelHeightEstimate - margin), - ); - - return `left: ${Math.round(left)}px; top: ${Math.round(top)}px; right: auto; bottom: auto; width: ${Math.round(panelWidth)}px; max-width: calc(100vw - ${margin * 2}px);`; + const width = Math.min(360, window.innerWidth - margin * 2); + const right = Math.max(margin, this.toolbarX); + const bottom = Math.max(margin, this.toolbarY + 58); + return `right:${Math.round(right)}px; bottom:${Math.round(bottom)}px; left:auto; top:auto; width:${Math.round(width)}px; max-width:calc(100vw - ${margin * 2}px); max-height:calc(100vh - ${Math.round(bottom + margin)}px);`; } private panelField(label: string, value: string): string { @@ -2446,7 +2786,7 @@ export class AngularRenderScanOverlay { label: string; } { if (entry.element && !entry.element.isConnected) { - return { kind: "slow", label: "Memory Leak" }; + return { kind: "medium", label: "Detached" }; } const maxDuration = Math.max(entry.latestDuration, entry.averageDuration); if (maxDuration >= this.slowThresholdMs) { @@ -2483,9 +2823,9 @@ export class AngularRenderScanOverlay { if (entry.element && !entry.element.isConnected) { recommendations.push({ - category: "Memory Leak", - action: `Component element is disconnected from the DOM but was not destroyed. Make sure subscriptions and global events are cleanly unsubscribed (e.g. takeUntilDestroyed).`, - severity: "slow", + category: "Detached host", + action: `The host is disconnected. Use a heap snapshot or allocation profile to verify the component is retained before changing cleanup logic.`, + severity: "medium", }); } @@ -2721,6 +3061,40 @@ export class AngularRenderScanOverlay { `; } + private diagnosisPanelHtml(): string { + const report = this.interactionReport; + if (!this.showDiagnosisPanel || !report) return ""; + const comparison = this.interactionComparison; + const outcomeColor = comparison?.outcome === "regressed" ? "#ef4444" : comparison?.outcome === "improved" ? "#10b981" : "#64748b"; + const comparisonHtml = comparison + ? `
+
Candidate ${comparison.outcome}
+
Total cycle time ${comparison.deltas.totalCycleDuration.absolute > 0 ? "+" : ""}${comparison.deltas.totalCycleDuration.absolute}ms · Max cycle ${comparison.deltas.maxCycleDuration.absolute > 0 ? "+" : ""}${comparison.deltas.maxCycleDuration.absolute}ms · No-mutation share ${comparison.deltas.wastedPercentage.absolute > 0 ? "+" : ""}${comparison.deltas.wastedPercentage.absolute}pt
+ ${comparison.regressions.length ? `
${escapeHtml(comparison.regressions.join(" "))}
` : ""} +
` + : ""; + const findingRows = report.findings.slice(0, 5).map((item, index) => ` +
+
${index + 1}
${escapeHtml(item.title)}
${escapeHtml(item.summary)}
+
Next: ${escapeHtml(item.action)}
+
`).join(""); + return ` +
+
+
${escapeHtml(report.name)}
${report.metrics.cycleCount} cycles · ${report.metrics.totalCycleDuration}ms total · ${report.metrics.wastedPercentage}% no-mutation
+ +
+ ${comparisonHtml} +
${findingRows || `
No actionable finding in this capture.
`}
+
+ + + +
+
CPU/FPS are context only. Detached hosts and OnPush opportunities require verification.
+
`; + } + private onPushPanelHtml(candidates: OnPushCandidate[]): string { if (!this.showOnPushPanel || candidates.length === 0) return ""; const panelRight = this.toolbarX; @@ -2749,9 +3123,7 @@ export class AngularRenderScanOverlay { ${confLabel}
- ${c.wastedPercentage}% wasted - - ~${c.estimatedSavingPct}% saving + ${c.opportunityPercentage}% no-mutation share
${escapeHtml(c.reason)}
@@ -2769,7 +3141,7 @@ export class AngularRenderScanOverlay {
- These components use Default CD with high wasted render rates. Adding ChangeDetectionStrategy.OnPush could significantly reduce unnecessary checks. + These are experiments, not predicted savings. Apply ChangeDetectionStrategy.OnPush only in a candidate change and verify the same interaction before and after.
${items} @@ -2821,7 +3193,7 @@ export class AngularRenderScanOverlay {
- These CD cycles were triggered by async operations with no user interaction — suspected Zone.js pollution. Use NgZone.runOutsideAngular() to escape Zone. + Applies only to Zone.js applications. These cycles followed async work without a nearby user event; verify the source before using NgZone.runOutsideAngular().
${items} @@ -3074,13 +3446,6 @@ export class AngularRenderScanOverlay { `; } - private signalsPanelHtml(): string { - return ""; - } - - private costPanelHtml(): string { - return ""; - } } function rgba(color: readonly [number, number, number], alpha: number): string { diff --git a/packages/angular-render-scan/src/noop.ts b/packages/angular-render-scan/src/noop.ts index cbf252b..9f7c09f 100644 --- a/packages/angular-render-scan/src/noop.ts +++ b/packages/angular-render-scan/src/noop.ts @@ -22,7 +22,9 @@ import type { SessionExportData, WastedStats, ZonePollutionEvent, - CdGraph + CdGraph, + InteractionComparison, + InteractionReport } from './domain/entities'; /** No-op provider for production — contributes zero bytes to output. */ @@ -40,6 +42,7 @@ export function getAIPrompt(): string { return ''; } export async function copyAIPrompt(): Promise { return false; } export function getWastedStats(): WastedStats { return { totalChecks: 0, wastedChecks: 0, wastedPercentage: 0 }; } export function getLeakedComponents(): AngularRenderEntry[] { return []; } +export function getDetachedComponents(): AngularRenderEntry[] { return []; } export function getOnPushCandidates(): OnPushCandidate[] { return []; } export function getReferentialInstability(): ReferentialInstabilityReport[] { return []; } export function getZonePollutionEvents(): ZonePollutionEvent[] { return []; } @@ -54,9 +57,26 @@ export function getSessionData(): SessionExportData { cycles: [], wastedStats: { totalChecks: 0, wastedChecks: 0, wastedPercentage: 0 }, budgetViolations: [], + detachedComponents: [], leakedComponents: [], onPushCandidates: [], zonePollutionEvents: [], referentialInstabilityReports: [] }; } + +let interactionName = 'Captured interaction'; +export function beginInteraction(name: string): void { interactionName = name || interactionName; } +export function endInteraction(): InteractionReport { + const session = getSessionData(); + return { + schemaVersion: 1, name: interactionName, startedAt: session.exportedAt, finishedAt: session.exportedAt, + url: '', viewport: '', findings: [], session, + metrics: { cycleCount: 0, componentCheckCount: 0, totalCycleDuration: 0, maxCycleDuration: 0, wastedChecks: 0, wastedPercentage: 0, budgetViolationCount: 0 } + }; +} +export function getInteractionReport(): InteractionReport | undefined { return undefined; } +export function setInteractionBaseline(_report: InteractionReport): void {} +export function compareWithInteractionBaseline(): InteractionComparison { throw new Error('[angular-render-scan] No reports exist in noop mode.'); } +export function formatInteractionReportMarkdown(_report: InteractionReport): string { return ''; } +export function formatInteractionReportHtml(_report: InteractionReport): string { return ''; } diff --git a/packages/angular-render-scan/src/public-api.ts b/packages/angular-render-scan/src/public-api.ts index 8787c32..fb9c709 100644 --- a/packages/angular-render-scan/src/public-api.ts +++ b/packages/angular-render-scan/src/public-api.ts @@ -8,12 +8,22 @@ export { getSessionData, getWastedStats, getLeakedComponents, + getDetachedComponents, getOnPushCandidates, getReferentialInstability, getZonePollutionEvents, getCdGraph, getSignalDependencyGraph, - getComponentCostAnalysis + getComponentCostAnalysis, + beginInteraction, + endInteraction, + getInteractionReport, + setInteractionBaseline, + compareWithInteractionBaseline, + compareInteractionReports, + createInteractionReport, + formatInteractionReportMarkdown, + formatInteractionReportHtml } from './application/runtime'; export { AngularRenderScanMarkDirective, @@ -41,7 +51,16 @@ export type { ZonePollutionEvent as ZonePollutionEventType, CdGraph, CdGraphNode, - CdGraphEdge + CdGraphEdge, + SignalDependencyGraph, + ComponentCostEntry, + InteractionFinding, + InteractionFindingKind, + InteractionFindingSeverity, + InteractionMetrics, + InteractionReport, + InteractionMetricDelta, + InteractionComparison } from './domain/entities'; export { startRenderAudit } from './testing'; export type { RenderAuditReport, RenderAuditSession } from './testing'; diff --git a/packages/angular-render-scan/src/testing.ts b/packages/angular-render-scan/src/testing.ts index 68b0fc4..4eb0164 100644 --- a/packages/angular-render-scan/src/testing.ts +++ b/packages/angular-render-scan/src/testing.ts @@ -1,74 +1,85 @@ +import type { BudgetViolation, InteractionComparison, InteractionReport, SessionExportData } from './domain/entities'; +import { compareInteractionReports, formatInteractionReportHtml, formatInteractionReportMarkdown } from './application/interaction'; + +interface BrowserScanApi { + scan(options: { enabled: boolean }): void; + stop(): void; + beginInteraction?(name: string): void; + endInteraction?(): InteractionReport; + getSessionData(): SessionExportData; +} + +export interface RenderAuditPage { + evaluate(pageFunction: () => Result | Promise): Promise>; + evaluate(pageFunction: (argument: Argument) => Result | Promise, argument: Argument): Promise>; +} + export interface RenderAuditReport { rendersFor(name: string): Promise; maxDurationFor(name: string): Promise; wastedRenderPercentage(): Promise; - budgetViolations(): Promise; + budgetViolations(): Promise; + sessionData(): Promise; + interactionReport(): Promise; + compareWith(baseline: InteractionReport): Promise; + toMarkdown(): Promise; + toHtml(): Promise; } export interface RenderAuditSession { stop(): Promise; } -/** - * Starts a Playwright-compatible headless render audit session on the given Page object. - * Intercepts change-detection metrics and budget violations. - */ -export async function startRenderAudit(page: any): Promise { +/** Starts a named, Playwright-compatible interaction audit. */ +export async function startRenderAudit(page: RenderAuditPage, name = 'Playwright interaction'): Promise { await page.evaluate(() => { - const scan = (window as any).AngularRenderScan; - if (scan) { - scan.scan({ enabled: true }); - } + const scan = (window as Window & { AngularRenderScan?: BrowserScanApi }).AngularRenderScan; + scan?.scan({ enabled: true }); }); + await page.evaluate((interactionName) => { + const scan = (window as Window & { AngularRenderScan?: BrowserScanApi }).AngularRenderScan; + scan?.beginInteraction?.(interactionName); + }, name); return { async stop(): Promise { - const sessionData = await page.evaluate(() => { - const scan = (window as any).AngularRenderScan; - if (scan && scan.getSessionData) { - const data = scan.getSessionData(); - scan.stop(); - return data; - } - return null; + const payload = await page.evaluate(() => { + const scan = (window as Window & { AngularRenderScan?: BrowserScanApi }).AngularRenderScan; + if (!scan?.getSessionData) return null; + const interaction = scan.endInteraction?.(); + const session = scan.getSessionData(); + scan.stop(); + return { interaction, session }; }); - - if (!sessionData) { - throw new Error( - '[angular-render-scan] Telemetry session data could not be fetched. ' + - 'Is the package enabled and running in development mode?' - ); + if (!payload) { + throw new Error('[angular-render-scan] Telemetry could not be fetched. Is the scanner enabled in development mode?'); } - + const interaction = payload.interaction ?? { + schemaVersion: 1 as const, + name, + startedAt: payload.session.exportedAt, + finishedAt: payload.session.exportedAt, + url: payload.session.url, + viewport: payload.session.viewport, + metrics: { cycleCount: 0, componentCheckCount: 0, totalCycleDuration: 0, maxCycleDuration: 0, wastedChecks: 0, wastedPercentage: payload.session.wastedStats.wastedPercentage, budgetViolationCount: payload.session.budgetViolations.length }, + findings: [], + session: payload.session + }; + const sessionData = payload.session; return { - async rendersFor(name: string): Promise { - let count = 0; - for (const cycle of sessionData.cycles) { - for (const entry of cycle.entries) { - if (entry.name === name) { - count = Math.max(count, entry.count); - } - } - } - return count; - }, - async maxDurationFor(name: string): Promise { - let maxDur = 0; - for (const cycle of sessionData.cycles) { - for (const entry of cycle.entries) { - if (entry.name === name) { - maxDur = Math.max(maxDur, entry.latestDuration); - } - } - } - return maxDur; + async rendersFor(componentName) { + return Math.max(0, ...sessionData.cycles.flatMap((cycle) => cycle.entries.filter((entry) => entry.name === componentName).map((entry) => entry.count))); }, - async wastedRenderPercentage(): Promise { - return sessionData.wastedStats.wastedPercentage; + async maxDurationFor(componentName) { + return Math.max(0, ...sessionData.cycles.flatMap((cycle) => cycle.entries.filter((entry) => entry.name === componentName).map((entry) => entry.latestDuration))); }, - async budgetViolations(): Promise { - return sessionData.budgetViolations; - } + async wastedRenderPercentage() { return interaction.metrics.wastedPercentage; }, + async budgetViolations() { return interaction.session.budgetViolations; }, + async sessionData() { return sessionData; }, + async interactionReport() { return interaction; }, + async compareWith(baseline) { return compareInteractionReports(baseline, interaction); }, + async toMarkdown() { return formatInteractionReportMarkdown(interaction); }, + async toHtml() { return formatInteractionReportHtml(interaction); } }; } }; diff --git a/packages/cli/README.md b/packages/cli/README.md index 81ed5f6..0138590 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -1,6 +1,6 @@ # angular-render-scan-cli -CLI installer for [angular-render-scan](https://www.npmjs.com/package/angular-render-scan). +CLI installer and CI report generator for [angular-render-scan](https://www.npmjs.com/package/angular-render-scan). ## Usage @@ -22,6 +22,21 @@ npx angular-render-scan-cli --help - `--script-tag`: add the CDN script tag instead of provider setup. - `--force`: patch even if `angular-render-scan` is already present. +## CI performance report + +Save the `InteractionReport` returned by `report.interactionReport()` in a Playwright test, then render or compare it: + +```sh +npx angular-render-scan-cli report --input candidate.json --format markdown +npx angular-render-scan-cli report \ + --input candidate.json \ + --baseline baseline.json \ + --github-summary \ + --fail-on-regression +``` + +`--format` accepts `markdown`, `html`, or `json`; `--output` writes to a file. In GitHub Actions, `--github-summary` appends the result to the job summary. `--fail-on-regression` exits with status 1 when total or maximum cycle time rises more than 10%, no-mutation check share rises more than 5 points, or new budget violations appear. + ## Docs https://github.com/edisonaugusthy/angular-render-scan diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index d6c5d8b..b486fe1 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -35,6 +35,113 @@ function step(n: number, total: number, msg: string) { log(`${c.gray}[${n}/${total}]${c.reset} ${msg}`); } +type ReportMetrics = { + cycleCount: number; + totalCycleDuration: number; + maxCycleDuration: number; + wastedPercentage: number; + budgetViolationCount: number; +}; + +type PortableReport = { + schemaVersion: 1; + name: string; + metrics: ReportMetrics; + findings: Array<{ title: string; summary: string; evidence: string[]; action: string }>; +}; + +type ReportComparison = { + outcome: 'improved' | 'regressed' | 'unchanged'; + regressions: string[]; + deltas: Record<'totalCycleDuration' | 'maxCycleDuration' | 'wastedPercentage' | 'budgetViolationCount', number>; +}; + +function optionValue(args: string[], flag: string): string | undefined { + const index = args.indexOf(flag); + return index >= 0 ? args[index + 1] : undefined; +} + +function readPortableReport(filePath: string): PortableReport { + const value: unknown = JSON.parse(readFile(path.resolve(filePath))); + if (!value || typeof value !== 'object' || !('metrics' in value) || !('findings' in value) || !('name' in value)) { + throw new Error(`Invalid interaction report: ${filePath}`); + } + return value as PortableReport; +} + +function comparePortableReports(baseline: PortableReport, candidate: PortableReport): ReportComparison { + const percent = (before: number, after: number) => before === 0 ? 0 : ((after - before) / before) * 100; + const deltas = { + totalCycleDuration: candidate.metrics.totalCycleDuration - baseline.metrics.totalCycleDuration, + maxCycleDuration: candidate.metrics.maxCycleDuration - baseline.metrics.maxCycleDuration, + wastedPercentage: candidate.metrics.wastedPercentage - baseline.metrics.wastedPercentage, + budgetViolationCount: candidate.metrics.budgetViolationCount - baseline.metrics.budgetViolationCount + }; + const regressions: string[] = []; + const totalPct = percent(baseline.metrics.totalCycleDuration, candidate.metrics.totalCycleDuration); + const maxPct = percent(baseline.metrics.maxCycleDuration, candidate.metrics.maxCycleDuration); + if (totalPct > 10) regressions.push(`Total cycle time increased ${totalPct.toFixed(1)}%.`); + if (maxPct > 10) regressions.push(`Maximum cycle time increased ${maxPct.toFixed(1)}%.`); + if (deltas.wastedPercentage > 5) regressions.push(`No-mutation check share increased ${deltas.wastedPercentage.toFixed(1)} points.`); + if (deltas.budgetViolationCount > 0) regressions.push(`${deltas.budgetViolationCount} additional budget violation(s).`); + const improved = totalPct < -10 || maxPct < -10; + return { outcome: regressions.length ? 'regressed' : improved ? 'improved' : 'unchanged', regressions, deltas }; +} + +function portableMarkdown(report: PortableReport, comparison?: ReportComparison): string { + const lines = [`# Angular Render Scan: ${report.name}`, '']; + if (comparison) { + lines.push(`## Comparison: ${comparison.outcome.toUpperCase()}`, '', + `- Total cycle time delta: ${comparison.deltas.totalCycleDuration.toFixed(2)}ms`, + `- Maximum cycle delta: ${comparison.deltas.maxCycleDuration.toFixed(2)}ms`, + `- No-mutation share delta: ${comparison.deltas.wastedPercentage.toFixed(2)} points`, + `- Budget violation delta: ${comparison.deltas.budgetViolationCount}`, ''); + comparison.regressions.forEach(message => lines.push(`- ❌ ${message}`)); + if (comparison.regressions.length) lines.push(''); + } + lines.push('## Interaction metrics', '', `- Cycles: ${report.metrics.cycleCount}`, + `- Total cycle time: ${report.metrics.totalCycleDuration}ms`, `- Maximum cycle: ${report.metrics.maxCycleDuration}ms`, + `- No-mutation check share: ${report.metrics.wastedPercentage}%`, `- Budget violations: ${report.metrics.budgetViolationCount}`, '', '## Ranked findings', ''); + if (!report.findings.length) lines.push('No actionable findings were observed.'); + report.findings.forEach((finding, index) => lines.push(`### ${index + 1}. ${finding.title}`, '', finding.summary, '', `**Evidence:** ${finding.evidence.join('; ')}`, '', `**Next action:** ${finding.action}`, '')); + lines.push('> CPU/FPS are context only. Detached hosts and OnPush opportunities require verification.'); + return lines.join('\n'); +} + +function portableHtml(report: PortableReport, markdown: string): string { + const escaped = markdown.replace(/&/g, '&').replace(//g, '>'); + return `${report.name.replace(/[<>]/g, '')}
${escaped}
`; +} + +async function runReport(args: string[]): Promise { + const input = optionValue(args, '--input'); + if (!input) throw new Error('report requires --input '); + const candidate = readPortableReport(input); + const baselinePath = optionValue(args, '--baseline'); + const comparison = baselinePath ? comparePortableReports(readPortableReport(baselinePath), candidate) : undefined; + const format = optionValue(args, '--format') ?? 'markdown'; + const markdown = portableMarkdown(candidate, comparison); + const rendered = format === 'json' + ? JSON.stringify(comparison ? { report: candidate, comparison } : candidate, null, 2) + : format === 'html' ? portableHtml(candidate, markdown) : markdown; + if (!['markdown', 'html', 'json'].includes(format)) throw new Error(`Unsupported format: ${format}`); + const output = optionValue(args, '--output'); + if (output) { + writeFile(path.resolve(output), rendered + (format === 'html' ? '' : '\n')); + ok(`Wrote ${format} report to ${output}`); + } else { + log(rendered); + } + if (args.includes('--github-summary')) { + const summaryPath = process.env.GITHUB_STEP_SUMMARY; + if (!summaryPath) warn('GITHUB_STEP_SUMMARY is not set; skipped job summary.'); + else fs.appendFileSync(summaryPath, markdown + '\n', 'utf-8'); + } + if (args.includes('--fail-on-regression') && comparison?.outcome === 'regressed') { + process.exitCode = 1; + } +} + // ─── File discovery helpers ─────────────────────────────────────────────────── function findUp(filename: string, startDir = process.cwd()): string | null { @@ -520,22 +627,37 @@ export async function run(): Promise { return; } + if (command === 'report') { + await runReport(rest); + return; + } + if (command === '--help' || command === '-h' || command === 'help') { log(''); log(`${c.bold}Usage:${c.reset} npx angular-render-scan-cli [command] [options]`); log(''); log(`${c.bold}Commands:${c.reset}`); log(' init Auto-patch your Angular project (default)'); + log(' report Render a capture or compare it with a baseline'); log(''); log(`${c.bold}Options for init:${c.reset}`); log(' --force Patch even if angular-render-scan is already present'); log(' --dry-run Show what would be patched without writing files'); log(' --script-tag Use CDN script tag in index.html instead of provider'); log(''); + log(`${c.bold}Options for report:${c.reset}`); + log(' --input Candidate interaction report JSON'); + log(' --baseline Baseline interaction report JSON'); + log(' --format markdown (default), html, or json'); + log(' --output Write output instead of stdout'); + log(' --github-summary Append Markdown to GITHUB_STEP_SUMMARY'); + log(' --fail-on-regression Exit 1 when guardrails regress'); + log(''); log(`${c.bold}Examples:${c.reset}`); log(' npx angular-render-scan-cli init'); log(' npx angular-render-scan-cli init --dry-run'); log(' npx angular-render-scan-cli init --script-tag'); + log(' npx angular-render-scan-cli report --input candidate.json --baseline baseline.json --github-summary --fail-on-regression'); log(''); return; }