From 19cb4828b1bc140af075f88c5bee9c9e569bd8d5 Mon Sep 17 00:00:00 2001 From: Edison Augusthy Date: Tue, 9 Jun 2026 20:49:37 +0200 Subject: [PATCH 1/3] feat: added more features --- README.md | 62 ++-- demo/src/main.ts | 216 ++++++++++-- e2e/demo.spec.ts | 105 +++--- .../src/application/runtime.spec.ts | 64 ++++ .../src/application/runtime.ts | 246 ++++++++++++- .../src/application/stats.spec.ts | 70 +++- .../src/application/stats.ts | 67 +++- .../src/domain/entities.ts | 45 +++ .../angular/auto-instrumentation.ts | 140 +++++++- .../src/infrastructure/ui/overlay.ts | 324 +++++++++++++----- .../angular-render-scan/src/public-api.ts | 4 +- 11 files changed, 1139 insertions(+), 204 deletions(-) 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..7a61feb 100644 --- a/e2e/demo.spec.ts +++ b/e2e/demo.spec.ts @@ -7,12 +7,12 @@ test('toolbar appears and toggle controls scanner state', async ({ page }) => { 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'); - 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 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) => { @@ -30,16 +30,24 @@ test('toolbar appears and toggle controls scanner state', async ({ page }) => { test('slow recommendations component can become the slowest latest entry', 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(); + // Activate OnPush tab so app-slow is rendered + await page.getByRole('button', { name: '🚀 OnPush' }).click(); - await expect.poll(async () => page.locator(overlaySelector).evaluate((host) => { - return host.shadowRoot?.textContent ?? ''; - })).toContain('Recommendations'); + await expect.poll(async () => { + const text = await overlay.evaluate((host) => host.shadowRoot?.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 +57,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 +90,15 @@ 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'); + + // 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 +114,14 @@ 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 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,7 +131,7 @@ 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 }) => { +test('details mode shows recommendation panel on hover with component prompt copy', async ({ page }) => { await page.addInitScript(() => { Object.defineProperty(navigator, 'clipboard', { configurable: true, @@ -126,42 +143,44 @@ test('details mode hover and click opens recommendation panel with component pro }); }); 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'); + })).toContain('hover'); - await page.locator('app-recommendations').click({ force: true }); + await page.locator('app-slow').hover({ 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) => { + 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) => { return host.shadowRoot?.querySelector('.panel-copy-btn')?.getAttribute('data-tooltip') ?? ''; })).toContain('scoped only to this slow component'); - await page.locator(overlaySelector).evaluate((host) => { + await overlay.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) => { + await page.mouse.move(8, 8); + await expect.poll(async () => overlay.evaluate((host) => { return host.shadowRoot?.querySelector('.inspect-panel'); })).toBeNull(); }); @@ -169,6 +188,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,14 +219,16 @@ 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(); }); - await page.locator('app-shopping-cart').click({ force: true }); + await page.locator('.shell').click({ force: true }); await expect.poll(async () => overlay.evaluate((host) => host.shadowRoot?.querySelector('.inspect-panel')?.textContent ?? '')).toContain('DOM Mutation Type'); }); @@ -213,8 +236,10 @@ test('mutation details appear in toolbar and details panel', async ({ page }) => 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/overlay.ts b/packages/angular-render-scan/src/infrastructure/ui/overlay.ts index e7f914c..2256451 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 { @@ -66,10 +68,10 @@ const TOOLBAR_CSS = ` display: flex; align-items: center; flex-wrap: wrap; - gap: 7px; + gap: 5px; width: auto; max-width: calc(100vw - 32px); - padding: 6px; + padding: 5px; border: 1px solid rgba(15, 23, 42, 0.1); border-radius: 13px; background: @@ -106,6 +108,11 @@ const TOOLBAR_CSS = ` .toolbar:active { cursor: grabbing; } + .toolbar.disabled { + opacity: 0.82; + pointer-events: auto; + cursor: grab; + } .toolbar.disabled { gap: 0; padding: 5px; @@ -117,7 +124,7 @@ const TOOLBAR_CSS = ` .toolbar-switch { display: inline-flex; align-items: center; - padding: 6px 7px; + padding: 4px 7px; border-radius: 9px; background: rgba(15, 23, 42, 0.045); box-shadow: inset 0 0 0 1px rgba(15, 23, 42, 0.055); @@ -132,20 +139,34 @@ const TOOLBAR_CSS = ` border-radius: 999px; background: transparent; box-shadow: none; + min-width: 0; + gap: 6px; } .toolbar-main { display: flex; align-items: stretch; flex-wrap: wrap; - gap: 5px; + gap: 4px; min-width: 0; 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: 4px; + } + .toolbar.compact .toolbar-extended { + display: none; + } .toolbar-actions { display: flex; align-items: center; - gap: 5px; + gap: 4px; justify-content: flex-end; min-width: max-content; flex: 0 0 auto; @@ -155,6 +176,33 @@ const TOOLBAR_CSS = ` :host(.dark) .toolbar-actions { border-left-color: rgba(255, 255, 255, 0.08); } + .toolbar.compact .toolbar-actions { + gap: 4px; + } + .toolbar-size-toggle { + min-width: 30px; + justify-content: center; + } + .toolbar-actions .toolbar-size-toggle { + width: 30px; + height: 30px; + min-width: 30px; + 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, .panel-close, .panel-copy-btn { cursor: pointer; } @@ -202,8 +250,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 +261,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 +272,7 @@ const TOOLBAR_CSS = ` background: #2563eb; } input:checked + .track::after { - transform: translateX(14px); + transform: translateX(12px); } .switch-text { display: none; @@ -236,7 +284,7 @@ const TOOLBAR_CSS = ` gap: 3px; min-width: 60px; flex: 0 0 auto; - padding: 7px 8px; + padding: 6px 7px; border-radius: 9px; background: rgba(255, 255, 255, 0.62); box-shadow: @@ -278,7 +326,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; @@ -533,7 +581,8 @@ const TOOLBAR_CSS = ` bottom: 72px; z-index: 2147483647; width: min(280px, calc(100vw - 32px)); - display: grid; + display: flex; + flex-direction: column; gap: 6px; padding: 10px; border: 1px solid var(--ars-border); @@ -553,9 +602,9 @@ 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; - align-items: center; + display: grid; + grid-template-columns: minmax(0, 1fr); + align-items: start; gap: 8px; border-bottom: 1px solid var(--ars-border); padding-bottom: 6px; @@ -564,12 +613,15 @@ const TOOLBAR_CSS = ` font-size: 12px; font-weight: 800; color: var(--ars-color); - overflow-wrap: anywhere; + overflow-wrap: break-word; + word-break: normal; } .panel-actions { display: flex; align-items: center; gap: 6px; + flex-wrap: wrap; + justify-content: flex-start; flex-shrink: 0; } .severity { @@ -600,7 +652,7 @@ 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; } @@ -614,6 +666,7 @@ const TOOLBAR_CSS = ` flex-direction: column; justify-content: center; min-height: 36px; + min-width: 0; } .panel-grid .panel-label { font-size: 8px; @@ -627,6 +680,8 @@ const TOOLBAR_CSS = ` font-weight: 700; color: var(--ars-color); margin-top: 1px; + min-width: 0; + overflow-wrap: anywhere; } .inspect-panel .panel-field:not(.panel-grid .panel-field) { border-top: 1px solid var(--ars-border); @@ -755,6 +810,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 +820,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]; @@ -885,49 +942,23 @@ 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.setDetailsHoverCursor(Boolean(hovered)); + if (previousHoveredId !== this.hoveredEntry?.id) { + 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.setDetailsHoverCursor(false); + this.renderToolbar(); }; document.addEventListener("mousemove", this.globalMoveListener, { @@ -944,10 +975,11 @@ 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") || + target.closest(".panel-close")) ) { return; // Don't drag if clicking buttons } @@ -1110,6 +1142,23 @@ export class AngularRenderScanOverlay { } } + private restoreToolbarCompact(): void { + const raw = globalThis.localStorage?.getItem(TOOLBAR_COMPACT_KEY); + if (raw === null) return; + this.compactToolbar = raw !== "false"; + } + + 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 +1289,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, @@ -1446,6 +1534,7 @@ 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" : ""; @@ -1534,6 +1623,8 @@ export class AngularRenderScanOverlay { ` : ""; + const cycleWasteText = cycle?.wastedCdStats ? ` (${cycle.wastedCdStats.wasteScore}% waste)` : ''; + const htmlChanged = this.replaceToolbarHtml( container, ` @@ -1544,7 +1635,7 @@ export class AngularRenderScanOverlay { ${this.options.enabled ? this.onPushPanelHtml(onPushCandidates) : ""} ${this.options.enabled ? this.zonePollutionPanelHtml(pollutionEvents) : ""} ${this.options.enabled ? this.cdGraphPanelHtml() : ""} -
+
- ${ - 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 +1666,21 @@ export class AngularRenderScanOverlay { ${onPushChip} ${pollutionChip}
+ `} - + ${compactToolbar ? "" : ` ${this.options.showCopyPrompt ? '' : ""} + `} + + - ` - : "" - } + ` : ""} ${this.copyStatus ? `${escapeHtml(this.copyStatus)}` : ""}
`, @@ -1618,6 +1713,31 @@ export class AngularRenderScanOverlay { { once: true }, ); + toolbarEl?.querySelector(".toolbar-size-toggle")?.addEventListener( + "click", + () => { + this.compactToolbar = !this.compactToolbar; + 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.setDetailsHoverCursor(false); + this.renderToolbar(); + }, + { once: true }, + ); + toolbarEl?.querySelector(".sparkline-toggle")?.addEventListener( "click", () => { @@ -1670,21 +1790,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 () => { @@ -1721,7 +1826,11 @@ export class AngularRenderScanOverlay { container.querySelector(".panel-close")?.addEventListener( "click", () => { + this.detailsMode = false; this.selectedEntry = undefined; + this.hoveredEntry = undefined; + this.hoveredRect = undefined; + this.setDetailsHoverCursor(false); this.renderToolbar(); }, { once: true }, @@ -1739,11 +1848,12 @@ export class AngularRenderScanOverlay { container.querySelector(".panel-copy-btn")?.addEventListener( "click", async () => { - if (!this.selectedEntry) { + const entry = this.currentDetailsEntry(); + if (!entry) { return; } const copied = await this.copyComponentPrompt( - this.selectedEntry, + entry, this.latestFps || this.fps.value, ); this.setCopyStatus(copied ? "Copied" : "Copy failed"); @@ -1754,10 +1864,10 @@ export class AngularRenderScanOverlay { 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 { @@ -1900,7 +2010,7 @@ export class AngularRenderScanOverlay { } private inspectPanelHtml(): string { - const entry = this.selectedEntry; + const entry = this.currentDetailsEntry(); if (!entry) { return ""; } @@ -1966,6 +2076,28 @@ export class AngularRenderScanOverlay { ${this.panelField("Change detection", entry.cdStrategy ?? "unknown")} ${this.panelField("Cycle #", String(entry.latestCycleId))}
+
+ DOM Mutation Type + ${escapeHtml(entry.mutationType ?? "none")} +
+ ${ + 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 +2160,10 @@ export class AngularRenderScanOverlay { `; } + private currentDetailsEntry(): AngularRenderEntry | undefined { + return this.hoveredEntry ?? this.selectedEntry; + } + private panelField(label: string, value: string): string { return `${escapeHtml(label)}${escapeHtml(value)}`; } @@ -2811,6 +2947,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, From 5f1b1832379e5387287c71ff1829a03f84096579 Mon Sep 17 00:00:00 2001 From: Edison Augusthy Date: Wed, 10 Jun 2026 21:41:04 +0200 Subject: [PATCH 2/3] feat: added more features --- .../infrastructure/ui/angular-debug.spec.ts | 21 + .../src/infrastructure/ui/angular-debug.ts | 18 +- .../src/infrastructure/ui/overlay.ts | 458 +++++++++++------- 3 files changed, 330 insertions(+), 167 deletions(-) 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 2256451..6a95736 100644 --- a/packages/angular-render-scan/src/infrastructure/ui/overlay.ts +++ b/packages/angular-render-scan/src/infrastructure/ui/overlay.ts @@ -45,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) { @@ -55,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 { @@ -68,12 +74,12 @@ const TOOLBAR_CSS = ` display: flex; align-items: center; flex-wrap: wrap; - gap: 5px; + gap: 6px; width: auto; max-width: calc(100vw - 32px); - padding: 5px; - border: 1px solid rgba(15, 23, 42, 0.1); - border-radius: 13px; + padding: 6px; + 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); @@ -84,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; @@ -99,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), @@ -123,8 +129,10 @@ const TOOLBAR_CSS = ` } .toolbar-switch { display: inline-flex; + flex-direction: column; align-items: center; - padding: 4px 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); @@ -146,7 +154,7 @@ const TOOLBAR_CSS = ` display: flex; align-items: stretch; flex-wrap: wrap; - gap: 4px; + gap: 5px; min-width: 0; max-width: calc(100vw - 230px); overflow: visible; @@ -158,15 +166,24 @@ const TOOLBAR_CSS = ` display: flex; align-items: stretch; flex-wrap: wrap; - gap: 4px; + 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; - gap: 4px; + gap: 5px; justify-content: flex-end; min-width: max-content; flex: 0 0 auto; @@ -184,9 +201,9 @@ const TOOLBAR_CSS = ` justify-content: center; } .toolbar-actions .toolbar-size-toggle { - width: 30px; - height: 30px; - min-width: 30px; + width: 28px; + height: 28px; + min-width: 28px; padding: 0; } .toolbar.compact .toolbar-size-toggle { @@ -203,7 +220,7 @@ const TOOLBAR_CSS = ` display: block; stroke: currentColor; } - .switch, .details-toggle, .clear-btn, .action-btn, .panel-close, .panel-copy-btn { + .switch, .details-toggle, .clear-btn, .action-btn { cursor: pointer; } .switch { @@ -282,34 +299,54 @@ const TOOLBAR_CSS = ` .metric { display: grid; gap: 3px; - min-width: 60px; + min-width: 62px; flex: 0 0 auto; - padding: 6px 7px; - border-radius: 9px; - background: rgba(255, 255, 255, 0.62); + padding: 7px 8px; + 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%; @@ -363,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; @@ -485,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, @@ -534,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; @@ -553,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); @@ -583,17 +604,17 @@ const TOOLBAR_CSS = ` width: min(280px, calc(100vw - 32px)); display: flex; flex-direction: column; - gap: 6px; - padding: 10px; + 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; @@ -603,26 +624,22 @@ const TOOLBAR_CSS = ` .inspect-panel.fast { border-top-color: #10b981; } .panel-head { display: grid; - grid-template-columns: minmax(0, 1fr); - align-items: start; + 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: break-word; - word-break: normal; - } - .panel-actions { - display: flex; - align-items: center; - gap: 6px; - flex-wrap: wrap; - justify-content: flex-start; - flex-shrink: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .severity { display: inline-flex; @@ -656,26 +673,29 @@ const TOOLBAR_CSS = ` 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); @@ -683,6 +703,44 @@ const TOOLBAR_CSS = ` 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); padding-top: 6px; @@ -716,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; @@ -776,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 = ""; @@ -879,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(); @@ -897,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(); @@ -933,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; } @@ -946,8 +1007,12 @@ export class AngularRenderScanOverlay { 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) { + if ( + previousHoveredId !== this.hoveredEntry?.id || + this.hoveredEntry + ) { this.renderToolbar(); } }; @@ -957,6 +1022,7 @@ export class AngularRenderScanOverlay { if (this.isOverlayTarget(e.target)) return; this.hoveredEntry = undefined; this.hoveredRect = undefined; + this.hoverPointer = undefined; this.setDetailsHoverCursor(false); this.renderToolbar(); }; @@ -978,8 +1044,7 @@ export class AngularRenderScanOverlay { this.options.enabled && (target.closest(".switch") || target.closest(".clear-btn") || - target.closest(".action-btn") || - target.closest(".panel-close")) + target.closest(".action-btn")) ) { return; // Don't drag if clicking buttons } @@ -1143,9 +1208,12 @@ export class AngularRenderScanOverlay { } private restoreToolbarCompact(): void { - const raw = globalThis.localStorage?.getItem(TOOLBAR_COMPACT_KEY); - if (raw === null) return; - this.compactToolbar = raw !== "false"; + try { + const raw = globalThis.localStorage?.getItem(TOOLBAR_COMPACT_KEY); + this.compactToolbar = raw === null ? true : raw !== "false"; + } catch { + this.compactToolbar = true; + } } private saveToolbarCompact(): void { @@ -1443,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; @@ -1537,6 +1606,7 @@ export class AngularRenderScanOverlay { const compactToolbar = this.compactToolbar || !this.options.enabled; const cpuVal = this.cpu.value; const cpuClass = cpuVal > 50 ? "cpu-high" : cpuVal > 20 ? "cpu-medium" : ""; + const angularVersion = this.angularVersionFromDom(); const wasted = getWastedStats(); const leaks = getLeakedComponents(); @@ -1637,6 +1707,7 @@ export class AngularRenderScanOverlay { ${this.options.enabled ? this.cdGraphPanelHtml() : ""}
+ ${angularVersion ? `ng ${escapeHtml(angularVersion)}` : ""}