diff --git a/.github/workflows/pr-validation.yml b/.github/workflows/pr-validation.yml new file mode 100644 index 0000000..f9ffe7e --- /dev/null +++ b/.github/workflows/pr-validation.yml @@ -0,0 +1,36 @@ +name: PR Validation + +on: + pull_request: + branches: + - main + +jobs: + validate: + name: Lint, Test, and E2E + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: "npm" + + - name: Install dependencies + run: npm ci + + - name: Run Build + run: npm run build + + - name: Run Unit Tests + run: npm run test + + - name: Install Playwright Browsers + run: npx playwright install --with-deps + + - name: Run End-to-End Tests + run: npm run test:e2e diff --git a/README.md b/README.md index d774f97..5eb39c5 100644 --- a/README.md +++ b/README.md @@ -2,23 +2,21 @@ Angular Render Scan is a visual debugging overlay for Angular change detection. It is inspired by the React Scan experience: install it, run your app, interact with the UI, and see which Angular components are updating, how often they update, and how long they take. -![Angular Render Scan demo](docs/assets/angular-render-scan-demo.png) +![Angular Render Scan in Action](docs/assets/angular-render-scan-demo.gif) ## Features -- Automatic Angular instrumentation through Angular dev-mode profiler hooks where available. -- Provider-based setup for Angular apps. -- Floating shadow-DOM toolbar with scan on/off, FPS, latest cycle time, changed component count, and slowest component. -- Canvas highlights around updated components. -- Compact component labels with component name, count, and latest duration. -- Heatmap colors for fast, medium, and slow updates. -- Optional console reports with `console.table()`. -- Details mode for hover-to-highlight inspection and pinned component recommendation panels. -- Draggable toolbar. -- Noise controls for minimum duration, render count, include/exclude filters, and label caps. -- Configurable fast/slow performance thresholds. -- Copy self-contained AI-ready prompts focused on slow/error components, issue context, and estimated cost. -- Production guard by default. +- **Automatic Angular Telemetry:** Out-of-the-box zero-setup component auto-instrumentation using Angular dev-mode profiler hooks. +- **Heatmap & Outlines:** Highlights are colored dynamically based on DOM mutations: **green** for no-op wasted renders, **blue** for text/attribute mutations, and **prominent red borders** for expensive renders exceeding thresholds, making bottlenecks instantly recognizable. +- **CD Waterfall View:** Click the SVG sparkline in the toolbar to expand a nested horizontal bar breakdown of component check execution stack offsets and children offsets. +- **Non-Intrusive Budget Alerts Feed:** Standardized budget violations (warning/error millisecond limits and rate alerts) are elegantly grouped and logged in a collapsible alerts feed panel, handling concurrent violations cleanly. +- **Live CPU & Main-Thread Telemetry:** Dotted CPU metric toggles a live popup showing detailed frame-lag latency and total main-thread blocking times. +- **Memory Leak Detector Badge:** Automatically tracks zombie components whose DOM elements were disconnected but not properly destroyed. +- **Click-to-Source IDE Integration:** Inspected details panel provides an "Open in Editor" link that deep links directly to Cursor, VS Code or WebStorm, and automatically copies the class query to your clipboard for instant search. +- **Session Export JSON:** Download a full profiling JSON bundle including CPU, cycle timelines, wasted statistics, and active budget violation logs. +- **Dark Mode & Theme Presets:** Sleek dark mode styles that match `prefers-color-scheme`, customizable dynamically via options. +- **Keyboard Shortcuts:** Keyboard hotkeys mapped to toggle scan, details panel, copy prompts, and clear stats instantly. +- **Production Guard:** Automatic safety guard shutting down package overhead entirely outside developer mode. ## Install @@ -41,8 +39,7 @@ bootstrapApplication(AppComponent, { providers: [ provideAngularRenderScan({ enabled: true, - animationSpeed: 'fast', - slowThresholdMs: 15 + animationSpeed: 'fast' }) ] }); @@ -135,15 +132,16 @@ interface AngularRenderScanOptions { include?: Array; exclude?: Array; maxLabelCount?: number; - fastThresholdMs?: number; - slowThresholdMs?: number; maxRecordedCycles?: number; showCopyPrompt?: boolean; promptContext?: string; theme?: Partial; + editorProtocol?: 'vscode' | 'webstorm' | 'cursor' | string; + darkMode?: 'auto' | 'dark' | 'light'; onCycleStart?: () => void; onRender?: (entry: AngularRenderEntry) => void; onCycleFinish?: (cycle: AngularRenderCycle) => void; + onBudgetViolation?: (violation: BudgetViolation) => void; } ``` @@ -155,8 +153,6 @@ provideAngularRenderScan({ showToolbar: true, showFPS: true, animationSpeed: 'fast', - fastThresholdMs: 5, - slowThresholdMs: 15, maxLabelCount: 20, maxRecordedCycles: 30, showCopyPrompt: true, @@ -172,7 +168,6 @@ provideAngularRenderScan({ - `dangerouslyForceRunInProduction`: allows the scanner to run outside Angular dev mode. - `minDurationMs`, `minRenderCount`, `include`, `exclude`: filter low-signal render entries. - `maxLabelCount`: limits how many highlighted components receive labels. -- `fastThresholdMs`, `slowThresholdMs`: tune heatmap thresholds. - `maxRecordedCycles`: controls how many recent cycles are included in the copied AI prompt. - `showCopyPrompt`, `promptContext`: control the copyable AI performance prompt. @@ -183,8 +178,6 @@ provideAngularRenderScan({ enabled: true, showToolbar: true, animationSpeed: 'slow', - fastThresholdMs: 5, - slowThresholdMs: 15, maxLabelCount: 12, maxRecordedCycles: 20, promptContext: 'Angular app using signals and OnPush components' @@ -289,7 +282,7 @@ The recommendation panel shows severity, latest duration, average duration, rend ## AI Performance Prompt -Use `Copy Slow Issues Prompt` in the toolbar, or call `getAIPrompt()` / `copyAIPrompt()`, to generate a self-contained prompt for an AI coding assistant. The prompt includes environment details, recent cycle history, the latest cycle, configured thresholds, and an issue list for components over `slowThresholdMs`. +Use `Copy Slow Issues Prompt` in the toolbar, or call `getAIPrompt()` / `copyAIPrompt()`, to generate a self-contained prompt for an AI coding assistant. The prompt includes environment details, recent cycle history, the latest cycle, configured thresholds, and an issue list for components exceeding the performance warning threshold (10ms by default). The copied prompt is intentionally focused: it does not copy every render entry. It lists slow components with selector, latest render time, average render time, render count, reason, changed inputs when available, and an estimated cost based on latest duration, cycle share, and observed render count. It does not include raw DOM nodes, component instances, or source code. @@ -300,6 +293,57 @@ provideAngularRenderScan({ }); ``` +## Keyboard Shortcuts + +The visual overlay responds to the following keyboard shortcuts when enabled: + +| Shortcut | Description | +|---|---| +| `Alt+Shift+S` | Toggles the active/enabled state of the scanner. | +| `Alt+Shift+D` | Toggles Details Mode (hover inspect and recommendations). | +| `Alt+Shift+C` | Copies the AI performance diagnostic prompt. | +| `Alt+Shift+X` | Clears all telemetry counts and history. | +| `Alt+Shift+T` | Toggles the floating toolbar visibility. | +| `Escape` | Closes any pinned recommendation, CPU breakdown, or CD waterfall panel. | + +## Playwright Headless Audit API + +You can programmatically verify Angular performance inside Playwright end-to-end tests using the headless audit API. + +```ts +import { test, expect } from '@playwright/test'; +import { startRenderAudit } from 'angular-render-scan'; + +test('verify no performance regressions or wasted checks', async ({ page }) => { + await page.goto('/'); + + // Start the audit session + const audit = await startRenderAudit(page); + + // Interact with the page + await page.click('button.expensive-operation'); + + // Stop the audit session and fetch the telemetry report + const report = await audit.stop(); + + // Validate rendering frequency + const cardRenders = await report.rendersFor('ProductCardComponent'); + expect(cardRenders).toBeLessThanOrEqual(2); + + // Validate render duration (ms) + const maxDuration = await report.maxDurationFor('ProductCardComponent'); + expect(maxDuration).toBeLessThan(16.7); // smooth 60fps check + + // Validate overall no-op waste ratio + const wasteRatio = await report.wastedRenderPercentage(); + expect(wasteRatio).toBeLessThan(20); // max 20% wasted checks + + // Validate budget violations + const violations = await report.budgetViolations(); + expect(violations.length).toBe(0); +}); +``` + ## Manual Marking Automatic instrumentation is preferred. If you need a specific manual target, you can still mark an element with `AngularRenderScanMarkDirective`. diff --git a/demo/src/main.ts b/demo/src/main.ts index 639017e..662c16d 100644 --- a/demo/src/main.ts +++ b/demo/src/main.ts @@ -1,7 +1,19 @@ -import { bootstrapApplication } from '@angular/platform-browser'; -import { ChangeDetectionStrategy, Component, Input, Output, EventEmitter, computed, signal, WritableSignal } from '@angular/core'; -import { AngularRenderScanMarkDirective, provideAngularRenderScan, setOptions } from 'angular-render-scan'; -import { CommonModule } from '@angular/common'; +import { bootstrapApplication } from "@angular/platform-browser"; +import { + ChangeDetectionStrategy, + Component, + computed, + signal, + WritableSignal, + effect, + input, + output, + OnDestroy, +} from "@angular/core"; +import { + AngularRenderScanMarkDirective, + provideAngularRenderScan, +} from "angular-render-scan"; interface Product { id: number; @@ -12,106 +24,134 @@ interface Product { } const PRODUCTS: Product[] = [ - { id: 1, title: 'Developer Coffee', price: 29.99, icon: '☕', description: 'Dark roast, high caffeine.' }, - { id: 2, title: 'Mechanical Keyboard', price: 149.00, icon: '⌨️', description: 'Clicky blue switches.' }, - { id: 3, title: 'Ergonomic Mouse', price: 79.50, icon: '🖱️', description: 'Saves your wrist.' }, - { id: 4, title: 'Noise Cancelling Headphones', price: 299.99, icon: '🎧', description: 'Zone out the world.' }, - { id: 5, title: 'Ultra-wide Monitor', price: 499.00, icon: '🖥️', description: 'See all the code.' }, - { id: 6, title: 'Standing Desk', price: 650.00, icon: '🪑', description: 'Stand up for your health.' }, + { + id: 1, + title: "Developer Coffee", + price: 29.99, + icon: "☕", + description: "Dark roast, high caffeine.", + }, + { + id: 2, + title: "Mechanical Keyboard", + price: 149.0, + icon: "⌨️", + description: "Clicky blue switches.", + }, ]; @Component({ - selector: 'app-product-card', + selector: "app-product-card", standalone: true, - imports: [AngularRenderScanMarkDirective, CommonModule], + imports: [AngularRenderScanMarkDirective], template: `
-
{{ product.icon }}
+
{{ product().icon }}
-

{{ product.title }}

-

{{ product.description }}

+

{{ product().title }}

+

{{ product().description }}

