From a860cc2ccf18bddbb89a9cd1ee4d46467ab21bb2 Mon Sep 17 00:00:00 2001 From: Edison Augusthy Date: Sun, 7 Jun 2026 16:57:37 +0200 Subject: [PATCH 1/2] feat: added more features --- e2e/demo.spec.ts | 5 +- .../src/domain/options.spec.ts | 25 +- .../angular-render-scan/src/domain/options.ts | 34 + .../infrastructure/ui/angular-debug.spec.ts | 46 + .../src/infrastructure/ui/angular-debug.ts | 74 + .../src/infrastructure/ui/overlay.ts | 1883 +++++++++++------ .../ui/runtime-telemetry.spec.ts | 96 + .../infrastructure/ui/runtime-telemetry.ts | 190 ++ 8 files changed, 1745 insertions(+), 608 deletions(-) create mode 100644 packages/angular-render-scan/src/infrastructure/ui/angular-debug.spec.ts create mode 100644 packages/angular-render-scan/src/infrastructure/ui/angular-debug.ts create mode 100644 packages/angular-render-scan/src/infrastructure/ui/runtime-telemetry.spec.ts create mode 100644 packages/angular-render-scan/src/infrastructure/ui/runtime-telemetry.ts diff --git a/e2e/demo.spec.ts b/e2e/demo.spec.ts index 986609b..ddf0ef7 100644 --- a/e2e/demo.spec.ts +++ b/e2e/demo.spec.ts @@ -195,21 +195,18 @@ test('toolbar can toggle live CPU details panel', async ({ page }) => { })).toBeNull(); }); -test('wasted render counter and mutation details appear in toolbar and details panel', async ({ page }) => { +test('mutation details appear in toolbar and details panel', async ({ page }) => { await page.goto('/'); const overlay = page.locator(overlaySelector); await page.locator('app-product-card').first().getByRole('button', { name: 'Add to Cart' }).click(); - await expect.poll(async () => overlay.evaluate((host) => host.shadowRoot?.textContent ?? '')).toContain('Wasted'); - await overlay.evaluate((host) => { host.shadowRoot?.querySelector('.details-checkbox')?.click(); }); await page.locator('app-shopping-cart').click({ force: true }); - await expect.poll(async () => overlay.evaluate((host) => host.shadowRoot?.querySelector('.inspect-panel')?.textContent ?? '')).toContain('Wasted Renders'); await expect.poll(async () => overlay.evaluate((host) => host.shadowRoot?.querySelector('.inspect-panel')?.textContent ?? '')).toContain('DOM Mutation Type'); }); diff --git a/packages/angular-render-scan/src/domain/options.spec.ts b/packages/angular-render-scan/src/domain/options.spec.ts index 53e1602..490c20e 100644 --- a/packages/angular-render-scan/src/domain/options.spec.ts +++ b/packages/angular-render-scan/src/domain/options.spec.ts @@ -1,8 +1,11 @@ -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { getResolvedOptions, resetOptionsForTest, setResolvedOptions } from './options'; describe('options', () => { - afterEach(() => resetOptionsForTest()); + afterEach(() => { + resetOptionsForTest(); + vi.unstubAllGlobals(); + }); it('merges partial options with defaults', () => { setResolvedOptions({ enabled: false, log: true }); @@ -76,4 +79,22 @@ describe('options', () => { expect(resolved.editorProtocol).toBe('cursor'); expect(resolved.darkMode).toBe('dark'); }); + + it('persists enabled state and reuses it as the next default', () => { + const storage = new Map(); + vi.stubGlobal('localStorage', { + getItem: (key: string) => storage.get(key) ?? null, + setItem: (key: string, value: string) => storage.set(key, value), + removeItem: (key: string) => storage.delete(key), + }); + + setResolvedOptions({ enabled: false }); + expect(globalThis.localStorage.getItem('angular-render-scan:enabled')).toBe('false'); + + setResolvedOptions({ enabled: true }); + globalThis.localStorage.setItem('angular-render-scan:enabled', 'false'); + setResolvedOptions({ log: true }); + + expect(getResolvedOptions().enabled).toBe(false); + }); }); diff --git a/packages/angular-render-scan/src/domain/options.ts b/packages/angular-render-scan/src/domain/options.ts index a1f7f32..d51394b 100644 --- a/packages/angular-render-scan/src/domain/options.ts +++ b/packages/angular-render-scan/src/domain/options.ts @@ -14,6 +14,8 @@ const defaultBudgets: Required = { maxRendersPerSecond: 20 }; +const STORAGE_ENABLED_KEY = 'angular-render-scan:enabled'; + const defaultOptions: AngularRenderScanResolvedOptions = { enabled: true, showToolbar: true, @@ -45,6 +47,11 @@ let options: AngularRenderScanResolvedOptions = { ...defaultOptions }; export function resolveOptions(next?: AngularRenderScanOptions): AngularRenderScanResolvedOptions { const merged = { ...options, ...next } as AngularRenderScanResolvedOptions; + const storedEnabled = readStoredEnabled(); + + merged.enabled = typeof next?.enabled === 'boolean' + ? next.enabled + : storedEnabled ?? merged.enabled; if (!['slow', 'fast', 'off'].includes(merged.animationSpeed)) { merged.animationSpeed = defaultOptions.animationSpeed; @@ -74,6 +81,9 @@ export function resolveOptions(next?: AngularRenderScanOptions): AngularRenderSc export function setResolvedOptions(next: Partial): AngularRenderScanResolvedOptions { options = resolveOptions(next); + if (typeof next.enabled === 'boolean') { + writeStoredEnabled(options.enabled); + } return options; } @@ -83,6 +93,30 @@ export function getResolvedOptions(): AngularRenderScanResolvedOptions { export function resetOptionsForTest(): void { options = { ...defaultOptions }; + try { + globalThis.localStorage?.removeItem(STORAGE_ENABLED_KEY); + } catch { + // localStorage can be unavailable in non-browser test environments. + } +} + +function readStoredEnabled(): boolean | undefined { + try { + const value = globalThis.localStorage?.getItem(STORAGE_ENABLED_KEY); + if (value === 'true') return true; + if (value === 'false') return false; + } catch { + // localStorage can be unavailable or blocked by the host app. + } + return undefined; +} + +function writeStoredEnabled(enabled: boolean): void { + try { + globalThis.localStorage?.setItem(STORAGE_ENABLED_KEY, String(enabled)); + } catch { + // Persisting preference should never break instrumentation. + } } function normalizeNonNegative(value: number, fallback: number): number { diff --git a/packages/angular-render-scan/src/infrastructure/ui/angular-debug.spec.ts b/packages/angular-render-scan/src/infrastructure/ui/angular-debug.spec.ts new file mode 100644 index 0000000..858a672 --- /dev/null +++ b/packages/angular-render-scan/src/infrastructure/ui/angular-debug.spec.ts @@ -0,0 +1,46 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { getAngularDebugSummary } from "./angular-debug"; + +class DemoComponent {} +class OwnerComponent {} +class DemoDirective {} +class RootComponent {} + +describe("getAngularDebugSummary", () => { + afterEach(() => { + delete (window as Window & { ng?: unknown }).ng; + }); + + it("falls back when Angular debug globals are unavailable", () => { + const element = document.createElement("app-demo"); + + expect(getAngularDebugSummary(element)).toEqual({ + available: false, + directiveNames: [], + listenerNames: [], + }); + }); + + it("returns sanitized Angular debug data", () => { + const element = document.createElement("app-demo"); + (window as Window & { ng?: unknown }).ng = { + getComponent: () => new DemoComponent(), + getOwningComponent: () => new OwnerComponent(), + getDirectives: () => [new DemoDirective()], + getListeners: () => [ + { name: "click", callback: () => undefined }, + { name: "valueChange", type: "output" }, + ], + getRootComponents: () => [new RootComponent()], + }; + + expect(getAngularDebugSummary(element)).toEqual({ + available: true, + componentName: "DemoComponent", + ownerName: "OwnerComponent", + directiveNames: ["DemoDirective"], + listenerNames: ["click", "valueChange"], + rootName: "RootComponent", + }); + }); +}); diff --git a/packages/angular-render-scan/src/infrastructure/ui/angular-debug.ts b/packages/angular-render-scan/src/infrastructure/ui/angular-debug.ts new file mode 100644 index 0000000..e347014 --- /dev/null +++ b/packages/angular-render-scan/src/infrastructure/ui/angular-debug.ts @@ -0,0 +1,74 @@ +export interface AngularDebugSummary { + available: boolean; + componentName?: string; + ownerName?: string; + directiveNames: string[]; + listenerNames: string[]; + rootName?: string; +} + +type AngularDebugGlobals = { + getComponent?: (element: Element) => unknown; + getOwningComponent?: (elementOrDir: Element | object) => unknown; + getDirectives?: (node: Node) => unknown[]; + getListeners?: (element: Element) => Array<{ name?: string; type?: string }>; + getRootComponents?: (elementOrDir: Element | object) => unknown[]; +}; + +export function getAngularDebugSummary(element: Element): AngularDebugSummary { + const ng = getAngularGlobals(); + if (!ng) { + return { available: false, directiveNames: [], listenerNames: [] }; + } + + const component = safeCall(() => ng.getComponent?.(element)); + const owner = safeCall(() => ng.getOwningComponent?.(element)); + const directives = safeCall(() => ng.getDirectives?.(element)) ?? []; + const listeners = safeCall(() => ng.getListeners?.(element)) ?? []; + const roots = safeCall(() => ng.getRootComponents?.(element)) ?? []; + + return { + available: true, + componentName: nameOf(component), + ownerName: nameOf(owner), + directiveNames: directives.map(nameOf).filter(isPresent), + listenerNames: listeners + .map((listener) => listener.name || listener.type) + .filter(isPresent), + rootName: nameOf(roots[0]), + }; +} + +function getAngularGlobals(): AngularDebugGlobals | undefined { + if (typeof window === "undefined") { + return undefined; + } + + const ng = (window as Window & { ng?: AngularDebugGlobals }).ng; + if (!ng || typeof ng !== "object") { + return undefined; + } + + return ng; +} + +function safeCall(read: () => T): T | undefined { + try { + return read(); + } catch { + return undefined; + } +} + +function nameOf(value: unknown): string | undefined { + if (!value || (typeof value !== "object" && typeof value !== "function")) { + return undefined; + } + + const candidate = value as { constructor?: { name?: string }; name?: string }; + return candidate.constructor?.name || candidate.name; +} + +function isPresent(value: string | undefined): value is string { + return Boolean(value); +} diff --git a/packages/angular-render-scan/src/infrastructure/ui/overlay.ts b/packages/angular-render-scan/src/infrastructure/ui/overlay.ts index 4734553..a5bc004 100644 --- a/packages/angular-render-scan/src/infrastructure/ui/overlay.ts +++ b/packages/angular-render-scan/src/infrastructure/ui/overlay.ts @@ -1,5 +1,7 @@ -import { FpsMeter } from './fps'; -import { CpuMeter } from './cpu'; +import { FpsMeter } from "./fps"; +import { CpuMeter } from "./cpu"; +import { getAngularDebugSummary } from "./angular-debug"; +import { RuntimeTelemetry } from "./runtime-telemetry"; import { clearRecording, copyAIPrompt, @@ -10,8 +12,8 @@ import { getOnPushCandidates, getReferentialInstability, getZonePollutionEvents, - getCdGraph -} from '../../application/runtime'; + getCdGraph, +} from "../../application/runtime"; import type { AngularRenderCycle, AngularRenderEntry, @@ -19,8 +21,8 @@ import type { BudgetViolation, OnPushCandidate, ZonePollutionEvent, - CdTriggerAttribution -} from '../../domain/entities'; + CdTriggerAttribution, +} from "../../domain/entities"; interface ActiveHighlight { entry: AngularRenderEntry; @@ -61,27 +63,82 @@ const TOOLBAR_CSS = ` z-index: 2147483647; display: flex; align-items: center; - gap: 12px; - padding: 8px 16px; - border: 1px solid var(--ars-border); - border-radius: 12px; - background: var(--ars-bg); - box-shadow: var(--ars-shadow); + flex-wrap: wrap; + gap: 7px; + width: auto; + max-width: calc(100vw - 32px); + padding: 6px; + border: 1px solid rgba(15, 23, 42, 0.1); + border-radius: 13px; + background: + linear-gradient(135deg, rgba(255,255,255,0.97), rgba(248,250,252,0.92)), + var(--ars-bg); + box-shadow: + 0 14px 36px rgba(15, 23, 42, 0.14), + 0 2px 6px rgba(15, 23, 42, 0.08), + inset 0 1px 0 rgba(255,255,255,0.78); color: var(--ars-color); - font: 500 11px/1.2 Inter, system-ui, -apple-system, sans-serif; + font: 500 11px/1.2 ui-sans-serif, system-ui, -apple-system, sans-serif; pointer-events: auto; - backdrop-filter: blur(16px); + backdrop-filter: blur(18px) saturate(1.25); cursor: grab; user-select: none; transition: box-shadow 0.2s ease, border-color 0.2s ease; } + :host(.dark) .toolbar { + border-color: rgba(148, 163, 184, 0.18); + background: + linear-gradient(135deg, rgba(15,23,42,0.96), rgba(30,41,59,0.9)), + var(--ars-bg); + box-shadow: + 0 18px 44px rgba(0,0,0,0.48), + inset 0 1px 0 rgba(255,255,255,0.08); + } .toolbar:hover { - border-color: rgba(15, 23, 42, 0.12); - box-shadow: var(--ars-shadow); + border-color: rgba(37, 99, 235, 0.22); + box-shadow: + 0 16px 40px rgba(15, 23, 42, 0.16), + 0 2px 8px rgba(15, 23, 42, 0.08), + inset 0 1px 0 rgba(255,255,255,0.78); } .toolbar:active { cursor: grabbing; } + .toolbar-switch { + display: inline-flex; + align-items: center; + padding: 6px 7px; + border-radius: 9px; + background: rgba(15, 23, 42, 0.045); + box-shadow: inset 0 0 0 1px rgba(15, 23, 42, 0.055); + flex: 0 0 auto; + } + :host(.dark) .toolbar-switch { + background: rgba(255, 255, 255, 0.06); + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08); + } + .toolbar-main { + display: flex; + align-items: stretch; + flex-wrap: wrap; + gap: 5px; + min-width: 0; + max-width: calc(100vw - 230px); + overflow: visible; + } + .toolbar-actions { + display: flex; + align-items: center; + gap: 5px; + justify-content: flex-end; + min-width: max-content; + flex: 0 0 auto; + padding-left: 2px; + border-left: 1px solid rgba(15, 23, 42, 0.08); + } + :host(.dark) .toolbar-actions { + border-left-color: rgba(255, 255, 255, 0.08); + } .switch, .details-toggle, .clear-btn, .action-btn, .panel-close, .panel-copy-btn { cursor: pointer; } @@ -89,27 +146,27 @@ const TOOLBAR_CSS = ` display: inline-flex; align-items: center; gap: 8px; - min-width: 72px; + min-width: 34px; user-select: none; } .details-toggle { display: inline-flex; align-items: center; gap: 6px; - padding: 6px 10px; - border: 1px solid var(--ars-border); - border-radius: 8px; + padding: 7px 10px; + border: 1px solid rgba(37, 99, 235, 0.14); + border-radius: 9px; color: var(--ars-label); font: inherit; font-weight: 600; - background: var(--ars-card-bg); + background: rgba(37, 99, 235, 0.045); user-select: none; transition: all 0.15s ease; } .details-toggle:hover { - background: #f1f5f9; - border: 1px dotted rgba(15, 23, 42, 0.4); - color: var(--ars-color); + background: rgba(37, 99, 235, 0.08); + border-color: rgba(37, 99, 235, 0.24); + color: #2563eb; } .details-toggle input { width: 13px; @@ -118,9 +175,9 @@ const TOOLBAR_CSS = ` accent-color: #2563eb; } .details-toggle.active { - border: 1px dotted #2563eb; + border-color: rgba(37, 99, 235, 0.36); color: #2563eb; - background: rgba(37, 99, 235, 0.05); + background: rgba(37, 99, 235, 0.1); } .switch input { position: absolute; @@ -154,14 +211,40 @@ const TOOLBAR_CSS = ` transform: translateX(14px); } .switch-text { + display: none; color: var(--ars-color); font-weight: 700; } - .metric { display: grid; gap: 3px; min-width: 50px; } + .metric { + display: grid; + gap: 3px; + min-width: 60px; + flex: 0 0 auto; + padding: 7px 8px; + border-radius: 9px; + background: rgba(255, 255, 255, 0.62); + box-shadow: + inset 0 0 0 1px rgba(15, 23, 42, 0.055), + inset 0 1px 0 rgba(255, 255, 255, 0.55); + transition: background 0.15s ease, box-shadow 0.15s ease; + } + .metric:hover { + background: rgba(255, 255, 255, 0.9); + box-shadow: + inset 0 0 0 1px rgba(37, 99, 235, 0.18), + 0 3px 8px rgba(15, 23, 42, 0.07); + } + :host(.dark) .metric { + background: rgba(255, 255, 255, 0.055); + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.075); + } + :host(.dark) .metric:hover { + background: rgba(255, 255, 255, 0.09); + } .metric.slowest-metric { - width: 120px; - min-width: 120px; - max-width: 120px; + min-width: 128px; + max-width: 160px; + flex: 0 0 140px; } .slowest-metric .value { display: block; @@ -170,25 +253,27 @@ const TOOLBAR_CSS = ` overflow: hidden; white-space: nowrap; } - .label { color: var(--ars-label); font-size: 9px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; } - .value { color: var(--ars-color); font-family: monospace; font-size: 11px; font-weight: 700; white-space: nowrap; } + .label { color: var(--ars-label); font-size: 8px; font-weight: 850; text-transform: uppercase; letter-spacing: 0.07em; } + .value { color: var(--ars-color); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; font-weight: 850; white-space: nowrap; } .value.fps-drop { color: #ef4444; } .value.cpu-high { color: #ef4444; } .value.cpu-medium { color: #f59e0b; } .metric.cpu-interactive { cursor: pointer; transition: background 0.15s ease, transform 0.1s ease, box-shadow 0.15s ease; - border-radius: 6px; - padding: 3px 6px; - margin: -3px -6px; + border-radius: 9px; + padding: 7px 8px; + margin: 0; user-select: none; - display: inline-flex; + display: inline-grid; flex-direction: column; position: relative; } .metric.cpu-interactive:hover { - background: rgba(37, 99, 235, 0.05); - box-shadow: 0 0 0 1px rgba(37, 99, 235, 0.15); + background: rgba(37, 99, 235, 0.08); + box-shadow: + inset 0 0 0 1px rgba(37, 99, 235, 0.22), + 0 3px 8px rgba(15, 23, 42, 0.07); } .metric.cpu-interactive:active { transform: scale(0.97); @@ -264,10 +349,34 @@ const TOOLBAR_CSS = ` .cpu-bar-fill.medium { background: #f59e0b; } .cpu-bar-fill.high { background: #ef4444; } - .toolbar-actions { - display: flex; - align-items: center; - gap: 6px; + .sparkline-toggle svg { + width: 86px; + } + .diagnostic-chip .value { + text-decoration: none !important; + } + .diagnostic-chip { + min-width: 42px; + padding-left: 7px; + padding-right: 7px; + } + .graph-toggle.active, + .sparkline-toggle.active, + .details-toggle.active, + .action-btn.active { + box-shadow: inset 0 0 0 1px rgba(37, 99, 235, 0.32); + } + @media (max-width: 720px) { + .toolbar { + width: calc(100vw - 32px); + align-items: center; + } + .toolbar-main { + max-width: none; + flex: 1 1 auto; + overflow: visible; + padding-bottom: 1px; + } } [data-tooltip] { position: relative; @@ -329,20 +438,36 @@ const TOOLBAR_CSS = ` transform: translateY(0); } .clear-btn, .action-btn, .panel-close, .panel-copy-btn { - background: var(--ars-card-bg); - border: 1px solid var(--ars-border); - border-radius: 8px; - padding: 6px 10px; + background: rgba(15, 23, 42, 0.035); + border: 1px solid rgba(15, 23, 42, 0.08); + border-radius: 9px; + padding: 7px 10px; font: inherit; font-weight: 600; color: var(--ars-label); transition: all 0.15s ease; } + .toolbar-actions .clear-btn, + .toolbar-actions .action-btn, + .toolbar-actions .details-toggle { + display: inline-grid; + place-items: center; + width: 32px; + height: 32px; + min-width: 32px; + padding: 0; + font-size: 14px; + line-height: 1; + } + .toolbar-actions .details-toggle input { + position: absolute; + opacity: 0; + pointer-events: none; + } .clear-btn:hover, .action-btn:hover, .panel-close:hover, .panel-copy-btn:hover { - background: #f1f5f9; - border-color: rgba(15, 23, 42, 0.15); - color: var(--ars-color); - transform: translateY(-0.5px); + background: rgba(37, 99, 235, 0.075); + border-color: rgba(37, 99, 235, 0.2); + color: #2563eb; } .clear-btn:active, .action-btn:active, .panel-close:active, .panel-copy-btn:active { transform: translateY(0); @@ -553,25 +678,30 @@ const TOOLBAR_CSS = ` `; export class AngularRenderScanOverlay { - private readonly host = document.createElement('angular-render-scan-overlay'); - private readonly shadow = this.host.attachShadow({ mode: 'open' }); - private readonly canvas = document.createElement('canvas'); - private readonly context = this.canvas.getContext('2d'); + private readonly host = document.createElement("angular-render-scan-overlay"); + private readonly shadow = this.host.attachShadow({ mode: "open" }); + private readonly canvas = document.createElement("canvas"); + private readonly context = this.canvas.getContext("2d"); private readonly fps = new FpsMeter(); private readonly cpu = new CpuMeter(() => this.renderToolbar()); + private readonly runtimeTelemetry = new RuntimeTelemetry(() => + this.renderToolbar(), + ); private showCpuDetails = false; private raf = 0; private latestFps = 0; private lastFpsSampleAt = 0; - private lastToolbarHtml = ''; + private lastToolbarHtml = ""; private latestCycle?: AngularRenderCycle; - private highlights: Array<{ entry: AngularRenderEntry; expiresAt: number }> = []; + private highlights: Array<{ entry: AngularRenderEntry; expiresAt: number }> = + []; private options: AngularRenderScanResolvedOptions; private selectedEntry?: AngularRenderEntry; private hoveredEntry?: AngularRenderEntry; private hoveredRect?: DOMRect; + private detailsHoverCursorActive = false; private detailsMode = false; - private copyStatus = ''; + private copyStatus = ""; private copyStatusTimer = 0; private toolbarX = 16; @@ -596,34 +726,42 @@ export class AngularRenderScanOverlay { return (this.options.budgets?.warnMs ?? 10) / 2; } - private readonly last30CycleDurations: Array<{ duration: number; isSlow: boolean }> = []; + private readonly last30CycleDurations: Array<{ + duration: number; + isSlow: boolean; + }> = []; private budgetViolations: BudgetViolation[] = []; private showAlertsPanel = false; private showWaterfallPanel = false; private keyListener?: (e: KeyboardEvent) => void; private budgetViolationListener?: (e: Event) => void; - constructor(options: AngularRenderScanResolvedOptions, private readonly onToggle: (enabled: boolean) => void) { + constructor( + options: AngularRenderScanResolvedOptions, + private readonly onToggle: (enabled: boolean) => void, + ) { this.options = options; const recorded = getRecording(); if (recorded && recorded.length > 0) { this.latestCycle = recorded[recorded.length - 1]; - this.last30CycleDurations.push(...recorded.slice(-30).map(c => ({ - duration: c.duration, - isSlow: c.duration >= this.slowThresholdMs - }))); + this.last30CycleDurations.push( + ...recorded.slice(-30).map((c) => ({ + duration: c.duration, + isSlow: c.duration >= this.slowThresholdMs, + })), + ); } - this.host.style.pointerEvents = 'none'; + this.host.style.pointerEvents = "none"; this.canvas.style.cssText = [ - 'position:fixed', - 'inset:0', - 'z-index:2147483646', - 'pointer-events:none' - ].join(';'); + "position:fixed", + "inset:0", + "z-index:2147483646", + "pointer-events:none", + ].join(";"); this.shadow.innerHTML = `
`; document.documentElement.append(this.canvas, this.host); this.resize(); - window.addEventListener('resize', this.resize); + window.addEventListener("resize", this.resize); this.loop(); this.setupDragListeners(); this.updateDarkMode(); @@ -634,37 +772,44 @@ export class AngularRenderScanOverlay { this.addBudgetViolation(e.detail); } }; - window.addEventListener('angular-render-scan:budget-violation', this.budgetViolationListener); + window.addEventListener( + "angular-render-scan:budget-violation", + this.budgetViolationListener, + ); // Setup Zone pollution event listener this.zonePollutionListener = (_e: Event) => { // Just trigger a toolbar re-render to update the pollution badge count this.renderToolbar(); }; - window.addEventListener('angular-render-scan:zone-pollution', this.zonePollutionListener); + window.addEventListener( + "angular-render-scan:zone-pollution", + this.zonePollutionListener, + ); // Setup keyboard shortcuts this.keyListener = (e: KeyboardEvent) => { if (e.altKey && e.shiftKey) { const key = e.key.toLowerCase(); - if (key === 's') { + if (key === "s") { e.preventDefault(); this.onToggle(!this.options.enabled); - } else if (key === 'd') { + } else if (key === "d") { e.preventDefault(); this.detailsMode = !this.detailsMode; this.hoveredEntry = undefined; this.hoveredRect = undefined; + this.setDetailsHoverCursor(false); if (!this.detailsMode) this.selectedEntry = undefined; this.renderToolbar(); - } else if (key === 'c') { + } else if (key === "c") { e.preventDefault(); - copyAIPrompt(this.latestFps || this.fps.value).then(copied => { - this.setCopyStatus(copied ? 'Copied' : 'No render data'); + copyAIPrompt(this.latestFps || this.fps.value).then((copied) => { + this.setCopyStatus(copied ? "Copied" : "No render data"); }); - } else if (key === 'x') { + } else if (key === "x") { e.preventDefault(); - import('../../application/stats').then(m => { + import("../../application/stats").then((m) => { m.clearStats(); clearRecording(); this.latestCycle = undefined; @@ -676,13 +821,20 @@ export class AngularRenderScanOverlay { this.showWaterfallPanel = false; this.renderToolbar(); }); - } else if (key === 't') { + } else if (key === "t") { e.preventDefault(); this.options.showToolbar = !this.options.showToolbar; this.renderToolbar(); } - } else if (e.key === 'Escape') { - if (this.selectedEntry || this.showCpuDetails || this.showWaterfallPanel || this.showAlertsPanel || this.showOnPushPanel || this.showZonePollutionPanel) { + } else if (e.key === "Escape") { + if ( + this.selectedEntry || + this.showCpuDetails || + this.showWaterfallPanel || + this.showAlertsPanel || + this.showOnPushPanel || + this.showZonePollutionPanel + ) { this.selectedEntry = undefined; this.showCpuDetails = false; this.showWaterfallPanel = false; @@ -693,7 +845,7 @@ export class AngularRenderScanOverlay { } } }; - window.addEventListener('keydown', this.keyListener); + window.addEventListener("keydown", this.keyListener); } private setupDragListeners(): void { @@ -701,16 +853,19 @@ export class AngularRenderScanOverlay { if (!this.detailsMode || !this.options.enabled) { this.hoveredEntry = undefined; this.hoveredRect = undefined; + this.setDetailsHoverCursor(false); return; } if (this.isOverlayTarget(e.target)) { + this.setDetailsHoverCursor(false); return; } const hovered = this.findClickedEntry(e.clientX, e.clientY); this.hoveredEntry = hovered?.entry; this.hoveredRect = hovered?.rect; + this.setDetailsHoverCursor(Boolean(hovered)); }; this.globalClickListener = (e: MouseEvent) => { @@ -720,44 +875,66 @@ export class AngularRenderScanOverlay { const x = e.clientX; const y = e.clientY; - const clicked = this.findClickedEntry(x, y) ?? (this.hoveredEntry && this.hoveredRect ? { - entry: this.hoveredEntry, - rect: this.hoveredRect, - expiresAt: 0 - } : undefined); + const clicked = + this.findClickedEntry(x, y) ?? + (this.hoveredEntry && this.hoveredRect + ? { + entry: this.hoveredEntry, + rect: this.hoveredRect, + expiresAt: 0, + } + : undefined); if (clicked) { e.preventDefault(); e.stopPropagation(); this.selectedEntry = clicked.entry; this.renderToolbar(); - + const globalNg = (window as any).ng; if (globalNg && globalNg.getComponent) { const component = globalNg.getComponent(clicked.entry.element); - console.info(`[angular-render-scan] Inspecting <${clicked.entry.name}>:`, component || clicked.entry.element); + console.info( + `[angular-render-scan] Inspecting <${clicked.entry.name}>:`, + component || clicked.entry.element, + ); } else { - console.info(`[angular-render-scan] Inspecting <${clicked.entry.name}> element:`, clicked.entry.element); + console.info( + `[angular-render-scan] Inspecting <${clicked.entry.name}> element:`, + clicked.entry.element, + ); } } }; - - document.addEventListener('mousemove', this.globalMoveListener, { passive: true, capture: true }); - document.addEventListener('click', this.globalClickListener, { capture: true }); - + + document.addEventListener("mousemove", this.globalMoveListener, { + passive: true, + capture: true, + }); + document.addEventListener("click", this.globalClickListener, { + capture: true, + }); + // Add cleanup to destroy method later... - + const handleDragStart = (e: Event) => { const event = e as MouseEvent | TouchEvent; const target = event.target as HTMLElement; - if (target.closest('.switch') || target.closest('.clear-btn') || target.closest('.action-btn') || target.closest('.panel-close')) { + if ( + target.closest(".switch") || + target.closest(".clear-btn") || + target.closest(".action-btn") || + target.closest(".panel-close") + ) { return; // Don't drag if clicking buttons } - const toolbar = this.shadow.querySelector('.toolbar'); - if (target.closest('.toolbar')) { + const toolbar = this.shadow.querySelector(".toolbar"); + if (target.closest(".toolbar")) { this.isDragging = true; - const clientX = 'touches' in event ? event.touches[0].clientX : event.clientX; - const clientY = 'touches' in event ? event.touches[0].clientY : event.clientY; + const clientX = + "touches" in event ? event.touches[0].clientX : event.clientX; + const clientY = + "touches" in event ? event.touches[0].clientY : event.clientY; this.dragStartX = clientX - (window.innerWidth - this.toolbarX); this.dragStartY = clientY - (window.innerHeight - this.toolbarY); } @@ -766,19 +943,25 @@ export class AngularRenderScanOverlay { const handleDragMove = (e: MouseEvent | TouchEvent) => { if (!this.isDragging) return; e.preventDefault(); - const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX; - const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY; - - const toolbar = this.shadow.querySelector('.toolbar') as HTMLElement; + const clientX = "touches" in e ? e.touches[0].clientX : e.clientX; + const clientY = "touches" in e ? e.touches[0].clientY : e.clientY; + + const toolbar = this.shadow.querySelector(".toolbar") as HTMLElement; if (toolbar) { const rect = toolbar.getBoundingClientRect(); this.toolbarX = window.innerWidth - (clientX - this.dragStartX); this.toolbarY = window.innerHeight - (clientY - this.dragStartY); - + // Bounds checking - this.toolbarX = Math.max(16, Math.min(this.toolbarX, window.innerWidth - rect.width - 16)); - this.toolbarY = Math.max(16, Math.min(this.toolbarY, window.innerHeight - rect.height - 16)); - + this.toolbarX = Math.max( + 16, + Math.min(this.toolbarX, window.innerWidth - rect.width - 16), + ); + this.toolbarY = Math.max( + 16, + Math.min(this.toolbarY, window.innerHeight - rect.height - 16), + ); + toolbar.style.right = `${this.toolbarX}px`; toolbar.style.bottom = `${this.toolbarY}px`; } @@ -788,13 +971,15 @@ export class AngularRenderScanOverlay { this.isDragging = false; }; - this.shadow.addEventListener('mousedown', handleDragStart); - window.addEventListener('mousemove', handleDragMove, { passive: false }); - window.addEventListener('mouseup', handleDragEnd); - - this.shadow.addEventListener('touchstart', handleDragStart, { passive: true }); - window.addEventListener('touchmove', handleDragMove, { passive: false }); - window.addEventListener('touchend', handleDragEnd); + this.shadow.addEventListener("mousedown", handleDragStart); + window.addEventListener("mousemove", handleDragMove, { passive: false }); + window.addEventListener("mouseup", handleDragEnd); + + this.shadow.addEventListener("touchstart", handleDragStart, { + passive: true, + }); + window.addEventListener("touchmove", handleDragMove, { passive: false }); + window.addEventListener("touchend", handleDragEnd); } updateOptions(options: AngularRenderScanResolvedOptions): void { @@ -812,7 +997,7 @@ export class AngularRenderScanOverlay { // Track sparkline durations this.last30CycleDurations.push({ duration: cycle.duration, - isSlow: cycle.duration >= this.slowThresholdMs + isSlow: cycle.duration >= this.slowThresholdMs, }); if (this.last30CycleDurations.length > 30) { this.last30CycleDurations.shift(); @@ -821,7 +1006,9 @@ export class AngularRenderScanOverlay { const ttl = this.highlightTtl(); if (ttl > 0 && this.options.enabled) { const expiresAt = performance.now() + ttl; - this.highlights.push(...cycle.entries.map((entry) => ({ entry, expiresAt }))); + this.highlights.push( + ...cycle.entries.map((entry) => ({ entry, expiresAt })), + ); } this.renderToolbar(); } @@ -831,24 +1018,36 @@ export class AngularRenderScanOverlay { destroy(): void { this.cpu.destroy(); + this.runtimeTelemetry.destroy(); cancelAnimationFrame(this.raf); - window.removeEventListener('resize', this.resize); + window.removeEventListener("resize", this.resize); if (this.globalClickListener) { - document.removeEventListener('click', this.globalClickListener, { capture: true }); + document.removeEventListener("click", this.globalClickListener, { + capture: true, + }); } if (this.globalMoveListener) { - document.removeEventListener('mousemove', this.globalMoveListener, { capture: true }); + document.removeEventListener("mousemove", this.globalMoveListener, { + capture: true, + }); } if (this.keyListener) { - window.removeEventListener('keydown', this.keyListener); + window.removeEventListener("keydown", this.keyListener); } if (this.budgetViolationListener) { - window.removeEventListener('angular-render-scan:budget-violation', this.budgetViolationListener); + window.removeEventListener( + "angular-render-scan:budget-violation", + this.budgetViolationListener, + ); } if (this.zonePollutionListener) { - window.removeEventListener('angular-render-scan:zone-pollution', this.zonePollutionListener); + window.removeEventListener( + "angular-render-scan:zone-pollution", + this.zonePollutionListener, + ); } window.clearTimeout(this.copyStatusTimer); + this.setDetailsHoverCursor(false); this.host.remove(); this.canvas.remove(); } @@ -881,21 +1080,31 @@ export class AngularRenderScanOverlay { } this.context.clearRect(0, 0, window.innerWidth, window.innerHeight); - if (!this.options.enabled || this.options.animationSpeed === 'off') { + if (!this.options.enabled || this.options.animationSpeed === "off") { this.highlights = []; return; } const now = performance.now(); const activeHighlights = this.getActiveHighlights(now); - const labelledIds = this.getLabelledEntryIds(activeHighlights).slice(0, this.options.maxLabelCount); + const labelledIds = this.getLabelledEntryIds(activeHighlights).slice( + 0, + this.options.maxLabelCount, + ); const fadeDuration = this.highlightTtl() || 1; for (const { entry, expiresAt, rect } of activeHighlights) { - const alpha = Math.max(0.18, Math.min(1, (expiresAt - now) / fadeDuration)); - + const alpha = Math.max( + 0.18, + Math.min(1, (expiresAt - now) / fadeDuration), + ); + this.drawOutline(rect, alpha, entry); + if (this.detailsMode) { + this.drawDetailsAffordance(rect, entry, alpha); + } + if (labelledIds.includes(entry.id)) { this.drawLabel(entry, rect, alpha, entry.latestDuration); } @@ -907,50 +1116,70 @@ export class AngularRenderScanOverlay { } private getActiveHighlights(now: number): ActiveHighlight[] { - this.highlights = this.highlights.filter((highlight) => highlight.expiresAt > now && highlight.entry.element.isConnected); + this.highlights = this.highlights.filter( + (highlight) => + highlight.expiresAt > now && highlight.entry.element.isConnected, + ); - return this.highlights.flatMap((highlight) => { - const rect = highlight.entry.element.getBoundingClientRect(); - if (rect.width <= 0 || rect.height <= 0) { - return []; - } + return this.highlights + .flatMap((highlight) => { + const rect = highlight.entry.element.getBoundingClientRect(); + if (rect.width <= 0 || rect.height <= 0) { + return []; + } - return [{ ...highlight, rect }]; - }).sort((a, b) => area(b.rect) - area(a.rect)); + return [{ ...highlight, rect }]; + }) + .sort((a, b) => area(b.rect) - area(a.rect)); } private findClickedEntry(x: number, y: number): ActiveHighlight | undefined { const activeHighlights = this.getActiveHighlights(performance.now()); - const activeMatch = this.smallestContainingHighlight(activeHighlights, x, y); + const activeMatch = this.smallestContainingHighlight( + activeHighlights, + x, + y, + ); if (activeMatch) { return activeMatch; } - const latestHighlights = this.latestCycle?.entries.flatMap((entry) => { - if (!entry.element.isConnected) { - return []; - } - const rect = entry.element.getBoundingClientRect(); - if (rect.width <= 0 || rect.height <= 0) { - return []; - } - return [{ entry, rect, expiresAt: 0 }]; - }) ?? []; + const latestHighlights = + this.latestCycle?.entries.flatMap((entry) => { + if (!entry.element.isConnected) { + return []; + } + const rect = entry.element.getBoundingClientRect(); + if (rect.width <= 0 || rect.height <= 0) { + return []; + } + return [{ entry, rect, expiresAt: 0 }]; + }) ?? []; return this.smallestContainingHighlight(latestHighlights, x, y); } - private smallestContainingHighlight(highlights: ActiveHighlight[], x: number, y: number): ActiveHighlight | undefined { + private smallestContainingHighlight( + highlights: ActiveHighlight[], + x: number, + y: number, + ): ActiveHighlight | undefined { return highlights - .filter((highlight) => x >= highlight.rect.left && x <= highlight.rect.right && y >= highlight.rect.top && y <= highlight.rect.bottom) + .filter( + (highlight) => + x >= highlight.rect.left && + x <= highlight.rect.right && + y >= highlight.rect.top && + y <= highlight.rect.bottom, + ) .sort((a, b) => area(a.rect) - area(b.rect))[0]; } private highlightTtl(): number { - if (this.options.animationSpeed === 'slow') { + if (this.options.animationSpeed === "slow") { return 2400; } - if (this.options.animationSpeed === 'fast') { + if (this.options.animationSpeed === "fast") { return 1200; } return 0; @@ -963,14 +1192,21 @@ export class AngularRenderScanOverlay { .map((highlight) => highlight.entry.id); } - private getColorForDuration(duration: number, type: 'stroke' | 'bg'): readonly [number, number, number] { + private getColorForDuration( + duration: number, + type: "stroke" | "bg", + ): readonly [number, number, number] { const { theme } = this.options; - if (duration >= this.slowThresholdMs) return type === 'bg' ? theme.labelBackgroundSlow! : theme.slow!; - if (duration > this.fastThresholdMs) return type === 'bg' ? theme.labelBackground! : theme.medium!; - return type === 'bg' ? theme.labelBackground! : theme.fast!; + if (duration >= this.slowThresholdMs) + return type === "bg" ? theme.labelBackgroundSlow! : theme.slow!; + if (duration > this.fastThresholdMs) + return type === "bg" ? theme.labelBackground! : theme.medium!; + return type === "bg" ? theme.labelBackground! : theme.fast!; } - private getStrokeColorForMutation(entry: AngularRenderEntry): readonly [number, number, number] { + private getStrokeColorForMutation( + entry: AngularRenderEntry, + ): readonly [number, number, number] { if (entry.element && !entry.element.isConnected) { return this.options.theme.slow; // Red for leaked/disconnected components! } @@ -981,29 +1217,27 @@ export class AngularRenderScanOverlay { if (maxDuration > this.fastThresholdMs) { return this.options.theme.medium; // Yellow/Warning for moderately expensive renders! } - const type = entry.mutationType || 'none'; - if (type === 'none') { + const type = entry.mutationType || "none"; + if (type === "none") { return [34, 197, 94]; // Green for wasted no-ops! } - if (type === 'structural') { + if (type === "structural") { return [239, 68, 68]; // Red for structural template/DOM mutations } return [59, 130, 246]; // Blue for text/attribute mutations } - private drawOutline(rect: DOMRect, alpha: number, entry: AngularRenderEntry): void { + private drawOutline( + rect: DOMRect, + alpha: number, + entry: AngularRenderEntry, + ): void { if (!this.context) return; const ctx = this.context; const strokeColor = this.getStrokeColorForMutation(entry); const r = 3; // corner radius ctx.save(); - // Subtle fill tint - ctx.fillStyle = rgba(strokeColor, Math.min(0.06, alpha * 0.08)); - ctx.beginPath(); - ctx.roundRect(rect.left, rect.top, rect.width, rect.height, r); - ctx.fill(); - // Clean sharp stroke — no glow, just a crisp colored border ctx.strokeStyle = rgba(strokeColor, Math.min(0.85, alpha)); ctx.lineWidth = 1.5; ctx.shadowBlur = 0; @@ -1018,12 +1252,6 @@ export class AngularRenderScanOverlay { const ctx = this.context; const color = this.getStrokeColorForMutation(entry); ctx.save(); - // Solid highlight fill - ctx.fillStyle = rgba(color, 0.07); - ctx.beginPath(); - ctx.roundRect(rect.left, rect.top, rect.width, rect.height, 3); - ctx.fill(); - // Dashed border ctx.strokeStyle = rgba(color, 0.9); ctx.lineWidth = 2; ctx.setLineDash([4, 3]); @@ -1033,7 +1261,39 @@ export class AngularRenderScanOverlay { ctx.restore(); } - private drawLabel(entry: AngularRenderEntry, rect: DOMRect, alpha: number, _duration: number): void { + private setDetailsHoverCursor(active: boolean): void { + if (this.detailsHoverCursorActive === active) { + return; + } + + this.detailsHoverCursorActive = active; + document.body.style.cursor = active ? "pointer" : ""; + } + + private drawDetailsAffordance( + rect: DOMRect, + entry: AngularRenderEntry, + alpha: number, + ): void { + if (!this.context) return; + const ctx = this.context; + const color = this.getStrokeColorForMutation(entry); + ctx.save(); + ctx.strokeStyle = rgba(color, Math.min(0.22, alpha * 0.28)); + ctx.lineWidth = 1; + ctx.setLineDash([5, 4]); + ctx.beginPath(); + ctx.roundRect(rect.left - 1, rect.top - 1, rect.width + 2, rect.height + 2, 4); + ctx.stroke(); + ctx.restore(); + } + + private drawLabel( + entry: AngularRenderEntry, + rect: DOMRect, + alpha: number, + _duration: number, + ): void { if (!this.context) return; const ctx = this.context; @@ -1042,21 +1302,24 @@ export class AngularRenderScanOverlay { const isLeak = entry.element && !entry.element.isConnected; ctx.save(); - ctx.font = '600 10px ui-sans-serif, system-ui, sans-serif'; + ctx.font = "600 10px ui-sans-serif, system-ui, sans-serif"; // Build pill text: "ComponentName · 12ms · ×4" const durationText = `${entry.latestDuration.toFixed(1)}ms`; const label = truncateText( ctx, `${entry.name} · ${durationText} · ×${entry.count}`, - Math.max(56, Math.min(rect.width - 4, 200)) + Math.max(56, Math.min(rect.width - 4, 200)), ); const textW = ctx.measureText(label).width; const pillW = textW + 14; const pillH = 17; const pillR = 4; - const pillX = Math.max(4, Math.min(rect.left + 4, window.innerWidth - pillW - 4)); + const pillX = Math.max( + 4, + Math.min(rect.left + 4, window.innerWidth - pillW - 4), + ); const pillY = Math.max(4, rect.top + 4); // Pill background — use stroke color with opacity @@ -1076,20 +1339,20 @@ export class AngularRenderScanOverlay { } private renderToolbar(): void { - const container = this.shadow.getElementById('toolbar-container'); + const container = this.shadow.getElementById("toolbar-container"); if (!container) { return; } if (!this.options.showToolbar) { - this.replaceToolbarHtml(container, ''); + this.replaceToolbarHtml(container, ""); return; } const cycle = this.latestCycle; const displayedFps = this.latestFps || this.fps.value; const cpuVal = this.cpu.value; - const cpuClass = cpuVal > 50 ? 'cpu-high' : cpuVal > 20 ? 'cpu-medium' : ''; + const cpuClass = cpuVal > 50 ? "cpu-high" : cpuVal > 20 ? "cpu-medium" : ""; const wasted = getWastedStats(); const leaks = getLeakedComponents(); @@ -1097,18 +1360,31 @@ export class AngularRenderScanOverlay { const pollutionEvents = getZonePollutionEvents(); // Generate timeline sparkline SVG - let sparklineSvg = ''; + let sparklineSvg = ""; if (this.last30CycleDurations.length > 0) { - const maxDuration = Math.max(...this.last30CycleDurations.map((d) => d.duration), 1); - const bars = this.last30CycleDurations.map((d, index) => { - const height = Math.max(2, Math.round((d.duration / maxDuration) * 16)); - const y = 16 - height; - const x = index * 3; - const color = d.duration >= this.slowThresholdMs ? '#ef4444' : d.duration > this.fastThresholdMs ? '#f59e0b' : '#3b82f6'; - return ``; - }).join(''); + const maxDuration = Math.max( + ...this.last30CycleDurations.map((d) => d.duration), + 1, + ); + const bars = this.last30CycleDurations + .map((d, index) => { + const height = Math.max( + 2, + Math.round((d.duration / maxDuration) * 16), + ); + const y = 16 - height; + const x = index * 3; + const color = + d.duration >= this.slowThresholdMs + ? "#ef4444" + : d.duration > this.fastThresholdMs + ? "#f59e0b" + : "#3b82f6"; + return ``; + }) + .join(""); sparklineSvg = ` - + Timeline ${bars} @@ -1118,46 +1394,54 @@ export class AngularRenderScanOverlay { } // Leaks metric chip - const leaksChip = leaks.length > 0 - ? ` - Memory leaks - ${leaks.length} + const leaksChip = + leaks.length > 0 + ? ` + Leaks + ${leaks.length} ` - : ''; + : ""; // Alerts metric chip - const hasError = this.budgetViolations.some((v) => v.type === 'error' || v.type === 'render-rate'); - const alertsChip = this.budgetViolations.length > 0 - ? ` - Budget alerts - - ⚠️ ${this.budgetViolations.length} + const hasError = this.budgetViolations.some( + (v) => v.type === "error" || v.type === "render-rate", + ); + const alertsChip = + this.budgetViolations.length > 0 + ? ` + Alerts + + ${this.budgetViolations.length} ` - : ''; + : ""; // CD Trigger badge const triggerBadge = this.lastTrigger ? this.triggerBadgeHtml(this.lastTrigger) - : ''; + : ""; // OnPush candidates chip - const onPushChip = onPushCandidates.length > 0 - ? ` - OnPush savings - ⚡ ${onPushCandidates.length} + const onPushChip = + onPushCandidates.length > 0 + ? ` + OnPush + ${onPushCandidates.length} ` - : ''; + : ""; // Zone pollution chip - const pollutionChip = pollutionEvents.length > 0 - ? ` - Zone pollution - ⚠ ${pollutionEvents.length} + const pollutionChip = + pollutionEvents.length > 0 + ? ` + Zone + ${pollutionEvents.length} ` - : ''; + : ""; - const htmlChanged = this.replaceToolbarHtml(container, ` + const htmlChanged = this.replaceToolbarHtml( + container, + ` ${this.inspectPanelHtml()} ${this.cpuDetailsHtml()} ${this.waterfallPanelHtml()} @@ -1166,225 +1450,338 @@ export class AngularRenderScanOverlay { ${this.zonePollutionPanelHtml(pollutionEvents)} ${this.cdGraphPanelHtml()}
- - ${this.metric('Frame rate', this.options.showFPS ? String(displayedFps) + ' fps' : '-', this.getFpsClass(displayedFps))} - - CPU busy - ${cpuVal}% - - ${sparklineSvg} - ${this.metric('Last cycle', cycle ? `${cycle.duration.toFixed(1)}ms` : '-')} - ${triggerBadge} - ${this.metric('Wasted renders', `${wasted.wastedChecks} (${wasted.wastedPercentage}%)`, wasted.wastedChecks > 0 ? 'cpu-medium' : '')} - ${leaksChip} - ${alertsChip} - ${onPushChip} - ${pollutionChip} - - CD Graph - - - ${this.metric('Slowest component', cycle?.slowest ? cycle.slowest.name : '-', '', 'slowest-metric')} +
+ +
+
+ ${this.metric("FPS", this.options.showFPS ? String(displayedFps) + " fps" : "-", this.getFpsClass(displayedFps))} + + CPU + ${cpuVal}% + + ${this.metric("Last cycle", cycle ? `${cycle.duration.toFixed(1)}ms` : "-")} + ${sparklineSvg} + + CD Graph + Graph + + ${this.metric("Slowest", cycle?.slowest ? cycle.slowest.name : "-", "", "slowest-metric")} + ${triggerBadge} + ${leaksChip} + ${alertsChip} + ${onPushChip} + ${pollutionChip} +
- - ${this.copyStatus ? `${escapeHtml(this.copyStatus)}` : ''} + ${this.copyStatus ? `${escapeHtml(this.copyStatus)}` : ""}
- `); - + `, + ); + if (!htmlChanged) { return; } - - const toolbarEl = container.querySelector('.toolbar'); - - toolbarEl?.querySelector('input')?.addEventListener('change', (event) => { - this.onToggle((event.target as HTMLInputElement).checked); - }, { once: true }); - toolbarEl?.querySelector('.cpu-interactive')?.addEventListener('click', () => { - this.showCpuDetails = !this.showCpuDetails; - this.renderToolbar(); - }, { once: true }); + const toolbarEl = container.querySelector(".toolbar"); - toolbarEl?.querySelector('.sparkline-toggle')?.addEventListener('click', () => { - this.showWaterfallPanel = !this.showWaterfallPanel; - this.renderToolbar(); - }, { once: true }); + toolbarEl?.querySelector("input")?.addEventListener( + "change", + (event) => { + this.onToggle((event.target as HTMLInputElement).checked); + }, + { once: true }, + ); - toolbarEl?.querySelector('.alerts-toggle')?.addEventListener('click', () => { - this.showAlertsPanel = !this.showAlertsPanel; - this.renderToolbar(); - }, { once: true }); + toolbarEl?.querySelector(".cpu-interactive")?.addEventListener( + "click", + () => { + this.showCpuDetails = !this.showCpuDetails; + this.renderToolbar(); + }, + { once: true }, + ); - toolbarEl?.querySelector('.leak-toggle')?.addEventListener('click', () => { - this.detailsMode = true; - if (leaks.length > 0) { - this.selectedEntry = leaks[0]; - } - this.renderToolbar(); - }, { once: true }); - - toolbarEl?.querySelector('.clear-btn')?.addEventListener('click', () => { - import('../../application/stats').then(m => { - m.clearStats(); - clearRecording(); - this.latestCycle = undefined; - this.highlights = []; - this.selectedEntry = undefined; + toolbarEl?.querySelector(".sparkline-toggle")?.addEventListener( + "click", + () => { + this.showWaterfallPanel = !this.showWaterfallPanel; + this.renderToolbar(); + }, + { once: true }, + ); + + toolbarEl?.querySelector(".alerts-toggle")?.addEventListener( + "click", + () => { + this.showAlertsPanel = !this.showAlertsPanel; + this.renderToolbar(); + }, + { once: true }, + ); + + toolbarEl?.querySelector(".leak-toggle")?.addEventListener( + "click", + () => { + this.detailsMode = true; + if (leaks.length > 0) { + this.selectedEntry = leaks[0]; + } + this.renderToolbar(); + }, + { once: true }, + ); + + toolbarEl?.querySelector(".clear-btn")?.addEventListener( + "click", + () => { + import("../../application/stats").then((m) => { + m.clearStats(); + clearRecording(); + this.latestCycle = undefined; + this.highlights = []; + this.selectedEntry = undefined; + this.hoveredEntry = undefined; + this.hoveredRect = undefined; + this.last30CycleDurations.length = 0; + this.showWaterfallPanel = false; + this.showCpuDetails = false; + this.showAlertsPanel = false; + this.budgetViolations = []; + this.renderToolbar(); + }); + }, + { once: true }, + ); + + toolbarEl?.querySelector(".details-checkbox")?.addEventListener( + "change", + (event) => { + this.detailsMode = (event.target as HTMLInputElement).checked; this.hoveredEntry = undefined; this.hoveredRect = undefined; - this.last30CycleDurations.length = 0; - this.showWaterfallPanel = false; - this.showCpuDetails = false; - this.showAlertsPanel = false; - this.budgetViolations = []; + this.setDetailsHoverCursor(false); + if (!this.detailsMode) { + this.selectedEntry = undefined; + } this.renderToolbar(); - }); - }, { once: true }); + }, + { once: true }, + ); - toolbarEl?.querySelector('.details-checkbox')?.addEventListener('change', (event) => { - this.detailsMode = (event.target as HTMLInputElement).checked; - this.hoveredEntry = undefined; - this.hoveredRect = undefined; - if (!this.detailsMode) { + toolbarEl?.querySelector(".copy-prompt-btn")?.addEventListener( + "click", + async () => { + const copied = await copyAIPrompt(this.latestFps || this.fps.value); + this.setCopyStatus( + copied + ? "Copied" + : this.latestCycle + ? "Copy failed" + : "No render data", + ); + }, + { once: true }, + ); + + toolbarEl?.querySelector(".export-btn")?.addEventListener( + "click", + () => { + const data = getSessionData(); + const blob = new Blob([JSON.stringify(data, null, 2)], { + type: "application/json", + }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `angular-render-scan-session-${Date.now()}.json`; + a.click(); + URL.revokeObjectURL(url); + this.setCopyStatus("Exported JSON"); + }, + { once: true }, + ); + + container.querySelector(".panel-close")?.addEventListener( + "click", + () => { this.selectedEntry = undefined; - } - this.renderToolbar(); - }, { once: true }); - - toolbarEl?.querySelector('.copy-prompt-btn')?.addEventListener('click', async () => { - const copied = await copyAIPrompt(this.latestFps || this.fps.value); - this.setCopyStatus(copied ? 'Copied' : this.latestCycle ? 'Copy failed' : 'No render data'); - }, { once: true }); - - toolbarEl?.querySelector('.export-btn')?.addEventListener('click', () => { - const data = getSessionData(); - const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `angular-render-scan-session-${Date.now()}.json`; - a.click(); - URL.revokeObjectURL(url); - this.setCopyStatus('Exported JSON'); - }, { once: true }); - - container.querySelector('.panel-close')?.addEventListener('click', () => { - this.selectedEntry = undefined; - this.renderToolbar(); - }, { once: true }); + this.renderToolbar(); + }, + { once: true }, + ); - container.querySelector('.waterfall-close-btn')?.addEventListener('click', () => { - this.showWaterfallPanel = false; - this.renderToolbar(); - }, { once: true }); + container.querySelector(".waterfall-close-btn")?.addEventListener( + "click", + () => { + this.showWaterfallPanel = false; + this.renderToolbar(); + }, + { once: true }, + ); - container.querySelector('.panel-copy-btn')?.addEventListener('click', async () => { - if (!this.selectedEntry) { - return; - } - const copied = await this.copyComponentPrompt(this.selectedEntry, this.latestFps || this.fps.value); - this.setCopyStatus(copied ? 'Copied' : 'Copy failed'); - }, { once: true }); + container.querySelector(".panel-copy-btn")?.addEventListener( + "click", + async () => { + if (!this.selectedEntry) { + return; + } + const copied = await this.copyComponentPrompt( + this.selectedEntry, + this.latestFps || this.fps.value, + ); + this.setCopyStatus(copied ? "Copied" : "Copy failed"); + }, + { once: true }, + ); - container.querySelector('.open-editor-btn')?.addEventListener('click', async () => { - if (!this.selectedEntry) { - return; - } - const entry = this.selectedEntry; - const query = `class ${entry.name}`; - - try { - await navigator.clipboard.writeText(query); - } catch (err) { - console.warn('[angular-render-scan] Clipboard copy failed', err); - } + container.querySelector(".open-editor-btn")?.addEventListener( + "click", + async () => { + if (!this.selectedEntry) { + return; + } + const entry = this.selectedEntry; + const query = `class ${entry.name}`; - const openInEditorUrl = this.getEditorUrl(entry); - if (openInEditorUrl) { - const w = window.open(openInEditorUrl, '_blank'); - if (w) { - setTimeout(() => w.close(), 500); + try { + await navigator.clipboard.writeText(query); + } catch (err) { + console.warn("[angular-render-scan] Clipboard copy failed", err); } - } - this.setCopyStatus('Copied class search query!'); - }, { once: true }); + const openInEditorUrl = this.getEditorUrl(entry); + if (openInEditorUrl) { + const w = window.open(openInEditorUrl, "_blank"); + if (w) { + setTimeout(() => w.close(), 500); + } + } - container.querySelector('.alerts-close-btn')?.addEventListener('click', () => { - this.showAlertsPanel = false; - this.renderToolbar(); - }, { once: true }); + this.setCopyStatus("Copied class search query!"); + }, + { once: true }, + ); - container.querySelector('.clear-alerts-btn')?.addEventListener('click', () => { - this.budgetViolations = []; - this.showAlertsPanel = false; - this.renderToolbar(); - }, { once: true }); + container.querySelector(".alerts-close-btn")?.addEventListener( + "click", + () => { + this.showAlertsPanel = false; + this.renderToolbar(); + }, + { once: true }, + ); - toolbarEl?.querySelector('.onpush-toggle')?.addEventListener('click', () => { - this.showOnPushPanel = !this.showOnPushPanel; - this.renderToolbar(); - }, { once: true }); + container.querySelector(".clear-alerts-btn")?.addEventListener( + "click", + () => { + this.budgetViolations = []; + this.showAlertsPanel = false; + this.renderToolbar(); + }, + { once: true }, + ); - toolbarEl?.querySelector('.pollution-toggle')?.addEventListener('click', () => { - this.showZonePollutionPanel = !this.showZonePollutionPanel; - this.renderToolbar(); - }, { once: true }); + toolbarEl?.querySelector(".onpush-toggle")?.addEventListener( + "click", + () => { + this.showOnPushPanel = !this.showOnPushPanel; + this.renderToolbar(); + }, + { once: true }, + ); - container.querySelector('.onpush-close-btn')?.addEventListener('click', () => { - this.showOnPushPanel = false; - this.renderToolbar(); - }, { once: true }); + toolbarEl?.querySelector(".pollution-toggle")?.addEventListener( + "click", + () => { + this.showZonePollutionPanel = !this.showZonePollutionPanel; + this.renderToolbar(); + }, + { once: true }, + ); - container.querySelector('.zone-pollution-close-btn')?.addEventListener('click', () => { - this.showZonePollutionPanel = false; - this.renderToolbar(); - }, { once: true }); + container.querySelector(".onpush-close-btn")?.addEventListener( + "click", + () => { + this.showOnPushPanel = false; + this.renderToolbar(); + }, + { once: true }, + ); - toolbarEl?.querySelector('.graph-toggle')?.addEventListener('click', () => { - this.showGraphPanel = !this.showGraphPanel; - this.graphCollapsed = false; - this.renderToolbar(); - }, { once: true }); + container.querySelector(".zone-pollution-close-btn")?.addEventListener( + "click", + () => { + this.showZonePollutionPanel = false; + this.renderToolbar(); + }, + { once: true }, + ); - container.querySelector('.graph-close-btn')?.addEventListener('click', () => { - this.showGraphPanel = false; - this.renderToolbar(); - }, { once: true }); + toolbarEl?.querySelector(".graph-toggle")?.addEventListener( + "click", + () => { + this.showGraphPanel = !this.showGraphPanel; + this.graphCollapsed = false; + this.renderToolbar(); + }, + { once: true }, + ); - container.querySelector('.graph-collapse-btn')?.addEventListener('click', () => { - this.graphCollapsed = !this.graphCollapsed; - this.renderToolbar(); - }, { once: true }); + container.querySelector(".graph-close-btn")?.addEventListener( + "click", + () => { + this.showGraphPanel = false; + this.renderToolbar(); + }, + { once: true }, + ); - container.querySelector('.graph-refresh-btn')?.addEventListener('click', () => { - this.renderToolbar(); - }, { once: true }); + container.querySelector(".graph-collapse-btn")?.addEventListener( + "click", + () => { + this.graphCollapsed = !this.graphCollapsed; + this.renderToolbar(); + }, + { once: true }, + ); + + container.querySelector(".graph-refresh-btn")?.addEventListener( + "click", + () => { + this.renderToolbar(); + }, + { once: true }, + ); } - private metric(label: string, value: string, extraClass = '', containerClass = ''): string { - const cls = containerClass ? `metric ${containerClass}` : 'metric'; + private metric( + label: string, + value: string, + extraClass = "", + containerClass = "", + ): string { + const cls = containerClass ? `metric ${containerClass}` : "metric"; const escapedValue = escapeHtml(value); return `${label}${escapedValue}`; } private getFpsClass(fps: number): string { if (!this.options.showFPS || fps === 0) { - return ''; + return ""; } - return fps < 50 ? 'fps-drop' : ''; + return fps < 50 ? "fps-drop" : ""; } private replaceToolbarHtml(toolbar: HTMLElement, html: string): boolean { @@ -1400,25 +1797,32 @@ export class AngularRenderScanOverlay { private inspectPanelHtml(): string { const entry = this.selectedEntry; if (!entry) { - return ''; + return ""; } const recentCycles = getRecording() - .filter((cycle) => cycle.entries.some((candidate) => candidate.id === entry.id)) + .filter((cycle) => + cycle.entries.some((candidate) => candidate.id === entry.id), + ) .slice(-5) - .map((cycle) => `#${cycle.id} ${cycle.entries.find((candidate) => candidate.id === entry.id)?.latestDuration.toFixed(1)}ms`); + .map( + (cycle) => + `#${cycle.id} ${cycle.entries.find((candidate) => candidate.id === entry.id)?.latestDuration.toFixed(1)}ms`, + ); const isSlow = entry.latestDuration >= this.slowThresholdMs; const severity = this.severityFor(entry); const cost = this.costFor(entry); const recommendations = this.recommendationsFor(entry); const changedInputs = entry.changedInputs?.length - ? entry.changedInputs.map((input) => { - const unstableTag = input.isReferentiallyUnstable - ? ` UNSTABLE REF` - : ''; - return `${escapeHtml(input.name)}: ${escapeHtml(input.previous)} → ${escapeHtml(input.current)}${unstableTag}`; - }).join('
') - : '-'; + ? entry.changedInputs + .map((input) => { + const unstableTag = input.isReferentiallyUnstable + ? ` UNSTABLE REF` + : ""; + return `${escapeHtml(input.name)}: ${escapeHtml(input.previous)} → ${escapeHtml(input.current)}${unstableTag}`; + }) + .join("
") + : "-"; const openInEditorUrl = this.getEditorUrl(entry); const openLinkHtml = openInEditorUrl @@ -1426,14 +1830,14 @@ export class AngularRenderScanOverlay { Open in Editor ` - : ''; + : ""; const leakWarningHtml = !entry.element?.isConnected ? `
⚠️ Memory Leak Warning: Element is disconnected from the DOM but was not destroyed!
` - : ''; + : ""; return `
@@ -1445,59 +1849,76 @@ export class AngularRenderScanOverlay {
${escapeHtml(severity.label)}
- ${isSlow ? '' : ''} + ${isSlow ? '' : ""}
- ${this.panelField('Last render', `${entry.latestDuration.toFixed(1)}ms`)} - ${this.panelField('Avg render', `${entry.averageDuration.toFixed(1)}ms`)} - ${this.panelField('Total renders', String(entry.count))} - ${this.panelField('Trigger reason', entry.reason ?? 'unknown')} - ${this.panelField('Change detection', entry.cdStrategy ?? 'unknown')} - ${this.panelField('Cycle #', String(entry.latestCycleId))} + ${this.panelField("Last render", `${entry.latestDuration.toFixed(1)}ms`)} + ${this.panelField("Avg render", `${entry.averageDuration.toFixed(1)}ms`)} + ${this.panelField("Total renders", String(entry.count))} + ${this.panelField("Trigger reason", entry.reason ?? "unknown")} + ${this.panelField("Change detection", entry.cdStrategy ?? "unknown")} + ${this.panelField("Cycle #", String(entry.latestCycleId))}
- ${entry.isOnPushCandidate ? ` + ${ + entry.isOnPushCandidate + ? `
OnPush candidate: This component has ${entry.wastedPercentage}% wasted renders and uses Default CD. Adding ChangeDetectionStrategy.OnPush could eliminate most unnecessary checks. -
` : ''} + ` + : "" + }
Skipped renders (no-ops) ${entry.wastedChecks} of ${entry.count} were skipped — ${entry.wastedPercentage}% waste
-
+
-
- DOM change type - ${escapeHtml(entry.mutationType ?? 'none')} -
Render cost estimate ${escapeHtml(cost)}
- ${entry.changedInputs?.length ? `
+ ${this.runtimeSignalsHtml(entry)} + ${ + entry.changedInputs?.length + ? `
Inputs that changed ${changedInputs} -
` : ''} - ${recentCycles.length > 0 ? `
+
` + : "" + } + ${ + recentCycles.length > 0 + ? `
Last 5 cycle durations - ${escapeHtml(recentCycles.join(' · '))} -
` : ''} - ${recommendations.length > 0 ? `
+ ${escapeHtml(recentCycles.join(" · "))} +
` + : "" + } + ${ + recommendations.length > 0 + ? `
Recommendations - ${recommendations.map((rec) => ` + ${recommendations + .map( + (rec) => `
${escapeHtml(rec.category)}

${escapeHtml(rec.action)}

- `).join('')} + `, + ) + .join("")}
-
` : ''} +
` + : "" + }
`; } @@ -1506,13 +1927,110 @@ export class AngularRenderScanOverlay { return `${escapeHtml(label)}${escapeHtml(value)}`; } + private panelFieldHtml(label: string, value: string): string { + return `${escapeHtml(label)}${value}`; + } + + private runtimeSignalsHtml(entry: AngularRenderEntry): string { + const angular = getAngularDebugSummary(entry.element); + const runtime = this.runtimeTelemetry.getSummary( + this.latestComponentWindowStart(entry), + ); + const signalFields: string[] = []; + const hierarchy = this.componentHierarchy(entry, angular); + const browserWarnings = this.browserSignalSummary(runtime); + + if (hierarchy) { + signalFields.push(this.panelFieldHtml("Hierarchy", hierarchy)); + } + if (angular.directiveNames.length > 0) { + signalFields.push( + this.panelFieldHtml( + "Directives", + escapeHtml(angular.directiveNames.slice(0, 3).join(", ")), + ), + ); + } + if (angular.listenerNames.length > 0) { + signalFields.push( + this.panelFieldHtml( + "Listeners", + escapeHtml(angular.listenerNames.slice(0, 4).join(", ")), + ), + ); + } + if (browserWarnings) { + signalFields.push(this.panelFieldHtml("Browser", browserWarnings)); + } + + if (signalFields.length === 0) { + return ""; + } + + return `
+ Runtime signals + ${signalFields.join("")} +
`; + } + + private componentHierarchy( + entry: AngularRenderEntry, + angular: ReturnType, + ): string { + const names = [ + angular.rootName, + angular.ownerName, + angular.componentName || entry.name, + ] + .filter((name): name is string => Boolean(name)) + .filter((name, index, list) => list.indexOf(name) === index); + + return names.length > 1 ? escapeHtml(names.join(" -> ")) : ""; + } + + private browserSignalSummary( + runtime: ReturnType, + ): string { + const warnings: string[] = []; + if (runtime.longTasks.count > 0) { + warnings.push(`${runtime.longTasks.maxDuration}ms long task`); + } + if (runtime.interaction && runtime.interaction.duration >= 100) { + warnings.push(`${runtime.interaction.duration}ms ${runtime.interaction.name}`); + } + if (runtime.layoutShift.score >= 0.01) { + warnings.push(`${runtime.layoutShift.score} layout shift`); + } + if (runtime.resources.slowCount > 0) { + warnings.push(`${runtime.resources.slowCount} slow resource(s)`); + } + + return warnings.length ? escapeHtml(warnings.slice(0, 3).join(" · ")) : ""; + } + + private latestComponentWindowStart(entry: AngularRenderEntry): number { + const matchingCycle = getRecording() + .slice() + .reverse() + .find((cycle) => + cycle.entries.some((candidate) => candidate.id === entry.id), + ); + + return matchingCycle?.startedAt ?? Math.max(0, performance.now() - 2000); + } + private cpuDetailsHtml(): string { if (!this.showCpuDetails) { - return ''; + return ""; } const details = this.cpu.getDetails(); - const fillClass = details.percentage > 50 ? 'high' : details.percentage > 20 ? 'medium' : 'low'; - + const fillClass = + details.percentage > 50 + ? "high" + : details.percentage > 20 + ? "medium" + : "low"; + return `
@@ -1547,7 +2065,7 @@ export class AngularRenderScanOverlay { window.clearTimeout(this.copyStatusTimer); this.renderToolbar(); this.copyStatusTimer = window.setTimeout(() => { - this.copyStatus = ''; + this.copyStatus = ""; this.renderToolbar(); }, 1800); } @@ -1556,101 +2074,107 @@ export class AngularRenderScanOverlay { return target instanceof Node && this.host.contains(target); } - private severityFor(entry: AngularRenderEntry): { kind: 'slow' | 'medium' | 'fast'; label: string } { + private severityFor(entry: AngularRenderEntry): { + kind: "slow" | "medium" | "fast"; + label: string; + } { if (entry.element && !entry.element.isConnected) { - return { kind: 'slow', label: 'Memory Leak' }; + return { kind: "slow", label: "Memory Leak" }; } const maxDuration = Math.max(entry.latestDuration, entry.averageDuration); if (maxDuration >= this.slowThresholdMs) { - return { kind: 'slow', label: 'Slow issue' }; + return { kind: "slow", label: "Slow issue" }; } if (maxDuration > this.fastThresholdMs) { - return { kind: 'medium', label: 'Watch' }; + return { kind: "medium", label: "Watch" }; } - return { kind: 'fast', label: 'Healthy' }; + return { kind: "fast", label: "Healthy" }; } private costFor(entry: AngularRenderEntry): string { const cycleDuration = this.latestCycle?.duration ?? 0; - const cycleShare = cycleDuration > 0 ? Math.round((entry.latestDuration / cycleDuration) * 100) : 0; + const cycleShare = + cycleDuration > 0 + ? Math.round((entry.latestDuration / cycleDuration) * 100) + : 0; const totalCost = entry.averageDuration * entry.count; return `${entry.latestDuration.toFixed(1)}ms latest, ${cycleShare}% of latest cycle, about ${totalCost.toFixed(1)}ms observed across ${entry.count} renders`; } - private recommendationsFor(entry: AngularRenderEntry): Array<{ category: string; action: string; severity: 'slow' | 'medium' | 'fast' }> { - const recommendations: Array<{ category: string; action: string; severity: 'slow' | 'medium' | 'fast' }> = []; - + private recommendationsFor( + entry: AngularRenderEntry, + ): Array<{ + category: string; + action: string; + severity: "slow" | "medium" | "fast"; + }> { + const recommendations: Array<{ + category: string; + action: string; + severity: "slow" | "medium" | "fast"; + }> = []; + if (entry.element && !entry.element.isConnected) { recommendations.push({ - category: 'Memory Leak', + 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' + severity: "slow", }); } const maxDuration = Math.max(entry.latestDuration, entry.averageDuration); if (maxDuration >= this.slowThresholdMs) { recommendations.push({ - category: 'Threshold Spike', + category: "Threshold Spike", action: `Exceeded the slow threshold (max: ${maxDuration.toFixed(1)}ms). Audit template calculations, expensive computed values, or blocking synchronous logic in this component.`, - severity: 'slow' + severity: "slow", }); } - if (entry.reason === 'input' || entry.changedInputs?.length) { - const inputNames = entry.changedInputs?.map((input) => input.name).join(', ') || 'unspecified inputs'; + if (entry.reason === "input" || entry.changedInputs?.length) { + const inputNames = + entry.changedInputs?.map((input) => input.name).join(", ") || + "unspecified inputs"; recommendations.push({ - category: 'Unstable Inputs', + category: "Unstable Inputs", action: `Re-rendered due to input changes: [${inputNames}]. Check if parent passes new object/array/function references during change detection; use stable signals or memoization.`, - severity: 'medium' + severity: "medium", }); } if (entry.count > 5) { recommendations.push({ - category: 'Render Fatigue', + category: "Render Fatigue", action: `Checked ${entry.count} times. Audit local subscriptions, interval timers, or event bindings triggering frequent CD ticks.`, - severity: 'medium' - }); - } - const isRepeated = entry.selector?.includes('item') || entry.selector?.includes('card') || - entry.name.toLowerCase().includes('item') || entry.name.toLowerCase().includes('card'); - if (isRepeated) { - recommendations.push({ - category: 'Repeated Node', - action: `Looks like an iterated list node. Verify track expressions in @for blocks and avoid editing unchanged items.`, - severity: 'fast' + severity: "medium", }); } // OnPush candidate recommendation if (entry.isOnPushCandidate) { recommendations.push({ - category: 'OnPush Candidate', + category: "OnPush Candidate", action: `${entry.wastedPercentage}% of this component's renders are no-ops. Add ChangeDetectionStrategy.OnPush to prevent unnecessary checks triggered by parent CD cycles.`, - severity: 'medium' + severity: "medium", }); } // Referential instability recommendation - const unstableInputs = entry.changedInputs?.filter(i => i.isReferentiallyUnstable) ?? []; + const unstableInputs = + entry.changedInputs?.filter((i) => i.isReferentiallyUnstable) ?? []; if (unstableInputs.length > 0) { recommendations.push({ - category: 'Referential Instability', - action: `Input(s) [${unstableInputs.map(i => i.name).join(', ')}] received new object references with the same value. Use stable factories, pure pipes, or signals to avoid reference churn that bypasses OnPush.`, - severity: 'medium' + category: "Referential Instability", + action: `Input(s) [${unstableInputs.map((i) => i.name).join(", ")}] received new object references with the same value. Use stable factories, pure pipes, or signals to avoid reference churn that bypasses OnPush.`, + severity: "medium", }); } - if (recommendations.length === 0) { - recommendations.push({ - category: 'Optimal Performance', - action: `Component is currently healthy. Verify that it is not checked on unrelated parent events by enforcing ChangeDetectionStrategy.OnPush.`, - severity: 'fast' - }); - } return recommendations; } - private async copyComponentPrompt(entry: AngularRenderEntry, fps?: number): Promise { - if (typeof navigator === 'undefined' || !navigator.clipboard?.writeText) { + private async copyComponentPrompt( + entry: AngularRenderEntry, + fps?: number, + ): Promise { + if (typeof navigator === "undefined" || !navigator.clipboard?.writeText) { return false; } @@ -1664,76 +2188,142 @@ export class AngularRenderScanOverlay { private componentPrompt(entry: AngularRenderEntry, fps?: number): string { const recentCycles = getRecording() - .filter((cycle) => cycle.entries.some((candidate) => candidate.id === entry.id)) + .filter((cycle) => + cycle.entries.some((candidate) => candidate.id === entry.id), + ) .slice(-8) .map((cycle) => { - const match = cycle.entries.find((candidate) => candidate.id === entry.id); + const match = cycle.entries.find( + (candidate) => candidate.id === entry.id, + ); return `- **Cycle #${cycle.id}**: Component rendered in \`${match?.latestDuration.toFixed(1)}ms\`, total cycle time \`${cycle.duration.toFixed(1)}ms\`, total rendered components: \`${cycle.renderedCount}\``; }); const changedInputs = entry.changedInputs?.length - ? entry.changedInputs.map((input) => `- \`${input.name}\`: \`${input.previous}\` -> \`${input.current}\``).join('\n') - : '- none captured'; + ? entry.changedInputs + .map( + (input) => + `- \`${input.name}\`: \`${input.previous}\` -> \`${input.current}\``, + ) + .join("\n") + : "- none captured"; + const runtimeSignals = this.runtimeSignalPromptLines(entry); return [ - '# ⚡️ Component Performance Optimization Request (via angular-render-scan)', - 'I need help fixing one slow/error Angular component found by angular-render-scan. This prompt is scoped to only this component and its local evidence.', - '', - '---', - '', - '## 📊 Telemetry Diagnostics', - 'Below is the diagnostic telemetry data captured for this component:', + "# ⚡️ Component Performance Optimization Request (via angular-render-scan)", + "I need help fixing one slow/error Angular component found by angular-render-scan. This prompt is scoped to only this component and its local evidence.", + "", + "---", + "", + "## 📊 Telemetry Diagnostics", + "Below is the diagnostic telemetry data captured for this component:", `* **Component Class:** \`${entry.name}\``, - `* **Selector:** \`${entry.selector ?? '-'}\``, + `* **Selector:** \`${entry.selector ?? "-"}\``, `* **Performance Severity:** **${this.severityFor(entry).label}**`, - `* **Trigger / Reason for Render:** \`${entry.reason ?? 'unknown'}\``, + `* **Trigger / Reason for Render:** \`${entry.reason ?? "unknown"}\``, `* **Latest render duration:** \`${entry.latestDuration.toFixed(1)}ms\``, `* **Average render duration:** \`${entry.averageDuration.toFixed(1)}ms\``, `* **Total captured renders:** ${entry.count}`, `* **Configured Thresholds:** Fast <= \`${this.fastThresholdMs.toFixed(1)}ms\` | Slow >= \`${this.slowThresholdMs.toFixed(1)}ms\``, `* **Estimated cost:** ${this.costFor(entry)}`, - typeof fps === 'number' && Number.isFinite(fps) ? `* **FPS during performance spike:** \`${fps} FPS\`` : '', - '', - '---', - '', - '## 📈 Input Mutations & Changed Properties', - 'The scanner detected the following property/input changes triggering change detection:', - 'Changed inputs:', + typeof fps === "number" && Number.isFinite(fps) + ? `* **FPS during performance spike:** \`${fps} FPS\`` + : "", + "", + "---", + "", + "## 📈 Input Mutations & Changed Properties", + "The scanner detected the following property/input changes triggering change detection:", + "Changed inputs:", changedInputs, - '', - 'Recent cycles for this component:', - ...(recentCycles.length > 0 ? recentCycles : ['- none captured']), - '', - '---', - '', - '## 🧠 Component-local recommendations from the scanner:', - 'The scanner automatically analyzed this component and surfaced the following optimization recommendations:', - ...this.recommendationsFor(entry).map((rec) => `- **[${rec.category}]** ${rec.action}`), - '', - '---', - '', - '## 🛠️ Requested Refactoring Instructions', - 'You are a senior Angular performance engineer. Please suggest concrete optimization and refactoring steps for this component. Your goal is to drastically reduce its rendering cost and avoid redundant change detection cycles.', - 'Focus on the following modern Angular practices:', - '1. **OnPush Change Detection Strategy:** Implement OnPush change detection to stop automatic parent-to-child render propagation.', - '2. **Angular Signals Migration:** Convert class inputs (`@Input`), output emitters (`@Output`), and component states to reactive signals and derived `computed()` selectors.', - '3. **Optimizing Templates:** Ensure templates do not execute expensive helper methods or getters by moving them to computed signals or component lifecycle caching.', - '4. **Stable Object/Array References:** Avoid instantiating array or object literals inside templates or parent component templates that feed into this component\'s inputs.', - '5. **Proper List Tracking:** Leverage optimized track expressions in `@for` control flow blocks.', - '', - 'Please return highly descriptive explanations along with complete TypeScript and HTML code blocks illustrating the **Before (Current)** and **After (Optimized)** states of the component. Make all refactored code clean, robust, and ready for production!' - ].filter(Boolean).join('\n'); + "", + "Recent cycles for this component:", + ...(recentCycles.length > 0 ? recentCycles : ["- none captured"]), + "", + ...(runtimeSignals.length > 0 + ? [ + "---", + "", + "## Runtime signals near this render", + ...runtimeSignals, + ] + : []), + "", + "---", + "", + "## 🧠 Component-local recommendations from the scanner:", + "The scanner automatically analyzed this component and surfaced the following optimization recommendations:", + ...this.recommendationsFor(entry).map( + (rec) => `- **[${rec.category}]** ${rec.action}`, + ), + "", + "---", + "", + "## 🛠️ Requested Refactoring Instructions", + "You are a senior Angular performance engineer. Please suggest concrete optimization and refactoring steps for this component. Your goal is to drastically reduce its rendering cost and avoid redundant change detection cycles.", + "Focus on the following modern Angular practices:", + "1. **OnPush Change Detection Strategy:** Implement OnPush change detection to stop automatic parent-to-child render propagation.", + "2. **Angular Signals Migration:** Convert class inputs (`@Input`), output emitters (`@Output`), and component states to reactive signals and derived `computed()` selectors.", + "3. **Optimizing Templates:** Ensure templates do not execute expensive helper methods or getters by moving them to computed signals or component lifecycle caching.", + "4. **Stable Object/Array References:** Avoid instantiating array or object literals inside templates or parent component templates that feed into this component's inputs.", + "5. **Proper List Tracking:** Leverage optimized track expressions in `@for` control flow blocks.", + "", + "Please return highly descriptive explanations along with complete TypeScript and HTML code blocks illustrating the **Before (Current)** and **After (Optimized)** states of the component. Make all refactored code clean, robust, and ready for production!", + ] + .filter(Boolean) + .join("\n"); + } + + private runtimeSignalPromptLines(entry: AngularRenderEntry): string[] { + const angular = getAngularDebugSummary(entry.element); + const runtime = this.runtimeTelemetry.getSummary( + this.latestComponentWindowStart(entry), + ); + const lines: string[] = []; + + if (angular.listenerNames.length > 0) { + lines.push( + `- Angular listeners on element: ${angular.listenerNames.join(", ")}`, + ); + } + if (angular.directiveNames.length > 0) { + lines.push( + `- Angular directives on element: ${angular.directiveNames.join(", ")}`, + ); + } + if (runtime.longTasks.count > 0) { + lines.push( + `- Long task overlap: ${runtime.longTasks.count} task(s), max ${runtime.longTasks.maxDuration}ms, total blocking ${runtime.longTasks.totalBlockingTime}ms.`, + ); + } + if (runtime.interaction) { + lines.push( + `- Slow interaction near render: ${runtime.interaction.name} took ${runtime.interaction.duration}ms with ${runtime.interaction.inputDelay}ms input delay.`, + ); + } + if (runtime.layoutShift.count > 0) { + lines.push( + `- Layout shift near render: ${runtime.layoutShift.score} across ${runtime.layoutShift.count} shift(s).`, + ); + } + if (runtime.resources.slowCount > 0) { + lines.push( + `- Resource activity near render: ${runtime.resources.slowCount} slow request(s), max ${runtime.resources.maxDuration}ms${runtime.resources.slowestName ? ` (${runtime.resources.slowestName})` : ""}.`, + ); + } + + return lines; } private getEditorUrl(entry: AngularRenderEntry): string { - const protocol = this.options.editorProtocol || 'vscode'; + const protocol = this.options.editorProtocol || "vscode"; const query = encodeURIComponent(`class ${entry.name}`); - if (protocol === 'vscode') { + if (protocol === "vscode") { return `vscode://vscode.code-search/search?query=${query}`; } - if (protocol === 'cursor') { + if (protocol === "cursor") { return `cursor://vscode.code-search/search?query=${query}`; } - if (protocol === 'webstorm') { + if (protocol === "webstorm") { return `webstorm://search?query=${query}`; } return `${protocol}://search?query=${query}`; @@ -1742,10 +2332,18 @@ export class AngularRenderScanOverlay { private triggerBadgeHtml(trigger: CdTriggerAttribution): string { const isUserInteraction = trigger.isUserInteraction; const isPollution = trigger.isZonePollution; - const color = isPollution ? '#f59e0b' : isUserInteraction ? '#10b981' : '#94a3b8'; - const icon = isPollution ? '⚠' : isUserInteraction ? '●' : '○'; - const label = trigger.source.replace('zone:', '').replace('manual:', '').replace('signal:', 'signal').replace('router:', 'nav'); - const tooltip = `Last CD trigger: ${trigger.source}${trigger.detail ? ` (${trigger.detail})` : ''}${isPollution ? ' — Zone pollution suspected' : ''}`; + const color = isPollution + ? "#f59e0b" + : isUserInteraction + ? "#10b981" + : "#94a3b8"; + const icon = isPollution ? "⚠" : isUserInteraction ? "●" : "○"; + const label = trigger.source + .replace("zone:", "") + .replace("manual:", "") + .replace("signal:", "signal") + .replace("router:", "nav"); + const tooltip = `Last CD trigger: ${trigger.source}${trigger.detail ? ` (${trigger.detail})` : ""}${isPollution ? " — Zone pollution suspected" : ""}`; return ` CD trigger @@ -1757,12 +2355,24 @@ export class AngularRenderScanOverlay { } private onPushPanelHtml(candidates: OnPushCandidate[]): string { - if (!this.showOnPushPanel || candidates.length === 0) return ''; + if (!this.showOnPushPanel || candidates.length === 0) return ""; const panelRight = this.toolbarX; - const items = candidates.slice(0, 10).map(c => { - const confColor = c.confidence === 'high' ? '#10b981' : c.confidence === 'medium' ? '#f59e0b' : '#94a3b8'; - const confLabel = c.confidence === 'high' ? 'HIGH' : c.confidence === 'medium' ? 'MED' : 'LOW'; - return ` + const items = candidates + .slice(0, 10) + .map((c) => { + const confColor = + c.confidence === "high" + ? "#10b981" + : c.confidence === "medium" + ? "#f59e0b" + : "#94a3b8"; + const confLabel = + c.confidence === "high" + ? "HIGH" + : c.confidence === "medium" + ? "MED" + : "LOW"; + return `
@@ -1782,7 +2392,8 @@ export class AngularRenderScanOverlay {
`; - }).join(''); + }) + .join(""); return `
@@ -1801,26 +2412,40 @@ export class AngularRenderScanOverlay { } private zonePollutionPanelHtml(events: ZonePollutionEvent[]): string { - if (!this.showZonePollutionPanel || events.length === 0) return ''; - const panelRight = this.showOnPushPanel ? this.toolbarX + 298 : this.toolbarX; - const items = events.slice().reverse().slice(0, 15).map(e => { - const timeStr = new Date(e.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }); - const sourceLabel = e.source.replace('zone:', '').replace('manual:', '').replace('signal:', 'signal'); - return ` + if (!this.showZonePollutionPanel || events.length === 0) return ""; + const panelRight = this.showOnPushPanel + ? this.toolbarX + 298 + : this.toolbarX; + const items = events + .slice() + .reverse() + .slice(0, 15) + .map((e) => { + const timeStr = new Date(e.timestamp).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); + const sourceLabel = e.source + .replace("zone:", "") + .replace("manual:", "") + .replace("signal:", "signal"); + return `
${escapeHtml(sourceLabel)} ${timeStr}
- ${e.detail ? `
${escapeHtml(e.detail)}
` : ''} + ${e.detail ? `
${escapeHtml(e.detail)}
` : ""}
${e.componentCount} components - ${e.cycleDuration.toFixed(1)}ms + ${e.cycleDuration.toFixed(1)}ms
- ${e.callSite ? `
${escapeHtml(e.callSite)}
` : ''} + ${e.callSite ? `
${escapeHtml(e.callSite)}
` : ""}
`; - }).join(''); + }) + .join(""); return `
@@ -1839,7 +2464,14 @@ export class AngularRenderScanOverlay { } private addBudgetViolation(violation: BudgetViolation): void { - if (this.budgetViolations.some(v => v.componentName === violation.componentName && v.timestamp === violation.timestamp && v.type === violation.type)) { + if ( + this.budgetViolations.some( + (v) => + v.componentName === violation.componentName && + v.timestamp === violation.timestamp && + v.type === violation.type, + ) + ) { return; } this.budgetViolations.push(violation); @@ -1852,22 +2484,24 @@ export class AngularRenderScanOverlay { private updateDarkMode(): void { const mode = this.options.darkMode; let isDark = false; - if (mode === 'dark') { + if (mode === "dark") { isDark = true; - } else if (mode === 'light') { + } else if (mode === "light") { isDark = false; } else { - isDark = typeof window !== 'undefined' && window.matchMedia?.('(prefers-color-scheme: dark)').matches; + isDark = + typeof window !== "undefined" && + window.matchMedia?.("(prefers-color-scheme: dark)").matches; } if (isDark) { - this.host.classList.add('dark'); + this.host.classList.add("dark"); } else { - this.host.classList.remove('dark'); + this.host.classList.remove("dark"); } } private cdGraphPanelHtml(): string { - if (!this.showGraphPanel) return ''; + if (!this.showGraphPanel) return ""; const graph = getCdGraph(); const nodes = graph.nodes; const panelRight = this.toolbarX; @@ -1882,51 +2516,56 @@ export class AngularRenderScanOverlay { const renderNode = (node: (typeof nodes)[0], depth: number): string => { const children = childMap.get(node.id) ?? []; - const stratColor = node.cdStrategy === 'OnPush' ? '#10b981' : '#f59e0b'; - const countColor = node.wastedChecks > 0 ? '#ef4444' : '#94a3b8'; + const stratColor = node.cdStrategy === "OnPush" ? "#10b981" : "#f59e0b"; + const countColor = node.wastedChecks > 0 ? "#ef4444" : "#94a3b8"; const indent = depth * 12; - const childRows = children.map(c => renderNode(c, depth + 1)).join(''); - const edgeCount = graph.edges.find(e => e.toId === node.id)?.triggerCount ?? 0; + const childRows = children.map((c) => renderNode(c, depth + 1)).join(""); + const edgeCount = + graph.edges.find((e) => e.toId === node.id)?.triggerCount ?? 0; return `
- ${depth > 0 ? `` : ''} + ${depth > 0 ? `` : ""} ${escapeHtml(node.name)} - ${edgeCount > 0 ? `${edgeCount}×` : ''} - ${node.cdStrategy === 'OnPush' ? 'OP' : 'D'} - ${node.renderCount}r${node.wastedChecks > 0 ? ` ${node.wastedChecks}w` : ''} + ${edgeCount > 0 ? `${edgeCount}×` : ""} + ${node.cdStrategy === "OnPush" ? "OP" : "D"} + ${node.renderCount}r${node.wastedChecks > 0 ? ` ${node.wastedChecks}w` : ""}
${childRows}
`; }; - const roots = childMap.get(null) ?? nodes.filter(n => !n.parentId); - const treeHtml = this.graphCollapsed ? '' : (roots.length > 0 - ? roots.map(n => renderNode(n, 0)).join('') - : `
No component data yet — interact with the app.
`); + const roots = childMap.get(null) ?? nodes.filter((n) => !n.parentId); + const treeHtml = this.graphCollapsed + ? "" + : roots.length > 0 + ? roots.map((n) => renderNode(n, 0)).join("") + : `
No component data yet — interact with the app.
`; - const onPushCount = nodes.filter(n => n.cdStrategy === 'OnPush').length; - const defaultCount = nodes.filter(n => n.cdStrategy !== 'OnPush').length; + const onPushCount = nodes.filter((n) => n.cdStrategy === "OnPush").length; + const defaultCount = nodes.filter((n) => n.cdStrategy !== "OnPush").length; const wastedTotal = nodes.reduce((s, n) => s + n.wastedChecks, 0); return ` -
-
+
+
">
⬡ CD Render Graph - ${nodes.length > 0 ? `${nodes.length} components` : ''} + ${nodes.length > 0 ? `${nodes.length} components` : ""}
- +
- ${!this.graphCollapsed ? ` + ${ + !this.graphCollapsed + ? `
OnPush: ${onPushCount} Default: ${defaultCount} - ${wastedTotal > 0 ? `Wasted: ${wastedTotal}` : ''} + ${wastedTotal > 0 ? `Wasted: ${wastedTotal}` : ""}
${treeHtml} @@ -1935,16 +2574,20 @@ export class AngularRenderScanOverlay { OP=OnPush D=Default · r=renders w=wasted
- ` : ''} + ` + : "" + }
`; } private waterfallPanelHtml(): string { - if (!this.showWaterfallPanel || !this.latestCycle) return ''; + if (!this.showWaterfallPanel || !this.latestCycle) return ""; const cycle = this.latestCycle; const waterfall = cycle.waterfall || []; - const rightOffset = this.showCpuDetails ? (this.toolbarX + 290) : (this.toolbarX + 120); + const rightOffset = this.showCpuDetails + ? this.toolbarX + 290 + : this.toolbarX + 120; if (waterfall.length === 0) { return `
@@ -1956,16 +2599,25 @@ export class AngularRenderScanOverlay {
`; } - - const maxOffset = Math.max(...waterfall.map(w => w.startOffset + w.totalDuration), 1); - const items = waterfall.map(w => { - const leftPct = (w.startOffset / maxOffset) * 100; - const widthPct = Math.max(2, (w.totalDuration / maxOffset) * 100); - const indent = w.depth * 8; - const color = w.selfDuration >= this.slowThresholdMs ? '#ef4444' : w.selfDuration > this.fastThresholdMs ? '#f59e0b' : '#3b82f6'; - return ` + + const maxOffset = Math.max( + ...waterfall.map((w) => w.startOffset + w.totalDuration), + 1, + ); + const items = waterfall + .map((w) => { + const leftPct = (w.startOffset / maxOffset) * 100; + const widthPct = Math.max(2, (w.totalDuration / maxOffset) * 100); + const indent = w.depth * 8; + const color = + w.selfDuration >= this.slowThresholdMs + ? "#ef4444" + : w.selfDuration > this.fastThresholdMs + ? "#f59e0b" + : "#3b82f6"; + return `
- + ${escapeHtml(w.name)}
@@ -1974,8 +2626,9 @@ export class AngularRenderScanOverlay { ${w.selfDuration.toFixed(1)}ms
`; - }).join(''); - + }) + .join(""); + return `
@@ -1990,13 +2643,30 @@ export class AngularRenderScanOverlay { } private alertsPanelHtml(): string { - if (!this.showAlertsPanel || this.budgetViolations.length === 0) return ''; - - const itemsHtml = this.budgetViolations.slice().reverse().map(v => { - const typeLabel = v.type === 'error' ? 'ERROR' : v.type === 'render-rate' ? 'RATE' : 'WARN'; - const typeColor = v.type === 'error' ? '#ef4444' : v.type === 'render-rate' ? '#3b82f6' : '#f59e0b'; - const timeStr = new Date(v.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }); - return ` + if (!this.showAlertsPanel || this.budgetViolations.length === 0) return ""; + + const itemsHtml = this.budgetViolations + .slice() + .reverse() + .map((v) => { + const typeLabel = + v.type === "error" + ? "ERROR" + : v.type === "render-rate" + ? "RATE" + : "WARN"; + const typeColor = + v.type === "error" + ? "#ef4444" + : v.type === "render-rate" + ? "#3b82f6" + : "#f59e0b"; + const timeStr = new Date(v.timestamp).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); + return `
@@ -2008,7 +2678,8 @@ export class AngularRenderScanOverlay {
${escapeHtml(v.message)}
`; - }).join(''); + }) + .join(""); let alertsRight = this.toolbarX + 120; if (this.showCpuDetails) { @@ -2035,7 +2706,6 @@ export class AngularRenderScanOverlay {
`; } - } function rgba(color: readonly [number, number, number], alpha: number): string { @@ -2048,20 +2718,29 @@ function area(rect: DOMRect): number { function containsRect(outer: DOMRect, inner: DOMRect): boolean { const tolerance = 1; - return inner.left >= outer.left - tolerance - && inner.top >= outer.top - tolerance - && inner.right <= outer.right + tolerance - && inner.bottom <= outer.bottom + tolerance; + return ( + inner.left >= outer.left - tolerance && + inner.top >= outer.top - tolerance && + inner.right <= outer.right + tolerance && + inner.bottom <= outer.bottom + tolerance + ); } -function truncateText(context: CanvasRenderingContext2D, text: string, maxWidth: number): string { +function truncateText( + context: CanvasRenderingContext2D, + text: string, + maxWidth: number, +): string { if (context.measureText(text).width <= maxWidth) { return text; } - const ellipsis = '...'; + const ellipsis = "..."; let next = text; - while (next.length > 0 && context.measureText(`${next}${ellipsis}`).width > maxWidth) { + while ( + next.length > 0 && + context.measureText(`${next}${ellipsis}`).width > maxWidth + ) { next = next.slice(0, -1); } @@ -2070,9 +2749,9 @@ function truncateText(context: CanvasRenderingContext2D, text: string, maxWidth: function escapeHtml(value: string): string { return value - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); } diff --git a/packages/angular-render-scan/src/infrastructure/ui/runtime-telemetry.spec.ts b/packages/angular-render-scan/src/infrastructure/ui/runtime-telemetry.spec.ts new file mode 100644 index 0000000..5fc3bc2 --- /dev/null +++ b/packages/angular-render-scan/src/infrastructure/ui/runtime-telemetry.spec.ts @@ -0,0 +1,96 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { RuntimeTelemetry } from "./runtime-telemetry"; + +type ObserverCallback = (list: { getEntries: () => PerformanceEntry[] }) => void; + +describe("RuntimeTelemetry", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("falls back when PerformanceObserver is unavailable", () => { + vi.stubGlobal("PerformanceObserver", undefined); + + const telemetry = new RuntimeTelemetry(); + + expect(telemetry.getSummary(0)).toMatchObject({ + longTasks: { count: 0, maxDuration: 0, totalBlockingTime: 0 }, + layoutShift: { count: 0, score: 0 }, + resources: { slowCount: 0, repeatedCount: 0, maxDuration: 0 }, + }); + }); + + it("summarizes buffered runtime entries", () => { + const callbacks: Record = {}; + class MockPerformanceObserver { + static supportedEntryTypes = ["longtask", "event", "layout-shift", "resource"]; + + constructor(private readonly callback: ObserverCallback) {} + + observe(options: { type?: string }) { + callbacks[options.type ?? ""] = this.callback; + } + + disconnect() {} + } + vi.stubGlobal("PerformanceObserver", MockPerformanceObserver); + + const telemetry = new RuntimeTelemetry(); + callbacks.longtask({ + getEntries: () => [ + entry({ name: "self", startTime: 100, duration: 120 }), + ], + }); + callbacks.event({ + getEntries: () => [ + entry({ + name: "click", + startTime: 130, + duration: 88, + processingStart: 150, + }), + ], + }); + callbacks["layout-shift"]({ + getEntries: () => [ + entry({ name: "", startTime: 150, duration: 0, value: 0.04 }), + ], + }); + callbacks.resource({ + getEntries: () => [ + entry({ + name: "https://example.test/assets/main.js", + startTime: 160, + duration: 300, + }), + entry({ + name: "https://example.test/assets/main.js", + startTime: 180, + duration: 40, + }), + ], + }); + + expect(telemetry.getSummary(90, 220)).toEqual({ + longTasks: { count: 1, maxDuration: 120, totalBlockingTime: 70 }, + interaction: { name: "click", duration: 88, inputDelay: 20 }, + layoutShift: { count: 1, score: 0.04 }, + resources: { + slowCount: 1, + repeatedCount: 1, + maxDuration: 300, + slowestName: "main.js", + }, + }); + }); +}); + +function entry(values: { + name: string; + startTime: number; + duration: number; + value?: number; + processingStart?: number; +}): PerformanceEntry { + return values as PerformanceEntry; +} diff --git a/packages/angular-render-scan/src/infrastructure/ui/runtime-telemetry.ts b/packages/angular-render-scan/src/infrastructure/ui/runtime-telemetry.ts new file mode 100644 index 0000000..0be2955 --- /dev/null +++ b/packages/angular-render-scan/src/infrastructure/ui/runtime-telemetry.ts @@ -0,0 +1,190 @@ +export interface RuntimeTelemetrySummary { + longTasks: { + count: number; + maxDuration: number; + totalBlockingTime: number; + }; + interaction?: { + name: string; + duration: number; + inputDelay: number; + }; + layoutShift: { + count: number; + score: number; + }; + resources: { + slowCount: number; + repeatedCount: number; + maxDuration: number; + slowestName?: string; + }; +} + +type StoredEntry = { + name: string; + startTime: number; + duration: number; + value?: number; + hadRecentInput?: boolean; + processingStart?: number; +}; + +const BUFFER_LIMIT = 120; +const SLOW_RESOURCE_MS = 250; + +export class RuntimeTelemetry { + private readonly observers: PerformanceObserver[] = []; + private readonly longTasks: StoredEntry[] = []; + private readonly interactions: StoredEntry[] = []; + private readonly layoutShifts: StoredEntry[] = []; + private readonly resources: StoredEntry[] = []; + + constructor(private readonly onChange?: () => void) { + this.observe("longtask", this.longTasks); + this.observe("event", this.interactions); + this.observe("layout-shift", this.layoutShifts); + this.observe("resource", this.resources); + } + + getSummary(startTime: number, endTime = performance.now()): RuntimeTelemetrySummary { + const longTasks = this.inWindow(this.longTasks, startTime, endTime); + const interactions = this.inWindow(this.interactions, startTime, endTime); + const shifts = this.inWindow(this.layoutShifts, startTime, endTime).filter( + (entry) => !entry.hadRecentInput, + ); + const resources = this.inWindow(this.resources, startTime, endTime); + const slowResources = resources.filter( + (entry) => entry.duration >= SLOW_RESOURCE_MS, + ); + const resourceCounts = new Map(); + for (const entry of resources) { + resourceCounts.set(entry.name, (resourceCounts.get(entry.name) ?? 0) + 1); + } + + const latestInteraction = interactions + .slice() + .sort((a, b) => b.startTime - a.startTime)[0]; + const slowestResource = slowResources + .slice() + .sort((a, b) => b.duration - a.duration)[0]; + + return { + longTasks: { + count: longTasks.length, + maxDuration: Math.round(max(longTasks.map((entry) => entry.duration))), + totalBlockingTime: Math.round( + longTasks.reduce( + (sum, entry) => sum + Math.max(0, entry.duration - 50), + 0, + ), + ), + }, + interaction: latestInteraction + ? { + name: latestInteraction.name, + duration: Math.round(latestInteraction.duration), + inputDelay: Math.round( + Math.max( + 0, + (latestInteraction.processingStart ?? latestInteraction.startTime) - + latestInteraction.startTime, + ), + ), + } + : undefined, + layoutShift: { + count: shifts.length, + score: Number( + shifts + .reduce((sum, entry) => sum + (entry.value ?? 0), 0) + .toFixed(4), + ), + }, + resources: { + slowCount: slowResources.length, + repeatedCount: [...resourceCounts.values()].filter((count) => count > 1) + .length, + maxDuration: Math.round(max(slowResources.map((entry) => entry.duration))), + slowestName: slowestResource ? shortResourceName(slowestResource.name) : undefined, + }, + }; + } + + destroy(): void { + for (const observer of this.observers) { + observer.disconnect(); + } + this.observers.length = 0; + } + + private observe(type: string, target: StoredEntry[]): void { + if ( + typeof PerformanceObserver === "undefined" || + !PerformanceObserver.supportedEntryTypes?.includes(type) + ) { + return; + } + + try { + const observer = new PerformanceObserver((list) => { + for (const entry of list.getEntries()) { + target.push(toStoredEntry(entry)); + } + trim(target); + this.onChange?.(); + }); + observer.observe({ type, buffered: true }); + this.observers.push(observer); + } catch { + // Unsupported observer options or browser-specific failures should not break the overlay. + } + } + + private inWindow( + entries: StoredEntry[], + startTime: number, + endTime: number, + ): StoredEntry[] { + return entries.filter((entry) => { + const entryEnd = entry.startTime + entry.duration; + return entry.startTime <= endTime && entryEnd >= startTime; + }); + } +} + +function toStoredEntry(entry: PerformanceEntry): StoredEntry { + const candidate = entry as PerformanceEntry & { + value?: number; + hadRecentInput?: boolean; + processingStart?: number; + }; + + return { + name: entry.name, + startTime: entry.startTime, + duration: entry.duration, + value: candidate.value, + hadRecentInput: candidate.hadRecentInput, + processingStart: candidate.processingStart, + }; +} + +function trim(entries: StoredEntry[]): void { + if (entries.length > BUFFER_LIMIT) { + entries.splice(0, entries.length - BUFFER_LIMIT); + } +} + +function max(values: number[]): number { + return values.length ? Math.max(...values) : 0; +} + +function shortResourceName(name: string): string { + try { + const url = new URL(name, window.location.href); + return url.pathname.split("/").filter(Boolean).pop() || url.hostname; + } catch { + return name.split("/").filter(Boolean).pop() || name; + } +} From cabc8cad81040bd162f0e43b5197a37afbdf1077 Mon Sep 17 00:00:00 2001 From: Edison Augusthy Date: Sun, 7 Jun 2026 16:57:43 +0200 Subject: [PATCH 2/2] feat: added more features --- README.md | 8 ++ packages/angular-render-scan/README.md | 8 ++ .../src/application/runtime.ts | 7 +- .../src/domain/options.spec.ts | 18 +++ .../angular-render-scan/src/domain/options.ts | 20 ++- .../src/infrastructure/ui/overlay.ts | 135 ++++++++++++++++-- 6 files changed, 174 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 7945426..b51a0a8 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,14 @@ Angular Render Scan is a visual debugging overlay for Angular change detection. [Live Demo](https://edisonaugusthy.github.io/angular-render-scan/) | [npm](https://www.npmjs.com/package/angular-render-scan) +## Versions + +| Package | Version | +|---|---| +| Angular | `^22.0.0` | +| `angular-render-scan` | `0.1.8` | +| `angular-render-scan-cli` | `0.1.5` | + ## What it shows - Component render outlines and heatmap colors. diff --git a/packages/angular-render-scan/README.md b/packages/angular-render-scan/README.md index c6ca43b..92428f3 100644 --- a/packages/angular-render-scan/README.md +++ b/packages/angular-render-scan/README.md @@ -6,6 +6,14 @@ Angular Render Scan is a visual debugging overlay for Angular change detection. [Live Demo](https://edisonaugusthy.github.io/angular-render-scan/) | [npm](https://www.npmjs.com/package/angular-render-scan) +## Versions + +| Package | Version | +|---|---| +| Angular | `^22.0.0` | +| `angular-render-scan` | `0.1.8` | +| `angular-render-scan-cli` | `0.1.5` | + ## What it shows - Component render outlines and heatmap colors. diff --git a/packages/angular-render-scan/src/application/runtime.ts b/packages/angular-render-scan/src/application/runtime.ts index 912f8f8..75cc7e0 100644 --- a/packages/angular-render-scan/src/application/runtime.ts +++ b/packages/angular-render-scan/src/application/runtime.ts @@ -1,5 +1,5 @@ import { AngularRenderScanOverlay } from '../infrastructure/ui/overlay'; -import { getResolvedOptions, resolveOptions, setResolvedOptions } from '../domain/options'; +import { getResolvedOptions, setResolvedOptions } from '../domain/options'; import { finishCycle, resetStats, @@ -58,7 +58,10 @@ export function setTaskScheduler(scheduler: (fn: () => void) => void) { } export function scan(options?: AngularRenderScanOptions): void { - const resolved = setResolvedOptions(resolveOptions(options)); + const resolved = setResolvedOptions(options ?? {}, { + persistEnabled: false, + preferStoredEnabled: true + }); if (!overlay && typeof document !== 'undefined') { overlay = new AngularRenderScanOverlay(resolved, (enabled) => setOptions({ enabled })); } diff --git a/packages/angular-render-scan/src/domain/options.spec.ts b/packages/angular-render-scan/src/domain/options.spec.ts index 490c20e..0afd478 100644 --- a/packages/angular-render-scan/src/domain/options.spec.ts +++ b/packages/angular-render-scan/src/domain/options.spec.ts @@ -97,4 +97,22 @@ describe('options', () => { expect(getResolvedOptions().enabled).toBe(false); }); + + it('can prefer saved enabled state during startup', () => { + const storage = new Map(); + vi.stubGlobal('localStorage', { + getItem: (key: string) => storage.get(key) ?? null, + setItem: (key: string, value: string) => storage.set(key, value), + removeItem: (key: string) => storage.delete(key), + }); + + globalThis.localStorage.setItem('angular-render-scan:enabled', 'false'); + setResolvedOptions({ enabled: true }, { + persistEnabled: false, + preferStoredEnabled: true, + }); + + expect(getResolvedOptions().enabled).toBe(false); + expect(globalThis.localStorage.getItem('angular-render-scan:enabled')).toBe('false'); + }); }); diff --git a/packages/angular-render-scan/src/domain/options.ts b/packages/angular-render-scan/src/domain/options.ts index d51394b..4bd4518 100644 --- a/packages/angular-render-scan/src/domain/options.ts +++ b/packages/angular-render-scan/src/domain/options.ts @@ -45,11 +45,21 @@ const defaultOptions: AngularRenderScanResolvedOptions = { let options: AngularRenderScanResolvedOptions = { ...defaultOptions }; -export function resolveOptions(next?: AngularRenderScanOptions): AngularRenderScanResolvedOptions { +interface ResolveOptionsConfig { + preferStoredEnabled?: boolean; +} + +interface SetResolvedOptionsConfig extends ResolveOptionsConfig { + persistEnabled?: boolean; +} + +export function resolveOptions(next?: AngularRenderScanOptions, config: ResolveOptionsConfig = {}): AngularRenderScanResolvedOptions { const merged = { ...options, ...next } as AngularRenderScanResolvedOptions; const storedEnabled = readStoredEnabled(); - merged.enabled = typeof next?.enabled === 'boolean' + merged.enabled = config.preferStoredEnabled + ? storedEnabled ?? (typeof next?.enabled === 'boolean' ? next.enabled : merged.enabled) + : typeof next?.enabled === 'boolean' ? next.enabled : storedEnabled ?? merged.enabled; @@ -79,9 +89,9 @@ export function resolveOptions(next?: AngularRenderScanOptions): AngularRenderSc return merged; } -export function setResolvedOptions(next: Partial): AngularRenderScanResolvedOptions { - options = resolveOptions(next); - if (typeof next.enabled === 'boolean') { +export function setResolvedOptions(next: Partial, config: SetResolvedOptionsConfig = {}): AngularRenderScanResolvedOptions { + options = resolveOptions(next, config); + if (config.persistEnabled !== false && typeof next.enabled === 'boolean') { writeStoredEnabled(options.enabled); } return options; diff --git a/packages/angular-render-scan/src/infrastructure/ui/overlay.ts b/packages/angular-render-scan/src/infrastructure/ui/overlay.ts index a5bc004..e7f914c 100644 --- a/packages/angular-render-scan/src/infrastructure/ui/overlay.ts +++ b/packages/angular-render-scan/src/infrastructure/ui/overlay.ts @@ -30,6 +30,8 @@ interface ActiveHighlight { rect: DOMRect; } +const TOOLBAR_POSITION_KEY = "angular-render-scan:toolbar-position"; + const TOOLBAR_CSS = ` :host { all: initial; @@ -104,6 +106,14 @@ const TOOLBAR_CSS = ` .toolbar:active { cursor: grabbing; } + .toolbar.disabled { + gap: 0; + padding: 5px; + border-radius: 999px; + box-shadow: + 0 8px 20px rgba(15, 23, 42, 0.12), + inset 0 1px 0 rgba(255,255,255,0.7); + } .toolbar-switch { display: inline-flex; align-items: center; @@ -117,6 +127,12 @@ const TOOLBAR_CSS = ` background: rgba(255, 255, 255, 0.06); box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08); } + .toolbar.disabled .toolbar-switch { + padding: 5px; + border-radius: 999px; + background: transparent; + box-shadow: none; + } .toolbar-main { display: flex; align-items: stretch; @@ -452,13 +468,19 @@ const TOOLBAR_CSS = ` .toolbar-actions .details-toggle { display: inline-grid; place-items: center; - width: 32px; - height: 32px; - min-width: 32px; + width: 30px; + height: 30px; + min-width: 30px; padding: 0; - font-size: 14px; + font-size: 13px; line-height: 1; } + .toolbar-actions svg { + width: 14px; + height: 14px; + display: block; + stroke: currentColor; + } .toolbar-actions .details-toggle input { position: absolute; opacity: 0; @@ -741,6 +763,7 @@ export class AngularRenderScanOverlay { private readonly onToggle: (enabled: boolean) => void, ) { this.options = options; + this.restoreToolbarPosition(); const recorded = getRecording(); if (recorded && recorded.length > 0) { this.latestCycle = recorded[recorded.length - 1]; @@ -968,6 +991,9 @@ export class AngularRenderScanOverlay { }; const handleDragEnd = () => { + if (this.isDragging) { + this.saveToolbarPosition(); + } this.isDragging = false; }; @@ -1059,8 +1085,63 @@ export class AngularRenderScanOverlay { this.canvas.style.width = `${window.innerWidth}px`; this.canvas.style.height = `${window.innerHeight}px`; this.context?.setTransform(ratio, 0, 0, ratio, 0, 0); + this.clampToolbarPosition(); }; + private restoreToolbarPosition(): void { + try { + const raw = globalThis.localStorage?.getItem(TOOLBAR_POSITION_KEY); + if (!raw) { + return; + } + + const parsed = JSON.parse(raw) as { right?: unknown; bottom?: unknown }; + if ( + typeof parsed.right === "number" && + Number.isFinite(parsed.right) && + typeof parsed.bottom === "number" && + Number.isFinite(parsed.bottom) + ) { + this.toolbarX = parsed.right; + this.toolbarY = parsed.bottom; + } + } catch { + // Persisted position is a convenience; invalid storage should not break rendering. + } + } + + private saveToolbarPosition(): void { + try { + globalThis.localStorage?.setItem( + TOOLBAR_POSITION_KEY, + JSON.stringify({ right: this.toolbarX, bottom: this.toolbarY }), + ); + } catch { + // Ignore storage failures in restricted host apps. + } + } + + private clampToolbarPosition(): void { + const toolbar = this.shadow.querySelector(".toolbar") as HTMLElement | null; + const rect = toolbar?.getBoundingClientRect(); + const width = rect?.width ?? 0; + const height = rect?.height ?? 0; + + this.toolbarX = Math.max( + 16, + Math.min(this.toolbarX, Math.max(16, window.innerWidth - width - 16)), + ); + this.toolbarY = Math.max( + 16, + Math.min(this.toolbarY, Math.max(16, window.innerHeight - height - 16)), + ); + + if (toolbar) { + toolbar.style.right = `${this.toolbarX}px`; + toolbar.style.bottom = `${this.toolbarY}px`; + } + } + private readonly loop = (): void => { this.fps.mark(); this.cpu.markFrame(); @@ -1270,6 +1351,20 @@ export class AngularRenderScanOverlay { document.body.style.cursor = active ? "pointer" : ""; } + private closeInteractivePanels(): void { + this.selectedEntry = undefined; + this.hoveredEntry = undefined; + this.hoveredRect = undefined; + this.detailsMode = false; + this.showCpuDetails = false; + this.showWaterfallPanel = false; + this.showAlertsPanel = false; + this.showOnPushPanel = false; + this.showZonePollutionPanel = false; + this.showGraphPanel = false; + this.setDetailsHoverCursor(false); + } + private drawDetailsAffordance( rect: DOMRect, entry: AngularRenderEntry, @@ -1442,14 +1537,14 @@ export class AngularRenderScanOverlay { const htmlChanged = this.replaceToolbarHtml( container, ` - ${this.inspectPanelHtml()} - ${this.cpuDetailsHtml()} - ${this.waterfallPanelHtml()} - ${this.alertsPanelHtml()} - ${this.onPushPanelHtml(onPushCandidates)} - ${this.zonePollutionPanelHtml(pollutionEvents)} - ${this.cdGraphPanelHtml()} -
+ ${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.metric("FPS", this.options.showFPS ? String(displayedFps) + " fps" : "-", this.getFpsClass(displayedFps))} @@ -1482,9 +1580,12 @@ export class AngularRenderScanOverlay { ${this.options.showCopyPrompt ? '' : ""} - - + + + ` + : "" + } ${this.copyStatus ? `${escapeHtml(this.copyStatus)}` : ""}
`, @@ -1499,7 +1600,11 @@ export class AngularRenderScanOverlay { toolbarEl?.querySelector("input")?.addEventListener( "change", (event) => { - this.onToggle((event.target as HTMLInputElement).checked); + const enabled = (event.target as HTMLInputElement).checked; + if (!enabled) { + this.closeInteractivePanels(); + } + this.onToggle(enabled); }, { once: true }, );