diff --git a/README.md b/README.md index b51a0a8..a402894 100644 --- a/README.md +++ b/README.md @@ -8,11 +8,11 @@ Angular Render Scan is a visual debugging overlay for Angular change detection. ## Versions -| Package | Version | -|---|---| -| Angular | `^22.0.0` | -| `angular-render-scan` | `0.1.8` | -| `angular-render-scan-cli` | `0.1.5` | +| Package | Version | +| ------------------------- | --------- | +| Angular | `^22.0.0` | +| `angular-render-scan` | `0.1.8` | +| `angular-render-scan-cli` | `0.1.5` | ## What it shows @@ -50,17 +50,17 @@ npx angular-render-scan-cli --help Add the provider to your Angular bootstrap config. ```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' - }) - ] + animationSpeed: "fast", + }), + ], }); ``` @@ -79,12 +79,12 @@ provideAngularRenderScan({ enabled: true, showToolbar: true, showFPS: true, - animationSpeed: 'fast', + animationSpeed: "fast", maxLabelCount: 20, maxRecordedCycles: 30, showCopyPrompt: true, - promptContext: 'Angular app using signals and OnPush components', - log: false + promptContext: "Angular app using signals and OnPush components", + log: false, }); ``` @@ -114,8 +114,8 @@ import { getZonePollutionEvents, scan, setOptions, - stop -} from 'angular-render-scan'; + stop, +} from "angular-render-scan"; scan(); setOptions({ enabled: false }); @@ -139,14 +139,14 @@ The toolbar shows render count, FPS, latest cycle time, slowest component, trigg Useful shortcuts: -| Shortcut | Action | -|---|---| -| `Alt+Shift+S` | Toggle scanner | -| `Alt+Shift+D` | Toggle Details Mode | +| Shortcut | Action | +| ------------- | -------------------------- | +| `Alt+Shift+S` | Toggle scanner | +| `Alt+Shift+D` | Toggle Details Mode | | `Alt+Shift+C` | Copy AI performance prompt | -| `Alt+Shift+X` | Clear stats | -| `Alt+Shift+T` | Toggle toolbar | -| `Escape` | Close open panels | +| `Alt+Shift+X` | Clear stats | +| `Alt+Shift+T` | Toggle toolbar | +| `Escape` | Close open panels | ## Details and AI Prompt @@ -157,17 +157,19 @@ Use `Copy Slow Issues Prompt` to copy a focused prompt for an AI coding assistan ## Playwright Audit ```ts -import { test, expect } from '@playwright/test'; -import { startRenderAudit } from 'angular-render-scan'; +import { test, expect } from "@playwright/test"; +import { startRenderAudit } from "angular-render-scan"; -test('no render regression', async ({ page }) => { - await page.goto('/'); +test("no render regression", async ({ page }) => { + await page.goto("/"); const audit = await startRenderAudit(page); - await page.click('button.expensive-operation'); + await page.click("button.expensive-operation"); const report = await audit.stop(); - expect(await report.maxDurationFor('ProductCardComponent')).toBeLessThan(16.7); + expect(await report.maxDurationFor("ProductCardComponent")).toBeLessThan( + 16.7, + ); expect(await report.wastedRenderPercentage()).toBeLessThan(20); expect(await report.budgetViolations()).toHaveLength(0); }); @@ -179,7 +181,7 @@ Angular Render Scan is intended for development and demo debugging. Provider mod ```ts provideAngularRenderScan({ - dangerouslyForceRunInProduction: true + dangerouslyForceRunInProduction: true, }); ``` diff --git a/demo/src/main.ts b/demo/src/main.ts index 1f2ae66..24682f9 100644 --- a/demo/src/main.ts +++ b/demo/src/main.ts @@ -16,6 +16,8 @@ import { getReferentialInstability, getZonePollutionEvents, provideAngularRenderScan, + getSignalDependencyGraph, + getComponentCostAnalysis, } from 'angular-render-scan'; // ── Types ────────────────────────────────────────────────────── @@ -89,7 +91,7 @@ class CartItemComponent { {{ compute() }}
- Ran {{ renders() }} times · Last counter: {{ counter() }} + Ran {{ renders }} times · Last counter: {{ counter() }}
`, @@ -97,12 +99,17 @@ class CartItemComponent { }) class SlowComponent { readonly counter = input.required(); - readonly renders = signal(0); + renders = 0; compute(): string { - this.renders.update(r => r + 1); - let n = 0; - for (let i = 0; i < 80_000; i++) n += Math.sqrt(i); + this.renders += 1; + const start = Date.now(); + let n = 0.5; + while (Date.now() - start < 20) { + n = Math.sin(n) + 0.1; + } + const elapsed = Date.now() - start; + console.log('COMPUTE ELAPSED:', elapsed); return n.toFixed(2); } } @@ -130,9 +137,9 @@ class RefDemoComponent { @Component({ selector: 'app-root', standalone: true, - imports: [ProductComponent, CartItemComponent, SlowComponent, RefDemoComponent, AngularRenderScanMarkDirective], + imports: [ProductComponent, CartItemComponent, SlowComponent, RefDemoComponent], template: ` -
+
@@ -181,7 +188,7 @@ class RefDemoComponent {
📋
Nothing yet
} @else {
- @for (e of log(); track e.time + e.msg) { + @for (e of log(); track e.time + e.msg + $index) {
{{ e.time }} {{ e.msg }} @@ -204,6 +211,8 @@ class RefDemoComponent { + +
@@ -259,7 +268,7 @@ class RefDemoComponent { - @for (t of triggers(); track t.time + t.source) { + @for (t of triggers(); track t.time + t.source + $index) { @@ -345,7 +354,7 @@ class RefDemoComponent {
TimeSourceType
{{ t.time }} {{ t.source }}
- @for (e of pollutionEvents(); track e.time + e.trigger) { + @for (e of pollutionEvents(); track e.time + e.trigger + $index) { @@ -464,6 +473,112 @@ class RefDemoComponent { } + + @if (activeTab() === 'signals') { +
+
+ + Shows dependencies between signals +
+ @if (signalGraph().nodes.length === 0) { +
📶
No signals detected yet — trigger signal writes/reads, then Refresh
+ } @else { +
+
+ Signal Nodes ({{ signalGraph().nodes.length }}) +
+
+
TimeTaskSource
{{ e.time }} {{ e.trigger }}
+ + + @for (n of signalGraph().nodes; track n.id) { + + + + + + + + } + +
NameKindUpdatesWastedValue
{{ n.name }} + + {{ n.kind }} + + {{ n.updateCount }} + @if (n.wastedCount > 0) { + {{ n.wastedCount }} + } @else { + 0 + } + {{ n.value ?? '—' }}
+
+
+ + @if (signalGraph().edges.length > 0) { +
+
+ Dependency Edges ({{ signalGraph().edges.length }}) +
+
+ + + + @for (e of signalGraph().edges; track e.fromId + '->' + e.toId) { + + + + + } + +
FromTo
{{ e.fromId }}➔ {{ e.toId }}
+
+
+ } + } +
+ } + + + @if (activeTab() === 'cost') { +
+
+ + Rank components by render cost +
+ @if (costAnalysis().length === 0) { +
💰
No cost analysis data yet — interact with the shop, then Refresh
+ } @else { +
+
+ Component Performance Costs +
+
+ + + + @for (c of costAnalysis(); track c.name) { + + + + + + + + } + +
ComponentRendersTotal durationAvg durationCost share
{{ c.name }}{{ c.renderCount }}{{ c.totalDuration.toFixed(2) }}ms{{ c.averageDuration.toFixed(2) }}ms +
+
+ {{ c.costPercentage.toFixed(1) }}% +
+
+
+
+ } +
+ } +
@@ -474,7 +589,7 @@ class RefDemoComponent { class AppComponent implements OnInit, OnDestroy { // ── Shared reactive counter ────────────────────────────────── - readonly activeTab = signal<'trigger'|'onpush'|'zone'|'ref'|'graph'>('trigger'); + readonly activeTab = signal<'trigger'|'onpush'|'zone'|'ref'|'graph'|'signals'|'cost'>('trigger'); readonly counter = signal(0); readonly signalTick = signal(0); @@ -556,32 +671,61 @@ readonly config = computed(() => ({ theme: this.theme(), size: this.size() }));` private streamId: ReturnType | null = null; private readonly onRender = (e: Event) => { - const d = (e as CustomEvent<{ name: string; duration: number; trigger?: string }>).detail; + const d = (e as CustomEvent).detail; + if (d.name === 'AppRoot') { + return; + } + + const trigger = d.renderCause?.trigger || d.reason; + const source = d.renderCause?.source; + + // Ignore render events triggered by updating demo debug/stats signals + if (source && ( + source.includes('totalCycles') || + source.includes('lastTrigger') || + source.includes('triggers') || + source.includes('log') || + source.includes('pollutionEvents') || + source.includes('onPushList') || + source.includes('refList') || + source.includes('graphNodes') || + source.includes('signalGraph') || + source.includes('costAnalysis') + )) { + return; + } + const t = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }); - this.totalCycles.update(c => c + 1); - if (d.trigger) { - this.lastTrigger.set(d.trigger); - this.triggers.update(h => [{ + const duration = d.latestDuration ?? d.duration ?? 0; + + setTimeout(() => { + this.totalCycles.update(c => c + 1); + if (trigger) { + this.lastTrigger.set(trigger); + this.triggers.update(h => [{ + time: t, + source: trigger, + kind: trigger.startsWith('zone') ? 'zone' : trigger.startsWith('signal') ? 'signal' : 'unknown', + }, ...h.slice(0, 49)]); + } + this.log.update(l => [{ time: t, - source: d.trigger!, - kind: d.trigger!.startsWith('zone') ? 'zone' : d.trigger!.startsWith('signal') ? 'signal' : 'unknown', - }, ...h.slice(0, 49)]); - } - this.log.update(l => [{ - time: t, - msg: `[${d.name}] ${d.duration.toFixed(2)}ms${d.trigger ? ` ← ${d.trigger}` : ''}`, - type: d.duration > 15 ? 'warn' : 'info', - }, ...l.slice(0, 24)]); + msg: `[${d.name}] ${duration.toFixed(2)}ms${trigger ? ` ← ${trigger}` : ''}`, + type: duration > 15 ? 'warn' : 'info', + }, ...l.slice(0, 24)]); + }); }; private readonly onPollution = (e: Event) => { const d = (e as CustomEvent).detail; const t = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }); - this.pollutionEvents.update(l => [{ - time: t, - trigger: d?.suspectedTrigger ?? 'unknown zone task', - comps: d?.componentCount ?? 0, - }, ...l.slice(0, 49)]); + setTimeout(() => { + this.pollutionEvents.update(l => [{ + time: t, + trigger: d?.suspectedTrigger ?? 'unknown zone task', + comps: d?.componentCount ?? 0, + }, ...l.slice(0, 49)]); + }); }; ngOnInit() { @@ -648,6 +792,18 @@ readonly config = computed(() => ({ theme: this.theme(), size: this.size() }));` this.graphNodes.set(g.nodes); } + // ── Signals graph ───────────────────────────────────────────── + readonly signalGraph = signal>({ nodes: [], edges: [] }); + refreshSignalGraph() { + this.signalGraph.set(getSignalDependencyGraph()); + } + + // ── Cost Analysis ───────────────────────────────────────────── + readonly costAnalysis = signal>([]); + refreshCostAnalysis() { + this.costAnalysis.set(getComponentCostAnalysis()); + } + } diff --git a/e2e/demo.spec.ts b/e2e/demo.spec.ts index ddf0ef7..efe59b0 100644 --- a/e2e/demo.spec.ts +++ b/e2e/demo.spec.ts @@ -2,17 +2,35 @@ import { expect, test } from '@playwright/test'; const overlaySelector = 'angular-render-scan-overlay'; +async function expandToolbar(page: import('@playwright/test').Page) { + const overlay = page.locator(overlaySelector); + await overlay.evaluate((host) => { + const root = host.shadowRoot; + if (root?.querySelector('.toolbar.compact')) { + root.querySelector('.toolbar-size-toggle')?.click(); + } + }); + await expect.poll(async () => overlay.evaluate((host) => { + return host.shadowRoot?.querySelector('.toolbar')?.classList.contains('expanded') ?? false; + })).toBe(true); +} + test('toolbar appears and toggle controls scanner state', async ({ page }) => { await page.goto('/'); const overlay = page.locator(overlaySelector); await expect(overlay).toBeAttached(); - await expect(page.getByRole('heading', { name: 'Developer Store' })).toBeVisible(); + await expect(page.locator('.card-label').first()).toContainText('Products'); + const angularVersion = await page.locator('[ng-version]').first().getAttribute('ng-version'); + await expect.poll(async () => overlay.evaluate((host) => { + return host.shadowRoot?.querySelector('.angular-version-chip')?.textContent?.trim() ?? ''; + })).toBe(angularVersion ? `ng ${angularVersion}` : ''); - await page.locator('app-product-card').filter({ hasText: 'Developer Coffee' }).getByRole('button', { name: 'Add to Cart' }).click(); + await page.locator('app-product').filter({ hasText: 'Developer Mug' }).getByRole('button', { name: 'Add' }).click(); + await expandToolbar(page); await expect.poll(async () => overlay.evaluate((host) => { const toolbar = host.shadowRoot?.textContent ?? ''; - return toolbar.includes('ShoppingCart') || toolbar.includes('CartItem') || toolbar.includes('Count'); + return toolbar.includes('ProductCard') || toolbar.includes('CartItem') || toolbar.includes('AppRoot') || toolbar.includes('AppComponent'); })).toBe(true); await overlay.evaluate((host) => { @@ -28,18 +46,74 @@ test('toolbar appears and toggle controls scanner state', async ({ page }) => { await expect.poll(async () => overlay.evaluate((host) => host.shadowRoot?.querySelector('.switch-text')?.textContent?.trim())).toBe('On'); }); -test('slow recommendations component can become the slowest latest entry', async ({ page }) => { +test('toolbar size toggle persists across refresh', async ({ page }) => { await page.goto('/'); + await page.evaluate(() => { + localStorage.removeItem('angular-render-scan:toolbar-compact'); + }); + await page.reload(); - await page.getByRole('button', { name: 'Recalculate' }).click(); + const overlay = page.locator(overlaySelector); + await expect(overlay).toBeAttached(); + await expect.poll(async () => overlay.evaluate((host) => { + return host.shadowRoot?.querySelector('.toolbar')?.classList.contains('compact') ?? false; + })).toBe(true); - await expect.poll(async () => page.locator(overlaySelector).evaluate((host) => { - return host.shadowRoot?.textContent ?? ''; - })).toContain('Recommendations'); + await overlay.evaluate((host) => { + host.shadowRoot?.querySelector('.toolbar-size-toggle')?.click(); + }); + await expect.poll(async () => overlay.evaluate((host) => { + return host.shadowRoot?.querySelector('.toolbar')?.classList.contains('expanded') ?? false; + })).toBe(true); + + await page.reload(); + await expect(overlay).toBeAttached(); + await expect.poll(async () => overlay.evaluate((host) => { + return host.shadowRoot?.querySelector('.toolbar')?.classList.contains('expanded') ?? false; + })).toBe(true); + + await overlay.evaluate((host) => { + host.shadowRoot?.querySelector('.toolbar-size-toggle')?.click(); + }); + await expect.poll(async () => overlay.evaluate((host) => { + return host.shadowRoot?.querySelector('.toolbar')?.classList.contains('compact') ?? false; + })).toBe(true); + + await page.reload(); + await expect(overlay).toBeAttached(); + await expect.poll(async () => overlay.evaluate((host) => { + return host.shadowRoot?.querySelector('.toolbar')?.classList.contains('compact') ?? false; + })).toBe(true); +}); + +test('slow recommendations component can be inspected from details mode', async ({ page }) => { + await page.goto('/'); + const overlay = page.locator(overlaySelector); + await expect(overlay).toBeAttached(); + await expect(page.locator('.card-label').first()).toContainText('Products'); + await expandToolbar(page); + + // Activate OnPush tab so app-slow is rendered + await page.getByRole('button', { name: '🚀 OnPush' }).click(); + await page.getByRole('button', { name: 'Refresh candidates' }).click(); + await expect(page.locator('app-slow')).toBeVisible(); + + await overlay.evaluate((host) => { + host.shadowRoot?.querySelector('.toolbar-picker-toggle')?.click(); + }); + await page.locator('app-slow').hover({ force: true }); + await expect.poll(async () => page.evaluate(() => { + const overlay = document.querySelector('angular-render-scan-overlay'); + const text = overlay?.shadowRoot?.querySelector('.inspect-panel')?.textContent ?? ''; + return text.includes('ExpensiveRecommendation') || text.includes('SlowComponent'); + })).toBe(true); }); test('cart updates emit component render events', async ({ page }) => { await page.goto('/'); + const overlay = page.locator(overlaySelector); + await expect(overlay).toBeAttached(); + await expect(page.locator('.card-label').first()).toContainText('Products'); await expect.poll(async () => page.evaluate(() => { return new Promise((resolve) => { @@ -49,22 +123,24 @@ test('cart updates emit component render events', async ({ page }) => { }, 700); const onRender = (event: Event) => { const detail = (event as CustomEvent<{ name: string }>).detail; - if (detail.name === 'CartItem' || detail.name === 'ShoppingCart') { + if (detail.name === 'CartItem' || detail.name === 'AppRoot' || detail.name === 'ProductCard') { window.clearTimeout(timeout); window.removeEventListener('angular-render-scan:render', onRender); resolve(detail.name); } }; window.addEventListener('angular-render-scan:render', onRender); - document.querySelector('app-product-card button')?.click(); + document.querySelector('app-product button')?.click(); }); })).not.toBe(''); }); test('manual package rename surfaces in browser integration', async ({ page }) => { await page.goto('/'); + const overlay = page.locator(overlaySelector); + await expect(overlay).toBeAttached(); + await expect(page.locator('.card-label').first()).toContainText('Products'); - await expect(page.locator(overlaySelector)).toBeAttached(); await expect(page.evaluate(() => 'AngularRenderScan' in window)).resolves.toBe(true); }); @@ -80,10 +156,16 @@ test('toolbar can copy an AI performance prompt', async ({ page }) => { }); }); await page.goto('/'); - - await page.getByRole('button', { name: 'Recalculate' }).click(); const overlay = page.locator(overlaySelector); - await expect.poll(async () => overlay.evaluate((host) => host.shadowRoot?.textContent ?? '')).toContain('Wasted'); + await expect(overlay).toBeAttached(); + await expect(page.locator('.card-label').first()).toContainText('Products'); + await expandToolbar(page); + + // Activate OnPush tab so app-slow is rendered + await page.getByRole('button', { name: '🚀 OnPush' }).click(); + + await page.getByRole('button', { name: 'Refresh candidates' }).click(); + await expect.poll(async () => overlay.evaluate((host) => host.shadowRoot?.textContent ?? '')).toContain('waste'); await overlay.evaluate((host) => { host.shadowRoot?.querySelector('.copy-prompt-btn')?.click(); }); @@ -99,12 +181,15 @@ test('toolbar can copy an AI performance prompt', async ({ page }) => { test('toolbar displays and triggers export session controls', async ({ page }) => { await page.goto('/'); const overlay = page.locator(overlaySelector); + await expect(overlay).toBeAttached(); + await expect(page.locator('.card-label').first()).toContainText('Products'); + await expandToolbar(page); await expect.poll(async () => overlay.evaluate((host) => { return host.shadowRoot?.querySelector('.export-btn') !== null; })).toBe(true); - await page.getByRole('button', { name: 'Recalculate' }).click(); + await page.getByRole('button', { name: 'Click (0)' }).click(); const downloadPromise = page.waitForEvent('download'); await overlay.evaluate((host) => { @@ -114,54 +199,56 @@ test('toolbar displays and triggers export session controls', async ({ page }) = expect(download.suggestedFilename()).toContain('angular-render-scan-session-'); }); -test('details mode hover and click opens recommendation panel with component prompt copy', async ({ page }) => { - await page.addInitScript(() => { - Object.defineProperty(navigator, 'clipboard', { - configurable: true, - value: { - writeText: async (text: string) => { - (window as any).__angularRenderScanCopiedComponentPrompt = text; - } - } - }); - }); +test('details mode shows hover-positioned recommendation panel without manual actions', async ({ page }) => { await page.goto('/'); + const overlay = page.locator(overlaySelector); + await expect(overlay).toBeAttached(); + await expect(page.locator('.card-label').first()).toContainText('Products'); + + // Activate OnPush tab so app-slow is rendered + await page.getByRole('button', { name: '🚀 OnPush' }).click(); + await page.evaluate(() => (window as any).AngularRenderScan.setOptions({ animationSpeed: 'slow' })); - await page.getByRole('button', { name: 'Recalculate' }).click(); - await expect.poll(async () => page.locator(overlaySelector).evaluate((host) => host.shadowRoot?.textContent ?? '')).toContain('Wasted'); + await page.getByRole('button', { name: 'Refresh candidates' }).click(); - await page.locator(overlaySelector).evaluate((host) => { - host.shadowRoot?.querySelector('.details-checkbox')?.click(); + await overlay.evaluate((host) => { + host.shadowRoot?.querySelector('.toolbar-picker-toggle')?.click(); }); - await expect.poll(async () => page.locator(overlaySelector).evaluate((host) => { - return (host.shadowRoot?.querySelector('.details-checkbox') as HTMLInputElement | null)?.checked; - })).toBe(true); - await expect.poll(async () => page.locator(overlaySelector).evaluate((host) => { + await expect.poll(async () => overlay.evaluate((host) => { + return host.shadowRoot?.querySelector('.toolbar-picker-toggle')?.getAttribute('aria-pressed'); + })).toBe('true'); + await expect.poll(async () => overlay.evaluate((host) => { return host.shadowRoot?.querySelector('.details-toggle')?.getAttribute('data-tooltip') ?? ''; - })).toContain('Uncheck to clear'); - await expect.poll(async () => page.locator(overlaySelector).evaluate((host) => { - return host.shadowRoot?.querySelector('.copy-prompt-btn')?.getAttribute('data-tooltip') ?? ''; - })).toContain('slow/error component issues'); - - await page.locator('app-recommendations').click({ force: true }); - - await expect.poll(async () => page.locator(overlaySelector).evaluate((host) => { - return host.shadowRoot?.querySelector('.inspect-panel')?.textContent ?? ''; - })).toContain('Recommendations'); - await expect.poll(async () => page.locator(overlaySelector).evaluate((host) => { - return host.shadowRoot?.querySelector('.panel-copy-btn')?.getAttribute('data-tooltip') ?? ''; - })).toContain('scoped only to this slow component'); - await page.locator(overlaySelector).evaluate((host) => { - host.shadowRoot?.querySelector('.panel-copy-btn')?.click(); - }); - await expect.poll(async () => page.evaluate(() => (window as any).__angularRenderScanCopiedComponentPrompt ?? '')).toContain('I need help fixing one slow/error Angular component'); - await expect.poll(async () => page.evaluate(() => (window as any).__angularRenderScanCopiedComponentPrompt ?? '')).toContain('Estimated cost:'); - await expect.poll(async () => page.evaluate(() => (window as any).__angularRenderScanCopiedComponentPrompt ?? '')).toContain('Component-local recommendations'); - await page.locator(overlaySelector).evaluate((host) => { - host.shadowRoot?.querySelector('.details-checkbox')?.click(); - }); - await expect.poll(async () => page.locator(overlaySelector).evaluate((host) => { + })).toContain('hover'); + + await page.locator('app-slow').hover({ force: true }); + + await expect.poll(async () => { + const text = await overlay.evaluate((host) => host.shadowRoot?.querySelector('.inspect-panel')?.textContent ?? ''); + return text.includes('ExpensiveRecommendation') || text.includes('SlowComponent'); + }).toBe(true); + await expect.poll(async () => overlay.evaluate((host) => { + const root = host.shadowRoot; + return { + copy: root?.querySelector('.panel-copy-btn') !== null, + close: root?.querySelector('.panel-close') !== null + }; + })).toEqual({ copy: false, close: false }); + const distance = await overlay.evaluate(() => { + const host = document.querySelector('angular-render-scan-overlay'); + const panel = host?.shadowRoot?.querySelector('.inspect-panel'); + const target = document.querySelector('app-slow'); + const panelRect = panel?.getBoundingClientRect(); + const targetRect = target?.getBoundingClientRect(); + if (!panelRect || !targetRect) return Number.POSITIVE_INFINITY; + const dx = Math.max(targetRect.left - panelRect.right, panelRect.left - targetRect.right, 0); + const dy = Math.max(targetRect.top - panelRect.bottom, panelRect.top - targetRect.bottom, 0); + return Math.hypot(dx, dy); + }); + expect(distance).toBeLessThan(180); + await page.mouse.move(8, 8); + await expect.poll(async () => overlay.evaluate((host) => { return host.shadowRoot?.querySelector('.inspect-panel'); })).toBeNull(); }); @@ -169,6 +256,8 @@ test('details mode hover and click opens recommendation panel with component pro test('toolbar can toggle live CPU details panel', async ({ page }) => { await page.goto('/'); const overlay = page.locator(overlaySelector); + await expect(overlay).toBeAttached(); + await expect(page.locator('.card-label').first()).toContainText('Products'); await expect.poll(async () => overlay.evaluate((host) => { return host.shadowRoot?.querySelector('.cpu-interactive')?.textContent ?? ''; @@ -198,23 +287,27 @@ test('toolbar can toggle live CPU details panel', async ({ page }) => { test('mutation details appear in toolbar and details panel', async ({ page }) => { await page.goto('/'); const overlay = page.locator(overlaySelector); + await expect(overlay).toBeAttached(); + await expect(page.locator('.card-label').first()).toContainText('Products'); - await page.locator('app-product-card').first().getByRole('button', { name: 'Add to Cart' }).click(); + await page.locator('app-product').first().getByRole('button', { name: 'Add' }).click(); await overlay.evaluate((host) => { - host.shadowRoot?.querySelector('.details-checkbox')?.click(); + host.shadowRoot?.querySelector('.toolbar-picker-toggle')?.click(); }); - await page.locator('app-shopping-cart').click({ force: true }); + await page.locator('app-product').first().hover({ force: true }); - await expect.poll(async () => overlay.evaluate((host) => host.shadowRoot?.querySelector('.inspect-panel')?.textContent ?? '')).toContain('DOM Mutation Type'); + await expect.poll(async () => overlay.evaluate((host) => host.shadowRoot?.querySelector('.inspect-panel')?.textContent ?? '')).toContain('DOM'); }); test('waterfall panel can be toggled via sparkline', async ({ page }) => { await page.goto('/'); const overlay = page.locator(overlaySelector); + await expect(overlay).toBeAttached(); + await expect(page.locator('.card-label').first()).toContainText('Products'); - await page.getByRole('button', { name: 'Recalculate' }).click(); + await page.getByRole('button', { name: 'Click (0)' }).click(); await expect.poll(async () => overlay.evaluate((host) => host.shadowRoot?.querySelector('.sparkline-toggle') !== null)).toBe(true); await expect.poll(async () => overlay.evaluate((host) => host.shadowRoot?.querySelector('.waterfall-panel') !== null)).toBe(false); diff --git a/packages/angular-render-scan/src/application/runtime.spec.ts b/packages/angular-render-scan/src/application/runtime.spec.ts index caa977e..0848fff 100644 --- a/packages/angular-render-scan/src/application/runtime.spec.ts +++ b/packages/angular-render-scan/src/application/runtime.spec.ts @@ -9,6 +9,12 @@ import { getAIPrompt, getRecording, stop, + getSignalDependencyGraph, + recordSignalRead, + recordSignalWrite, + getRenderCause, + setActiveCheckingComponent, + setResolvedTriggerForTest } from './runtime'; describe('runtime diagnostics', () => { @@ -69,4 +75,62 @@ describe('runtime diagnostics', () => { expect(getRecording()).toHaveLength(1); }); + + it('tracks signal dependencies and builds dependency graph', () => { + setActiveCheckingComponent('comp1', 'ProductComponent'); + recordSignalRead('productsSignal', 'signal'); + setActiveCheckingComponent(null, null); + + const graph = getSignalDependencyGraph(); + expect(graph.nodes.some(n => n.name === 'productsSignal' && n.kind === 'signal')).toBe(true); + expect(graph.nodes.some(n => n.name === 'ProductComponent' && n.kind === 'component')).toBe(true); + expect(graph.edges).toHaveLength(1); + expect(graph.edges[0]).toEqual({ fromId: 'productsSignal', toId: 'ProductComponent' }); + }); + + it('tracks signal writes and flags wasted writes', () => { + recordSignalWrite('productsSignal', 'set', ['frame1'], true); // wasted + recordSignalWrite('productsSignal', 'set', ['frame2'], false); // effective + + const graph = getSignalDependencyGraph(); + const node = graph.nodes.find(n => n.name === 'productsSignal'); + expect(node).toBeDefined(); + expect(node?.updateCount).toBe(2); + expect(node?.wastedCount).toBe(1); + }); + + it('resolves render cause correctly based on signal writes and dependency graph', () => { + const element = document.createElement('app-product'); + document.body.append(element); + registerComponent({ id: 'product', name: 'ProductComponent', element, selector: 'app-product' }); + + // Component reads signal + setActiveCheckingComponent('product', 'ProductComponent'); + recordSignalRead('productsSignal', 'signal'); + setActiveCheckingComponent(null, null); + + // Write to signal + recordSignalWrite('productsSignal', 'set', ['frame1'], false); + + // CD cycle triggered by signal:write + const mockTrigger = { source: 'signal:write' as const, detail: '', callSite: '', isUserInteraction: false, isZonePollution: false }; + + // Simulate beginCycle and record Component check + const cycleId = beginCycle(); + // Overwrite the resolved activeCycleTrigger since Zone hook isn't loaded + setResolvedTriggerForTest(mockTrigger); + + const entry = recordComponentCheck('product', 5.0, cycleId); + endCycle(cycleId); + + expect(entry).toBeDefined(); + expect(entry?.renderCause).toBeDefined(); + expect(entry?.renderCause?.trigger).toBe('signal:write'); + expect(entry?.renderCause?.source).toBe('productsSignal'); + expect(entry?.renderCause?.stack).toEqual(['frame1']); + + const cause = getRenderCause('ProductComponent'); + expect(cause).toBeDefined(); + expect(cause?.source).toBe('productsSignal'); + }); }); diff --git a/packages/angular-render-scan/src/application/runtime.ts b/packages/angular-render-scan/src/application/runtime.ts index 75cc7e0..08911e3 100644 --- a/packages/angular-render-scan/src/application/runtime.ts +++ b/packages/angular-render-scan/src/application/runtime.ts @@ -9,7 +9,10 @@ import { getOnPushCandidates as statsGetOnPushCandidates, getReferentialInstability as statsGetReferentialInstability, getCdGraph as statsGetCdGraph, - clearStats + clearStats, + getComponentCostEntries, + getRegisteredComponentEntries, + registerGetRenderCauseCallback } from './stats'; import type { AngularRenderCycle, @@ -22,9 +25,19 @@ import type { SessionExportData, WastedStats, ZonePollutionEvent, - CdGraph + CdGraph, + RenderCause, + SignalDependencyGraph, + ComponentCostEntry, + SignalDependencyNode, + SignalDependencyEdge } from '../domain/entities'; +// Register callback for stats to query render cause to avoid circular imports +registerGetRenderCauseCallback((name) => { + return findRenderCauseForComponent(name, activeCycleTrigger); +}); + let overlay: AngularRenderScanOverlay | undefined; let activeCycleId = 0; let activeCycleStartedAt = 0; @@ -34,9 +47,19 @@ let recentCycles: AngularRenderCycle[] = []; let activeSessionBudgetViolations: BudgetViolation[] = []; let activeZonePollutionEvents: ZonePollutionEvent[] = []; +// v0.2 state tracking +let activeCycleTrigger: CdTriggerAttribution | undefined; +let recentSignalWrites: { name: string; action: 'set' | 'update'; stack?: string[]; timestamp: number; isWasted: boolean }[] = []; +let activeCheckingComponentId: string | null = null; +let activeCheckingComponentName: string | null = null; +let activeComputedSignalName: string | null = null; +const dependencyEdges = new Set(); +const signalUpdateCounts = new Map(); + // Lazily-loaded Zone tracker references (avoid importing in test/non-Zone envs) let _resolveTrigger: (() => CdTriggerAttribution) | null = null; let _resetCycleTrigger: (() => void) | null = null; +let _notifySignalWrite: (() => void) | null = null; async function ensureZoneTracker() { if (_resolveTrigger) return; @@ -44,6 +67,7 @@ async function ensureZoneTracker() { const mod = await import('../infrastructure/angular/zone-tracker'); _resolveTrigger = mod.resolveTriggerAttribution; _resetCycleTrigger = mod.resetCycleTriggerState; + _notifySignalWrite = mod.notifySignalWrite; } catch { // Zone tracker unavailable } @@ -99,6 +123,15 @@ export function beginCycle(): number { scan(); activeCycleId = startCycle(); activeCycleStartedAt = performance.now(); + + // Resolve trigger at the start of the cycle (when tick starts) + let trigger: CdTriggerAttribution | undefined; + if (_resolveTrigger) { + trigger = _resolveTrigger(); + } + activeCycleTrigger = trigger; + _resetCycleTrigger?.(); + options.onCycleStart?.(); return activeCycleId; } @@ -127,13 +160,11 @@ export function endCycle(cycleId = activeCycleId): AngularRenderCycle | undefine const cycle = finishCycle(cycleId, activeCycleStartedAt, finishedAt, options); // ─── CD Trigger Attribution ─────────────────────────────────────────────── - let trigger: CdTriggerAttribution | undefined; - if (_resolveTrigger) { - trigger = _resolveTrigger(); + const trigger = activeCycleTrigger; + if (trigger) { cycle.trigger = trigger; cycle.isZonePollution = trigger.isZonePollution; } - _resetCycleTrigger?.(); // ─── Zone Pollution Detection ───────────────────────────────────────────── if (trigger?.isZonePollution && cycle.entries.length > 0) { @@ -295,13 +326,210 @@ export function getRecording(): AngularRenderCycle[] { return recentCycles.slice(); } +export function getRegisteredComponents(): AngularRenderEntry[] { + return getRegisteredComponentEntries(); +} + export function clearRecording(): void { recentCycles = []; activeZonePollutionEvents = []; activeSessionBudgetViolations = []; + recentSignalWrites = []; + dependencyEdges.clear(); + signalUpdateCounts.clear(); + activeCheckingComponentId = null; + activeCheckingComponentName = null; + activeComputedSignalName = null; + activeCycleTrigger = undefined; clearStats(); } +// ─── v0.2 Signal dependency & cause tracking logic ──────────────────────────── + +export function setActiveCheckingComponent(id: string | null, name: string | null): void { + activeCheckingComponentId = id; + activeCheckingComponentName = name; +} + +export function setActiveComputedSignal(name: string | null): void { + activeComputedSignalName = name; +} + +export function recordSignalRead(name: string, kind: 'signal' | 'computed'): void { + if (!signalUpdateCounts.has(name)) { + signalUpdateCounts.set(name, { total: 0, wasted: 0, kind }); + } + + let consumer: string | null = null; + let consumerKind: 'component' | 'computed' | null = null; + + if (activeComputedSignalName) { + consumer = activeComputedSignalName; + consumerKind = 'computed'; + } else if (activeCheckingComponentName) { + consumer = activeCheckingComponentName; + consumerKind = 'component'; + } + + if (consumer && consumer !== name) { + const edgeKey = `${name}->${consumer}`; + dependencyEdges.add(edgeKey); + + if (consumerKind === 'computed' && !signalUpdateCounts.has(consumer)) { + signalUpdateCounts.set(consumer, { total: 0, wasted: 0, kind: 'computed' }); + } + } +} + +export function recordSignalWrite(name: string, action: 'set' | 'update', stack: string[], isWasted: boolean): void { + if (recentSignalWrites.length > 0) { + const last = recentSignalWrites[recentSignalWrites.length - 1]; + if (last.name === name && last.action === 'update' && action === 'set' && Date.now() - last.timestamp < 2) { + return; + } + } + + recentSignalWrites.push({ + name, + action, + stack, + timestamp: Date.now(), + isWasted + }); + if (recentSignalWrites.length > 200) { + recentSignalWrites = recentSignalWrites.slice(-200); + } + + const stats = signalUpdateCounts.get(name) || { total: 0, wasted: 0, kind: 'signal' }; + stats.total += 1; + if (isWasted) { + stats.wasted += 1; + } + signalUpdateCounts.set(name, stats); + + // Notify the zone tracker + _notifySignalWrite?.(); +} + +function isSignalDependent(consumer: string, producer: string): boolean { + if (consumer === producer) return true; + + const adj = new Map(); + for (const edge of dependencyEdges) { + const [from, to] = edge.split('->'); + if (!adj.has(from)) adj.set(from, []); + adj.get(from)!.push(to); + } + + const visited = new Set(); + const queue = [producer]; + while (queue.length > 0) { + const curr = queue.shift()!; + if (curr === consumer) return true; + if (visited.has(curr)) continue; + visited.add(curr); + + const next = adj.get(curr) || []; + for (const n of next) { + if (!visited.has(n)) { + queue.push(n); + } + } + } + return false; +} + +export function findRenderCauseForComponent(componentName: string, cycleTrigger: CdTriggerAttribution | undefined): RenderCause | undefined { + if (!cycleTrigger) return undefined; + + if (cycleTrigger.source === 'signal:write') { + const now = Date.now(); + const possibleWrites = recentSignalWrites + .filter(w => now - w.timestamp < 1000) + .reverse(); + + for (const write of possibleWrites) { + if (isSignalDependent(componentName, write.name)) { + return { + trigger: 'signal:write', + source: write.name, + stack: write.stack, + timestamp: write.timestamp + }; + } + } + + if (possibleWrites.length > 0) { + const write = possibleWrites[0]; + return { + trigger: 'signal:write', + source: write.name, + stack: write.stack, + timestamp: write.timestamp + }; + } + } + + return { + trigger: cycleTrigger.source, + source: cycleTrigger.detail, + stack: cycleTrigger.callSite ? [cycleTrigger.callSite] : undefined, + timestamp: Date.now() + }; +} + +export function getRenderCause(component: string | Element): RenderCause | null { + const name = typeof component === 'string' ? component : component.tagName.toLowerCase(); + + // Find the component's entry from the last cycle or stats + for (let i = recentCycles.length - 1; i >= 0; i--) { + const entry = recentCycles[i].entries.find(e => e.name === name || e.selector?.includes(name)); + if (entry && entry.renderCause) { + return entry.renderCause; + } + } + return null; +} + +export function getSignalDependencyGraph(): SignalDependencyGraph { + const nodes: SignalDependencyNode[] = []; + const edges: SignalDependencyEdge[] = []; + + const nodeNames = new Set(); + for (const [name, stats] of signalUpdateCounts.entries()) { + nodeNames.add(name); + nodes.push({ + id: name, + name, + kind: stats.kind, + updateCount: stats.total, + wastedCount: stats.wasted + }); + } + + for (const edge of dependencyEdges) { + const [from, to] = edge.split('->'); + nodeNames.add(from); + nodeNames.add(to); + edges.push({ fromId: from, toId: to }); + } + + for (const name of nodeNames) { + if (!signalUpdateCounts.has(name)) { + const isComponent = !name.includes('.') && /^[A-Z]/.test(name); + nodes.push({ + id: name, + name, + kind: isComponent ? 'component' : 'signal', + updateCount: 0, + wastedCount: 0 + }); + } + } + + return { nodes, edges }; +} + // ─── New public exports ──────────────────────────────────────────────────────── export function getOnPushCandidates(threshold?: number): OnPushCandidate[] { @@ -568,4 +796,8 @@ export function getSessionData(): SessionExportData { }; } -export { statsGetWastedStats as getWastedStats, statsGetLeakedComponents as getLeakedComponents }; +export function setResolvedTriggerForTest(trigger: CdTriggerAttribution | undefined): void { + activeCycleTrigger = trigger; +} + +export { statsGetWastedStats as getWastedStats, statsGetLeakedComponents as getLeakedComponents, getComponentCostEntries as getComponentCostAnalysis }; diff --git a/packages/angular-render-scan/src/application/stats.spec.ts b/packages/angular-render-scan/src/application/stats.spec.ts index 3125694..3f3a8cc 100644 --- a/packages/angular-render-scan/src/application/stats.spec.ts +++ b/packages/angular-render-scan/src/application/stats.spec.ts @@ -1,5 +1,15 @@ import { afterEach, describe, expect, it } from 'vitest'; -import { finishCycle, recordComponentCheck, registerComponent, resetStats, startCycle, getWastedStats, getLeakedComponents } from './stats'; +import { + finishCycle, + recordComponentCheck, + registerComponent, + resetStats, + startCycle, + getWastedStats, + getLeakedComponents, + getComponentCostEntries, + getRegisteredComponentEntries +} from './stats'; import { getResolvedOptions, resetOptionsForTest, setResolvedOptions } from '../domain/options'; describe('stats', () => { @@ -91,4 +101,62 @@ describe('stats', () => { document.body.append(element); expect(getLeakedComponents().length).toBe(0); }); + + it('returns connected registered components for picker hit testing', () => { + const root = document.createElement('app-root'); + const child = document.createElement('app-child'); + root.append(child); + document.body.append(root); + + registerComponent({ id: 'root', name: 'RootComponent', element: root, selector: 'app-root' }); + registerComponent({ id: 'child', name: 'ChildComponent', element: child, selector: 'app-child', parentId: 'root' }); + + expect(getRegisteredComponentEntries().map((entry) => entry.name)).toEqual([ + 'RootComponent', + 'ChildComponent' + ]); + + child.remove(); + expect(getRegisteredComponentEntries().map((entry) => entry.name)).toEqual([ + 'RootComponent' + ]); + }); + + it('calculates component cost entries and ranks them by total duration', () => { + const first = document.createElement('div'); + const second = document.createElement('div'); + document.body.append(first, second); + registerComponent({ id: 'first', name: 'FirstComponent', element: first }); + registerComponent({ id: 'second', name: 'SecondComponent', element: second }); + const cycleId = startCycle(); + + recordComponentCheck('first', 10, cycleId); + recordComponentCheck('second', 40, cycleId); + finishCycle(cycleId, 10, 20); + + const costEntries = getComponentCostEntries(); + expect(costEntries).toHaveLength(2); + expect(costEntries[0].name).toBe('SecondComponent'); + expect(costEntries[0].costPercentage).toBe(80); + expect(costEntries[1].name).toBe('FirstComponent'); + expect(costEntries[1].costPercentage).toBe(20); + }); + + it('calculates wastedCdStats inside finishCycle', () => { + const first = document.createElement('div'); + const second = document.createElement('div'); + document.body.append(first, second); + registerComponent({ id: 'first', name: 'FirstComponent', element: first }); + registerComponent({ id: 'second', name: 'SecondComponent', element: second }); + const cycleId = startCycle(); + + recordComponentCheck('first', 10, cycleId, { mutationType: 'none' }); + recordComponentCheck('second', 20, cycleId, { mutationType: 'text' }); + const cycle = finishCycle(cycleId, 10, 20); + + expect(cycle.wastedCdStats).toBeDefined(); + expect(cycle.wastedCdStats?.checked).toBe(2); + expect(cycle.wastedCdStats?.changed).toBe(1); + expect(cycle.wastedCdStats?.wasteScore).toBe(50); + }); }); diff --git a/packages/angular-render-scan/src/application/stats.ts b/packages/angular-render-scan/src/application/stats.ts index e680049..89636ab 100644 --- a/packages/angular-render-scan/src/application/stats.ts +++ b/packages/angular-render-scan/src/application/stats.ts @@ -8,7 +8,9 @@ import type { WaterfallEntry, OnPushCandidate, ReferentialInstabilityReport, - CdTriggerSource + CdTriggerSource, + RenderCause, + ComponentCostEntry } from '../domain/entities'; import { analyzeOnPushCandidates } from './onpush-analyzer'; import { getReferentialInstabilityReports, resetReferentialStability, clearReferentialStabilityStats } from './referential-stability'; @@ -24,6 +26,8 @@ interface ComponentStats extends AngularRenderScanRegisteredComponent { wastedChecks: number; inputChangeCount: number; lastTrigger?: CdTriggerSource; + templateChanges: number; + renderCause?: RenderCause; } let cycleId = 0; @@ -31,6 +35,13 @@ let cycleStartedAt = 0; let activeCycleWaterfall: WaterfallEntry[] = []; const components = new Map(); +// Callback to resolve render cause from runtime to avoid circular dependency +let getRenderCauseCallback: ((componentName: string) => RenderCause | undefined) | null = null; + +export function registerGetRenderCauseCallback(cb: (componentName: string) => RenderCause | undefined): void { + getRenderCauseCallback = cb; +} + // Track MutationObservers for connected elements to classify mutation types const observers = new Map(); @@ -45,6 +56,7 @@ export function registerComponent(component: AngularRenderScanRegisteredComponen latestCycleId: existing?.latestCycleId ?? 0, latestDetails: existing?.latestDetails ?? {}, wastedChecks: existing?.wastedChecks ?? 0, + templateChanges: existing?.templateChanges ?? 0, inputChangeCount: existing?.inputChangeCount ?? 0, cdStrategy: component.cdStrategy ?? existing?.cdStrategy ?? 'unknown', parentId: component.parentId ?? existing?.parentId ?? null @@ -108,10 +120,17 @@ export function recordComponentCheck( stats.latestCycleId = currentCycleId; if (lastTrigger) stats.lastTrigger = lastTrigger; - // Track wasted checks + // Track wasted checks vs template changes const isWasted = details.mutationType === 'none'; if (isWasted) { stats.wastedChecks += 1; + } else { + stats.templateChanges = (stats.templateChanges || 0) + 1; + } + + // Resolve render cause using runtime callback + if (getRenderCauseCallback) { + stats.renderCause = getRenderCauseCallback(stats.name); } // Track input changes @@ -184,6 +203,10 @@ export function finishCycle( const waterfall = [...activeCycleWaterfall]; + const checked = entries.length; + const changed = entries.filter((e) => e.mutationType !== 'none').length; + const wasteScore = checked === 0 ? 0 : Math.round(((checked - changed) / checked) * 100); + return { id, startedAt, @@ -192,7 +215,12 @@ export function finishCycle( renderedCount: entries.length, slowest: entries[0], entries, - waterfall + waterfall, + wastedCdStats: { + checked, + changed, + wasteScore + } }; } @@ -218,7 +246,9 @@ export function clearStats(): void { stats.latestCycleId = 0; stats.latestDetails = {}; stats.wastedChecks = 0; + stats.templateChanges = 0; stats.inputChangeCount = 0; + stats.renderCause = undefined; } for (const state of observers.values()) { state.lastMutation = 'none'; @@ -244,6 +274,12 @@ export function getLeakedComponents(): AngularRenderEntry[] { .map(toEntry); } +export function getRegisteredComponentEntries(): AngularRenderEntry[] { + return [...components.values()] + .filter((stats) => stats.element.isConnected) + .map(toEntry); +} + export function getOnPushCandidates(threshold = 40): OnPushCandidate[] { const data = [...components.values()].map(c => ({ id: c.id, @@ -284,6 +320,27 @@ export function getCdGraph(): CdGraph { return buildCdGraph(data); } +export function getComponentCostEntries(): ComponentCostEntry[] { + const totalDurationAll = [...components.values()].reduce((sum, c) => sum + c.totalDuration, 0); + + return [...components.values()] + .map(c => { + const totalDuration = c.totalDuration; + const averageDuration = c.totalChecks === 0 ? 0 : totalDuration / c.totalChecks; + const costPercentage = totalDurationAll === 0 ? 0 : Math.round((totalDuration / totalDurationAll) * 100); + + return { + name: c.name, + selector: c.selector ?? (c.element ? selectorFor(c.element) : ''), + totalDuration, + averageDuration, + renderCount: c.totalChecks, + costPercentage + }; + }) + .sort((a, b) => b.totalDuration - a.totalDuration); +} + function toEntry(stats: ComponentStats): AngularRenderEntry { const count = stats.totalChecks; const wastedChecks = stats.wastedChecks || 0; @@ -310,7 +367,9 @@ function toEntry(stats: ComponentStats): AngularRenderEntry { mutationType: stats.latestDetails.mutationType ?? 'none', cdStrategy: stats.cdStrategy ?? 'unknown', isOnPushCandidate, - parentId: stats.parentId ?? null + parentId: stats.parentId ?? null, + renderCause: stats.renderCause, + templateChanges: stats.templateChanges ?? 0 }; } diff --git a/packages/angular-render-scan/src/domain/entities.ts b/packages/angular-render-scan/src/domain/entities.ts index e371a7b..214a384 100644 --- a/packages/angular-render-scan/src/domain/entities.ts +++ b/packages/angular-render-scan/src/domain/entities.ts @@ -133,6 +133,41 @@ export interface AngularRenderScanBudgets { maxRendersPerSecond?: number; } +export interface RenderCause { + trigger: string; + source?: string; + stack?: string[]; + timestamp: number; +} + +export interface SignalDependencyNode { + id: string; + name: string; + kind: 'signal' | 'computed' | 'component'; + updateCount: number; + wastedCount: number; + value?: string; +} + +export interface SignalDependencyEdge { + fromId: string; + toId: string; +} + +export interface SignalDependencyGraph { + nodes: SignalDependencyNode[]; + edges: SignalDependencyEdge[]; +} + +export interface ComponentCostEntry { + name: string; + selector: string; + totalDuration: number; + averageDuration: number; + renderCount: number; + costPercentage: number; +} + export interface AngularRenderEntry { id: string; name: string; @@ -156,6 +191,10 @@ export interface AngularRenderEntry { isOnPushCandidate?: boolean; /** NEW: parent component id */ parentId?: string | null; + /** v0.2: Render cause chain */ + renderCause?: RenderCause; + /** v0.2: Number of renders that mutated the DOM */ + templateChanges?: number; } export interface AngularRenderCycle { @@ -171,6 +210,12 @@ export interface AngularRenderCycle { trigger?: CdTriggerAttribution; /** NEW: Whether this cycle is suspected Zone pollution */ isZonePollution?: boolean; + /** v0.2: Wasted CD Cycle Stats */ + wastedCdStats?: { + checked: number; + changed: number; + wasteScore: number; + }; } export interface WaterfallEntry { 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 f875098..cb9df27 100644 --- a/packages/angular-render-scan/src/infrastructure/angular/auto-instrumentation.ts +++ b/packages/angular-render-scan/src/infrastructure/angular/auto-instrumentation.ts @@ -1,4 +1,13 @@ -import { beginCycle, currentCycleId, endCycle, ensureCycleForComponentCheck } from '../../application/runtime'; +import { + beginCycle, + currentCycleId, + endCycle, + ensureCycleForComponentCheck, + setActiveCheckingComponent, + setActiveComputedSignal, + recordSignalRead, + recordSignalWrite +} from '../../application/runtime'; import { recordComponentCheck, registerComponent, unregisterComponent } from '../../application/stats'; import { checkReferentialStability } from '../../application/referential-stability'; import { getResolvedOptions } from '../../domain/options'; @@ -7,6 +16,131 @@ import type { AngularRenderChangedInput, AngularRenderScanRenderDetails } from ' const ProfilerEventTemplateUpdateStart = 2; const ProfilerEventTemplateUpdateEnd = 3; +function patchSignalsOnInstance(instance: any, compName: string): void { + if (!instance || typeof instance !== 'object') return; + if (instance.__angularRenderScanPatchedSignals) return; + instance.__angularRenderScanPatchedSignals = true; + + let signalSymbol: symbol | undefined; + const getSignalSymbol = (obj: any) => { + if (!obj || (typeof obj !== 'object' && typeof obj !== 'function')) return undefined; + if (signalSymbol) return signalSymbol; + const sym = Reflect.ownKeys(obj).find(k => typeof k === 'symbol' && String(k).includes('SIGNAL')); + if (sym) signalSymbol = sym as symbol; + return signalSymbol; + }; + + const keys = Object.getOwnPropertyNames(instance); + for (const key of keys) { + let prop: any; + try { + prop = instance[key]; + } catch { + continue; + } + if (!prop) continue; + + const sym = getSignalSymbol(prop); + if (sym && typeof prop === 'function') { + const internalNode = prop[sym]; + const debugName = internalNode?.debugName || `${compName}.${key}`; + + if (!prop.__angularRenderScanPatched) { + const originalSignal = prop; + + const wrapped = function(this: any) { + const kind = typeof originalSignal.set === 'function' ? 'signal' : 'computed'; + const previousComputed = (window as any).__angularActiveComputedSignalName || null; + if (kind === 'computed') { + (window as any).__angularActiveComputedSignalName = debugName; + setActiveComputedSignal(debugName); + } + + recordSignalRead(debugName, kind); + + try { + return originalSignal.apply(this, arguments); + } finally { + if (kind === 'computed') { + (window as any).__angularActiveComputedSignalName = previousComputed; + setActiveComputedSignal(previousComputed); + } + } + } as any; + + wrapped.__angularRenderScanPatched = true; + wrapped[sym] = internalNode; + wrapped.toString = originalSignal.toString.bind(originalSignal); + + if (typeof originalSignal.set === 'function') { + wrapped.set = function(val: any) { + const curVal = internalNode?.value; + const equal = internalNode?.equal || ((a: any, b: any) => a === b); + const isWasted = equal(curVal, val); + + const err = new Error(); + const stack = err.stack ? err.stack.split('\n').slice(2) : []; + const cleanStack = stack + .map((f: any) => f.trim().replace(/^at\s+/, '')) + .filter((f: any) => + !f.includes('angular-render-scan') && + !f.includes('zone.js') && + !f.includes('node_modules') + ); + + recordSignalWrite(debugName, 'set', cleanStack, isWasted); + return originalSignal.set.apply(originalSignal, arguments); + }; + } + + if (typeof originalSignal.update === 'function') { + wrapped.update = function(fn: any) { + const curVal = internalNode?.value; + const newVal = fn(curVal); + const equal = internalNode?.equal || ((a: any, b: any) => a === b); + const isWasted = equal(curVal, newVal); + + const err = new Error(); + const stack = err.stack ? err.stack.split('\n').slice(2) : []; + const cleanStack = stack + .map((f: any) => f.trim().replace(/^at\s+/, '')) + .filter((f: any) => + !f.includes('angular-render-scan') && + !f.includes('zone.js') && + !f.includes('node_modules') + ); + + recordSignalWrite(debugName, 'update', cleanStack, isWasted); + return originalSignal.update.apply(originalSignal, arguments); + }; + } + + if (typeof originalSignal.asReadonly === 'function') { + wrapped.asReadonly = originalSignal.asReadonly.bind(originalSignal); + } + + instance[key] = wrapped; + } + } else if (typeof prop === 'object' && prop !== null && !Array.isArray(prop)) { + const ctorName = prop.constructor?.name; + if ( + ctorName && + !ctorName.startsWith('ɵ') && + !ctorName.startsWith('ElementRef') && + !ctorName.startsWith('ViewContainerRef') && + !ctorName.startsWith('ChangeDetectorRef') && + !ctorName.startsWith('NgZone') && + !ctorName.startsWith('ApplicationRef') && + !ctorName.startsWith('Router') && + !ctorName.startsWith('ActivatedRoute') && + !(prop instanceof HTMLElement) + ) { + patchSignalsOnInstance(prop, ctorName); + } + } + } +} + let nextAutoComponentId = 0; interface AutoCompData { @@ -211,6 +345,9 @@ export function setupAutoInstrumentation(): void { } if (compData && compData.element) { + patchSignalsOnInstance(instance, compData.name); + setActiveCheckingComponent(compData.id, compData.name); + const changedInputs = detectInputChanges( instance, compData, @@ -283,6 +420,7 @@ export function setupAutoInstrumentation(): void { // Stack mismatch — recover componentCheckStack.length = 0; } + setActiveCheckingComponent(null, null); } } }); diff --git a/packages/angular-render-scan/src/infrastructure/ui/angular-debug.spec.ts b/packages/angular-render-scan/src/infrastructure/ui/angular-debug.spec.ts index 858a672..348c160 100644 --- a/packages/angular-render-scan/src/infrastructure/ui/angular-debug.spec.ts +++ b/packages/angular-render-scan/src/infrastructure/ui/angular-debug.spec.ts @@ -9,6 +9,7 @@ class RootComponent {} describe("getAngularDebugSummary", () => { afterEach(() => { delete (window as Window & { ng?: unknown }).ng; + document.body.replaceChildren(); }); it("falls back when Angular debug globals are unavailable", () => { @@ -21,8 +22,27 @@ describe("getAngularDebugSummary", () => { }); }); + it("reads Angular version from ng-version in the DOM", () => { + const host = document.createElement("app-root"); + host.setAttribute("ng-version", "19.2.4"); + const element = document.createElement("app-demo"); + host.append(element); + document.body.append(host); + + expect(getAngularDebugSummary(element)).toEqual({ + available: false, + version: "19.2.4", + directiveNames: [], + listenerNames: [], + }); + }); + it("returns sanitized Angular debug data", () => { + const host = document.createElement("app-root"); + host.setAttribute("ng-version", "20.1.2"); const element = document.createElement("app-demo"); + host.append(element); + document.body.append(host); (window as Window & { ng?: unknown }).ng = { getComponent: () => new DemoComponent(), getOwningComponent: () => new OwnerComponent(), @@ -36,6 +56,7 @@ describe("getAngularDebugSummary", () => { expect(getAngularDebugSummary(element)).toEqual({ available: true, + version: "20.1.2", componentName: "DemoComponent", ownerName: "OwnerComponent", directiveNames: ["DemoDirective"], diff --git a/packages/angular-render-scan/src/infrastructure/ui/angular-debug.ts b/packages/angular-render-scan/src/infrastructure/ui/angular-debug.ts index e347014..91b9c81 100644 --- a/packages/angular-render-scan/src/infrastructure/ui/angular-debug.ts +++ b/packages/angular-render-scan/src/infrastructure/ui/angular-debug.ts @@ -1,5 +1,6 @@ export interface AngularDebugSummary { available: boolean; + version?: string; componentName?: string; ownerName?: string; directiveNames: string[]; @@ -16,9 +17,15 @@ type AngularDebugGlobals = { }; export function getAngularDebugSummary(element: Element): AngularDebugSummary { + const version = versionFromDom(element); const ng = getAngularGlobals(); if (!ng) { - return { available: false, directiveNames: [], listenerNames: [] }; + return { + available: false, + version, + directiveNames: [], + listenerNames: [], + }; } const component = safeCall(() => ng.getComponent?.(element)); @@ -29,6 +36,7 @@ export function getAngularDebugSummary(element: Element): AngularDebugSummary { return { available: true, + version, componentName: nameOf(component), ownerName: nameOf(owner), directiveNames: directives.map(nameOf).filter(isPresent), @@ -52,6 +60,14 @@ function getAngularGlobals(): AngularDebugGlobals | undefined { return ng; } +function versionFromDom(element: Element): string | undefined { + return ( + element.closest("[ng-version]")?.getAttribute("ng-version") || + document.querySelector("[ng-version]")?.getAttribute("ng-version") || + undefined + ); +} + function safeCall(read: () => T): T | undefined { try { return read(); diff --git a/packages/angular-render-scan/src/infrastructure/ui/overlay.ts b/packages/angular-render-scan/src/infrastructure/ui/overlay.ts index e7f914c..6a95736 100644 --- a/packages/angular-render-scan/src/infrastructure/ui/overlay.ts +++ b/packages/angular-render-scan/src/infrastructure/ui/overlay.ts @@ -9,6 +9,7 @@ import { getSessionData, getWastedStats, getLeakedComponents, + getRegisteredComponents, getOnPushCandidates, getReferentialInstability, getZonePollutionEvents, @@ -31,6 +32,7 @@ interface ActiveHighlight { } const TOOLBAR_POSITION_KEY = "angular-render-scan:toolbar-position"; +const TOOLBAR_COMPACT_KEY = "angular-render-scan:toolbar-compact"; const TOOLBAR_CSS = ` :host { @@ -43,9 +45,12 @@ const TOOLBAR_CSS = ` --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); + --ars-panel-bg: rgba(255, 255, 255, 0.94); + --ars-card-bg: rgba(248, 250, 252, 0.86); + --ars-chip-bg: rgba(255, 255, 255, 0.72); + --ars-chip-border: rgba(15, 23, 42, 0.07); + --ars-accent-soft: rgba(37, 99, 235, 0.1); + --ars-shadow: 0 18px 48px rgba(15, 23, 42, 0.12), 0 1px 2px rgba(15, 23, 42, 0.08), inset 0 1px 0 rgba(255,255,255,0.7); } :host(.dark) { @@ -53,9 +58,12 @@ const TOOLBAR_CSS = ` --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); + --ars-panel-bg: rgba(15, 23, 42, 0.94); + --ars-card-bg: rgba(30, 41, 59, 0.84); + --ars-chip-bg: rgba(255, 255, 255, 0.07); + --ars-chip-border: rgba(255, 255, 255, 0.09); + --ars-accent-soft: rgba(96, 165, 250, 0.14); + --ars-shadow: 0 18px 48px rgba(0,0,0,0.38), 0 1px 2px rgba(0,0,0,0.28), inset 0 1px 0 rgba(255,255,255,0.08); } .toolbar { @@ -66,12 +74,12 @@ const TOOLBAR_CSS = ` display: flex; align-items: center; flex-wrap: wrap; - gap: 7px; + gap: 6px; width: auto; max-width: calc(100vw - 32px); padding: 6px; - border: 1px solid rgba(15, 23, 42, 0.1); - border-radius: 13px; + border: 1px solid rgba(15, 23, 42, 0.08); + border-radius: 12px; background: linear-gradient(135deg, rgba(255,255,255,0.97), rgba(248,250,252,0.92)), var(--ars-bg); @@ -82,7 +90,7 @@ const TOOLBAR_CSS = ` color: var(--ars-color); font: 500 11px/1.2 ui-sans-serif, system-ui, -apple-system, sans-serif; pointer-events: auto; - backdrop-filter: blur(18px) saturate(1.25); + backdrop-filter: blur(20px) saturate(1.2); cursor: grab; user-select: none; transition: box-shadow 0.2s ease, border-color 0.2s ease; @@ -97,7 +105,7 @@ const TOOLBAR_CSS = ` inset 0 1px 0 rgba(255,255,255,0.08); } .toolbar:hover { - border-color: rgba(37, 99, 235, 0.22); + border-color: rgba(37, 99, 235, 0.2); box-shadow: 0 16px 40px rgba(15, 23, 42, 0.16), 0 2px 8px rgba(15, 23, 42, 0.08), @@ -106,6 +114,11 @@ const TOOLBAR_CSS = ` .toolbar:active { cursor: grabbing; } + .toolbar.disabled { + opacity: 0.82; + pointer-events: auto; + cursor: grab; + } .toolbar.disabled { gap: 0; padding: 5px; @@ -116,8 +129,10 @@ const TOOLBAR_CSS = ` } .toolbar-switch { display: inline-flex; + flex-direction: column; align-items: center; - padding: 6px 7px; + gap: 2px; + padding: 3px 6px 5px; border-radius: 9px; background: rgba(15, 23, 42, 0.045); box-shadow: inset 0 0 0 1px rgba(15, 23, 42, 0.055); @@ -132,6 +147,8 @@ const TOOLBAR_CSS = ` border-radius: 999px; background: transparent; box-shadow: none; + min-width: 0; + gap: 6px; } .toolbar-main { display: flex; @@ -142,6 +159,27 @@ const TOOLBAR_CSS = ` max-width: calc(100vw - 230px); overflow: visible; } + .toolbar.compact .toolbar-main { + max-width: calc(100vw - 168px); + } + .toolbar-extended { + display: flex; + align-items: stretch; + flex-wrap: wrap; + gap: 5px; + } + .toolbar.compact .toolbar-extended { + display: none; + } + .toolbar.expanded .metric { + min-width: 56px; + padding: 6px 7px; + } + .toolbar.expanded .metric.slowest-metric { + min-width: 106px; + max-width: 128px; + flex-basis: 118px; + } .toolbar-actions { display: flex; align-items: center; @@ -155,7 +193,34 @@ const TOOLBAR_CSS = ` :host(.dark) .toolbar-actions { border-left-color: rgba(255, 255, 255, 0.08); } - .switch, .details-toggle, .clear-btn, .action-btn, .panel-close, .panel-copy-btn { + .toolbar.compact .toolbar-actions { + gap: 4px; + } + .toolbar-size-toggle { + min-width: 30px; + justify-content: center; + } + .toolbar-actions .toolbar-size-toggle { + width: 28px; + height: 28px; + min-width: 28px; + padding: 0; + } + .toolbar.compact .toolbar-size-toggle { + min-width: 30px; + } + .toolbar-picker-toggle { + display: inline-grid; + place-items: center; + } + .toolbar-picker-toggle svg, + .toolbar-size-toggle svg { + width: 14px; + height: 14px; + display: block; + stroke: currentColor; + } + .switch, .details-toggle, .clear-btn, .action-btn { cursor: pointer; } .switch { @@ -202,8 +267,8 @@ const TOOLBAR_CSS = ` } .track { position: relative; - width: 32px; - height: 18px; + width: 28px; + height: 16px; border-radius: 999px; background: #e2e8f0; transition: background 0.15s ease; @@ -213,8 +278,8 @@ const TOOLBAR_CSS = ` position: absolute; top: 2px; left: 2px; - width: 14px; - height: 14px; + width: 12px; + height: 12px; border-radius: 999px; background: #ffffff; box-shadow: 0 1px 3px rgba(15, 23, 42, 0.15); @@ -224,7 +289,7 @@ const TOOLBAR_CSS = ` background: #2563eb; } input:checked + .track::after { - transform: translateX(14px); + transform: translateX(12px); } .switch-text { display: none; @@ -234,34 +299,54 @@ const TOOLBAR_CSS = ` .metric { display: grid; gap: 3px; - min-width: 60px; + min-width: 62px; flex: 0 0 auto; padding: 7px 8px; - border-radius: 9px; - background: rgba(255, 255, 255, 0.62); + border: 1px solid var(--ars-chip-border); + border-radius: 8px; + background: var(--ars-chip-bg); box-shadow: - inset 0 0 0 1px rgba(15, 23, 42, 0.055), - inset 0 1px 0 rgba(255, 255, 255, 0.55); - transition: background 0.15s ease, box-shadow 0.15s ease; + inset 0 1px 0 rgba(255, 255, 255, 0.5), + 0 1px 2px rgba(15, 23, 42, 0.025); + transition: background 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease; } .metric:hover { background: rgba(255, 255, 255, 0.9); + border-color: rgba(37, 99, 235, 0.16); box-shadow: - inset 0 0 0 1px rgba(37, 99, 235, 0.18), + inset 0 1px 0 rgba(255, 255, 255, 0.62), 0 3px 8px rgba(15, 23, 42, 0.07); } :host(.dark) .metric { - background: rgba(255, 255, 255, 0.055); - box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.075); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06); } :host(.dark) .metric:hover { background: rgba(255, 255, 255, 0.09); + border-color: rgba(96, 165, 250, 0.22); } .metric.slowest-metric { min-width: 128px; max-width: 160px; flex: 0 0 140px; } + .angular-version-chip { + flex: 0 0 auto; + max-width: 38px; + overflow: hidden; + text-overflow: ellipsis; + padding: 1px 4px; + border: 1px solid rgba(37, 99, 235, 0.16); + border-radius: 999px; + background: rgba(37, 99, 235, 0.08); + color: #2563eb; + font: 850 7px/1 ui-monospace, SFMono-Regular, Menlo, monospace; + white-space: nowrap; + } + :host(.dark) .angular-version-chip { + border-color: rgba(96, 165, 250, 0.22); + background: rgba(96, 165, 250, 0.12); + color: #93c5fd; + } .slowest-metric .value { display: block; width: 100%; @@ -278,7 +363,7 @@ const TOOLBAR_CSS = ` cursor: pointer; transition: background 0.15s ease, transform 0.1s ease, box-shadow 0.15s ease; border-radius: 9px; - padding: 7px 8px; + padding: 6px 7px; margin: 0; user-select: none; display: inline-grid; @@ -315,7 +400,7 @@ const TOOLBAR_CSS = ` border: 1px solid var(--ars-border); border-radius: 10px; padding: 10px; - backdrop-filter: blur(16px); + backdrop-filter: blur(20px) saturate(1.15); box-shadow: var(--ars-shadow); display: grid; gap: 6px; @@ -437,31 +522,15 @@ const TOOLBAR_CSS = ` opacity: 1; transform: translateX(-50%) translateY(0); } - .panel-actions [data-tooltip]::after { - left: auto; - right: 0; - transform: translateY(4px); - } - .panel-actions [data-tooltip]::before { - left: auto; - right: 16px; - transform: translateY(4px); - } - .panel-actions [data-tooltip]:hover::after, - .panel-actions [data-tooltip]:hover::before, - .panel-actions [data-tooltip]:focus-within::after, - .panel-actions [data-tooltip]:focus-within::before { - transform: translateY(0); - } - .clear-btn, .action-btn, .panel-close, .panel-copy-btn { - background: rgba(15, 23, 42, 0.035); - border: 1px solid rgba(15, 23, 42, 0.08); - border-radius: 9px; + .clear-btn, .action-btn { + background: var(--ars-chip-bg); + border: 1px solid var(--ars-chip-border); + border-radius: 8px; padding: 7px 10px; font: inherit; font-weight: 600; color: var(--ars-label); - transition: all 0.15s ease; + transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease; } .toolbar-actions .clear-btn, .toolbar-actions .action-btn, @@ -486,18 +555,18 @@ const TOOLBAR_CSS = ` opacity: 0; pointer-events: none; } - .clear-btn:hover, .action-btn:hover, .panel-close:hover, .panel-copy-btn:hover { - background: rgba(37, 99, 235, 0.075); - border-color: rgba(37, 99, 235, 0.2); + .clear-btn:hover, .action-btn:hover { + background: var(--ars-accent-soft); + border-color: rgba(37, 99, 235, 0.22); color: #2563eb; } - .clear-btn:active, .action-btn:active, .panel-close:active, .panel-copy-btn:active { - transform: translateY(0); + .clear-btn:active, .action-btn:active { + filter: brightness(0.98); } .action-btn.active { - border-color: rgba(37, 99, 235, 0.2); + border-color: rgba(37, 99, 235, 0.24); color: #2563eb; - background: rgba(37, 99, 235, 0.05); + background: var(--ars-accent-soft); } .status { position: absolute; @@ -505,11 +574,11 @@ const TOOLBAR_CSS = ` right: 16px; z-index: 2147483647; color: #ffffff; - background: #2563eb; + background: linear-gradient(135deg, #2563eb, #0891b2); font-size: 10px; font-weight: 700; padding: 4px 8px; - border-radius: 6px; + border-radius: 999px; box-shadow: 0 1px 3px rgba(0,0,0,0.02), 0 8px 20px rgba(37, 99, 235, 0.15); @@ -533,18 +602,19 @@ const TOOLBAR_CSS = ` bottom: 72px; z-index: 2147483647; width: min(280px, calc(100vw - 32px)); - display: grid; - gap: 6px; - padding: 10px; + display: flex; + flex-direction: column; + gap: 7px; + padding: 11px; border: 1px solid var(--ars-border); border-top: 3px solid #3b82f6; - border-radius: 10px; + border-radius: 11px; background: var(--ars-panel-bg); box-shadow: var(--ars-shadow); color: var(--ars-color); font: 500 10px/1.3 Inter, system-ui, -apple-system, sans-serif; pointer-events: auto; - backdrop-filter: blur(16px); + backdrop-filter: blur(20px) saturate(1.15); transition: border-top-color 0.2s ease; max-height: calc(100vh - 100px); overflow-y: auto; @@ -553,24 +623,23 @@ const TOOLBAR_CSS = ` .inspect-panel.medium { border-top-color: #f59e0b; } .inspect-panel.fast { border-top-color: #10b981; } .panel-head { - display: flex; - justify-content: space-between; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 8px; border-bottom: 1px solid var(--ars-border); padding-bottom: 6px; } + .panel-head-main { + min-width: 0; + } .panel-title { font-size: 12px; font-weight: 800; color: var(--ars-color); - overflow-wrap: anywhere; - } - .panel-actions { - display: flex; - align-items: center; - gap: 6px; - flex-shrink: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .severity { display: inline-flex; @@ -600,33 +669,77 @@ const TOOLBAR_CSS = ` } .panel-grid { display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); + grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 4px; margin: 2px 0; } - .panel-grid .panel-field { + .panel-grid .panel-field, + .panel-summary-metrics .panel-field { background: var(--ars-card-bg); border: 1px solid var(--ars-border); border-radius: 6px; - padding: 4px; + padding: 5px 4px; text-align: center; display: flex; flex-direction: column; justify-content: center; - min-height: 36px; + min-height: 30px; + min-width: 0; } - .panel-grid .panel-label { + .panel-grid .panel-label, + .panel-summary-metrics .panel-label { font-size: 8px; font-weight: 700; color: var(--ars-label); text-transform: uppercase; letter-spacing: 0.05em; } - .panel-grid .panel-value { + .panel-grid .panel-value, + .panel-summary-metrics .panel-value { font-size: 10px; font-weight: 700; color: var(--ars-color); margin-top: 1px; + min-width: 0; + overflow-wrap: anywhere; + } + .panel-summary { + display: grid; + gap: 4px; + margin: 2px 0; + } + .panel-summary-metrics { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 4px; + } + .summary-metric, + .summary-context { + min-width: 0; + background: var(--ars-card-bg); + border: 1px solid var(--ars-border); + border-radius: 6px; + padding: 5px 6px; + } + .summary-metric { + display: grid; + gap: 1px; + text-align: center; + } + .summary-context { + display: flex; + align-items: center; + gap: 6px; + min-height: 24px; + } + .summary-context .panel-label { + flex: 0 0 auto; + } + .summary-context .panel-value { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .inspect-panel .panel-field:not(.panel-grid .panel-field) { border-top: 1px solid var(--ars-border); @@ -661,11 +774,10 @@ const TOOLBAR_CSS = ` display: flex; flex-direction: column; gap: 2px; - transition: all 0.15s ease; + transition: border-color 0.15s ease, background 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; @@ -721,6 +833,7 @@ export class AngularRenderScanOverlay { private selectedEntry?: AngularRenderEntry; private hoveredEntry?: AngularRenderEntry; private hoveredRect?: DOMRect; + private hoverPointer?: { x: number; y: number }; private detailsHoverCursorActive = false; private detailsMode = false; private copyStatus = ""; @@ -755,6 +868,7 @@ export class AngularRenderScanOverlay { private budgetViolations: BudgetViolation[] = []; private showAlertsPanel = false; private showWaterfallPanel = false; + private compactToolbar = true; private keyListener?: (e: KeyboardEvent) => void; private budgetViolationListener?: (e: Event) => void; @@ -764,6 +878,7 @@ export class AngularRenderScanOverlay { ) { this.options = options; this.restoreToolbarPosition(); + this.restoreToolbarCompact(); const recorded = getRecording(); if (recorded && recorded.length > 0) { this.latestCycle = recorded[recorded.length - 1]; @@ -822,6 +937,7 @@ export class AngularRenderScanOverlay { this.detailsMode = !this.detailsMode; this.hoveredEntry = undefined; this.hoveredRect = undefined; + this.hoverPointer = undefined; this.setDetailsHoverCursor(false); if (!this.detailsMode) this.selectedEntry = undefined; this.renderToolbar(); @@ -840,6 +956,7 @@ export class AngularRenderScanOverlay { this.selectedEntry = undefined; this.hoveredEntry = undefined; this.hoveredRect = undefined; + this.hoverPointer = undefined; this.last30CycleDurations.length = 0; this.showWaterfallPanel = false; this.renderToolbar(); @@ -876,6 +993,7 @@ export class AngularRenderScanOverlay { if (!this.detailsMode || !this.options.enabled) { this.hoveredEntry = undefined; this.hoveredRect = undefined; + this.hoverPointer = undefined; this.setDetailsHoverCursor(false); return; } @@ -885,49 +1003,28 @@ export class AngularRenderScanOverlay { return; } - const hovered = this.findClickedEntry(e.clientX, e.clientY); + const previousHoveredId = this.hoveredEntry?.id; + const hovered = this.findPickerEntry(e.clientX, e.clientY); this.hoveredEntry = hovered?.entry; this.hoveredRect = hovered?.rect; + this.hoverPointer = hovered ? { x: e.clientX, y: e.clientY } : undefined; this.setDetailsHoverCursor(Boolean(hovered)); + if ( + previousHoveredId !== this.hoveredEntry?.id || + this.hoveredEntry + ) { + this.renderToolbar(); + } }; this.globalClickListener = (e: MouseEvent) => { - if (!this.detailsMode && !e.metaKey && !e.ctrlKey) return; - if (!this.options.enabled) return; + if (!this.detailsMode || !this.options.enabled) return; if (this.isOverlayTarget(e.target)) return; - - const x = e.clientX; - const y = e.clientY; - const clicked = - this.findClickedEntry(x, y) ?? - (this.hoveredEntry && this.hoveredRect - ? { - entry: this.hoveredEntry, - rect: this.hoveredRect, - expiresAt: 0, - } - : undefined); - - if (clicked) { - e.preventDefault(); - e.stopPropagation(); - this.selectedEntry = clicked.entry; - this.renderToolbar(); - - const globalNg = (window as any).ng; - if (globalNg && globalNg.getComponent) { - const component = globalNg.getComponent(clicked.entry.element); - console.info( - `[angular-render-scan] Inspecting <${clicked.entry.name}>:`, - component || clicked.entry.element, - ); - } else { - console.info( - `[angular-render-scan] Inspecting <${clicked.entry.name}> element:`, - clicked.entry.element, - ); - } - } + this.hoveredEntry = undefined; + this.hoveredRect = undefined; + this.hoverPointer = undefined; + this.setDetailsHoverCursor(false); + this.renderToolbar(); }; document.addEventListener("mousemove", this.globalMoveListener, { @@ -944,10 +1041,10 @@ export class AngularRenderScanOverlay { const event = e as MouseEvent | TouchEvent; const target = event.target as HTMLElement; if ( - target.closest(".switch") || - target.closest(".clear-btn") || - target.closest(".action-btn") || - target.closest(".panel-close") + this.options.enabled && + (target.closest(".switch") || + target.closest(".clear-btn") || + target.closest(".action-btn")) ) { return; // Don't drag if clicking buttons } @@ -1110,6 +1207,26 @@ export class AngularRenderScanOverlay { } } + private restoreToolbarCompact(): void { + try { + const raw = globalThis.localStorage?.getItem(TOOLBAR_COMPACT_KEY); + this.compactToolbar = raw === null ? true : raw !== "false"; + } catch { + this.compactToolbar = true; + } + } + + private saveToolbarCompact(): void { + try { + globalThis.localStorage?.setItem( + TOOLBAR_COMPACT_KEY, + String(this.compactToolbar), + ); + } catch { + // Ignore storage failures. + } + } + private saveToolbarPosition(): void { try { globalThis.localStorage?.setItem( @@ -1240,6 +1357,45 @@ export class AngularRenderScanOverlay { return this.smallestContainingHighlight(latestHighlights, x, y); } + private findPickerEntry(x: number, y: number): ActiveHighlight | undefined { + const hitElement = document.elementFromPoint(x, y); + if (!hitElement || this.isOverlayTarget(hitElement)) { + return undefined; + } + + const matches = getRegisteredComponents() + .flatMap((entry) => { + if (!entry.element.isConnected || !entry.element.contains(hitElement)) { + return []; + } + + const rect = entry.element.getBoundingClientRect(); + if ( + rect.width <= 0 || + rect.height <= 0 || + x < rect.left || + x > rect.right || + y < rect.top || + y > rect.bottom + ) { + return []; + } + + return [{ entry, rect, expiresAt: 0 }]; + }) + .sort((a, b) => area(a.rect) - area(b.rect)); + + if ( + matches.length === 1 && + matches[0].entry.parentId === null && + hitElement !== matches[0].entry.element + ) { + return undefined; + } + + return matches[0] ?? this.findClickedEntry(x, y); + } + private smallestContainingHighlight( highlights: ActiveHighlight[], x: number, @@ -1355,6 +1511,7 @@ export class AngularRenderScanOverlay { this.selectedEntry = undefined; this.hoveredEntry = undefined; this.hoveredRect = undefined; + this.hoverPointer = undefined; this.detailsMode = false; this.showCpuDetails = false; this.showWaterfallPanel = false; @@ -1446,8 +1603,10 @@ export class AngularRenderScanOverlay { const cycle = this.latestCycle; const displayedFps = this.latestFps || this.fps.value; + const compactToolbar = this.compactToolbar || !this.options.enabled; const cpuVal = this.cpu.value; const cpuClass = cpuVal > 50 ? "cpu-high" : cpuVal > 20 ? "cpu-medium" : ""; + const angularVersion = this.angularVersionFromDom(); const wasted = getWastedStats(); const leaks = getLeakedComponents(); @@ -1534,6 +1693,8 @@ export class AngularRenderScanOverlay { ` : ""; + const cycleWasteText = cycle?.wastedCdStats ? ` (${cycle.wastedCdStats.wasteScore}% waste)` : ''; + const htmlChanged = this.replaceToolbarHtml( container, ` @@ -1544,25 +1705,27 @@ export class AngularRenderScanOverlay { ${this.options.enabled ? this.onPushPanelHtml(onPushCandidates) : ""} ${this.options.enabled ? this.zonePollutionPanelHtml(pollutionEvents) : ""} ${this.options.enabled ? this.cdGraphPanelHtml() : ""} -
+
+ ${angularVersion ? `ng ${escapeHtml(angularVersion)}` : ""}
- ${ - this.options.enabled - ? ` + ${this.options.enabled ? `
${this.metric("FPS", this.options.showFPS ? String(displayedFps) + " fps" : "-", this.getFpsClass(displayedFps))} CPU ${cpuVal}% - ${this.metric("Last cycle", cycle ? `${cycle.duration.toFixed(1)}ms` : "-")} ${sparklineSvg} +
+ ${compactToolbar ? "" : ` +
+ ${this.metric("Last cycle", cycle ? `${cycle.duration.toFixed(1)}ms${cycleWasteText}` : "-")} CD Graph Graph @@ -1574,18 +1737,21 @@ export class AngularRenderScanOverlay { ${onPushChip} ${pollutionChip}
+ `} - + ${compactToolbar ? "" : ` ${this.options.showCopyPrompt ? '' : ""} + `} + + - ` - : "" - } + ` : ""} ${this.copyStatus ? `${escapeHtml(this.copyStatus)}` : ""}
`, @@ -1618,6 +1784,39 @@ export class AngularRenderScanOverlay { { once: true }, ); + toolbarEl?.querySelector(".toolbar-size-toggle")?.addEventListener( + "click", + () => { + this.compactToolbar = !this.compactToolbar; + this.closeInteractivePanels(); + this.showWaterfallPanel = false; + this.showAlertsPanel = false; + this.showOnPushPanel = false; + this.showZonePollutionPanel = false; + this.showGraphPanel = false; + this.graphCollapsed = false; + this.saveToolbarCompact(); + this.renderToolbar(); + this.clampToolbarPosition(); + this.saveToolbarPosition(); + }, + { once: true }, + ); + + toolbarEl?.querySelector(".toolbar-picker-toggle")?.addEventListener( + "click", + () => { + this.detailsMode = !this.detailsMode; + this.selectedEntry = undefined; + this.hoveredEntry = undefined; + this.hoveredRect = undefined; + this.hoverPointer = undefined; + this.setDetailsHoverCursor(false); + this.renderToolbar(); + }, + { once: true }, + ); + toolbarEl?.querySelector(".sparkline-toggle")?.addEventListener( "click", () => { @@ -1659,6 +1858,7 @@ export class AngularRenderScanOverlay { this.selectedEntry = undefined; this.hoveredEntry = undefined; this.hoveredRect = undefined; + this.hoverPointer = undefined; this.last30CycleDurations.length = 0; this.showWaterfallPanel = false; this.showCpuDetails = false; @@ -1670,21 +1870,6 @@ export class AngularRenderScanOverlay { { once: true }, ); - toolbarEl?.querySelector(".details-checkbox")?.addEventListener( - "change", - (event) => { - this.detailsMode = (event.target as HTMLInputElement).checked; - this.hoveredEntry = undefined; - this.hoveredRect = undefined; - this.setDetailsHoverCursor(false); - if (!this.detailsMode) { - this.selectedEntry = undefined; - } - this.renderToolbar(); - }, - { once: true }, - ); - toolbarEl?.querySelector(".copy-prompt-btn")?.addEventListener( "click", async () => { @@ -1718,15 +1903,6 @@ export class AngularRenderScanOverlay { { once: true }, ); - container.querySelector(".panel-close")?.addEventListener( - "click", - () => { - this.selectedEntry = undefined; - this.renderToolbar(); - }, - { once: true }, - ); - container.querySelector(".waterfall-close-btn")?.addEventListener( "click", () => { @@ -1736,28 +1912,13 @@ export class AngularRenderScanOverlay { { once: true }, ); - container.querySelector(".panel-copy-btn")?.addEventListener( - "click", - async () => { - if (!this.selectedEntry) { - return; - } - const copied = await this.copyComponentPrompt( - this.selectedEntry, - this.latestFps || this.fps.value, - ); - this.setCopyStatus(copied ? "Copied" : "Copy failed"); - }, - { once: true }, - ); - container.querySelector(".open-editor-btn")?.addEventListener( "click", async () => { - if (!this.selectedEntry) { + const entry = this.currentDetailsEntry(); + if (!entry) { return; } - const entry = this.selectedEntry; const query = `class ${entry.name}`; try { @@ -1889,6 +2050,13 @@ export class AngularRenderScanOverlay { return fps < 50 ? "fps-drop" : ""; } + private angularVersionFromDom(): string | undefined { + return ( + document.querySelector("[ng-version]")?.getAttribute("ng-version") || + undefined + ); + } + private replaceToolbarHtml(toolbar: HTMLElement, html: string): boolean { if (this.lastToolbarHtml === html) { return false; @@ -1900,7 +2068,7 @@ export class AngularRenderScanOverlay { } private inspectPanelHtml(): string { - const entry = this.selectedEntry; + const entry = this.currentDetailsEntry(); if (!entry) { return ""; } @@ -1914,10 +2082,17 @@ export class AngularRenderScanOverlay { (cycle) => `#${cycle.id} ${cycle.entries.find((candidate) => candidate.id === entry.id)?.latestDuration.toFixed(1)}ms`, ); - const isSlow = entry.latestDuration >= this.slowThresholdMs; const severity = this.severityFor(entry); const cost = this.costFor(entry); const recommendations = this.recommendationsFor(entry); + const triggerReason = entry.reason ?? "unknown"; + const mutationType = entry.mutationType ?? "none"; + const cdStrategy = entry.cdStrategy ?? "unknown"; + const angular = getAngularDebugSummary(entry.element); + const frameworkLabel = angular.version + ? `${cdStrategy} · Angular ${angular.version}` + : cdStrategy; + const panelStyle = this.inspectPanelPositionStyle(entry); const changedInputs = entry.changedInputs?.length ? entry.changedInputs .map((input) => { @@ -1945,27 +2120,48 @@ export class AngularRenderScanOverlay { : ""; return ` -
+
${leakWarningHtml}
-
+
${escapeHtml(entry.name)}
${openLinkHtml}
${escapeHtml(severity.label)}
-
- ${isSlow ? '' : ""} - -
-
- ${this.panelField("Last render", `${entry.latestDuration.toFixed(1)}ms`)} - ${this.panelField("Avg render", `${entry.averageDuration.toFixed(1)}ms`)} - ${this.panelField("Total renders", String(entry.count))} - ${this.panelField("Trigger reason", entry.reason ?? "unknown")} - ${this.panelField("Change detection", entry.cdStrategy ?? "unknown")} - ${this.panelField("Cycle #", String(entry.latestCycleId))} +
+
+ ${this.panelField("Last", `${entry.latestDuration.toFixed(1)}ms`)} + ${this.panelField("Avg", `${entry.averageDuration.toFixed(1)}ms`)} + ${this.panelField("Renders", `${entry.count} · #${entry.latestCycleId}`)} +
+
+ CD + ${escapeHtml(frameworkLabel)} · ${escapeHtml(triggerReason)} +
+
+ DOM + ${escapeHtml(mutationType)} +
+ ${ + entry.renderCause + ? ` +
+ Render Cause Chain + + ${escapeHtml(entry.renderCause.trigger.replace("signal:", "signal ").replace("zone:", "zone "))}${entry.renderCause.source ? ` → ${escapeHtml(entry.renderCause.source)}` : ""} + ${ + entry.renderCause.stack && entry.renderCause.stack.length > 0 + ? `
+ ${entry.renderCause.stack.slice(0, 3).map(f => `
└─ ${escapeHtml(f)}
`).join("")} +
` + : "" + } +
+
` + : "" + } ${ entry.isOnPushCandidate ? ` @@ -2028,6 +2224,72 @@ export class AngularRenderScanOverlay { `; } + private currentDetailsEntry(): AngularRenderEntry | undefined { + return this.hoveredEntry ?? this.selectedEntry; + } + + private inspectPanelPositionStyle(entry: AngularRenderEntry): string { + const margin = 12; + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + const panelWidth = Math.min(280, Math.max(220, viewportWidth - margin * 2)); + const panelHeightEstimate = Math.min( + 300, + Math.max(180, viewportHeight - margin * 2), + ); + const rect = + this.hoveredEntry?.id === entry.id + ? this.hoveredRect + : entry.element?.getBoundingClientRect(); + const pointer = + this.hoveredEntry?.id === entry.id ? this.hoverPointer : undefined; + + let left = viewportWidth - this.toolbarX - panelWidth; + let top = viewportHeight - this.toolbarY - panelHeightEstimate - 60; + + if (rect) { + const anchorX = pointer?.x ?? rect.left + rect.width / 2; + const anchorY = pointer?.y ?? rect.top + rect.height / 2; + const isLargeTarget = + rect.width > panelWidth + 48 || rect.height > panelHeightEstimate; + + if (isLargeTarget && pointer) { + left = + anchorX + margin + panelWidth <= viewportWidth + ? anchorX + margin + : anchorX - panelWidth - margin; + top = + anchorY + margin + panelHeightEstimate <= viewportHeight + ? anchorY + margin + : anchorY - panelHeightEstimate - margin; + } else { + if (rect.right + margin + panelWidth <= viewportWidth) { + left = rect.right + margin; + } else if (rect.left - margin - panelWidth >= 0) { + left = rect.left - panelWidth - margin; + } else { + left = + anchorX + margin + panelWidth <= viewportWidth + ? anchorX + margin + : anchorX - panelWidth - margin; + } + + top = rect.top; + if (rect.height > panelHeightEstimate / 2) { + top = anchorY - panelHeightEstimate / 2; + } + } + } + + left = Math.max(margin, Math.min(left, viewportWidth - panelWidth - margin)); + top = Math.max( + margin, + Math.min(top, viewportHeight - panelHeightEstimate - margin), + ); + + return `left: ${Math.round(left)}px; top: ${Math.round(top)}px; right: auto; bottom: auto; width: ${Math.round(panelWidth)}px; max-width: calc(100vw - ${margin * 2}px);`; + } + private panelField(label: string, value: string): string { return `${escapeHtml(label)}${escapeHtml(value)}`; } @@ -2501,12 +2763,12 @@ export class AngularRenderScanOverlay { .join(""); return ` -
+
⚡ OnPush Candidates
-
+
These components use Default CD with high wasted render rates. Adding ChangeDetectionStrategy.OnPush could significantly reduce unnecessary checks.
@@ -2519,7 +2781,7 @@ export class AngularRenderScanOverlay { private zonePollutionPanelHtml(events: ZonePollutionEvent[]): string { if (!this.showZonePollutionPanel || events.length === 0) return ""; const panelRight = this.showOnPushPanel - ? this.toolbarX + 298 + ? this.toolbarX + 260 : this.toolbarX; const items = events .slice() @@ -2553,12 +2815,12 @@ export class AngularRenderScanOverlay { .join(""); return ` -
+
⚠ Zone Pollution
-
+
These CD cycles were triggered by async operations with no user interaction — suspected Zone.js pollution. Use NgZone.runOutsideAngular() to escape Zone.
@@ -2623,18 +2885,18 @@ export class AngularRenderScanOverlay { const children = childMap.get(node.id) ?? []; const stratColor = node.cdStrategy === "OnPush" ? "#10b981" : "#f59e0b"; const countColor = node.wastedChecks > 0 ? "#ef4444" : "#94a3b8"; - const indent = depth * 12; + const indent = depth * 9; const childRows = children.map((c) => renderNode(c, depth + 1)).join(""); const edgeCount = graph.edges.find((e) => e.toId === node.id)?.triggerCount ?? 0; return `
-
- ${depth > 0 ? `` : ""} - ${escapeHtml(node.name)} - ${edgeCount > 0 ? `${edgeCount}×` : ""} - ${node.cdStrategy === "OnPush" ? "OP" : "D"} - ${node.renderCount}r${node.wastedChecks > 0 ? ` ${node.wastedChecks}w` : ""} +
+ ${depth > 0 ? `` : ""} + ${escapeHtml(node.name)} + ${edgeCount > 0 ? `${edgeCount}x` : ""} + ${node.cdStrategy === "OnPush" ? "OP" : "D"} + ${node.renderCount}r${node.wastedChecks > 0 ? `/${node.wastedChecks}w` : ""}
${childRows}
@@ -2653,11 +2915,11 @@ export class AngularRenderScanOverlay { const wastedTotal = nodes.reduce((s, n) => s + n.wastedChecks, 0); return ` -
-
"> -
- ⬡ CD Render Graph - ${nodes.length > 0 ? `${nodes.length} components` : ""} +
+
"> +
+ CD Graph + ${nodes.length > 0 ? `${nodes.length} cmp` : ""}
@@ -2667,17 +2929,17 @@ export class AngularRenderScanOverlay { ${ !this.graphCollapsed ? ` -
- OnPush: ${onPushCount} - Default: ${defaultCount} - ${wastedTotal > 0 ? `Wasted: ${wastedTotal}` : ""} +
+ OP ${onPushCount} + D ${defaultCount} + ${wastedTotal > 0 ? `W ${wastedTotal}` : ""}
-
+
${treeHtml}
-
- OP=OnPush D=Default · r=renders w=wasted - +
+ r renders · w wasted +
` : "" @@ -2695,7 +2957,7 @@ export class AngularRenderScanOverlay { : this.toolbarX + 120; if (waterfall.length === 0) { return ` -
+
CD Waterfall @@ -2735,7 +2997,7 @@ export class AngularRenderScanOverlay { .join(""); return ` -
+
Waterfall (Cycle #${cycle.id}) @@ -2791,11 +3053,11 @@ export class AngularRenderScanOverlay { alertsRight += 170; } if (this.showWaterfallPanel) { - alertsRight += 310; + alertsRight += 270; } return ` -
+
⚠️ Budget Violations @@ -2811,6 +3073,14 @@ export class AngularRenderScanOverlay {
`; } + + private signalsPanelHtml(): string { + return ""; + } + + private costPanelHtml(): string { + return ""; + } } function rgba(color: readonly [number, number, number], alpha: number): string { diff --git a/packages/angular-render-scan/src/public-api.ts b/packages/angular-render-scan/src/public-api.ts index f1015cc..8787c32 100644 --- a/packages/angular-render-scan/src/public-api.ts +++ b/packages/angular-render-scan/src/public-api.ts @@ -11,7 +11,9 @@ export { getOnPushCandidates, getReferentialInstability, getZonePollutionEvents, - getCdGraph + getCdGraph, + getSignalDependencyGraph, + getComponentCostAnalysis } from './application/runtime'; export { AngularRenderScanMarkDirective,