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 {
} @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 {
⚠️ Zone
🔗 Refs
📊 Graph
+ 📶 Signals
+ 💰 Cost
@@ -259,7 +268,7 @@ class RefDemoComponent {
Time Source Type
- @for (t of triggers(); track t.time + t.source) {
+ @for (t of triggers(); track t.time + t.source + $index) {
{{ t.time }}
{{ t.source }}
@@ -345,7 +354,7 @@ class RefDemoComponent {
Time Task Source
- @for (e of pollutionEvents(); track e.time + e.trigger) {
+ @for (e of pollutionEvents(); track e.time + e.trigger + $index) {
{{ e.time }}
{{ e.trigger }}
@@ -464,6 +473,112 @@ class RefDemoComponent {
}
+
+ @if (activeTab() === 'signals') {
+
+
+ Refresh 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 }})
+
+
+
+ Name Kind Updates Wasted Value
+
+ @for (n of signalGraph().nodes; track n.id) {
+
+ {{ 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 }})
+
+
+
+ From To
+
+ @for (e of signalGraph().edges; track e.fromId + '->' + e.toId) {
+
+ {{ e.fromId }}
+ ➔ {{ e.toId }}
+
+ }
+
+
+
+
+ }
+ }
+
+ }
+
+
+ @if (activeTab() === 'cost') {
+
+
+ Refresh 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
+
+
+
+ Component Renders Total duration Avg duration Cost share
+
+ @for (c of costAnalysis(); track c.name) {
+
+ {{ 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() : ""}
-
+
`,
@@ -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 ? 'Copy AI Fix Prompt ' : ""}
- Close
-
-
- ${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 ` : ""}
${this.graphCollapsed ? "▼" : "▲"}
@@ -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
-
↺ Refresh
+
+ r renders · w wasted
+ Refresh
`
: ""
@@ -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,