`, - changeDetection: ChangeDetectionStrategy.OnPush // Optimized + changeDetection: ChangeDetectionStrategy.OnPush, }) class ProductCardComponent { - @Input({ required: true }) product!: Product; - @Output() onAdd = new EventEmitter(); + readonly product = input.required(); + readonly onAdd = output(); } @Component({ - selector: 'app-cart-item', + selector: "app-cart-item", standalone: true, - imports: [AngularRenderScanMarkDirective, CommonModule], + imports: [AngularRenderScanMarkDirective], template: `
- {{ item.icon }} {{ item.title }} + {{ item().icon }} {{ item().title }}
- x{{ quantity }} - + x{{ quantity() }} +
- ` + `, }) class CartItemComponent { - @Input({ required: true }) item!: Product; - @Input({ required: true }) quantity!: number; - @Output() onRemove = new EventEmitter(); + readonly item = input.required(); + readonly quantity = input.required(); + readonly onRemove = output(); } @Component({ - selector: 'app-shopping-cart', + selector: "app-shopping-cart", standalone: true, - imports: [AngularRenderScanMarkDirective, CommonModule, CartItemComponent], + imports: [AngularRenderScanMarkDirective, CartItemComponent], template: ` - ` + `, }) class ShoppingCartComponent { - @Input({ required: true }) cartMap!: WritableSignal>; - @Output() checkoutEvent = new EventEmitter(); + readonly cartMap = input.required< + WritableSignal< + Map< + number, + { + product: Product; + quantity: number; + } + > + > + >(); + readonly checkoutEvent = output(); + + readonly cartKeys = computed(() => Array.from(this.cartMap()().keys())); - cartKeys = computed(() => Array.from(this.cartMap().keys())); - - totalItems = computed(() => { + readonly totalItems = computed(() => { let total = 0; - this.cartMap().forEach((v: any) => total += v.quantity); + this.cartMap()().forEach((v: any) => (total += v.quantity)); return total; }); - totalPrice = computed(() => { + readonly totalPrice = computed(() => { let total = 0; - this.cartMap().forEach((v: any) => total += (v.product.price * v.quantity)); + this.cartMap()().forEach( + (v: any) => (total += v.product.price * v.quantity), + ); return total; }); removeFromCart(product: Product) { - const map = new Map(this.cartMap()); + const map = new Map(this.cartMap()()); const existing: any = map.get(product.id); if (existing) { if (existing.quantity > 1) { @@ -119,42 +159,44 @@ class ShoppingCartComponent { } else { map.delete(product.id); } - this.cartMap.set(map); + this.cartMap().set(map); } } checkout() { this.checkoutEvent.emit(); - this.cartMap.set(new Map()); + this.cartMap().set(new Map()); } } @Component({ - selector: 'app-recommendations', + selector: "app-recommendations", standalone: true, imports: [AngularRenderScanMarkDirective], template: `
- Slow path + Slow path (Default CD)

Recommendation Engine

-

Runs intentionally expensive computed work so the scanner can surface a slow component.

+

+ Runs intentionally expensive computed work so the scanner can surface a + slow component. +

Confidence {{ expensiveScore() }}
- ` + `, }) class RecommendationsComponent { readonly seed = signal(2000); readonly expensiveScore = computed(() => { let total = 0; - // Intentionally slow loop to trigger the >15ms red heat map for (let i = 0; i < this.seed() * 400; i += 1) { - total += Math.sqrt((i % 97) + total % 13); + total += Math.sqrt((i % 97) + (total % 13)); } return Math.round(total).toLocaleString(); }); @@ -165,73 +207,253 @@ class RecommendationsComponent { } @Component({ - selector: 'app-hero-banner', + selector: "app-hero-banner", standalone: true, - imports: [AngularRenderScanMarkDirective], template: `
-
-

Developer Store

-

Interact with this compact Angular storefront to watch render cost, slow paths, and component updates in real time.

+
+
+ + + + + +
+
+

Developer Store

+
+ Render Diagnostics Cockpit + + Angular v21.2 +
+
- Render Scan live - + Zone.js + Signals + SCANNER ACTIVE + +
- ` + `, }) class HeroBannerComponent { - isDark = signal(false); - toggleTheme() { - this.isDark.update(v => !v); - if (this.isDark()) { - document.body.classList.add('dark-theme'); + readonly showGrid = signal(true); + readonly darkMode = signal<"light" | "dark">("light"); + + toggleGrid() { + this.showGrid.update((v) => !v); + if (this.showGrid()) { + document.body.classList.remove("grid-lines-off"); } else { - document.body.classList.remove('dark-theme'); + document.body.classList.add("grid-lines-off"); + } + } + + toggleDarkMode() { + const next = this.darkMode() === "light" ? "dark" : "light"; + this.darkMode.set(next); + if (next === "dark") { + document.documentElement.classList.add("dark"); + (window as any).AngularRenderScan?.setOptions({ darkMode: "dark" }); + } else { + document.documentElement.classList.remove("dark"); + (window as any).AngularRenderScan?.setOptions({ darkMode: "light" }); } } } @Component({ - selector: 'app-root', + selector: "app-root", standalone: true, imports: [ - CommonModule, ProductCardComponent, ShoppingCartComponent, RecommendationsComponent, - HeroBannerComponent + AngularRenderScanMarkDirective, ], template: `
- +

Developer Store

-
-
-
- OnPush products -

Products

+
+ +
+
+ SYSTEM CONTROLLER +

Diagnostics Control

-
+ +
+
+ + +
+ +
+ +
+ LIVE CYCLES: {{ reactiveCounter() }} + REAL-TIME PERFORMANCE LOG +
+ @if (auditLogs().length === 0) { +
+ [SYSTEM] + Listening for Angular render scan telemetry... +
+ } + @for (log of auditLogs(); track log.time + log.message) { +
+ [{{ log.time }}] + + {{ log.message }} + +
+ } +
+
+
+ + +
+
+ SANDBOX NODES +

Component Grid Matrix

+
+ +
@for (product of products; track product.id) { - + } + +
-
- + +
+
- -
- ` + `, }) -class AppComponent { - products = PRODUCTS; - cartMap = signal(new Map()); +class AppComponent implements OnDestroy { + readonly products = PRODUCTS; + readonly cartMap = signal( + new Map(), + ); + + readonly reactiveCounter = signal(0); + readonly autoStreamActive = signal(false); + readonly auditLogs = signal< + { time: string; message: string; type: string }[] + >([]); + + private streamIntervalId: any = null; + + constructor() { + if (typeof window !== "undefined") { + window.addEventListener("angular-render-scan:render", this.onRenderEvent); + } + } + + ngOnDestroy() { + if (typeof window !== "undefined") { + window.removeEventListener( + "angular-render-scan:render", + this.onRenderEvent, + ); + } + if (this.streamIntervalId) { + clearInterval(this.streamIntervalId); + } + } + + private readonly onRenderEvent = (e: Event) => { + const detail = (e as CustomEvent<{ name: string; duration: number }>) + .detail; + const now = new Date().toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + }); + this.auditLogs.update((logs) => [ + { + time: now, + message: `Render: [${detail.name}] in ${detail.duration.toFixed(2)}ms`, + type: detail.duration > 15 ? "warn" : "info", + }, + ...logs.slice(0, 14), + ]); + }; addToCart(product: Product) { const map = new Map(this.cartMap()); @@ -245,7 +467,50 @@ class AppComponent { } onCheckout() { - alert('Thanks for your purchase!'); + alert("Thanks for your purchase!"); + } + + triggerSpike() { + for (let i = 0; i < 30; i++) { + setTimeout(() => { + this.reactiveCounter.update((c) => c + 1); + }, i * 15); + } + } + + toggleAutoStream() { + this.autoStreamActive.update((v) => !v); + if (this.autoStreamActive()) { + this.streamIntervalId = setInterval(() => { + this.reactiveCounter.update((c) => c + 1); + }, 250); + } else { + if (this.streamIntervalId) { + clearInterval(this.streamIntervalId); + this.streamIntervalId = null; + } + } + } + + simulateMemoryLeak() { + const card = document.querySelector("app-product-card"); + if (card) { + card.remove(); // Remove element dynamically to create a disconnected leak! + this.auditLogs.update((logs) => [ + { + time: new Date().toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + }), + message: + "SIMULATED LEAK: Removed product card DOM element without destroying component!", + type: "warn", + }, + ...logs.slice(0, 14), + ]); + } } } @@ -254,9 +519,9 @@ bootstrapApplication(AppComponent, { provideAngularRenderScan({ enabled: true, showToolbar: true, - animationSpeed: 'fast', + animationSpeed: "slow", showFPS: true, - log: true - }) - ] + log: true, + }), + ], }).catch((error: unknown) => console.error(error)); diff --git a/demo/src/styles.css b/demo/src/styles.css index 8cbef6f..9dccba8 100644 --- a/demo/src/styles.css +++ b/demo/src/styles.css @@ -1,280 +1,512 @@ :root { - color-scheme: light; - font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; - color: #172033; - background: #eef2f7; - --app-bg: #eef2f7; - --panel-bg: rgba(255, 255, 255, 0.9); - --panel-strong: #ffffff; - --border-color: rgba(71, 85, 105, 0.18); - --text-muted: #64748b; - --text-soft: #475569; - --accent: #2563eb; - --accent-strong: #1d4ed8; - --success: #0f9f6e; - --danger: #dc2626; - --warning-bg: #fff7ed; - --warning-border: #fb923c; - --shadow: 0 18px 48px rgba(31, 41, 55, 0.12); -} - -body.dark-theme { - color: #e5edf8; - background: #0d1320; - --app-bg: #0d1320; - --panel-bg: rgba(20, 31, 49, 0.92); - --panel-strong: #172033; - --border-color: rgba(148, 163, 184, 0.2); - --text-muted: #9aa8bc; - --text-soft: #cbd5e1; - --accent: #60a5fa; - --accent-strong: #93c5fd; + --font-cyber: "Courier New", Courier, monospace; + --font-sans: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + + /* Premium SaaS Light Theme Palette */ + --bg-primary: #f8fafc; /* Slate 50 */ + --bg-panel: rgba(255, 255, 255, 0.75); + --bg-panel-inner: #f1f5f9; /* Slate 100 */ + --border-cyber: rgba(15, 23, 42, 0.08); + --border-glow: #3b82f6; /* Blue 500 */ + + --color-neon-cyan: #2563eb; /* Indigo 600 */ + --color-neon-purple: #7c3aed; /* Violet 600 */ + --color-neon-rose: #f43f5e; /* Rose 500 */ + + --text-primary: #0f172a; /* Slate 900 */ + --text-muted: #64748b; /* Slate 500 */ + --text-cyber: #2563eb; + + --success: #10b981; /* Emerald 500 */ + --warning: #f59e0b; /* Amber 500 */ + --danger: #ef4444; /* Red 500 */ + + --shadow-glow: 0 4px 6px -1px rgba(0, 0, 0, 0.02), 0 2px 4px -2px rgba(0, 0, 0, 0.02), 0 10px 15px -3px rgba(0, 0, 0, 0.03); + --shadow-glow-strong: 0 10px 30px rgba(37, 99, 235, 0.06); + --transition-fast: 0.15s ease; +} + +:root.dark { + --bg-primary: #0f172a; + --bg-panel: rgba(30, 41, 59, 0.75); + --bg-panel-inner: #1e293b; + --border-cyber: rgba(255, 255, 255, 0.08); + --border-glow: #60a5fa; + + --color-neon-cyan: #3b82f6; + --color-neon-purple: #a78bfa; + --color-neon-rose: #fb7185; + + --text-primary: #f8fafc; + --text-muted: #94a3b8; + --text-cyber: #3b82f6; + --success: #34d399; - --danger: #fb7185; - --warning-bg: rgba(127, 29, 29, 0.34); - --warning-border: #fb7185; - --shadow: 0 18px 54px rgba(0, 0, 0, 0.35); + --warning: #fbbf24; + --danger: #f87171; + + --shadow-glow: 0 4px 6px -1px rgba(0, 0, 0, 0.5); + --shadow-glow-strong: 0 10px 30px rgba(0, 0, 0, 0.5); } * { box-sizing: border-box; + margin: 0; + padding: 0; } -html, -body { +html, body { width: 100%; - height: 100%; + height: 100vh; overflow: hidden; + background-color: var(--bg-primary); + font-family: var(--font-sans); + color: var(--text-primary); + transition: background-color var(--transition-fast); } +/* Subtle architectural grid background */ body { - margin: 0; - min-width: 1024px; - transition: background 0.2s, color 0.2s; + position: relative; } body::before { content: ""; - position: fixed; + position: absolute; inset: 0; - z-index: -1; - background: - radial-gradient(circle at 16% 12%, rgba(37, 99, 235, 0.16), transparent 32%), - radial-gradient(circle at 82% 18%, rgba(15, 159, 110, 0.12), transparent 28%), - linear-gradient(135deg, var(--app-bg), #f8fafc 46%, var(--app-bg)); + z-index: -2; + background-image: + radial-gradient(circle at 10% 20%, rgba(37, 99, 235, 0.04) 0%, transparent 40%), + radial-gradient(circle at 90% 80%, rgba(124, 58, 237, 0.03) 0%, transparent 40%), + linear-gradient(rgba(15, 23, 42, 0.015) 1px, transparent 1px), + linear-gradient(90deg, rgba(15, 23, 42, 0.015) 1px, transparent 1px); + background-size: 100% 100%, 100% 100%, 24px 24px, 24px 24px; } -body.dark-theme::before { - background: - radial-gradient(circle at 16% 12%, rgba(59, 130, 246, 0.22), transparent 32%), - radial-gradient(circle at 82% 18%, rgba(20, 184, 166, 0.14), transparent 28%), - linear-gradient(135deg, #0d1320, #111827 48%, #0f172a); +body.grid-lines-off::before { + background-image: + radial-gradient(circle at 10% 20%, rgba(37, 99, 235, 0.04) 0%, transparent 40%), + radial-gradient(circle at 90% 80%, rgba(124, 58, 237, 0.03) 0%, transparent 40%); } -h1, -h2, -h3, -p { - margin: 0; +/* Main Layout Framework: Fixed 100vh, No Scroll */ +main { + width: 100vw; + height: 100vh; + display: grid; + grid-template-rows: minmax(0, 1fr); + gap: 16px; + padding: 16px; } -button { - min-height: 34px; +.visually-hidden { + position: absolute; + top: -9999px; + left: -9999px; + width: 1px; + height: 1px; + margin: 0; + padding: 0; + overflow: hidden; + clip: rect(0, 0, 0, 0); border: 0; - border-radius: 6px; - padding: 0 13px; - background: var(--accent); - color: #ffffff; - font: 700 12px/1 ui-sans-serif, system-ui, sans-serif; - cursor: pointer; - transition: background 0.16s ease, transform 0.16s ease, box-shadow 0.16s ease; + font-size: 0px; } -button:hover:not(:disabled) { - background: var(--accent-strong); - transform: translateY(-1px); +/* cyber header */ +.hero-banner { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 24px; + background: var(--bg-panel); + border: 1px solid var(--border-cyber); + border-radius: 12px; + backdrop-filter: blur(16px); + box-shadow: var(--shadow-glow); + z-index: 10; } -button:disabled { - opacity: 0.45; - cursor: not-allowed; +.hero-brand { + display: flex; + align-items: center; + gap: 14px; } -main { - width: 100vw; - height: 100vh; +.logo-container { + width: 40px; + height: 40px; + border-radius: 10px; + background: linear-gradient(135deg, rgba(37, 99, 235, 0.08), rgba(124, 58, 237, 0.08)); + border: 1px solid rgba(37, 99, 235, 0.18); display: grid; - grid-template-rows: 118px minmax(0, 1fr); - gap: 16px; - padding: 18px; + place-items: center; + color: var(--color-neon-cyan); } -.hero-banner { +.logo-svg { + width: 22px; + height: 22px; +} + +.brand-details { + display: flex; + flex-direction: column; +} + +.hero-brand h1 { + font-size: 18px; + font-weight: 800; + letter-spacing: -0.02em; + color: var(--text-primary); + line-height: 1.2; +} + +.brand-meta { display: flex; align-items: center; - justify-content: space-between; - gap: 28px; - min-height: 0; - padding: 22px 24px; - border: 1px solid var(--border-color); - border-radius: 8px; - background: var(--panel-bg); - box-shadow: var(--shadow); - backdrop-filter: blur(18px); + gap: 8px; + margin-top: 2px; +} + +.hero-brand .subtitle { + font-size: 11px; + color: var(--text-muted); + font-weight: 500; + letter-spacing: 0.01em; } -h1 { - font-size: clamp(32px, 4vw, 52px); - line-height: 0.96; - letter-spacing: 0; +.meta-divider { + width: 3px; + height: 3px; + border-radius: 50%; + background: var(--text-muted); + opacity: 0.6; } -.hero-banner p { - max-width: 720px; - margin-top: 10px; - color: var(--text-soft); - font-size: 15px; - line-height: 1.45; +.version-badge { + font-size: 9px; + font-family: var(--font-cyber); + color: var(--color-neon-purple); + background: rgba(124, 58, 237, 0.06); + border: 1px solid rgba(124, 58, 237, 0.15); + padding: 1px 6px; + border-radius: 4px; + font-weight: 600; +} + +.zone-badge { + font-size: 10px; + font-weight: 600; + color: var(--text-muted); + background: var(--bg-panel-inner); + border: 1px solid var(--border-cyber); + padding: 5px 10px; + border-radius: 20px; + text-transform: uppercase; + letter-spacing: 0.02em; } .hero-actions { display: flex; align-items: center; - gap: 10px; - flex-shrink: 0; + gap: 16px; } .status-pill { display: inline-flex; align-items: center; - height: 34px; - padding: 0 12px; - border: 1px solid rgba(15, 159, 110, 0.24); - border-radius: 999px; - background: rgba(16, 185, 129, 0.12); + gap: 6px; + padding: 6px 12px; + border-radius: 20px; + background: rgba(16, 185, 129, 0.08); + border: 1px solid rgba(16, 185, 129, 0.15); color: var(--success); - font-size: 12px; - font-weight: 800; - white-space: nowrap; + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; } -.secondary { - background: transparent; - color: inherit; - border: 1px solid var(--border-color); +.pulse-dot { + width: 6px; + height: 6px; + background: var(--success); + border-radius: 50%; + animation: pulse-glow 1.5s infinite; } -.secondary:hover { - background: rgba(37, 99, 235, 0.08); +@keyframes pulse-glow { + 0% { transform: scale(0.9); opacity: 0.6; } + 50% { transform: scale(1.3); opacity: 1; box-shadow: 0 0 8px var(--success); } + 100% { transform: scale(0.9); opacity: 0.6; } } -.store-layout { - min-height: 0; - display: grid; - grid-template-columns: minmax(0, 1.35fr) minmax(280px, 0.62fr) minmax(300px, 0.58fr); - gap: 16px; +.theme-toggle-btn { + background: var(--bg-panel-inner); + border: 1px solid var(--border-cyber); + color: var(--text-primary); + padding: 8px 16px; + border-radius: 8px; + font-size: 11px; + font-weight: 700; + cursor: pointer; + transition: all var(--transition-fast); } -.products-section, -.diagnostics-column, -.cart-sidebar { +.theme-toggle-btn:hover { + border-color: var(--border-glow); + box-shadow: var(--shadow-glow); + transform: translateY(-1px); +} + +/* Core Dashboard Grid - 3 columns, 100vh constraint */ +.cyber-grid { + display: grid; + grid-template-columns: 320px minmax(0, 1fr) 350px; + gap: 16px; min-height: 0; - border: 1px solid var(--border-color); - border-radius: 8px; - background: var(--panel-bg); - box-shadow: var(--shadow); - backdrop-filter: blur(18px); } -.products-section { +/* Panel Containers */ +.cyber-panel { display: grid; grid-template-rows: auto minmax(0, 1fr); - gap: 14px; - padding: 18px; + background: var(--bg-panel); + border: 1px solid var(--border-cyber); + border-radius: 12px; + box-shadow: var(--shadow-glow); + backdrop-filter: blur(16px); + min-height: 0; + overflow: hidden; } -.panel-heading { - display: grid; - gap: 4px; +.panel-header { + padding: 20px 20px 12px 20px; + border-bottom: 1px solid rgba(15, 23, 42, 0.04); } -.panel-kicker { - color: var(--accent); +.panel-header h2 { + font-size: 18px; + font-weight: 800; + color: var(--text-primary); +} + +.kicker { + font-size: 9px; + font-family: var(--font-cyber); + color: var(--color-neon-cyan); + font-weight: 700; + letter-spacing: 0.15em; + text-transform: uppercase; + margin-bottom: 2px; + display: block; +} + +/* LEFT PANEL: Diagnostics controls & log */ +.control-panel { + grid-template-rows: auto auto minmax(0, 1fr); +} + +.control-actions { + padding: 16px 20px; + display: flex; + flex-direction: column; + gap: 10px; + border-bottom: 1px solid rgba(15, 23, 42, 0.04); +} + +.cyber-btn { + display: flex; + align-items: center; + justify-content: center; + padding: 12px; + border-radius: 8px; + border: 1px solid var(--border-cyber); font-size: 11px; - font-weight: 900; - letter-spacing: 0.08em; + font-weight: 700; + cursor: pointer; + transition: all var(--transition-fast); + color: var(--text-primary); text-transform: uppercase; + letter-spacing: 0.05em; } -h2 { - color: inherit; - font-size: 22px; - line-height: 1; - letter-spacing: 0; +.primary-glow { + background: linear-gradient(135deg, rgba(37, 99, 235, 0.08), rgba(124, 58, 237, 0.08)); + border-color: rgba(37, 99, 235, 0.25); + color: var(--color-neon-cyan); } -.products-grid { - min-height: 0; +.primary-glow:hover { + border-color: var(--color-neon-cyan); + box-shadow: var(--shadow-glow-strong); + transform: translateY(-1px); +} + +.secondary-glow { + background: var(--bg-panel-inner); +} + +.secondary-glow:hover { + border-color: var(--color-neon-purple); + box-shadow: 0 0 15px rgba(124, 58, 237, 0.1); + transform: translateY(-1px); +} + +/* Metrics and Logs Visualizer */ +.metrics-visualizer { display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 12px; + grid-template-rows: auto auto minmax(0, 1fr); + padding: 16px 20px 20px 20px; + min-height: 0; + height: 100%; } -.product-card { - min-width: 0; +.metrics-visualizer .kicker { + margin-bottom: 10px; +} + +.audit-log { + background: #f8fafc; + border: 1px solid rgba(15, 23, 42, 0.06); + border-radius: 8px; + padding: 12px; + font-family: var(--font-cyber); + font-size: 10px; + overflow-y: auto; + overflow-x: hidden; + min-height: 0; + display: flex; + flex-direction: column; + gap: 8px; +} + +.log-line { + line-height: 1.4; + border-left: 2px solid var(--text-muted); + padding-left: 6px; + animation: fadeIn 0.3s ease-out; + display: flex; + align-items: flex-start; + flex-wrap: wrap; +} + +.log-time { + color: var(--text-muted); + margin-right: 6px; + flex-shrink: 0; +} + +.log-msg { + word-break: break-word; + overflow-wrap: anywhere; + white-space: normal; + flex: 1 1 auto; +} + +.log-msg.info { + color: var(--color-neon-cyan); + font-weight: 600; +} + +.log-msg.warn { + color: var(--color-neon-rose); + font-weight: 700; +} + +@keyframes fadeIn { + from { opacity: 0; transform: translateY(-3px); } + to { opacity: 1; transform: translateY(0); } +} + +/* MIDDLE PANEL: Sandbox Components Node Matrix */ +.sandbox-panel { + grid-template-rows: auto minmax(0, 1fr); +} + +.nodes-container { + padding: 20px; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 16px; min-height: 0; +} + +/* Custom modern scrollbars */ +.audit-log::-webkit-scrollbar, +.nodes-container::-webkit-scrollbar, +.cart-items::-webkit-scrollbar { + width: 5px; +} + +.audit-log::-webkit-scrollbar-track, +.nodes-container::-webkit-scrollbar-track, +.cart-items::-webkit-scrollbar-track { + background: transparent; +} + +.audit-log::-webkit-scrollbar-thumb, +.nodes-container::-webkit-scrollbar-thumb, +.cart-items::-webkit-scrollbar-thumb { + background: rgba(15, 23, 42, 0.08); + border-radius: 10px; +} + +.audit-log::-webkit-scrollbar-thumb:hover, +.nodes-container::-webkit-scrollbar-thumb:hover, +.cart-items::-webkit-scrollbar-thumb:hover { + background: var(--border-glow); +} + +/* Premium Reskin of Product Cards */ +.product-card { display: grid; - grid-template-columns: 58px minmax(0, 1fr); - grid-template-rows: minmax(0, 1fr) auto; - column-gap: 12px; - row-gap: 10px; - padding: 13px; - border: 1px solid var(--border-color); - border-radius: 8px; - background: var(--panel-strong); - box-shadow: 0 10px 28px rgba(15, 23, 42, 0.06); - transition: transform 0.16s ease, border-color 0.16s ease, box-shadow 0.16s ease; + grid-template-columns: 52px 1fr; + grid-template-rows: auto auto; + gap: 12px; + padding: 16px; + background: var(--bg-panel-inner); + border: 1px solid var(--border-cyber); + border-radius: 10px; + transition: all var(--transition-fast); } .product-card:hover { - border-color: rgba(37, 99, 235, 0.36); - box-shadow: 0 16px 34px rgba(37, 99, 235, 0.12); + border-color: var(--border-glow); + box-shadow: var(--shadow-glow); transform: translateY(-2px); } .product-icon { - width: 58px; - height: 58px; + width: 52px; + height: 52px; display: grid; place-items: center; - border: 1px solid var(--border-color); border-radius: 8px; - background: linear-gradient(180deg, rgba(37, 99, 235, 0.08), rgba(15, 159, 110, 0.08)); - font-size: 29px; + background: rgba(37, 99, 235, 0.05); + border: 1px solid var(--border-cyber); + font-size: 24px; } .product-info { - min-width: 0; + display: flex; + flex-direction: column; + justify-content: center; } .product-info h3 { - overflow: hidden; - color: inherit; - font-size: 15px; - line-height: 1.15; - text-overflow: ellipsis; - white-space: nowrap; + font-size: 14px; + font-weight: 700; + color: var(--text-primary); } .product-info p { - display: -webkit-box; - margin-top: 6px; - overflow: hidden; + font-size: 11px; color: var(--text-muted); - font-size: 12px; - line-height: 1.35; - -webkit-box-orient: vertical; - -webkit-line-clamp: 2; + margin-top: 4px; + line-height: 1.3; } .product-footer { @@ -282,112 +514,130 @@ h2 { display: flex; align-items: center; justify-content: space-between; - gap: 10px; + padding-top: 8px; + border-top: 1px solid rgba(15, 23, 42, 0.03); } .price { - color: inherit; - font-size: 16px; - font-weight: 900; + font-size: 14px; + font-weight: 800; + font-family: var(--font-cyber); + color: var(--color-neon-cyan); } -.diagnostics-column { - display: grid; - padding: 16px; +.product-footer button { + background: linear-gradient(135deg, var(--color-neon-cyan), var(--color-neon-purple)); + color: #fff; + border: none; + padding: 8px 16px; + border-radius: 6px; + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + cursor: pointer; + transition: all var(--transition-fast); } -.panel { - min-height: 0; - display: grid; - grid-template-rows: auto auto auto auto; - align-content: start; - gap: 16px; - padding: 18px; - border: 1px solid var(--warning-border); - border-radius: 8px; - background: var(--warning-bg); - color: #7f1d1d; +.product-footer button:hover { + box-shadow: 0 4px 12px rgba(37, 99, 235, 0.25); + transform: translateY(-1px); } -body.dark-theme .panel { - color: #fecaca; +/* Recommendations Panel (Slow Node) */ +.slow-panel { + display: flex; + flex-direction: column; + gap: 12px; + padding: 16px; + background: rgba(244, 63, 94, 0.02); + border: 1px solid rgba(244, 63, 94, 0.12); + border-radius: 10px; + color: var(--text-primary); } .slow-panel p { - color: currentColor; - font-size: 13px; - line-height: 1.45; - opacity: 0.78; + font-size: 11px; + color: var(--text-muted); + line-height: 1.35; } .recommendation-badge { - display: grid; - gap: 6px; - padding: 16px; - border: 1px solid rgba(220, 38, 38, 0.16); + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px; + background: rgba(244, 63, 94, 0.04); + border: 1px solid rgba(244, 63, 94, 0.08); border-radius: 8px; - background: rgba(255, 255, 255, 0.56); } -body.dark-theme .recommendation-badge { - background: rgba(127, 29, 29, 0.32); +.recommendation-badge span { + font-size: 10px; + font-family: var(--font-cyber); + color: var(--color-neon-rose); + font-weight: 700; } -.recommendation-badge span { - font-size: 11px; - font-weight: 900; - letter-spacing: 0.08em; +.recommendation-badge strong { + font-size: 20px; + font-family: var(--font-cyber); + color: var(--color-neon-rose); + text-shadow: 0 0 8px rgba(244, 63, 94, 0.15); +} + +.slow-panel button { + width: 100%; + background: transparent; + border: 1px solid var(--color-neon-rose); + color: var(--color-neon-rose); + padding: 10px; + border-radius: 6px; + font-size: 10px; + font-weight: 700; text-transform: uppercase; - opacity: 0.72; + cursor: pointer; + transition: all var(--transition-fast); } -.recommendation-badge strong { - font-size: clamp(27px, 4vw, 46px); - line-height: 1; +.slow-panel button:hover { + background: rgba(244, 63, 94, 0.05); + box-shadow: 0 4px 10px rgba(244, 63, 94, 0.1); } +/* RIGHT PANEL: Reactive Shopping Cart Pipeline */ .cart-sidebar { display: grid; - grid-template-rows: auto auto minmax(0, 1fr) auto; - gap: 14px; - padding: 18px; + grid-template-rows: auto minmax(0, 1fr) auto; + gap: 16px; + padding: 20px; + height: 100%; + min-height: 0; } .cart-summary { - color: var(--text-muted); - font-size: 13px; - font-weight: 700; + font-size: 11px; + font-family: var(--font-cyber); + color: var(--color-neon-purple); + margin-top: -8px; } .cart-items { - min-height: 0; display: flex; flex-direction: column; gap: 10px; - overflow: hidden; + overflow-y: auto; + min-height: 0; } .cart-item { - display: grid; - grid-template-columns: minmax(0, 1fr) auto; + display: flex; align-items: center; - gap: 10px; - padding: 10px; - border: 1px solid var(--border-color); + justify-content: space-between; + padding: 10px 12px; + background: var(--bg-panel-inner); + border: 1px solid var(--border-cyber); border-radius: 8px; - background: rgba(15, 23, 42, 0.035); - color: inherit; - font-size: 13px; -} - -body.dark-theme .cart-item { - background: rgba(255, 255, 255, 0.05); -} - -.cart-item > span:first-child { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + font-size: 12px; } .cart-item-actions { @@ -397,63 +647,64 @@ body.dark-theme .cart-item { } .qty { - color: var(--text-muted); - font-weight: 900; + font-family: var(--font-cyber); + color: var(--color-neon-cyan); + font-weight: 700; } .icon-btn { - width: 25px; - min-height: 25px; - padding: 0; background: transparent; + border: none; color: var(--danger); - font-size: 12px; + font-size: 10px; + cursor: pointer; + transition: transform var(--transition-fast); } .icon-btn:hover { - background: rgba(220, 38, 38, 0.12); + transform: scale(1.2); } .checkout-btn { + background: linear-gradient(135deg, var(--color-neon-purple), var(--color-neon-rose)); + color: #fff; + border: none; + padding: 12px; + border-radius: 8px; + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + cursor: pointer; + transition: all var(--transition-fast); width: 100%; - min-height: 42px; - background: var(--success); - font-size: 14px; } .checkout-btn:hover:not(:disabled) { - background: #05865d; + box-shadow: 0 4px 15px rgba(244, 63, 94, 0.25); + transform: translateY(-1px); +} + +.checkout-btn:disabled { + opacity: 0.4; + cursor: not-allowed; } .empty-cart { - height: 100%; - min-height: 160px; - display: grid; - place-items: center; - border: 1px dashed var(--border-color); + flex-grow: 1; + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + border: 1px dashed var(--border-cyber); border-radius: 8px; color: var(--text-muted); - font-size: 13px; - font-weight: 700; + font-size: 12px; text-align: center; } -@media (max-width: 1180px) { - main { - padding: 12px; - gap: 12px; - } - - .store-layout { - grid-template-columns: minmax(0, 1.2fr) 280px 280px; - gap: 12px; - } - - .products-grid { - gap: 10px; - } - - .product-card { - padding: 11px; +/* Adaptive styles for narrower screens */ +@media (max-width: 1024px) { + .cyber-grid { + grid-template-columns: 260px minmax(0, 1fr) 280px; } } diff --git a/docs/assets/angular-render-scan-demo.gif b/docs/assets/angular-render-scan-demo.gif new file mode 100644 index 0000000..5e8a726 Binary files /dev/null and b/docs/assets/angular-render-scan-demo.gif differ diff --git a/e2e/demo.spec.ts b/e2e/demo.spec.ts index e6c653c..986609b 100644 --- a/e2e/demo.spec.ts +++ b/e2e/demo.spec.ts @@ -83,7 +83,7 @@ test('toolbar can copy an AI performance prompt', async ({ page }) => { await page.getByRole('button', { name: 'Recalculate' }).click(); const overlay = page.locator(overlaySelector); - await expect.poll(async () => overlay.evaluate((host) => host.shadowRoot?.textContent ?? '')).toContain('Count'); + await expect.poll(async () => overlay.evaluate((host) => host.shadowRoot?.textContent ?? '')).toContain('Wasted'); await overlay.evaluate((host) => { host.shadowRoot?.querySelector('.copy-prompt-btn')?.click(); }); @@ -96,13 +96,22 @@ test('toolbar can copy an AI performance prompt', async ({ page }) => { await expect.poll(async () => overlay.evaluate((host) => host.shadowRoot?.textContent ?? '')).toContain('Copied'); }); -test('toolbar hides recording and export controls', async ({ page }) => { +test('toolbar displays and triggers export session controls', async ({ page }) => { await page.goto('/'); const overlay = page.locator(overlaySelector); - await expect.poll(async () => overlay.evaluate((host) => host.shadowRoot?.querySelector('.recording-btn'))).toBeNull(); - await expect.poll(async () => overlay.evaluate((host) => host.shadowRoot?.querySelector('.export-btn'))).toBeNull(); - await expect.poll(async () => overlay.evaluate((host) => host.shadowRoot?.textContent ?? '')).toContain('Copy Slow Issues Prompt'); + await expect.poll(async () => overlay.evaluate((host) => { + return host.shadowRoot?.querySelector('.export-btn') !== null; + })).toBe(true); + + await page.getByRole('button', { name: 'Recalculate' }).click(); + + const downloadPromise = page.waitForEvent('download'); + await overlay.evaluate((host) => { + host.shadowRoot?.querySelector('.export-btn')?.click(); + }); + const download = await downloadPromise; + expect(download.suggestedFilename()).toContain('angular-render-scan-session-'); }); test('details mode hover and click opens recommendation panel with component prompt copy', async ({ page }) => { @@ -120,9 +129,8 @@ test('details mode hover and click opens recommendation panel with component pro await page.evaluate(() => (window as any).AngularRenderScan.setOptions({ animationSpeed: 'slow' })); await page.getByRole('button', { name: 'Recalculate' }).click(); - await expect.poll(async () => page.locator(overlaySelector).evaluate((host) => host.shadowRoot?.textContent ?? '')).toContain('Count'); - const box = await page.locator('app-recommendations').boundingBox(); - expect(box).not.toBeNull(); + await expect.poll(async () => page.locator(overlaySelector).evaluate((host) => host.shadowRoot?.textContent ?? '')).toContain('Wasted'); + await page.locator(overlaySelector).evaluate((host) => { host.shadowRoot?.querySelector('.details-checkbox')?.click(); }); @@ -136,18 +144,7 @@ test('details mode hover and click opens recommendation panel with component pro return host.shadowRoot?.querySelector('.copy-prompt-btn')?.getAttribute('data-tooltip') ?? ''; })).toContain('slow/error component issues'); - await page.mouse.move(box.x + box.width / 2, box.y + Math.min(40, box.height / 2)); - await page.evaluate(({ x, y }) => { - document.elementFromPoint(x, y)?.dispatchEvent(new MouseEvent('click', { - bubbles: true, - cancelable: true, - clientX: x, - clientY: y - })); - }, { - x: box!.x + box!.width / 2, - y: box!.y + Math.min(40, box!.height / 2) - }); + await page.locator('app-recommendations').click({ force: true }); await expect.poll(async () => page.locator(overlaySelector).evaluate((host) => { return host.shadowRoot?.querySelector('.inspect-panel')?.textContent ?? ''; @@ -168,3 +165,66 @@ test('details mode hover and click opens recommendation panel with component pro return host.shadowRoot?.querySelector('.inspect-panel'); })).toBeNull(); }); + +test('toolbar can toggle live CPU details panel', async ({ page }) => { + await page.goto('/'); + const overlay = page.locator(overlaySelector); + + await expect.poll(async () => overlay.evaluate((host) => { + return host.shadowRoot?.querySelector('.cpu-interactive')?.textContent ?? ''; + })).toContain('CPU'); + + await expect.poll(async () => overlay.evaluate((host) => { + return host.shadowRoot?.querySelector('.cpu-details-panel'); + })).toBeNull(); + + await overlay.evaluate((host) => { + host.shadowRoot?.querySelector('.cpu-interactive')?.click(); + }); + + await expect.poll(async () => overlay.evaluate((host) => { + return host.shadowRoot?.querySelector('.cpu-details-panel')?.textContent ?? ''; + })).toContain('CPU Usage'); + + await overlay.evaluate((host) => { + host.shadowRoot?.querySelector('.cpu-interactive')?.click(); + }); + + await expect.poll(async () => overlay.evaluate((host) => { + return host.shadowRoot?.querySelector('.cpu-details-panel'); + })).toBeNull(); +}); + +test('wasted render counter and 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'); +}); + +test('waterfall panel can be toggled via sparkline', async ({ page }) => { + await page.goto('/'); + const overlay = page.locator(overlaySelector); + + await page.getByRole('button', { name: 'Recalculate' }).click(); + + await expect.poll(async () => overlay.evaluate((host) => host.shadowRoot?.querySelector('.sparkline-toggle') !== null)).toBe(true); + await expect.poll(async () => overlay.evaluate((host) => host.shadowRoot?.querySelector('.waterfall-panel') !== null)).toBe(false); + + await overlay.evaluate((host) => { + host.shadowRoot?.querySelector('.sparkline-toggle')?.click(); + }); + + await expect.poll(async () => overlay.evaluate((host) => host.shadowRoot?.querySelector('.waterfall-panel')?.textContent ?? '')).toContain('Waterfall'); +}); diff --git a/feature.md b/feature.md index 4312a77..cd55879 100644 --- a/feature.md +++ b/feature.md @@ -14,13 +14,19 @@ The project is structured using Domain-Driven Design (DDD) principles: ## Current Feature Slice -- **Public API:** `scan`, `setOptions`, `getOptions`, `stop`. -- **Angular Integration:** Provider mode wraps `ApplicationRef.tick()` for full-cycle timing and uses Angular's internal global `ɵsetProfiler` for zero-setup component auto-instrumentation. +- **Public API:** `scan`, `setOptions`, `getOptions`, `stop`, `getSessionData`, `getWastedStats`, `getLeakedComponents`, `startRenderAudit`. +- **Angular Integration:** Provider mode wraps `ApplicationRef.tick()` for full-cycle timing and uses Angular's internal global `ɵsetProfiler` for zero-setup component auto-instrumentation. Supports manual directives as a fallback. - **Production Safety:** Built-in `isDevMode()` guard entirely shuts down the scanner for production builds unless bypassed via `dangerouslyForceRunInProduction`. -- **Overlay:** Fixed canvas plus shadow-DOM toolbar. Features include pointer-events passthrough, draggable toolbar, clear stats button, checkbox toggle, sampled FPS, current cycle time, changed-component count, and slowest component. -- **UX & Visuals:** Configurable color themes with Heatmap styling: components updating under 5ms are blue, up to 15ms are yellow, and above 15ms display a red error border. -- **Developer Tools:** Click-to-inspect (Cmd/Ctrl+Click) captures the component instance and logs it. Console reporting (`log: true`) prints a native `console.table()` of cycle data. -- **Demo app:** E-Commerce storefront featuring a layout with `OnPush` components, signal bindings, and mocked expensive operations. +- **Overlay:** Fixed canvas plus shadow-DOM toolbar. Features include pointer-events passthrough, draggable toolbar, clear stats button, checkbox toggle, sampled FPS, current cycle time, CPU main thread tracking, and slowest component. +- **UX & Visuals:** Sleek dark-mode capabilities out of the box, customizable color themes, and inline SVG timeline sparklines. +- **DOM Mutation Heatmap:** Outline border flashes are colored dynamically: **green** for no-ops/wasted renders, **blue** for text/attribute changes, and **red** for structural layout modifications. +- **Performance Budgets & Toast Alerts:** Standardized warning, error, and rendering-rate millisecond limits. Violet/amber/red toasts float above the toolbar live on performance violations. +- **CD Waterfall View:** Expandable timeline panel visualizes rendering duration tree stack offsets and nested child durations. +- **Memory Leak Detector:** Audits zombie components whose DOM host elements have been disconnected but not properly garbage collected. +- **Click-to-Source IDE Integration:** Pinned recommendations details panel has deep links to open files directly in Cursor, VS Code, or WebStorm. +- **Session Export:** Downloadable JSON file profiling reports containing full cycle stats, budgets violations, and memory leak listings. +- **Headless Audit E2E API:** Playwright-compatible `startRenderAudit` API for continuous integration performance gate-keeping. +- **Demo app:** Cyberpunk storefront dashboard displaying OnPush updates, signal bindings, expensive computations, grid overlays, dark mode, and memory leak sandbox simulations. ## Design Direction @@ -29,8 +35,8 @@ The overlay feels close to React Scan: lightweight, bright, and developer-tool o To update the color, change the `theme` option in your `AngularRenderScanOptions` config: - `fast`: Color for sub-5ms renders. -- `medium`: Color for 5-15ms renders. -- `slow`: Color for >15ms renders. +- `medium`: Color for 5-10ms renders. +- `slow`: Color for >=10ms renders. - `labelBackground`: Background for fast/medium chips. - `labelBackgroundSlow`: Background for slow chips. @@ -47,7 +53,7 @@ Alpha values are kept in the paint loop so highlights fade smoothly with `animat ## Next Feature Candidates - "Why did this render?" change-cause analysis via `@Input()` tracking or Angular 18's new change detection signals. -- Configurable thresholds for the heat map (currently hardcoded to 5ms/15ms). +- Configurable thresholds for the heat map (currently derived from default budgets: 5ms/10ms). - More robust cycle correlation for async updates. - Angular 9, 16, and latest compatibility smoke matrix. diff --git a/packages/angular-render-scan/README.md b/packages/angular-render-scan/README.md index 4b68755..4f8a879 100644 --- a/packages/angular-render-scan/README.md +++ b/packages/angular-render-scan/README.md @@ -2,7 +2,7 @@ Angular Render Scan is a visual debugging overlay for Angular change detection. It helps you see which components update, how often they update, and how long those updates take. -![Angular Render Scan demo](https://raw.githubusercontent.com/edisonaugusthy/angular-render-scan/main/docs/assets/angular-render-scan-demo.png) +![Angular Render Scan demo](https://raw.githubusercontent.com/edisonaugusthy/angular-render-scan/main/docs/assets/angular-render-scan-demo.gif) ## Install @@ -17,18 +17,17 @@ Angular Render Scan expects Angular 9+ as a peer dependency. Add `provideAngularRenderScan()` to your Angular bootstrap providers. ```ts -import { bootstrapApplication } from '@angular/platform-browser'; -import { provideAngularRenderScan } from 'angular-render-scan'; -import { AppComponent } from './app/app.component'; +import { bootstrapApplication } from "@angular/platform-browser"; +import { provideAngularRenderScan } from "angular-render-scan"; +import { AppComponent } from "./app/app.component"; bootstrapApplication(AppComponent, { providers: [ provideAngularRenderScan({ enabled: true, - animationSpeed: 'fast', - slowThresholdMs: 15 - }) - ] + animationSpeed: "fast", + }), + ], }); ``` @@ -47,7 +46,14 @@ Provider mode is recommended for Angular component-level instrumentation because ## API ```ts -import { copyAIPrompt, getAIPrompt, getOptions, scan, setOptions, stop } from 'angular-render-scan'; +import { + copyAIPrompt, + getAIPrompt, + getOptions, + scan, + setOptions, + stop, +} from "angular-render-scan"; scan(); setOptions({ enabled: false }); @@ -65,13 +71,11 @@ provideAngularRenderScan({ enabled: true, showToolbar: true, showFPS: true, - animationSpeed: 'fast', + animationSpeed: "fast", log: false, - fastThresholdMs: 5, - slowThresholdMs: 15, maxLabelCount: 20, maxRecordedCycles: 30, - showCopyPrompt: true + showCopyPrompt: true, }); ``` @@ -82,7 +86,6 @@ provideAngularRenderScan({ - `log`: prints cycle summaries to the console. - `minDurationMs`, `minRenderCount`, `include`, `exclude`: hide low-signal entries. - `maxLabelCount`: caps visible component labels. -- `fastThresholdMs`, `slowThresholdMs`: tune heatmap thresholds. - `maxRecordedCycles`: controls how many recent cycles are included in the copied AI prompt. - `showCopyPrompt`, `promptContext`: control the copyable AI performance prompt. - `dangerouslyForceRunInProduction`: allows the scanner to run outside Angular dev mode. @@ -93,12 +96,10 @@ provideAngularRenderScan({ provideAngularRenderScan({ enabled: true, showToolbar: true, - animationSpeed: 'slow', - fastThresholdMs: 5, - slowThresholdMs: 15, + animationSpeed: "slow", maxLabelCount: 12, maxRecordedCycles: 20, - promptContext: 'Angular app using signals and OnPush components' + promptContext: "Angular app using signals and OnPush components", }); ``` @@ -106,7 +107,7 @@ Use `animationSpeed: 'slow'` when you want more time to read the borders and lab ## AI Performance Prompt -Click `Copy Slow Issues Prompt` in the toolbar, or call `copyAIPrompt()`, to copy a self-contained prompt for an AI coding assistant. It includes environment details, recent cycle history, Angular render-cycle evidence, thresholds, and an issue list for components over `slowThresholdMs`. +Click `Copy Slow Issues Prompt` in the toolbar, or call `copyAIPrompt()`, to copy a self-contained prompt for an AI coding assistant. It includes environment details, recent cycle history, Angular render-cycle evidence, thresholds, and an issue list for components exceeding the performance warning threshold (10ms by default). The copied prompt is intentionally focused: it does not copy every render entry. It lists slow components with selector, latest render time, average render time, render count, reason, changed inputs when available, and an estimated cost based on latest duration, cycle share, and observed render count. It does not include raw DOM nodes, component instances, source code, or large object values. @@ -116,14 +117,13 @@ Check `Details` in the toolbar to turn on component inspection. Hover over a cap The panel shows severity, latest duration, average duration, render count, reason, selector, changed inputs, recent cycles, estimated cost, and component-local Angular recommendations based on the captured issue. For slow components, it also shows `Copy Slow Issue Prompt`, which copies a prompt for only that component with the details needed by an AI coding assistant. - ## Production Behavior Angular Render Scan is intended for development and demo debugging. Provider mode checks Angular `isDevMode()` and does not run in production unless explicitly enabled. ```ts provideAngularRenderScan({ - dangerouslyForceRunInProduction: true + dangerouslyForceRunInProduction: true, }); ``` diff --git a/packages/angular-render-scan/src/application/runtime.spec.ts b/packages/angular-render-scan/src/application/runtime.spec.ts index 9aedc8b..caa977e 100644 --- a/packages/angular-render-scan/src/application/runtime.spec.ts +++ b/packages/angular-render-scan/src/application/runtime.spec.ts @@ -26,7 +26,7 @@ describe('runtime diagnostics', () => { }); it('keeps recent cycles and builds a self-contained AI-ready performance prompt', () => { - setResolvedOptions({ promptContext: 'Angular signals storefront', slowThresholdMs: 10 }); + setResolvedOptions({ promptContext: 'Angular signals storefront' }); const element = document.createElement('app-cart'); document.body.append(element); registerComponent({ id: 'cart', name: 'CartComponent', element, selector: 'app-cart' }); diff --git a/packages/angular-render-scan/src/application/runtime.ts b/packages/angular-render-scan/src/application/runtime.ts index 7f528a2..c813dd2 100644 --- a/packages/angular-render-scan/src/application/runtime.ts +++ b/packages/angular-render-scan/src/application/runtime.ts @@ -1,7 +1,14 @@ import { AngularRenderScanOverlay } from '../infrastructure/ui/overlay'; import { getResolvedOptions, resolveOptions, setResolvedOptions } from '../domain/options'; -import { finishCycle, resetStats, startCycle } from './stats'; -import type { AngularRenderCycle, AngularRenderEntry, AngularRenderScanOptions } from '../domain/entities'; +import { finishCycle, resetStats, startCycle, getWastedStats, getLeakedComponents } from './stats'; +import type { + AngularRenderCycle, + AngularRenderEntry, + AngularRenderScanOptions, + BudgetViolation, + SessionExportData, + WastedStats +} from '../domain/entities'; let overlay: AngularRenderScanOverlay | undefined; let activeCycleId = 0; @@ -9,6 +16,7 @@ let activeCycleStartedAt = 0; let lastCycle: AngularRenderCycle | undefined; let implicitCycleScheduled = false; let recentCycles: AngularRenderCycle[] = []; +let activeSessionBudgetViolations: BudgetViolation[] = []; let scheduleTask = (fn: () => void) => queueMicrotask(fn); @@ -42,6 +50,7 @@ export function stop(): void { activeCycleId = 0; activeCycleStartedAt = 0; implicitCycleScheduled = false; + activeSessionBudgetViolations = []; if (typeof window !== 'undefined') { const globalWindow = window as any; @@ -65,6 +74,20 @@ export function beginCycle(): number { return activeCycleId; } +function getRendersInLastSecond(id: string, now: number): number { + let count = 0; + for (let i = recentCycles.length - 1; i >= 0; i--) { + const cycle = recentCycles[i]; + if (now - cycle.finishedAt > 1000) { + break; + } + if (cycle.entries.some(e => e.id === id)) { + count++; + } + } + return count; +} + export function endCycle(cycleId = activeCycleId): AngularRenderCycle | undefined { if (!cycleId) { return undefined; @@ -75,6 +98,70 @@ export function endCycle(cycleId = activeCycleId): AngularRenderCycle | undefine const cycle = finishCycle(cycleId, activeCycleStartedAt, finishedAt, options); lastCycle = cycle; recordRecentCycle(cycle, options.maxRecordedCycles); + + // Budget validation checking + const now = performance.now(); + const realNowTimestamp = Date.now(); + if (options.budgets) { + const { warnMs, errorMs, maxRendersPerSecond } = options.budgets; + for (const entry of cycle.entries) { + // Check warnMs + if (warnMs !== undefined && entry.latestDuration > warnMs && entry.latestDuration <= (errorMs ?? Infinity)) { + const violation: BudgetViolation = { + componentName: entry.name, + selector: entry.selector ?? '', + type: 'warn', + actual: entry.latestDuration, + budget: warnMs, + message: `Component ${entry.name} exceeded warning budget of ${warnMs}ms (took ${entry.latestDuration.toFixed(1)}ms)`, + timestamp: realNowTimestamp + }; + activeSessionBudgetViolations.push(violation); + options.onBudgetViolation?.(violation); + if (typeof window !== 'undefined') { + window.dispatchEvent(new CustomEvent('angular-render-scan:budget-violation', { detail: violation })); + } + } + // Check errorMs + if (errorMs !== undefined && entry.latestDuration > errorMs) { + const violation: BudgetViolation = { + componentName: entry.name, + selector: entry.selector ?? '', + type: 'error', + actual: entry.latestDuration, + budget: errorMs, + message: `Component ${entry.name} exceeded error budget of ${errorMs}ms (took ${entry.latestDuration.toFixed(1)}ms)`, + timestamp: realNowTimestamp + }; + activeSessionBudgetViolations.push(violation); + options.onBudgetViolation?.(violation); + if (typeof window !== 'undefined') { + window.dispatchEvent(new CustomEvent('angular-render-scan:budget-violation', { detail: violation })); + } + } + // Check render rate (maxRendersPerSecond) + if (maxRendersPerSecond !== undefined) { + const rendersInLastSec = getRendersInLastSecond(entry.id, now); + if (rendersInLastSec > maxRendersPerSecond) { + const violation: BudgetViolation = { + componentName: entry.name, + selector: entry.selector ?? '', + type: 'render-rate', + actual: rendersInLastSec, + budget: maxRendersPerSecond, + message: `Component ${entry.name} exceeded max render rate budget of ${maxRendersPerSecond}/sec (rendered ${rendersInLastSec} times in last second)`, + timestamp: realNowTimestamp + }; + activeSessionBudgetViolations.push(violation); + options.onBudgetViolation?.(violation); + if (typeof window !== 'undefined') { + window.dispatchEvent(new CustomEvent('angular-render-scan:budget-violation', { detail: violation })); + } + } + } + } + } + for (const entry of cycle.entries) { options.onRender?.(entry); } @@ -176,32 +263,39 @@ function buildAIPrompt(cycles: AngularRenderCycle[], options = getResolvedOption const context = options.promptContext.trim(); const environment = getPromptEnvironment(fps); + const slowThreshold = options.budgets?.warnMs ?? 10; + const fastThreshold = slowThreshold / 2; + return [ - 'I am debugging Angular change-detection performance issues using angular-render-scan. This prompt is self-contained and intentionally includes only the slow/actionable component evidence, not every render entry.', - context ? `Project context: ${context}` : '', + '# ⚡️ Angular change-detection Performance Audit (via angular-render-scan)', + 'This prompt is self-contained and includes the telemetry evidence of slow/actionable components in the application.', + context ? `* **Project context:** ${context}` : '', '', - 'Environment:', + '## 💻 Environment:', ...environment, '', - 'Latest render cycle:', - `- Cycle id: ${latest.id}`, - `- Duration: ${formatMs(latest.duration)}`, - `- Rendered components: ${latest.renderedCount}`, - latest.slowest ? `- Slowest component: ${latest.slowest.name} (${formatMs(latest.slowest.latestDuration)}, reason: ${latest.slowest.reason ?? 'unknown'})` : '', - `- Thresholds: fast <= ${formatMs(options.fastThresholdMs)}, slow >= ${formatMs(options.slowThresholdMs)}`, - `- Filters: min duration ${formatMs(options.minDurationMs)}, min render count ${options.minRenderCount}, max listed components ${options.maxLabelCount}`, + '## ⏱️ Latest Render Cycle Details:', + 'Here are the telemetry details of the last captured change detection cycle:', + `* **Cycle id:** #${latest.id}`, + `* **Duration:** \`${formatMs(latest.duration)}\``, + `* **Rendered components count:** ${latest.renderedCount}`, + latest.slowest ? `* **Slowest component:** \`${latest.slowest.name}\` (${formatMs(latest.slowest.latestDuration)}, reason: \`${latest.slowest.reason ?? 'unknown'}\`)` : '', + `* **Thresholds:** Fast <= \`${formatMs(fastThreshold)}\` | Slow >= \`${formatMs(slowThreshold)}\``, + `* **Filters:** Min duration \`${formatMs(options.minDurationMs)}\`, min render count ${options.minRenderCount}`, '', - 'Recent cycle history:', + '## 📈 Recent cycle history:', ...cycles.slice(-8).map(formatPromptCycle), '', - 'Slow/error component issues to fix:', - ...issueEntries.map((entry, index) => formatIssueEntry(entry, index + 1, latest.duration, options.slowThresholdMs)), + '## 🚨 Slow/error component issues to fix:', + ...issueEntries.map((entry, index) => formatIssueEntry(entry, index + 1, latest.duration, slowThreshold)), issueEntries.length === 0 ? '- No component exceeded the configured slow threshold in the captured cycles.' : '', '', - 'Reference only, capped top observed components:', + '## 📊 Reference metrics (top observed components):', + 'Overall render footprint and frequencies for active components:', ...entries.map((entry, index) => formatReferenceEntry(entry, index + 1)), '', - 'Please identify the likely root causes for each slow/error component issue and suggest concrete Angular fixes. Keep recommendations tied to the listed component evidence. Focus on OnPush strategy, signal/computed usage, expensive template work, unnecessary input identity changes, event handlers, list tracking, and component boundaries. Prioritize the highest estimated cost first. Do not assume access to source code beyond the diagnostics above.' + '## 🛠️ Optimization Instructions:', + 'Please identify the likely root causes for each slow/error component issue and suggest concrete Angular fixes. Focus on ChangeDetectionStrategy.OnPush, signal/computed usage, template calculations, input reference stabilization, event handlers, and list tracking. Prioritize resolving the highest estimated cost first. Please generate complete, refactored TypeScript templates showing the exact optimized before/after structures. Do not assume access to source code beyond this diagnostic snapshot.' ].filter(Boolean).join('\n'); } @@ -220,25 +314,26 @@ function topEntries(cycles: AngularRenderCycle[], limit: number): AngularRenderE function issueEntriesForPrompt(cycles: AngularRenderCycle[], options = getResolvedOptions()): AngularRenderEntry[] { const entries = topEntries(cycles, Math.min(options.maxLabelCount, 8)); - return entries.filter((entry) => entry.latestDuration >= options.slowThresholdMs); + const slowThreshold = options.budgets?.warnMs ?? 10; + return entries.filter((entry) => entry.latestDuration >= slowThreshold); } function formatIssueEntry(entry: AngularRenderEntry, index: number, latestCycleDuration: number, slowThresholdMs: number): string { - const changedInputs = entry.changedInputs?.length - ? `\n Changed inputs: ${entry.changedInputs.map((input) => `${input.name} ${input.previous} -> ${input.current}`).join('; ')}` - : '\n Changed inputs: none captured'; const overBy = Math.max(0, entry.latestDuration - slowThresholdMs); const cycleShare = latestCycleDuration > 0 ? (entry.latestDuration / latestCycleDuration) * 100 : 0; const estimatedTotalCost = entry.averageDuration * entry.count; + const changedInputsStr = entry.changedInputs?.length + ? entry.changedInputs.map((input) => `${input.name} ${input.previous} -> ${input.current}`).join('; ') + : 'none captured'; return [ - `${index}. ${entry.name}`, - ` Selector: ${entry.selector ?? '-'}`, - ` Issue: latest render ${formatMs(entry.latestDuration)} exceeded slow threshold ${formatMs(slowThresholdMs)} by ${formatMs(overBy)}.`, + `### 🛑 Component #${index}: \`${entry.name}\``, + ` Selector: selector ${entry.selector ?? '-'}`, + ` Performance Issue: latest render ${formatMs(entry.latestDuration)} exceeded slow threshold ${formatMs(slowThresholdMs)} by ${formatMs(overBy)}.`, ` Cost: ${formatMs(entry.latestDuration)} in latest cycle, about ${cycleShare.toFixed(0)}% of latest cycle time, estimated observed total ${formatMs(estimatedTotalCost)} across ${entry.count} renders.`, ` Average render: ${formatMs(entry.averageDuration)}`, ` Render reason: ${entry.reason ?? 'unknown'}`, - changedInputs + ` Changed inputs: ${changedInputsStr}` ].join('\n'); } @@ -270,3 +365,53 @@ function getPromptEnvironment(fps?: number): string[] { function formatMs(value: number): string { return `${value.toFixed(1)}ms`; } + +export function getSessionData(): SessionExportData { + const options = getResolvedOptions(); + const wasted = getWastedStats(); + const leaks = getLeakedComponents().map((c) => c.name); + + const mappedCycles = recentCycles.map((cycle) => ({ + id: cycle.id, + startedAt: cycle.startedAt, + finishedAt: cycle.finishedAt, + duration: cycle.duration, + renderedCount: cycle.renderedCount, + entries: cycle.entries.map((e) => ({ + id: e.id, + name: e.name, + count: e.count, + latestDuration: e.latestDuration, + averageDuration: e.averageDuration, + latestCycleId: e.latestCycleId, + reason: e.reason, + changedInputs: e.changedInputs, + selector: e.selector, + wastedChecks: e.wastedChecks, + wastedPercentage: e.wastedPercentage, + mutationType: e.mutationType + })), + waterfall: cycle.waterfall + })); + + const viewport = typeof window !== 'undefined' + ? `${window.innerWidth}x${window.innerHeight} @${window.devicePixelRatio || 1}x` + : ''; + const url = typeof window !== 'undefined' ? window.location.href : ''; + const userAgent = typeof navigator !== 'undefined' ? navigator.userAgent : ''; + + return { + exportedAt: new Date().toISOString(), + url, + viewport, + userAgent, + options, + cycles: mappedCycles, + wastedStats: wasted, + budgetViolations: activeSessionBudgetViolations, + leakedComponents: leaks + }; +} + +export { getWastedStats, getLeakedComponents }; + diff --git a/packages/angular-render-scan/src/application/session.spec.ts b/packages/angular-render-scan/src/application/session.spec.ts new file mode 100644 index 0000000..e767f68 --- /dev/null +++ b/packages/angular-render-scan/src/application/session.spec.ts @@ -0,0 +1,54 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { getSessionData, beginCycle, endCycle, stop } from './runtime'; +import { registerComponent, resetStats, recordComponentCheck } from './stats'; +import { setResolvedOptions, resetOptionsForTest } from '../domain/options'; + +describe('session export', () => { + afterEach(() => { + stop(); + resetStats(); + resetOptionsForTest(); + }); + + it('serializes session data safely without DOM elements', () => { + const element = document.createElement('div'); + document.body.append(element); + registerComponent({ id: 'export-test', name: 'ExportTestComponent', element }); + + setResolvedOptions({ + enabled: true, + maxRecordedCycles: 10 + }); + + const cycleId = beginCycle(); + recordComponentCheck('export-test', 15.0, cycleId); + const cycle = endCycle(cycleId); + + const session = getSessionData(); + + expect(session).toBeDefined(); + expect(session.exportedAt).toBeDefined(); + expect(session.options).toBeDefined(); + expect(session.cycles.length).toBeGreaterThan(0); + expect(session.wastedStats).toMatchObject({ + totalChecks: 1, + wastedChecks: 0, + wastedPercentage: 0 + }); + + // Verify entries are fully mapped and contain no raw DOM elements + const sessionCycle = session.cycles[0]; + expect(sessionCycle.entries.length).toBe(1); + const entry = sessionCycle.entries[0]; + expect(entry.name).toBe('ExportTestComponent'); + expect((entry as any).element).toBeUndefined(); // Crucial! Must not have DOM reference + + // Verify budget violations were recorded + expect(session.budgetViolations.length).toBe(1); + expect(session.budgetViolations[0]).toMatchObject({ + componentName: 'ExportTestComponent', + type: 'warn', + budget: 10 + }); + }); +}); diff --git a/packages/angular-render-scan/src/application/stats.spec.ts b/packages/angular-render-scan/src/application/stats.spec.ts index 1249521..3125694 100644 --- a/packages/angular-render-scan/src/application/stats.spec.ts +++ b/packages/angular-render-scan/src/application/stats.spec.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it } from 'vitest'; -import { finishCycle, recordComponentCheck, registerComponent, resetStats, startCycle } from './stats'; +import { finishCycle, recordComponentCheck, registerComponent, resetStats, startCycle, getWastedStats, getLeakedComponents } from './stats'; import { getResolvedOptions, resetOptionsForTest, setResolvedOptions } from '../domain/options'; describe('stats', () => { @@ -65,4 +65,30 @@ describe('stats', () => { expect(cycle.entries.map((entry) => entry.name)).toEqual(['CartComponent']); }); + + it('tracks wasted checks and percentage', () => { + const element = document.createElement('div'); + document.body.append(element); + registerComponent({ id: 'wasted', name: 'WastedComponent', element }); + const cycleId = startCycle(); + + recordComponentCheck('wasted', 2.0, cycleId, { mutationType: 'none' }); + recordComponentCheck('wasted', 3.0, cycleId, { mutationType: 'text' }); + + const entry = recordComponentCheck('wasted', 1.0, cycleId, { mutationType: 'none' }); + expect(entry?.wastedChecks).toBe(2); + expect(entry?.wastedPercentage).toBe(67); + }); + + it('detects memory leaks (disconnected elements)', () => { + const element = document.createElement('div'); + registerComponent({ id: 'leak', name: 'LeakComponent', element }); + + const leaks = getLeakedComponents(); + expect(leaks.length).toBe(1); + expect(leaks[0].name).toBe('LeakComponent'); + + document.body.append(element); + expect(getLeakedComponents().length).toBe(0); + }); }); diff --git a/packages/angular-render-scan/src/application/stats.ts b/packages/angular-render-scan/src/application/stats.ts index 4642fba..40851e5 100644 --- a/packages/angular-render-scan/src/application/stats.ts +++ b/packages/angular-render-scan/src/application/stats.ts @@ -3,7 +3,9 @@ import type { AngularRenderEntry, AngularRenderScanRegisteredComponent, AngularRenderScanRenderDetails, - AngularRenderScanResolvedOptions + AngularRenderScanResolvedOptions, + AngularRenderMutationType, + WaterfallEntry } from '../domain/entities'; interface ComponentStats extends AngularRenderScanRegisteredComponent { @@ -12,11 +14,17 @@ interface ComponentStats extends AngularRenderScanRegisteredComponent { latestDuration: number; latestCycleId: number; latestDetails: AngularRenderScanRenderDetails; + wastedChecks: number; } let cycleId = 0; +let cycleStartedAt = 0; +let activeCycleWaterfall: WaterfallEntry[] = []; const components = new Map(); +// Track MutationObservers for connected elements to classify mutation types +const observers = new Map(); + export function registerComponent(component: AngularRenderScanRegisteredComponent): void { const existing = components.get(component.id); components.set(component.id, { @@ -26,19 +34,55 @@ export function registerComponent(component: AngularRenderScanRegisteredComponen totalChecks: existing?.totalChecks ?? 0, latestDuration: existing?.latestDuration ?? 0, latestCycleId: existing?.latestCycleId ?? 0, - latestDetails: existing?.latestDetails ?? {} + latestDetails: existing?.latestDetails ?? {}, + wastedChecks: existing?.wastedChecks ?? 0 }); + + if (component.element && typeof MutationObserver !== 'undefined' && !observers.has(component.id)) { + try { + const state = { lastMutation: 'none' as AngularRenderMutationType } as any; + const observer = new MutationObserver((mutations) => { + let highest: AngularRenderMutationType = 'none'; + for (const m of mutations) { + if (m.addedNodes.length > 0 || m.removedNodes.length > 0) { + highest = 'structural'; + break; + } else if (m.type === 'attributes') { + highest = 'attribute'; + } else if (m.type === 'characterData') { + if (highest !== 'attribute') { + highest = 'text'; + } + } + } + if (highest !== 'none') { + state.lastMutation = highest; + } + }); + observer.observe(component.element, { attributes: true, characterData: true, childList: true, subtree: true }); + state.observer = observer; + observers.set(component.id, state); + } catch (e) { + // Ignore observer failures in test environments + } + } } export function unregisterComponent(id: string): void { components.delete(id); + const state = observers.get(id); + if (state) { + state.observer.disconnect(); + observers.delete(id); + } } export function recordComponentCheck( id: string, duration: number, currentCycleId = cycleId, - details: AngularRenderScanRenderDetails = {} + details: AngularRenderScanRenderDetails = {}, + timing?: { startTime: number; totalDuration: number; depth: number } ): AngularRenderEntry | undefined { const stats = components.get(id); if (!stats || !stats.element.isConnected) { @@ -49,15 +93,55 @@ export function recordComponentCheck( stats.latestDuration = Math.max(0, duration); stats.totalDuration += stats.latestDuration; stats.latestCycleId = currentCycleId; + + // Track wasted checks + const isWasted = details.mutationType === 'none'; + if (isWasted) { + stats.wastedChecks += 1; + } + + // Determine final mutation type + const obsState = observers.get(id); + let finalMutationType: AngularRenderMutationType = 'none'; + if (isWasted) { + finalMutationType = 'none'; + } else { + finalMutationType = obsState ? obsState.lastMutation : 'text'; + if (finalMutationType === 'none') { + finalMutationType = 'text'; + } + } + + if (obsState) { + obsState.lastMutation = 'none'; // reset for next check + } + stats.latestDetails = { reason: details.reason ?? 'unknown', - changedInputs: details.changedInputs?.slice(0, 6) + changedInputs: details.changedInputs?.slice(0, 6), + mutationType: finalMutationType }; + + // Add waterfall entry + if (timing) { + const startOffset = Math.max(0, timing.startTime - cycleStartedAt); + activeCycleWaterfall.push({ + id: stats.id, + name: stats.name, + startOffset, + selfDuration: duration, + totalDuration: timing.totalDuration, + depth: timing.depth + }); + } + return toEntry(stats); } export function startCycle(): number { cycleId += 1; + cycleStartedAt = performance.now(); + activeCycleWaterfall = []; return cycleId; } @@ -73,6 +157,8 @@ export function finishCycle( .filter((entry) => shouldIncludeEntry(entry, options)) .sort((a, b) => b.latestDuration - a.latestDuration); + const waterfall = [...activeCycleWaterfall]; + return { id, startedAt, @@ -80,38 +166,76 @@ export function finishCycle( duration: Math.max(0, finishedAt - startedAt), renderedCount: entries.length, slowest: entries[0], - entries + entries, + waterfall }; } export function resetStats(): void { cycleId = 0; + cycleStartedAt = 0; + activeCycleWaterfall = []; + for (const state of observers.values()) { + state.observer.disconnect(); + } + observers.clear(); components.clear(); } export function clearStats(): void { + activeCycleWaterfall = []; for (const stats of components.values()) { stats.totalChecks = 0; stats.totalDuration = 0; stats.latestDuration = 0; stats.latestCycleId = 0; stats.latestDetails = {}; + stats.wastedChecks = 0; + } + for (const state of observers.values()) { + state.lastMutation = 'none'; } } +export function getWastedStats(): { totalChecks: number; wastedChecks: number; wastedPercentage: number } { + let totalChecks = 0; + let wastedChecks = 0; + for (const stats of components.values()) { + totalChecks += stats.totalChecks; + wastedChecks += stats.wastedChecks || 0; + } + const wastedPercentage = totalChecks === 0 ? 0 : Math.round((wastedChecks / totalChecks) * 100); + return { totalChecks, wastedChecks, wastedPercentage }; +} + +export function getLeakedComponents(): AngularRenderEntry[] { + return [...components.values()] + .filter((stats) => !stats.element.isConnected) + .map(toEntry); +} + function toEntry(stats: ComponentStats): AngularRenderEntry { + const count = stats.totalChecks; + const wastedChecks = stats.wastedChecks || 0; + const wastedPercentage = count === 0 ? 0 : Math.round((wastedChecks / count) * 100); + return { id: stats.id, name: stats.name, element: stats.element, - rect: stats.element.getBoundingClientRect(), - count: stats.totalChecks, + rect: stats.element && stats.element.isConnected + ? stats.element.getBoundingClientRect() + : { left: 0, top: 0, width: 0, height: 0 } as DOMRect, + count, latestDuration: stats.latestDuration, - averageDuration: stats.totalChecks === 0 ? 0 : stats.totalDuration / stats.totalChecks, + averageDuration: count === 0 ? 0 : stats.totalDuration / count, latestCycleId: stats.latestCycleId, reason: stats.latestDetails.reason ?? 'unknown', changedInputs: stats.latestDetails.changedInputs, - selector: stats.selector ?? selectorFor(stats.element) + selector: stats.selector ?? (stats.element ? selectorFor(stats.element) : ''), + wastedChecks, + wastedPercentage, + mutationType: stats.latestDetails.mutationType ?? 'none' }; } diff --git a/packages/angular-render-scan/src/domain/entities.ts b/packages/angular-render-scan/src/domain/entities.ts index 1d9feb2..040719f 100644 --- a/packages/angular-render-scan/src/domain/entities.ts +++ b/packages/angular-render-scan/src/domain/entities.ts @@ -1,5 +1,7 @@ export type AngularRenderScanAnimationSpeed = 'slow' | 'fast' | 'off'; export type AngularRenderReason = 'input' | 'event' | 'tick' | 'dom' | 'unknown'; +export type AngularRenderMutationType = 'none' | 'text' | 'attribute' | 'structural'; +export type AngularRenderScanDarkMode = 'auto' | 'dark' | 'light'; export interface AngularRenderScanTheme { fast: readonly [number, number, number]; @@ -9,6 +11,12 @@ export interface AngularRenderScanTheme { labelBackgroundSlow: readonly [number, number, number]; } +export interface AngularRenderScanBudgets { + warnMs?: number; + errorMs?: number; + maxRendersPerSecond?: number; +} + export interface AngularRenderEntry { id: string; name: string; @@ -21,6 +29,9 @@ export interface AngularRenderEntry { reason?: AngularRenderReason; changedInputs?: AngularRenderChangedInput[]; selector?: string; + wastedChecks: number; + wastedPercentage: number; + mutationType?: AngularRenderMutationType; } export interface AngularRenderCycle { @@ -31,6 +42,69 @@ export interface AngularRenderCycle { renderedCount: number; slowest?: AngularRenderEntry; entries: AngularRenderEntry[]; + waterfall: WaterfallEntry[]; +} + +export interface WaterfallEntry { + id: string; + name: string; + startOffset: number; + selfDuration: number; + totalDuration: number; + depth: number; +} + +export interface BudgetViolation { + componentName: string; + selector: string; + type: 'warn' | 'error' | 'render-rate'; + actual: number; + budget: number; + message: string; + timestamp: number; +} + +export interface SessionExportData { + exportedAt: string; + url: string; + viewport: string; + userAgent: string; + options: Partial; + cycles: SessionCycleData[]; + wastedStats: WastedStats; + budgetViolations: BudgetViolation[]; + leakedComponents: string[]; +} + +export interface SessionCycleData { + id: number; + startedAt: number; + finishedAt: number; + duration: number; + renderedCount: number; + entries: SessionEntryData[]; + waterfall: WaterfallEntry[]; +} + +export interface SessionEntryData { + id: string; + name: string; + count: number; + latestDuration: number; + averageDuration: number; + latestCycleId: number; + reason?: AngularRenderReason; + changedInputs?: AngularRenderChangedInput[]; + selector?: string; + wastedChecks: number; + wastedPercentage: number; + mutationType?: AngularRenderMutationType; +} + +export interface WastedStats { + totalChecks: number; + wastedChecks: number; + wastedPercentage: number; } export interface AngularRenderScanOptions { @@ -45,22 +119,25 @@ export interface AngularRenderScanOptions { include?: Array; exclude?: Array; maxLabelCount?: number; - fastThresholdMs?: number; - slowThresholdMs?: number; maxRecordedCycles?: number; showCopyPrompt?: boolean; promptContext?: string; theme?: Partial; + editorProtocol?: 'vscode' | 'webstorm' | 'cursor' | string; + darkMode?: AngularRenderScanDarkMode; onCycleStart?: () => void; onRender?: (entry: AngularRenderEntry) => void; onCycleFinish?: (cycle: AngularRenderCycle) => void; + onBudgetViolation?: (violation: BudgetViolation) => void; } -export interface AngularRenderScanResolvedOptions extends Required> { +export interface AngularRenderScanResolvedOptions extends Required> { theme: AngularRenderScanTheme; + budgets: Required; onCycleStart?: () => void; onRender?: (entry: AngularRenderEntry) => void; onCycleFinish?: (cycle: AngularRenderCycle) => void; + onBudgetViolation?: (violation: BudgetViolation) => void; } export interface AngularRenderScanRegisteredComponent { @@ -79,4 +156,5 @@ export interface AngularRenderChangedInput { export interface AngularRenderScanRenderDetails { reason?: AngularRenderReason; changedInputs?: AngularRenderChangedInput[]; + mutationType?: AngularRenderMutationType; } diff --git a/packages/angular-render-scan/src/domain/options.spec.ts b/packages/angular-render-scan/src/domain/options.spec.ts index a4d62eb..53e1602 100644 --- a/packages/angular-render-scan/src/domain/options.spec.ts +++ b/packages/angular-render-scan/src/domain/options.spec.ts @@ -29,8 +29,6 @@ describe('options', () => { include: ['Cart'], exclude: [/Legacy/], maxLabelCount: 5, - fastThresholdMs: 4, - slowThresholdMs: 12, maxRecordedCycles: 10, showCopyPrompt: false, promptContext: 'signals app' @@ -41,8 +39,6 @@ describe('options', () => { minRenderCount: 3, include: ['Cart'], maxLabelCount: 5, - fastThresholdMs: 4, - slowThresholdMs: 12, maxRecordedCycles: 10, showCopyPrompt: false, promptContext: 'signals app' @@ -54,8 +50,6 @@ describe('options', () => { minDurationMs: -1, minRenderCount: Number.NaN, maxLabelCount: 0, - fastThresholdMs: 20, - slowThresholdMs: 10, maxRecordedCycles: -1 }); @@ -63,9 +57,23 @@ describe('options', () => { minDurationMs: 0, minRenderCount: 0, maxLabelCount: 20, - fastThresholdMs: 20, - slowThresholdMs: 15, maxRecordedCycles: 30 }); }); + + it('validates budget, editorProtocol and darkMode options', () => { + setResolvedOptions({ + editorProtocol: 'cursor', + darkMode: 'dark' + }); + + const resolved = getResolvedOptions(); + expect(resolved.budgets).toMatchObject({ + warnMs: 10, + errorMs: 30, + maxRendersPerSecond: 20 + }); + expect(resolved.editorProtocol).toBe('cursor'); + expect(resolved.darkMode).toBe('dark'); + }); }); diff --git a/packages/angular-render-scan/src/domain/options.ts b/packages/angular-render-scan/src/domain/options.ts index 314c3f2..8176059 100644 --- a/packages/angular-render-scan/src/domain/options.ts +++ b/packages/angular-render-scan/src/domain/options.ts @@ -1,4 +1,4 @@ -import type { AngularRenderScanOptions, AngularRenderScanResolvedOptions, AngularRenderScanTheme } from './entities'; +import type { AngularRenderScanBudgets, AngularRenderScanOptions, AngularRenderScanResolvedOptions, AngularRenderScanTheme } from './entities'; const defaultTheme: AngularRenderScanTheme = { fast: [147, 197, 253], // blue-300 @@ -8,6 +8,12 @@ const defaultTheme: AngularRenderScanTheme = { labelBackgroundSlow: [220, 38, 38], // red-600 }; +const defaultBudgets: Required = { + warnMs: 10, + errorMs: 30, + maxRendersPerSecond: 20 +}; + const defaultOptions: AngularRenderScanResolvedOptions = { enabled: true, showToolbar: true, @@ -20,12 +26,13 @@ const defaultOptions: AngularRenderScanResolvedOptions = { include: [], exclude: [], maxLabelCount: 20, - fastThresholdMs: 5, - slowThresholdMs: 15, maxRecordedCycles: 30, showCopyPrompt: true, promptContext: '', - theme: defaultTheme + theme: defaultTheme, + budgets: defaultBudgets, + editorProtocol: 'vscode', + darkMode: 'auto' }; let options: AngularRenderScanResolvedOptions = { ...defaultOptions }; @@ -40,16 +47,17 @@ export function resolveOptions(next?: AngularRenderScanOptions): AngularRenderSc merged.minDurationMs = normalizeNonNegative(merged.minDurationMs, defaultOptions.minDurationMs); merged.minRenderCount = normalizeNonNegative(merged.minRenderCount, defaultOptions.minRenderCount); merged.maxLabelCount = normalizePositiveInteger(merged.maxLabelCount, defaultOptions.maxLabelCount); - merged.fastThresholdMs = normalizeNonNegative(merged.fastThresholdMs, defaultOptions.fastThresholdMs); - merged.slowThresholdMs = normalizeNonNegative(merged.slowThresholdMs, defaultOptions.slowThresholdMs); - if (merged.slowThresholdMs < merged.fastThresholdMs) { - merged.slowThresholdMs = defaultOptions.slowThresholdMs; - } merged.maxRecordedCycles = normalizePositiveInteger(merged.maxRecordedCycles, defaultOptions.maxRecordedCycles); merged.include = Array.isArray(merged.include) ? merged.include : defaultOptions.include; merged.exclude = Array.isArray(merged.exclude) ? merged.exclude : defaultOptions.exclude; merged.promptContext = typeof merged.promptContext === 'string' ? merged.promptContext : defaultOptions.promptContext; - merged.theme = { ...defaultTheme, ...(next?.theme || {}) }; + merged.showCopyPrompt = typeof next?.showCopyPrompt === 'boolean' ? next.showCopyPrompt : options.showCopyPrompt; + merged.theme = { ...options.theme, ...(next?.theme || {}) }; + + merged.budgets = defaultBudgets; + + merged.editorProtocol = typeof next?.editorProtocol === 'string' ? next.editorProtocol : options.editorProtocol; + merged.darkMode = ['auto', 'dark', 'light'].includes(next?.darkMode as any) ? next!.darkMode! : options.darkMode; return merged; } diff --git a/packages/angular-render-scan/src/infrastructure/angular/angular.ts b/packages/angular-render-scan/src/infrastructure/angular/angular.ts index 58d8c5e..e89165b 100644 --- a/packages/angular-render-scan/src/infrastructure/angular/angular.ts +++ b/packages/angular-render-scan/src/infrastructure/angular/angular.ts @@ -1,4 +1,4 @@ -import { APP_INITIALIZER, ApplicationRef, Directive, ElementRef, EnvironmentProviders, InjectionToken, Input, OnDestroy, Provider, inject, makeEnvironmentProviders, isDevMode, NgZone } from '@angular/core'; +import { APP_INITIALIZER, ApplicationRef, Directive, ElementRef, EnvironmentProviders, InjectionToken, input, effect, OnDestroy, Provider, inject, makeEnvironmentProviders, isDevMode, NgZone } from '@angular/core'; import { beginCycle, copyAIPrompt, @@ -9,6 +9,9 @@ import { scan, setOptions, setTaskScheduler, + getSessionData, + getWastedStats, + getLeakedComponents } from '../../application/runtime'; import { recordComponentCheck, registerComponent, unregisterComponent } from '../../application/stats'; import type { AngularRenderScanOptions } from '../../domain/entities'; @@ -30,16 +33,14 @@ export class AngularRenderScanMarkDirective implements OnDestroy { private childrenDuration = 0; private name = this.inferName(); - @Input('angularRenderScanMark') - set angularRenderScanMark(name: string | undefined) { - if (name) { - this.name = name; - this.register(); - } - } + readonly angularRenderScanMark = input(undefined, { alias: 'angularRenderScanMark' }); constructor() { - this.register(); + effect(() => { + const customName = this.angularRenderScanMark(); + this.name = customName || this.inferName(); + this.register(); + }); } ngDoCheck(): void { @@ -61,7 +62,24 @@ export class AngularRenderScanMarkDirective implements OnDestroy { return; } - const entry = recordComponentCheck(this.id, selfDuration, cycleId); + let depth = 1; + let curr = this.parent; + while (curr) { + depth++; + curr = curr.parent; + } + + const entry = recordComponentCheck( + this.id, + selfDuration, + cycleId, + {}, + { + startTime: this.checkStartedAt, + totalDuration, + depth + } + ); if (entry) { window.dispatchEvent(new CustomEvent('angular-render-scan:render', { detail: entry })); } @@ -166,6 +184,9 @@ function registerGlobalApplicationRef(appRef: ApplicationRef): void { setOptions: typeof setOptions; getAIPrompt: typeof getAIPrompt; copyAIPrompt: typeof copyAIPrompt; + getSessionData: () => any; + getWastedStats: () => any; + getLeakedComponents: () => any; stop: () => void; }; }; @@ -176,6 +197,9 @@ function registerGlobalApplicationRef(appRef: ApplicationRef): void { setOptions, getAIPrompt, copyAIPrompt, + getSessionData, + getWastedStats, + getLeakedComponents, stop: () => { import('../../application/runtime').then(m => m.stop()); restoreApplicationRef(appRef); diff --git a/packages/angular-render-scan/src/infrastructure/angular/auto-instrumentation.ts b/packages/angular-render-scan/src/infrastructure/angular/auto-instrumentation.ts index 08c7fb3..c4ecc05 100644 --- a/packages/angular-render-scan/src/infrastructure/angular/auto-instrumentation.ts +++ b/packages/angular-render-scan/src/infrastructure/angular/auto-instrumentation.ts @@ -123,6 +123,7 @@ export function setupAutoInstrumentation(): void { } else if (event === ProfilerEventTemplateUpdateEnd) { let compData = instanceMap.get(instance); if (compData && compData.element && componentCheckStack.length > 0) { + const depth = componentCheckStack.length; const frame = componentCheckStack.pop()!; if (frame.id === compData.id) { const cycleId = currentCycleId(); @@ -138,15 +139,29 @@ export function setupAutoInstrumentation(): void { // Check if actual DOM mutation occurred to prevent full-screen flashing const nextSignature = getRenderedSignature(compData.element); const signatureChanged = compData.signature !== nextSignature; - if (signatureChanged || frame.details.reason === 'input') { + const isWasted = !signatureChanged && frame.details.reason !== 'input'; + + if (signatureChanged) { compData.signature = nextSignature; - const entry = recordComponentCheck(frame.id, selfDuration, cycleId, { + } + + const entry = recordComponentCheck( + frame.id, + selfDuration, + cycleId, + { reason: frame.details.reason === 'input' ? 'input' : signatureChanged ? 'dom' : 'unknown', - changedInputs: frame.details.changedInputs - }); - if (entry) { - window.dispatchEvent(new CustomEvent('angular-render-scan:render', { detail: entry })); + changedInputs: frame.details.changedInputs, + mutationType: isWasted ? 'none' : undefined + }, + { + startTime: frame.startTime, + totalDuration, + depth } + ); + if (entry) { + window.dispatchEvent(new CustomEvent('angular-render-scan:render', { detail: entry })); } } } else { diff --git a/packages/angular-render-scan/src/infrastructure/ui/cpu.spec.ts b/packages/angular-render-scan/src/infrastructure/ui/cpu.spec.ts new file mode 100644 index 0000000..f5c34f9 --- /dev/null +++ b/packages/angular-render-scan/src/infrastructure/ui/cpu.spec.ts @@ -0,0 +1,15 @@ +import { CpuMeter } from './cpu'; +import { describe, expect, it } from 'vitest'; + +describe('CpuMeter', () => { + it('instantiates and provides CPU metrics smoothly', () => { + const meter = new CpuMeter(); + expect(meter.value).toBe(0); + const details = meter.getDetails(); + expect(details.percentage).toBe(0); + expect(details.longTaskCount).toBe(0); + expect(details.maxDuration).toBe(0); + expect(details.totalBlockingTime).toBe(0); + meter.destroy(); + }); +}); diff --git a/packages/angular-render-scan/src/infrastructure/ui/cpu.ts b/packages/angular-render-scan/src/infrastructure/ui/cpu.ts new file mode 100644 index 0000000..28e18e2 --- /dev/null +++ b/packages/angular-render-scan/src/infrastructure/ui/cpu.ts @@ -0,0 +1,143 @@ +export class CpuMeter { + private observer: PerformanceObserver | null = null; + private tasks: Array<{ start: number; duration: number }> = []; + private frameTicks: Array<{ time: number; busy: number }> = []; + private lastFrameTime = performance.now(); + + constructor(private readonly onChange?: () => void) { + if (typeof PerformanceObserver !== 'undefined' && + PerformanceObserver.supportedEntryTypes && + PerformanceObserver.supportedEntryTypes.includes('longtask')) { + try { + this.observer = new PerformanceObserver((list) => { + const now = performance.now(); + for (const entry of list.getEntries()) { + this.tasks.push({ + start: entry.startTime, + duration: entry.duration + }); + } + this.cleanOldTasks(now); + if (this.onChange) { + this.onChange(); + } + }); + this.observer.observe({ entryTypes: ['longtask'] }); + } catch { + // Safe fallback if observer fails + } + } + } + + markFrame(now = performance.now()): void { + const elapsed = now - this.lastFrameTime; + this.lastFrameTime = now; + + // Target frame duration is 16.67ms (60fps) + const busy = Math.max(0, elapsed - 16.67); + this.frameTicks.push({ time: now, busy }); + + this.cleanOldTicks(now); + + // If a frame was severely blocked, trigger onChange instantly to show live updates! + if (busy > 20 && this.onChange) { + this.onChange(); + } + } + + private cleanOldTicks(now: number): void { + const cutoff = now - 2000; + while (this.frameTicks.length > 0 && this.frameTicks[0].time < cutoff) { + this.frameTicks.shift(); + } + } + + private cleanOldTasks(now: number): void { + const cutoff = now - 2000; // 2 second sliding window + while (this.tasks.length > 0 && this.tasks[0].start + this.tasks[0].duration < cutoff) { + this.tasks.shift(); + } + } + + get value(): number { + const now = performance.now(); + this.cleanOldTasks(now); + this.cleanOldTicks(now); + + const cutoff = now - 2000; + + // 1. Long Tasks Busy Time + let taskBusyTime = 0; + for (const task of this.tasks) { + const taskStart = Math.max(task.start, cutoff); + const taskEnd = Math.min(task.start + task.duration, now); + if (taskEnd > taskStart) { + taskBusyTime += (taskEnd - taskStart); + } + } + + // 2. Frame Lag Busy Time + let frameBusyTime = 0; + for (const tick of this.frameTicks) { + frameBusyTime += tick.busy; + } + + const totalBusyTime = Math.max(taskBusyTime, frameBusyTime); + return Math.min(100, Math.round((totalBusyTime / 2000) * 100)); + } + + getDetails() { + const now = performance.now(); + this.cleanOldTasks(now); + this.cleanOldTicks(now); + + const cutoff = now - 2000; + let taskBusyTime = 0; + let maxDuration = 0; + let longTaskCount = 0; + + for (const task of this.tasks) { + const taskStart = Math.max(task.start, cutoff); + const taskEnd = Math.min(task.start + task.duration, now); + if (taskEnd > taskStart) { + const overlap = taskEnd - taskStart; + taskBusyTime += overlap; + longTaskCount++; + if (task.duration > maxDuration) { + maxDuration = task.duration; + } + } + } + + let frameBusyTime = 0; + let maxFrameDelay = 0; + for (const tick of this.frameTicks) { + frameBusyTime += tick.busy; + if (tick.busy > maxFrameDelay) { + maxFrameDelay = tick.busy; + } + } + + const totalBusyTime = Math.max(taskBusyTime, frameBusyTime); + const percentage = Math.min(100, Math.round((totalBusyTime / 2000) * 100)); + + const displayMaxDuration = Math.round(Math.max(maxDuration, maxFrameDelay)); + const totalBlockingTime = Math.round(Math.max( + this.tasks.reduce((sum, t) => sum + Math.max(0, t.duration - 50), 0), + frameBusyTime + )); + + return { + percentage, + longTaskCount: Math.max(longTaskCount, frameBusyTime > 80 ? 1 : 0), + maxDuration: displayMaxDuration, + totalBlockingTime + }; + } + + destroy(): void { + if (this.observer) { + this.observer.disconnect(); + } + } +} diff --git a/packages/angular-render-scan/src/infrastructure/ui/overlay.ts b/packages/angular-render-scan/src/infrastructure/ui/overlay.ts index 7ea85fb..29df38f 100644 --- a/packages/angular-render-scan/src/infrastructure/ui/overlay.ts +++ b/packages/angular-render-scan/src/infrastructure/ui/overlay.ts @@ -1,6 +1,7 @@ import { FpsMeter } from './fps'; -import { clearRecording, copyAIPrompt, getRecording } from '../../application/runtime'; -import type { AngularRenderCycle, AngularRenderEntry, AngularRenderScanResolvedOptions } from '../../domain/entities'; +import { CpuMeter } from './cpu'; +import { clearRecording, copyAIPrompt, getRecording, getSessionData, getWastedStats, getLeakedComponents } from '../../application/runtime'; +import type { AngularRenderCycle, AngularRenderEntry, AngularRenderScanResolvedOptions, BudgetViolation } from '../../domain/entities'; interface ActiveHighlight { entry: AngularRenderEntry; @@ -9,7 +10,31 @@ interface ActiveHighlight { } const TOOLBAR_CSS = ` - :host { all: initial; } + :host { + all: initial; + display: block; + position: fixed; + z-index: 2147483647; + pointer-events: none; + --ars-bg: rgba(255, 255, 255, 0.85); + --ars-border: rgba(15, 23, 42, 0.08); + --ars-color: #0f172a; + --ars-label: #64748b; + --ars-panel-bg: rgba(255, 255, 255, 0.96); + --ars-card-bg: #f8fafc; + --ars-shadow: 0 1px 3px rgba(0,0,0,0.02), 0 10px 30px rgba(15, 23, 42, 0.08), inset 0 1px 0 rgba(255,255,255,0.6); + } + + :host(.dark) { + --ars-bg: rgba(15, 23, 42, 0.85); + --ars-border: rgba(255, 255, 255, 0.1); + --ars-color: #f8fafc; + --ars-label: #94a3b8; + --ars-panel-bg: rgba(15, 23, 42, 0.96); + --ars-card-bg: #1e293b; + --ars-shadow: 0 1px 3px rgba(0,0,0,0.05), 0 10px 30px rgba(0,0,0,0.5), inset 0 1px 0 rgba(255,255,255,0.05); + } + .toolbar { position: fixed; right: 16px; @@ -17,18 +42,23 @@ const TOOLBAR_CSS = ` z-index: 2147483647; display: flex; align-items: center; - gap: 10px; - padding: 10px 12px; - border: 1px solid rgba(15, 23, 42, 0.14); - border-radius: 8px; - background: rgba(255, 255, 255, 0.94); - box-shadow: 0 18px 40px rgba(15, 23, 42, 0.16); - color: #111827; - font: 500 12px/1.2 ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + gap: 12px; + padding: 8px 16px; + border: 1px solid var(--ars-border); + border-radius: 12px; + background: var(--ars-bg); + box-shadow: var(--ars-shadow); + color: var(--ars-color); + font: 500 11px/1.2 Inter, system-ui, -apple-system, sans-serif; pointer-events: auto; - backdrop-filter: blur(12px); + backdrop-filter: blur(16px); cursor: grab; user-select: none; + transition: box-shadow 0.2s ease, border-color 0.2s ease; + } + .toolbar:hover { + border-color: rgba(15, 23, 42, 0.12); + box-shadow: var(--ars-shadow); } .toolbar:active { cursor: grabbing; @@ -39,36 +69,39 @@ const TOOLBAR_CSS = ` .switch { display: inline-flex; align-items: center; - gap: 7px; - min-width: 78px; + gap: 8px; + min-width: 72px; user-select: none; } .details-toggle { display: inline-flex; align-items: center; gap: 6px; - padding: 4px 8px; - border: 1px solid #cbd5e1; - border-radius: 4px; - color: #475569; + padding: 6px 10px; + border: 1px solid var(--ars-border); + border-radius: 8px; + color: var(--ars-label); font: inherit; - font-size: 11px; + font-weight: 600; + background: var(--ars-card-bg); user-select: none; + transition: all 0.15s ease; } .details-toggle:hover { background: #f1f5f9; - color: #0f172a; + border: 1px dotted rgba(15, 23, 42, 0.4); + color: var(--ars-color); } .details-toggle input { width: 13px; height: 13px; margin: 0; - accent-color: #0891b2; + accent-color: #2563eb; } .details-toggle.active { - border-color: #0891b2; - color: #0e7490; - background: #ecfeff; + border: 1px dotted #2563eb; + color: #2563eb; + background: rgba(37, 99, 235, 0.05); } .switch input { position: absolute; @@ -77,37 +110,141 @@ const TOOLBAR_CSS = ` } .track { position: relative; - width: 34px; - height: 20px; + width: 32px; + height: 18px; border-radius: 999px; - background: #cbd5e1; - transition: background 140ms ease; + background: #e2e8f0; + transition: background 0.15s ease; } .track::after { content: ""; position: absolute; - top: 3px; - left: 3px; + top: 2px; + left: 2px; width: 14px; height: 14px; border-radius: 999px; background: #ffffff; - box-shadow: 0 1px 4px rgba(15, 23, 42, 0.25); - transition: transform 140ms ease; + box-shadow: 0 1px 3px rgba(15, 23, 42, 0.15); + transition: transform 0.15s cubic-bezier(0.4, 0, 0.2, 1); } input:checked + .track { - background: #7c3aed; + background: #2563eb; } input:checked + .track::after { transform: translateX(14px); } .switch-text { - color: #111827; + color: var(--ars-color); font-weight: 700; } - .metric { display: grid; gap: 2px; min-width: 54px; } - .label { color: #64748b; font-size: 10px; text-transform: uppercase; } - .value { color: #111827; white-space: nowrap; } + .metric { display: grid; gap: 3px; min-width: 50px; } + .metric.slowest-metric { + width: 120px; + min-width: 120px; + max-width: 120px; + } + .slowest-metric .value { + display: block; + width: 100%; + text-overflow: ellipsis; + 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; } + .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; + user-select: none; + display: inline-flex; + 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); + } + .metric.cpu-interactive:active { + transform: scale(0.97); + } + .metric.cpu-interactive.active { + background: rgba(37, 99, 235, 0.08); + box-shadow: inset 0 0 0 1px rgba(37, 99, 235, 0.25); + } + .metric.cpu-interactive .value { + border-bottom: 1.5px dotted rgba(37, 99, 235, 0.4); + padding-bottom: 0.5px; + } + .metric.cpu-interactive:hover .value { + border-bottom: 1.5px solid rgba(37, 99, 235, 0.8); + color: #2563eb; + } + .cpu-details-panel { + position: fixed; + z-index: 2147483647; + pointer-events: auto; + width: 160px; + background: var(--ars-panel-bg); + border: 1px solid var(--ars-border); + border-radius: 10px; + padding: 10px; + backdrop-filter: blur(16px); + box-shadow: var(--ars-shadow); + display: grid; + gap: 6px; + font-family: Inter, system-ui, -apple-system, sans-serif; + color: var(--ars-color); + transition: all 0.2s ease; + } + .cpu-details-panel .title { + font-size: 9px; + font-weight: 800; + text-transform: uppercase; + color: var(--ars-label); + border-bottom: 1px solid var(--ars-border); + padding-bottom: 4px; + display: flex; + justify-content: space-between; + align-items: center; + letter-spacing: 0.05em; + } + .cpu-details-panel .row { + display: flex; + justify-content: space-between; + font-size: 10px; + font-weight: 500; + } + .cpu-details-panel .row-val { + font-family: monospace; + font-weight: 700; + } + .cpu-details-panel .row-val.low { color: #10b981; } + .cpu-details-panel .row-val.medium { color: #f59e0b; } + .cpu-details-panel .row-val.high { color: #ef4444; } + + .cpu-bar-bg { + width: 100%; + height: 4px; + background: #e2e8f0; + border-radius: 2px; + overflow: hidden; + margin: 2px 0; + } + .cpu-bar-fill { + height: 100%; + transition: width 0.2s ease, background-color 0.2s ease; + } + .cpu-bar-fill.low { background: #10b981; } + .cpu-bar-fill.medium { background: #f59e0b; } + .cpu-bar-fill.high { background: #ef4444; } + .toolbar-actions { display: flex; align-items: center; @@ -123,14 +260,14 @@ const TOOLBAR_CSS = ` bottom: calc(100% + 8px); z-index: 2147483647; width: max-content; - max-width: 260px; + max-width: 240px; transform: translateX(-50%) translateY(4px); - padding: 7px 9px; - border-radius: 6px; - background: #111827; + padding: 6px 10px; + border-radius: 8px; + background: #0f172a; color: #ffffff; - box-shadow: 0 10px 28px rgba(15, 23, 42, 0.22); - font: 500 11px/1.35 ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + box-shadow: 0 10px 25px rgba(15, 23, 42, 0.12); + font: 500 10px/1.35 Inter, system-ui, -apple-system, sans-serif; opacity: 0; pointer-events: none; white-space: normal; @@ -144,7 +281,7 @@ const TOOLBAR_CSS = ` z-index: 2147483647; transform: translateX(-50%) translateY(4px); border: 5px solid transparent; - border-top-color: #111827; + border-top-color: #0f172a; opacity: 0; pointer-events: none; transition: opacity 120ms ease, transform 120ms ease; @@ -173,54 +310,92 @@ const TOOLBAR_CSS = ` transform: translateY(0); } .clear-btn, .action-btn, .panel-close, .panel-copy-btn { - background: none; - border: 1px solid #cbd5e1; - border-radius: 4px; - padding: 4px 8px; + background: var(--ars-card-bg); + border: 1px solid var(--ars-border); + border-radius: 8px; + padding: 6px 10px; font: inherit; - font-size: 11px; - color: #475569; - transition: all 0.2s; + font-weight: 600; + color: var(--ars-label); + transition: all 0.15s ease; } .clear-btn:hover, .action-btn:hover, .panel-close:hover, .panel-copy-btn:hover { background: #f1f5f9; - color: #0f172a; + border-color: rgba(15, 23, 42, 0.15); + color: var(--ars-color); + transform: translateY(-0.5px); + } + .clear-btn:active, .action-btn:active, .panel-close:active, .panel-copy-btn:active { + transform: translateY(0); } .action-btn.active { - border-color: #7c3aed; - color: #6d28d9; - background: #f5f3ff; + border-color: rgba(37, 99, 235, 0.2); + color: #2563eb; + background: rgba(37, 99, 235, 0.05); } .status { - color: #6d28d9; - font-size: 11px; - min-width: 56px; + position: absolute; + bottom: calc(100% + 8px); + right: 16px; + z-index: 2147483647; + color: #ffffff; + background: #2563eb; + font-size: 10px; + font-weight: 700; + padding: 4px 8px; + border-radius: 6px; + box-shadow: + 0 1px 3px rgba(0,0,0,0.02), + 0 8px 20px rgba(37, 99, 235, 0.15); + white-space: nowrap; + pointer-events: none; + animation: floatUp 0.2s cubic-bezier(0.4, 0, 0.2, 1); + } + @keyframes floatUp { + from { + opacity: 0; + transform: translateY(4px); + } + to { + opacity: 1; + transform: translateY(0); + } } .inspect-panel { position: fixed; right: 16px; - bottom: 76px; + bottom: 72px; z-index: 2147483647; - width: min(360px, calc(100vw - 32px)); + width: min(300px, calc(100vw - 32px)); display: grid; - gap: 10px; + gap: 8px; padding: 12px; - border: 1px solid rgba(15, 23, 42, 0.14); - border-radius: 8px; - background: rgba(255, 255, 255, 0.97); - box-shadow: 0 18px 40px rgba(15, 23, 42, 0.16); - color: #111827; - font: 500 12px/1.35 ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + border: 1px solid var(--ars-border); + border-top: 4px solid #3b82f6; + border-radius: 12px; + background: var(--ars-panel-bg); + box-shadow: var(--ars-shadow); + color: var(--ars-color); + font: 500 11px/1.3 Inter, system-ui, -apple-system, sans-serif; pointer-events: auto; - backdrop-filter: blur(12px); + backdrop-filter: blur(16px); + transition: border-top-color 0.2s ease; } + .inspect-panel.slow { border-top-color: #ef4444; } + .inspect-panel.medium { border-top-color: #f59e0b; } + .inspect-panel.fast { border-top-color: #10b981; } .panel-head { display: flex; justify-content: space-between; + align-items: center; gap: 8px; + border-bottom: 1px solid var(--ars-border); + padding-bottom: 6px; } .panel-title { + font-size: 12px; font-weight: 800; + color: var(--ars-color); overflow-wrap: anywhere; } .panel-actions { @@ -232,50 +407,128 @@ const TOOLBAR_CSS = ` .severity { display: inline-flex; width: fit-content; - padding: 3px 7px; - border-radius: 999px; - font-size: 10px; - font-weight: 800; + padding: 1px 6px; + border-radius: 20px; + font-size: 8px; + font-weight: 700; text-transform: uppercase; + letter-spacing: 0.02em; + margin-top: 2px; } .severity.slow { - color: #991b1b; - background: #fee2e2; + color: #e11d48; + background: rgba(225, 29, 72, 0.08); + border: 1px solid rgba(225, 29, 72, 0.15); } .severity.medium { - color: #92400e; - background: #fef3c7; + color: #d97706; + background: rgba(217, 119, 6, 0.08); + border: 1px solid rgba(217, 119, 6, 0.15); } .severity.fast { - color: #075985; - background: #e0f2fe; + color: #059669; + background: rgba(5, 150, 105, 0.08); + border: 1px solid rgba(5, 150, 105, 0.15); } .panel-grid { display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 8px; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 4px; + margin: 2px 0; + } + .panel-grid .panel-field { + background: var(--ars-card-bg); + border: 1px solid var(--ars-border); + border-radius: 6px; + padding: 4px; + text-align: center; + display: flex; + flex-direction: column; + justify-content: center; + min-height: 36px; + } + .panel-grid .panel-label { + font-size: 8px; + font-weight: 700; + color: var(--ars-label); + text-transform: uppercase; + letter-spacing: 0.05em; + } + .panel-grid .panel-value { + font-size: 10px; + font-weight: 700; + color: var(--ars-color); + margin-top: 1px; } - .panel-field { + .inspect-panel .panel-field:not(.panel-grid .panel-field) { + border-top: 1px solid var(--ars-border); + padding-top: 6px; display: grid; gap: 2px; } - .panel-label { - color: #64748b; - font-size: 10px; + .inspect-panel .panel-label { + color: var(--ars-label); + font-size: 9px; + font-weight: 700; text-transform: uppercase; + letter-spacing: 0.05em; } - .panel-value { - color: #111827; + .inspect-panel .panel-value { + color: var(--ars-color); + font-size: 11px; + line-height: 1.35; overflow-wrap: anywhere; } .panel-list { display: grid; - gap: 4px; - color: #334155; + gap: 6px; + margin-top: 2px; } - .panel-list div { - overflow-wrap: anywhere; + .rec-card { + padding: 6px 10px; + border-radius: 6px; + border: 1px solid var(--ars-border); + border-left: 3px solid #3b82f6; + background: var(--ars-card-bg); + display: flex; + flex-direction: column; + gap: 2px; + transition: all 0.15s ease; + } + .rec-card:hover { + border-color: rgba(15, 23, 42, 0.1); + transform: translateY(-0.5px); + } + .rec-card.slow { + border-left-color: #ef4444; + background: rgba(239, 68, 68, 0.015); } + .rec-card.medium { + border-left-color: #f59e0b; + background: rgba(245, 158, 11, 0.015); + } + .rec-card.fast { + border-left-color: #10b981; + background: rgba(16, 185, 129, 0.015); + } + .rec-category { + font-size: 7.5px; + font-weight: 800; + text-transform: uppercase; + letter-spacing: 0.05em; + } + .rec-card.slow .rec-category { color: #e11d48; } + .rec-card.medium .rec-category { color: #d97706; } + .rec-card.fast .rec-category { color: #059669; } + .rec-action { + font-size: 10px; + line-height: 1.35; + color: var(--ars-color); + font-weight: 500; + margin: 0; + } + + `; export class AngularRenderScanOverlay { @@ -284,6 +537,8 @@ export class AngularRenderScanOverlay { 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 showCpuDetails = false; private raf = 0; private latestFps = 0; private lastFpsSampleAt = 0; @@ -304,8 +559,31 @@ export class AngularRenderScanOverlay { private dragStartX = 0; private dragStartY = 0; + private get slowThresholdMs(): number { + return this.options.budgets?.warnMs ?? 10; + } + + private get fastThresholdMs(): number { + return (this.options.budgets?.warnMs ?? 10) / 2; + } + + 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) { 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.host.style.pointerEvents = 'none'; this.canvas.style.cssText = [ 'position:fixed', @@ -319,6 +597,65 @@ export class AngularRenderScanOverlay { window.addEventListener('resize', this.resize); this.loop(); this.setupDragListeners(); + this.updateDarkMode(); + + // Setup budget violation event listener + this.budgetViolationListener = (e: any) => { + if (e.detail) { + this.addBudgetViolation(e.detail); + } + }; + window.addEventListener('angular-render-scan:budget-violation', this.budgetViolationListener); + + // Setup keyboard shortcuts + this.keyListener = (e: KeyboardEvent) => { + if (e.altKey && e.shiftKey) { + const key = e.key.toLowerCase(); + if (key === 's') { + e.preventDefault(); + this.onToggle(!this.options.enabled); + } else if (key === 'd') { + e.preventDefault(); + this.detailsMode = !this.detailsMode; + this.hoveredEntry = undefined; + this.hoveredRect = undefined; + if (!this.detailsMode) this.selectedEntry = undefined; + this.renderToolbar(); + } else if (key === 'c') { + e.preventDefault(); + copyAIPrompt(this.latestFps || this.fps.value).then(copied => { + this.setCopyStatus(copied ? 'Copied' : 'No render data'); + }); + } else if (key === 'x') { + e.preventDefault(); + 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.renderToolbar(); + }); + } 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.selectedEntry = undefined; + this.showCpuDetails = false; + this.showWaterfallPanel = false; + this.showAlertsPanel = false; + this.renderToolbar(); + } + } + }; + window.addEventListener('keydown', this.keyListener); } private setupDragListeners(): void { @@ -424,11 +761,22 @@ export class AngularRenderScanOverlay { updateOptions(options: AngularRenderScanResolvedOptions): void { this.options = options; + this.updateDarkMode(); this.renderToolbar(); } showCycle(cycle: AngularRenderCycle): void { this.latestCycle = cycle; + + // Track sparkline durations + this.last30CycleDurations.push({ + duration: cycle.duration, + isSlow: cycle.duration >= this.slowThresholdMs + }); + if (this.last30CycleDurations.length > 30) { + this.last30CycleDurations.shift(); + } + const ttl = this.highlightTtl(); if (ttl > 0 && this.options.enabled) { const expiresAt = performance.now() + ttl; @@ -441,6 +789,7 @@ export class AngularRenderScanOverlay { private globalMoveListener?: (e: MouseEvent) => void; destroy(): void { + this.cpu.destroy(); cancelAnimationFrame(this.raf); window.removeEventListener('resize', this.resize); if (this.globalClickListener) { @@ -449,6 +798,12 @@ export class AngularRenderScanOverlay { if (this.globalMoveListener) { document.removeEventListener('mousemove', this.globalMoveListener, { capture: true }); } + if (this.keyListener) { + window.removeEventListener('keydown', this.keyListener); + } + if (this.budgetViolationListener) { + window.removeEventListener('angular-render-scan:budget-violation', this.budgetViolationListener); + } window.clearTimeout(this.copyStatusTimer); this.host.remove(); this.canvas.remove(); @@ -465,6 +820,7 @@ export class AngularRenderScanOverlay { private readonly loop = (): void => { this.fps.mark(); + this.cpu.markFrame(); const now = performance.now(); if (now - this.lastFpsSampleAt >= 500) { this.latestFps = this.fps.value; @@ -490,14 +846,11 @@ export class AngularRenderScanOverlay { const activeHighlights = this.getActiveHighlights(now); const labelledIds = this.getLabelledEntryIds(activeHighlights).slice(0, this.options.maxLabelCount); - // Sort activeHighlights so parents are drawn first, then children, ensuring child labels stay on top if overlapping - // We already sort by area descending in getActiveHighlights, which is good. - const fadeDuration = this.highlightTtl() || 1; for (const { entry, expiresAt, rect } of activeHighlights) { const alpha = Math.max(0.18, Math.min(1, (expiresAt - now) / fadeDuration)); - this.drawOutline(rect, alpha, entry.latestDuration); + this.drawOutline(rect, alpha, entry); if (labelledIds.includes(entry.id)) { this.drawLabel(entry, rect, alpha, entry.latestDuration); @@ -505,7 +858,7 @@ export class AngularRenderScanOverlay { } if (this.detailsMode && this.hoveredEntry && this.hoveredRect) { - this.drawHoverTarget(this.hoveredRect, this.hoveredEntry.latestDuration); + this.drawHoverTarget(this.hoveredRect, this.hoveredEntry); } } @@ -568,18 +921,39 @@ export class AngularRenderScanOverlay { private getColorForDuration(duration: number, type: 'stroke' | 'bg'): readonly [number, number, number] { const { theme } = this.options; - if (duration >= this.options.slowThresholdMs) return type === 'bg' ? theme.labelBackgroundSlow! : theme.slow!; - if (duration > this.options.fastThresholdMs) return type === 'bg' ? theme.labelBackground! : theme.medium!; + 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 drawOutline(rect: DOMRect, alpha: number, duration: number): void { + private getStrokeColorForMutation(entry: AngularRenderEntry): readonly [number, number, number] { + if (entry.element && !entry.element.isConnected) { + return this.options.theme.slow; // Red for leaked/disconnected components! + } + const maxDuration = Math.max(entry.latestDuration, entry.averageDuration); + if (maxDuration >= this.slowThresholdMs) { + return this.options.theme.slow; // Red for expensive/slow renders! + } + if (maxDuration > this.fastThresholdMs) { + return this.options.theme.medium; // Yellow/Warning for moderately expensive renders! + } + const type = entry.mutationType || 'none'; + if (type === 'none') { + return [34, 197, 94]; // Green for wasted no-ops! + } + 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 { if (!this.context) { return; } - const strokeColor = this.getColorForDuration(duration, 'stroke'); - const glowColor = strokeColor; // Use the same color for glow but with alpha + const strokeColor = this.getStrokeColorForMutation(entry); + const glowColor = strokeColor; this.context.shadowColor = rgba(glowColor, Math.min(0.45, alpha)); this.context.shadowBlur = 16; @@ -589,16 +963,16 @@ export class AngularRenderScanOverlay { this.context.shadowBlur = 0; } - private drawHoverTarget(rect: DOMRect, duration: number): void { + private drawHoverTarget(rect: DOMRect, entry: AngularRenderEntry): void { if (!this.context) { return; } - const color = this.getColorForDuration(duration, 'stroke'); + const color = this.getStrokeColorForMutation(entry); this.context.save(); this.context.strokeStyle = rgba(color, 0.95); this.context.lineWidth = 3; - this.context.setLineDash([6, 4]); + this.context.setLineDash([2, 4]); this.context.strokeRect(rect.left, rect.top, rect.width, rect.height); this.context.restore(); } @@ -608,7 +982,11 @@ export class AngularRenderScanOverlay { return; } - const bgColor = this.getColorForDuration(duration, 'bg'); + const maxDuration = Math.max(entry.latestDuration, entry.averageDuration); + let bgColor = this.getColorForDuration(maxDuration, 'bg'); + if (entry.element && !entry.element.isConnected) { + bgColor = this.options.theme.labelBackgroundSlow!; // Red for leaked component labels + } this.context.fillStyle = rgba(bgColor, Math.min(0.9, alpha + 0.1)); this.context.font = '600 11px ui-sans-serif, system-ui, sans-serif'; @@ -639,28 +1017,84 @@ export class AngularRenderScanOverlay { 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 wasted = getWastedStats(); + const leaks = getLeakedComponents(); + + // Generate timeline sparkline SVG + 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(''); + sparklineSvg = ` + + Timeline + ${bars} + + `; + } else { + sparklineSvg = `Timeline-`; + } + + // Leaks metric chip + 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 + ? ` + Alerts + + ⚠️ ${this.budgetViolations.length} + + ` + : ''; + const htmlChanged = this.replaceToolbarHtml(container, ` ${this.inspectPanelHtml()} + ${this.cpuDetailsHtml()} + ${this.waterfallPanelHtml()} + ${this.alertsPanelHtml()}
- ${this.metric('FPS', this.options.showFPS ? String(displayedFps) : '-')} + ${this.metric('FPS', this.options.showFPS ? String(displayedFps) : '-', this.getFpsClass(displayedFps))} + + CPU + ${cpuVal}% + + ${sparklineSvg} ${this.metric('Cycle', cycle ? `${cycle.duration.toFixed(1)}ms` : '-')} - ${this.metric('Count', cycle ? String(cycle.renderedCount) : '0')} - ${this.metric('Slowest', cycle?.slowest ? cycle.slowest.name : '-')} + ${this.metric('Wasted', `${wasted.wastedChecks} (${wasted.wastedPercentage}%)`, wasted.wastedChecks > 0 ? 'cpu-medium' : '')} + ${leaksChip} + ${alertsChip} + ${this.metric('Slowest', cycle?.slowest ? cycle.slowest.name : '-', '', 'slowest-metric')} - - ${this.options.showCopyPrompt ? '' : ''} + ${this.options.showCopyPrompt ? '' : ''} + - ${escapeHtml(this.copyStatus)} + ${this.copyStatus ? `${escapeHtml(this.copyStatus)}` : ''}
`); @@ -673,6 +1107,29 @@ export class AngularRenderScanOverlay { 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 }); + + 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 => { @@ -683,6 +1140,11 @@ export class AngularRenderScanOverlay { 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 }); @@ -702,11 +1164,28 @@ export class AngularRenderScanOverlay { 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 }); + 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; @@ -714,10 +1193,54 @@ export class AngularRenderScanOverlay { 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); + } + + const openInEditorUrl = this.getEditorUrl(entry); + if (openInEditorUrl) { + const w = window.open(openInEditorUrl, '_blank'); + if (w) { + setTimeout(() => w.close(), 500); + } + } + + this.setCopyStatus('Copied class search query!'); + }, { once: true }); + + container.querySelector('.alerts-close-btn')?.addEventListener('click', () => { + this.showAlertsPanel = false; + this.renderToolbar(); + }, { once: true }); + + container.querySelector('.clear-alerts-btn')?.addEventListener('click', () => { + this.budgetViolations = []; + this.showAlertsPanel = false; + this.renderToolbar(); + }, { once: true }); + } + + private metric(label: string, value: string, extraClass = '', containerClass = ''): string { + const cls = containerClass ? `metric ${containerClass}` : 'metric'; + const escapedValue = escapeHtml(value); + return `${label}${escapedValue}`; } - private metric(label: string, value: string): string { - return `${label}${value}`; + private getFpsClass(fps: number): string { + if (!this.options.showFPS || fps === 0) { + return ''; + } + return fps < 50 ? 'fps-drop' : ''; } private replaceToolbarHtml(toolbar: HTMLElement, html: string): boolean { @@ -740,7 +1263,7 @@ export class AngularRenderScanOverlay { .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`); - const isSlow = entry.latestDuration >= this.options.slowThresholdMs; + const isSlow = entry.latestDuration >= this.slowThresholdMs; const severity = this.severityFor(entry); const cost = this.costFor(entry); const recommendations = this.recommendationsFor(entry); @@ -748,15 +1271,32 @@ export class AngularRenderScanOverlay { ? entry.changedInputs.map((input) => `${escapeHtml(input.name)}: ${escapeHtml(input.previous)} -> ${escapeHtml(input.current)}`).join('
') : '-'; + const openInEditorUrl = this.getEditorUrl(entry); + const openLinkHtml = openInEditorUrl + ? `` + : ''; + + const leakWarningHtml = !entry.element?.isConnected + ? `
+ ⚠️ + Memory Leak Warning: Element is disconnected from the DOM but was not destroyed! +
` + : ''; + return ` -
+
+ ${leakWarningHtml}
${escapeHtml(entry.name)}
- ${escapeHtml(severity.label)} + ${openLinkHtml} +
${escapeHtml(severity.label)}
- ${isSlow ? '' : ''} + ${isSlow ? '' : ''}
@@ -768,6 +1308,19 @@ export class AngularRenderScanOverlay { ${this.panelField('Selector', entry.selector ?? '-')} ${this.panelField('Cycle', `#${entry.latestCycleId}`)} +
+ Wasted Renders + + ${entry.wastedChecks} of ${entry.count} checks were no-ops (${entry.wastedPercentage}% wasted) +
+
+
+
+
+
+ DOM Mutation Type + ${escapeHtml(entry.mutationType ?? 'none')} +
Estimated cost ${escapeHtml(cost)} @@ -782,7 +1335,14 @@ export class AngularRenderScanOverlay {
Recommendations - ${recommendations.map((item) => `
${escapeHtml(item)}
`).join('')}
+ + ${recommendations.map((rec) => ` +
+ ${escapeHtml(rec.category)} +

${escapeHtml(rec.action)}

+
+ `).join('')} +
`; @@ -792,6 +1352,42 @@ export class AngularRenderScanOverlay { return `${escapeHtml(label)}${escapeHtml(value)}`; } + private cpuDetailsHtml(): string { + if (!this.showCpuDetails) { + return ''; + } + const details = this.cpu.getDetails(); + const fillClass = details.percentage > 50 ? 'high' : details.percentage > 20 ? 'medium' : 'low'; + + return ` +
+
+ CPU Usage + Main Thread +
+
+
+
+
+ Busy Rate + ${details.percentage}% +
+
+ Blocking Tasks + ${details.longTaskCount} +
+
+ Max Task Delay + ${details.maxDuration}ms +
+
+ Total Block Time + ${details.totalBlockingTime}ms +
+
+ `; + } + private setCopyStatus(status: string): void { this.copyStatus = status; window.clearTimeout(this.copyStatusTimer); @@ -807,10 +1403,14 @@ export class AngularRenderScanOverlay { } private severityFor(entry: AngularRenderEntry): { kind: 'slow' | 'medium' | 'fast'; label: string } { - if (entry.latestDuration >= this.options.slowThresholdMs) { + if (entry.element && !entry.element.isConnected) { + return { kind: 'slow', label: 'Memory Leak' }; + } + const maxDuration = Math.max(entry.latestDuration, entry.averageDuration); + if (maxDuration >= this.slowThresholdMs) { return { kind: 'slow', label: 'Slow issue' }; } - if (entry.latestDuration > this.options.fastThresholdMs) { + if (maxDuration > this.fastThresholdMs) { return { kind: 'medium', label: 'Watch' }; } return { kind: 'fast', label: 'Healthy' }; @@ -823,23 +1423,55 @@ export class AngularRenderScanOverlay { 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): string[] { - const recommendations: string[] = []; - if (entry.latestDuration >= this.options.slowThresholdMs) { - recommendations.push(`${entry.name} crossed the slow threshold by ${(entry.latestDuration - this.options.slowThresholdMs).toFixed(1)}ms; inspect template expressions, computed values, pipes, and synchronous work inside this component first.`); + 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', + 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' + }); + } + + const maxDuration = Math.max(entry.latestDuration, entry.averageDuration); + if (maxDuration >= this.slowThresholdMs) { + recommendations.push({ + 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' + }); } if (entry.reason === 'input' || entry.changedInputs?.length) { - const inputNames = entry.changedInputs?.map((input) => input.name).join(', ') || 'its inputs'; - recommendations.push(`${entry.name} rendered after input changes (${inputNames}); check whether the parent recreates these values or passes unstable object/array/function identities.`); + const inputNames = entry.changedInputs?.map((input) => input.name).join(', ') || 'unspecified inputs'; + recommendations.push({ + 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' + }); } if (entry.count > 5) { - recommendations.push(`${entry.name} rendered ${entry.count} times in the observed session; look for local subscriptions, timers, repeated events, or parent updates that specifically target this component.`); + recommendations.push({ + category: 'Render Fatigue', + action: `Checked ${entry.count} times. Audit local subscriptions, interval timers, or event bindings triggering frequent CD ticks.`, + severity: 'medium' + }); } - if (entry.selector?.includes('item') || entry.selector?.includes('card') || entry.name.toLowerCase().includes('item') || entry.name.toLowerCase().includes('card')) { - recommendations.push(`${entry.name} looks like a repeated UI component; verify track expressions and avoid rebuilding item inputs for unchanged rows.`); + 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' + }); } if (recommendations.length === 0) { - recommendations.push(`${entry.name} is currently below the slow threshold; use this panel mainly to confirm render reason, input changes, and whether the component keeps rendering unnecessarily.`); + 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; } @@ -863,39 +1495,201 @@ export class AngularRenderScanOverlay { .slice(-8) .map((cycle) => { const match = cycle.entries.find((candidate) => candidate.id === entry.id); - return `- #${cycle.id}: component ${match?.latestDuration.toFixed(1)}ms, cycle ${cycle.duration.toFixed(1)}ms, rendered ${cycle.renderedCount}`; + 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') + ? entry.changedInputs.map((input) => `- \`${input.name}\`: \`${input.previous}\` -> \`${input.current}\``).join('\n') : '- none captured'; 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.', '', - `Component: ${entry.name}`, - `Selector: ${entry.selector ?? '-'}`, - `Severity: ${this.severityFor(entry).label}`, - `Latest render: ${entry.latestDuration.toFixed(1)}ms`, - `Average render: ${entry.averageDuration.toFixed(1)}ms`, - `Render count: ${entry.count}`, - `Reason: ${entry.reason ?? 'unknown'}`, - `Thresholds: fast <= ${this.options.fastThresholdMs.toFixed(1)}ms, slow >= ${this.options.slowThresholdMs.toFixed(1)}ms`, - `Estimated cost: ${this.costFor(entry)}`, - typeof fps === 'number' && Number.isFinite(fps) ? `FPS: ${fps}` : '', + '---', '', + '## 📊 Telemetry Diagnostics', + 'Below is the diagnostic telemetry data captured for this component:', + `* **Component Class:** \`${entry.name}\``, + `* **Selector:** \`${entry.selector ?? '-'}\``, + `* **Performance Severity:** **${this.severityFor(entry).label}**`, + `* **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:', changedInputs, '', 'Recent cycles for this component:', ...(recentCycles.length > 0 ? recentCycles : ['- none captured']), '', - 'Component-local recommendations from the scanner:', - ...this.recommendationsFor(entry).map((item) => `- ${item}`), + '---', + '', + '## 🧠 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}`), + '', + '---', '', - 'Please suggest concrete Angular fixes for this component. Prioritize reducing render cost and avoiding unnecessary change detection. Focus on OnPush, signals/computed values, stable input identity, track expressions, event handlers, subscriptions, and expensive template work. Do not assume access to source code beyond this diagnostic snapshot.' + '## 🛠️ 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 getEditorUrl(entry: AngularRenderEntry): string { + const protocol = this.options.editorProtocol || 'vscode'; + const query = encodeURIComponent(`class ${entry.name}`); + if (protocol === 'vscode') { + return `vscode://vscode.code-search/search?query=${query}`; + } + if (protocol === 'cursor') { + return `cursor://vscode.code-search/search?query=${query}`; + } + if (protocol === 'webstorm') { + return `webstorm://search?query=${query}`; + } + return `${protocol}://search?query=${query}`; + } + + private addBudgetViolation(violation: BudgetViolation): void { + if (this.budgetViolations.some(v => v.componentName === violation.componentName && v.timestamp === violation.timestamp && v.type === violation.type)) { + return; + } + this.budgetViolations.push(violation); + if (this.budgetViolations.length > 50) { + this.budgetViolations.shift(); + } + this.renderToolbar(); + } + + private updateDarkMode(): void { + const mode = this.options.darkMode; + let isDark = false; + if (mode === 'dark') { + isDark = true; + } else if (mode === 'light') { + isDark = false; + } else { + isDark = typeof window !== 'undefined' && window.matchMedia?.('(prefers-color-scheme: dark)').matches; + } + if (isDark) { + this.host.classList.add('dark'); + } else { + this.host.classList.remove('dark'); + } + } + + private waterfallPanelHtml(): string { + if (!this.showWaterfallPanel || !this.latestCycle) return ''; + const cycle = this.latestCycle; + const waterfall = cycle.waterfall || []; + const rightOffset = this.showCpuDetails ? (this.toolbarX + 290) : (this.toolbarX + 120); + if (waterfall.length === 0) { + return ` +
+
+ CD Waterfall + +
+
No waterfall data for this cycle
+
+ `; + } + + 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)} + +
+
+
+ ${w.selfDuration.toFixed(1)}ms +
+ `; + }).join(''); + + return ` +
+
+ Waterfall (Cycle #${cycle.id}) + +
+
+ ${items} +
+
+ `; + } + + 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 ` +
+
+ + ${typeLabel} + + ${timeStr} +
+
${escapeHtml(v.componentName)}
+
${escapeHtml(v.message)}
+
+ `; + }).join(''); + + let alertsRight = this.toolbarX + 120; + if (this.showCpuDetails) { + alertsRight += 170; + } + if (this.showWaterfallPanel) { + alertsRight += 310; + } + + return ` +
+
+ + ⚠️ Budget Violations + +
+ + +
+
+
+ ${itemsHtml} +
+
+ `; + } + } function rgba(color: readonly [number, number, number], alpha: number): string { diff --git a/packages/angular-render-scan/src/public-api.ts b/packages/angular-render-scan/src/public-api.ts index 92084e4..d1285ef 100644 --- a/packages/angular-render-scan/src/public-api.ts +++ b/packages/angular-render-scan/src/public-api.ts @@ -5,6 +5,23 @@ export { scan, setOptions, stop, + getSessionData, + getWastedStats, + getLeakedComponents } from './application/runtime'; export { AngularRenderScanMarkDirective, ANGULAR_RENDER_SCAN_OPTIONS, provideAngularRenderScan, restoreApplicationRef } from './infrastructure/angular/angular'; -export type { AngularRenderChangedInput, AngularRenderCycle, AngularRenderEntry, AngularRenderReason, AngularRenderScanOptions } from './domain/entities'; +export type { + AngularRenderChangedInput, + AngularRenderCycle, + AngularRenderEntry, + AngularRenderReason, + AngularRenderScanOptions, + AngularRenderScanBudgets, + BudgetViolation, + SessionExportData, + WaterfallEntry, + WastedStats, + AngularRenderMutationType +} from './domain/entities'; +export { startRenderAudit } from './testing'; +export type { RenderAuditReport, RenderAuditSession } from './testing'; diff --git a/packages/angular-render-scan/src/testing.ts b/packages/angular-render-scan/src/testing.ts new file mode 100644 index 0000000..68b0fc4 --- /dev/null +++ b/packages/angular-render-scan/src/testing.ts @@ -0,0 +1,75 @@ +export interface RenderAuditReport { + rendersFor(name: string): Promise; + maxDurationFor(name: string): Promise; + wastedRenderPercentage(): Promise; + budgetViolations(): Promise; +} + +export interface RenderAuditSession { + stop(): Promise; +} + +/** + * Starts a Playwright-compatible headless render audit session on the given Page object. + * Intercepts change-detection metrics and budget violations. + */ +export async function startRenderAudit(page: any): Promise { + await page.evaluate(() => { + const scan = (window as any).AngularRenderScan; + if (scan) { + scan.scan({ enabled: true }); + } + }); + + return { + async stop(): Promise { + const sessionData = await page.evaluate(() => { + const scan = (window as any).AngularRenderScan; + if (scan && scan.getSessionData) { + const data = scan.getSessionData(); + scan.stop(); + return data; + } + return null; + }); + + if (!sessionData) { + throw new Error( + '[angular-render-scan] Telemetry session data could not be fetched. ' + + 'Is the package enabled and running in development mode?' + ); + } + + return { + async rendersFor(name: string): Promise { + let count = 0; + for (const cycle of sessionData.cycles) { + for (const entry of cycle.entries) { + if (entry.name === name) { + count = Math.max(count, entry.count); + } + } + } + return count; + }, + async maxDurationFor(name: string): Promise { + let maxDur = 0; + for (const cycle of sessionData.cycles) { + for (const entry of cycle.entries) { + if (entry.name === name) { + maxDur = Math.max(maxDur, entry.latestDuration); + } + } + } + return maxDur; + }, + async wastedRenderPercentage(): Promise { + return sessionData.wastedStats.wastedPercentage; + }, + async budgetViolations(): Promise { + return sessionData.budgetViolations; + } + }; + } + }; +}