angular-scan 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,129 @@
1
+ # angular-scan
2
+
3
+ Automatically detects and highlights Angular components that are re-rendering — the Angular equivalent of [react-scan](https://github.com/aidenybai/react-scan).
4
+
5
+ - **Yellow flash** — component was checked and its DOM changed (normal re-render)
6
+ - **Red flash** — component was checked but its DOM did **not** change (unnecessary render)
7
+ - **Counter badge** — cumulative render count on each component host element
8
+ - **Toolbar HUD** — floating panel with total checks, wasted renders, and a per-component inspector
9
+
10
+ Zero overhead in production — the entire library is tree-shaken when `isDevMode()` returns `false`.
11
+
12
+ Works with both **zone.js** and **zoneless** Angular applications.
13
+
14
+ ---
15
+
16
+ ## Installation
17
+
18
+ ```bash
19
+ npm install angular-scan --save-dev
20
+ ```
21
+
22
+ ---
23
+
24
+ ## Usage
25
+
26
+ ### Provider-based (recommended)
27
+
28
+ Add `provideAngularScan()` to your application providers:
29
+
30
+ ```ts
31
+ // app.config.ts
32
+ import { ApplicationConfig } from '@angular/core';
33
+ import { provideAngularScan } from 'angular-scan';
34
+
35
+ export const appConfig: ApplicationConfig = {
36
+ providers: [
37
+ provideAngularScan(),
38
+ ],
39
+ };
40
+ ```
41
+
42
+ ### Imperative API
43
+
44
+ For micro-frontends or apps where you can't modify providers, call `scan()` before `bootstrapApplication`:
45
+
46
+ ```ts
47
+ // main.ts
48
+ import { bootstrapApplication } from '@angular/platform-browser';
49
+ import { scan } from 'angular-scan';
50
+ import { AppComponent } from './app/app.component';
51
+ import { appConfig } from './app/app.config';
52
+
53
+ scan();
54
+ bootstrapApplication(AppComponent, appConfig);
55
+ ```
56
+
57
+ `scan()` returns a teardown function:
58
+
59
+ ```ts
60
+ const stop = scan();
61
+ // later...
62
+ stop(); // removes overlay and stops tracking
63
+ ```
64
+
65
+ ---
66
+
67
+ ## Options
68
+
69
+ ```ts
70
+ provideAngularScan({
71
+ enabled: true, // set false to disable entirely (default: true)
72
+ flashDurationMs: 500, // how long the flash animation lasts in ms (default: 500)
73
+ showBadges: true, // show render count badges on host elements (default: true)
74
+ showToolbar: true, // show the floating toolbar HUD (default: true)
75
+ });
76
+ ```
77
+
78
+ The same options are accepted by `scan()`.
79
+
80
+ ---
81
+
82
+ ## How it works
83
+
84
+ Angular exposes `window.ng.ɵsetProfiler()` in development mode — the same hook used by Angular DevTools in Chrome. `angular-scan` registers a profiler callback to intercept every change detection cycle:
85
+
86
+ 1. **`ChangeDetectionStart`** — a `MutationObserver` begins recording all DOM mutations
87
+ 2. **`ChangeDetectionSyncStart/End`** — gates which `TemplateUpdateStart` events count as real renders (excludes the dev-mode `checkNoChanges` pass)
88
+ 3. **`TemplateUpdateStart`** — the exact component instance being checked is captured
89
+ 4. **`ChangeDetectionEnd`** — `MutationObserver.takeRecords()` flushes synchronously; each captured instance is mapped to its host element via `ng.getHostElement()`; components whose subtree had DOM mutations are marked as **renders**, the rest as **unnecessary renders**
90
+
91
+ All Angular signal writes are deferred via `queueMicrotask()` to avoid triggering a new CD cycle from inside the profiler callback.
92
+
93
+ The canvas overlay (`position: fixed`, full viewport, `pointer-events: none`) uses a `requestAnimationFrame` loop to draw and fade rectangles over component host elements. The toolbar is created via `createComponent()` and attached to `ApplicationRef` outside the normal component tree, so its own renders are excluded from tracking.
94
+
95
+ ---
96
+
97
+ ## Interpreting the output
98
+
99
+ | Signal | Meaning | Common cause |
100
+ |--------|---------|-------------|
101
+ | Yellow flash | Component re-rendered (DOM changed) | Normal update — signal/input changed |
102
+ | Red flash | Component checked but DOM unchanged | Parent uses `Default` CD strategy; child is `OnPush` with no changed inputs |
103
+ | High wasted count on a component | It's being checked unnecessarily on every tick | Wrap it in `OnPush`; ensure parent isn't `Default` CD |
104
+ | Counter badge turns red | More unnecessary than necessary renders | Same as above — component is `OnPush` but still gets walked |
105
+
106
+ ---
107
+
108
+ ## Requirements
109
+
110
+ - Angular **≥ 20**
111
+ - Must be used in **development mode** (`ng serve` / `ng build --configuration development`)
112
+ - The Angular debug APIs (`window.ng`) are only available in dev mode — `angular-scan` is silently disabled otherwise
113
+
114
+ ---
115
+
116
+ ## Building
117
+
118
+ ```bash
119
+ ng build angular-scan
120
+ ```
121
+
122
+ Output is placed in `dist/angular-scan`.
123
+
124
+ ## Publishing
125
+
126
+ ```bash
127
+ cd dist/angular-scan
128
+ npm publish
129
+ ```
@@ -0,0 +1,7 @@
1
+ {
2
+ "$schema": "../../node_modules/ng-packagr/ng-package.schema.json",
3
+ "dest": "../../dist/angular-scan",
4
+ "lib": {
5
+ "entryFile": "src/public-api.ts"
6
+ }
7
+ }
package/package.json ADDED
@@ -0,0 +1,12 @@
1
+ {
2
+ "name": "angular-scan",
3
+ "version": "0.0.1",
4
+ "peerDependencies": {
5
+ "@angular/common": ">=20.0.0",
6
+ "@angular/core": ">=20.0.0"
7
+ },
8
+ "dependencies": {
9
+ "tslib": "^2.3.0"
10
+ },
11
+ "sideEffects": false
12
+ }
@@ -0,0 +1,22 @@
1
+ import { ComponentFixture, TestBed } from '@angular/core/testing';
2
+
3
+ import { AngularScan } from './angular-scan';
4
+
5
+ describe('AngularScan', () => {
6
+ let component: AngularScan;
7
+ let fixture: ComponentFixture<AngularScan>;
8
+
9
+ beforeEach(async () => {
10
+ await TestBed.configureTestingModule({
11
+ imports: [AngularScan],
12
+ }).compileComponents();
13
+
14
+ fixture = TestBed.createComponent(AngularScan);
15
+ component = fixture.componentInstance;
16
+ await fixture.whenStable();
17
+ });
18
+
19
+ it('should create', () => {
20
+ expect(component).toBeTruthy();
21
+ });
22
+ });
@@ -0,0 +1,9 @@
1
+ import { Component } from '@angular/core';
2
+
3
+ @Component({
4
+ selector: 'ngscan-angular-scan',
5
+ imports: [],
6
+ template: ` <p>angular-scan works!</p> `,
7
+ styles: ``,
8
+ })
9
+ export class AngularScan {}
@@ -0,0 +1,80 @@
1
+ import { Injectable, signal } from '@angular/core';
2
+ import type { ComponentStats, RenderKind } from './types';
3
+
4
+ @Injectable({ providedIn: 'root' })
5
+ export class ComponentTracker {
6
+ // WeakMap: component instance → stats. GC-safe: entries are freed when
7
+ // the component instance is garbage collected after destruction.
8
+ private readonly byInstance = new WeakMap<object, ComponentStats>();
9
+
10
+ // Strong map: host element → component instance. Used for mutation-to-component
11
+ // lookup. Entries must be manually removed when components are destroyed.
12
+ private readonly byElement = new Map<Element, object>();
13
+
14
+ private readonly _totalRenders = signal(0);
15
+ private readonly _totalUnnecessary = signal(0);
16
+ private readonly _trackedComponents = signal<readonly ComponentStats[]>([]);
17
+
18
+ readonly totalRenders = this._totalRenders.asReadonly();
19
+ readonly totalUnnecessary = this._totalUnnecessary.asReadonly();
20
+ readonly trackedComponents = this._trackedComponents.asReadonly();
21
+
22
+ /**
23
+ * Record a render event for a component.
24
+ * NOTE: Must be called outside of a profiler callback to avoid triggering CD.
25
+ * Use queueMicrotask() to defer calls from profiler events.
26
+ */
27
+ recordRender(instance: object, hostElement: Element, kind: RenderKind): ComponentStats {
28
+ let stats = this.byInstance.get(instance);
29
+
30
+ if (!stats) {
31
+ stats = {
32
+ componentName: instance.constructor.name,
33
+ hostElement,
34
+ totalRenders: 0,
35
+ unnecessaryRenders: 0,
36
+ lastRenderKind: kind,
37
+ lastRenderTimestamp: 0,
38
+ };
39
+ this.byInstance.set(instance, stats);
40
+ this.byElement.set(hostElement, instance);
41
+ }
42
+
43
+ stats.totalRenders++;
44
+ stats.lastRenderKind = kind;
45
+ stats.lastRenderTimestamp = Date.now();
46
+
47
+ if (kind === 'unnecessary') {
48
+ stats.unnecessaryRenders++;
49
+ this._totalUnnecessary.update(n => n + 1);
50
+ }
51
+
52
+ this._totalRenders.update(n => n + 1);
53
+ return stats;
54
+ }
55
+
56
+ /**
57
+ * Rebuild the trackedComponents signal from the current element map.
58
+ * Call once per tick after all recordRender() calls.
59
+ */
60
+ snapshotTrackedComponents(): void {
61
+ const list: ComponentStats[] = [];
62
+ for (const [, instance] of this.byElement) {
63
+ const stats = this.byInstance.get(instance);
64
+ if (stats) list.push(stats);
65
+ }
66
+ this._trackedComponents.set(list);
67
+ }
68
+
69
+ /** Remove a component from tracking (call when its host element is removed). */
70
+ unregister(el: Element): void {
71
+ this.byElement.delete(el);
72
+ }
73
+
74
+ reset(): void {
75
+ this.byElement.clear();
76
+ this._totalRenders.set(0);
77
+ this._totalUnnecessary.set(0);
78
+ this._trackedComponents.set([]);
79
+ }
80
+ }
@@ -0,0 +1,23 @@
1
+ import type { NgDebugApi } from './types';
2
+
3
+ /**
4
+ * Returns the Angular debug API (window.ng) if available.
5
+ * Returns null in SSR, production builds, or when Angular DevTools APIs are absent.
6
+ *
7
+ * Safe to call before Angular is bootstrapped — the API is attached to window
8
+ * by Angular's dev mode runtime.
9
+ */
10
+ export function getNgDebugApi(): NgDebugApi | null {
11
+ if (typeof window === 'undefined') return null;
12
+
13
+ const ng = (window as unknown as Record<string, unknown>)['ng'];
14
+ if (!ng || typeof ng !== 'object') return null;
15
+
16
+ const api = ng as Partial<NgDebugApi>;
17
+
18
+ // ɵsetProfiler is the sentinel for dev mode — absent in production builds
19
+ if (typeof api.ɵsetProfiler !== 'function') return null;
20
+ if (typeof api.getHostElement !== 'function') return null;
21
+
22
+ return api as NgDebugApi;
23
+ }
@@ -0,0 +1,121 @@
1
+ import type { RenderKind } from '../types';
2
+
3
+ interface FlashRect {
4
+ x: number;
5
+ y: number;
6
+ width: number;
7
+ height: number;
8
+ kind: RenderKind;
9
+ startTime: number;
10
+ durationMs: number;
11
+ }
12
+
13
+ // rgb(...) strings for fill and stroke
14
+ const RENDER_COLOR = '255, 200, 0'; // yellow — normal re-render
15
+ const UNNECESSARY_COLOR = '255, 60, 60'; // red — unnecessary render
16
+
17
+ export class CanvasOverlay {
18
+ private canvas: HTMLCanvasElement | null = null;
19
+ private ctx: CanvasRenderingContext2D | null = null;
20
+ private rects: FlashRect[] = [];
21
+ private rafId = 0;
22
+ private attached = false;
23
+
24
+ attach(doc: Document): void {
25
+ if (this.attached) return;
26
+
27
+ const canvas = doc.createElement('canvas');
28
+ canvas.setAttribute('aria-hidden', 'true');
29
+ canvas.setAttribute('role', 'presentation');
30
+ canvas.style.cssText = [
31
+ 'position:fixed',
32
+ 'top:0',
33
+ 'left:0',
34
+ 'width:100%',
35
+ 'height:100%',
36
+ 'pointer-events:none',
37
+ `z-index:2147483646`,
38
+ ].join(';');
39
+
40
+ doc.body.appendChild(canvas);
41
+ this.canvas = canvas;
42
+ this.ctx = canvas.getContext('2d');
43
+ this.attached = true;
44
+
45
+ this.resize();
46
+ window.addEventListener('resize', this.onResize);
47
+
48
+ this.scheduleFrame();
49
+ }
50
+
51
+ detach(): void {
52
+ if (!this.attached) return;
53
+ this.attached = false;
54
+ cancelAnimationFrame(this.rafId);
55
+ window.removeEventListener('resize', this.onResize);
56
+ this.canvas?.remove();
57
+ this.canvas = null;
58
+ this.ctx = null;
59
+ this.rects = [];
60
+ }
61
+
62
+ /** Queue a flash animation over an element's bounding rect. */
63
+ flash(element: Element, kind: RenderKind, durationMs: number): void {
64
+ if (!this.attached) return;
65
+
66
+ const rect = element.getBoundingClientRect();
67
+ if (rect.width === 0 || rect.height === 0) return;
68
+
69
+ this.rects.push({
70
+ x: rect.left,
71
+ y: rect.top,
72
+ width: rect.width,
73
+ height: rect.height,
74
+ kind,
75
+ startTime: performance.now(),
76
+ durationMs,
77
+ });
78
+ }
79
+
80
+ private readonly onResize = (): void => this.resize();
81
+
82
+ private resize(): void {
83
+ if (!this.canvas) return;
84
+ this.canvas.width = window.innerWidth;
85
+ this.canvas.height = window.innerHeight;
86
+ }
87
+
88
+ private scheduleFrame(): void {
89
+ if (!this.attached) return;
90
+ this.rafId = requestAnimationFrame(this.drawFrame);
91
+ }
92
+
93
+ private readonly drawFrame = (now: number): void => {
94
+ if (!this.ctx || !this.canvas || !this.attached) return;
95
+
96
+ this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
97
+
98
+ // Decay alphas and remove expired rects
99
+ this.rects = this.rects.filter(r => {
100
+ const elapsed = now - r.startTime;
101
+ return elapsed < r.durationMs;
102
+ });
103
+
104
+ for (const r of this.rects) {
105
+ const elapsed = now - r.startTime;
106
+ const alpha = Math.max(0, 1 - elapsed / r.durationMs);
107
+ const color = r.kind === 'render' ? RENDER_COLOR : UNNECESSARY_COLOR;
108
+
109
+ // Semi-transparent fill
110
+ this.ctx!.fillStyle = `rgba(${color}, ${alpha * 0.1})`;
111
+ this.ctx!.fillRect(r.x, r.y, r.width, r.height);
112
+
113
+ // Solid border
114
+ this.ctx!.strokeStyle = `rgba(${color}, ${alpha * 0.9})`;
115
+ this.ctx!.lineWidth = 2;
116
+ this.ctx!.strokeRect(r.x + 1, r.y + 1, r.width - 2, r.height - 2);
117
+ }
118
+
119
+ this.scheduleFrame();
120
+ };
121
+ }
@@ -0,0 +1,85 @@
1
+ import { Injectable, inject, isDevMode, DOCUMENT, PLATFORM_ID } from '@angular/core';
2
+ import { isPlatformBrowser } from '@angular/common';
3
+ import { CanvasOverlay } from './canvas-overlay';
4
+ import { ANGULAR_SCAN_OPTIONS } from '../tokens';
5
+ import type { ComponentStats } from '../types';
6
+
7
+ @Injectable({ providedIn: 'root' })
8
+ export class OverlayService {
9
+ private readonly document = inject(DOCUMENT);
10
+ private readonly platformId = inject(PLATFORM_ID);
11
+ private readonly options = inject(ANGULAR_SCAN_OPTIONS);
12
+ private readonly canvas = new CanvasOverlay();
13
+
14
+ // Badge elements keyed by host element
15
+ private readonly badges = new Map<Element, HTMLElement>();
16
+
17
+ initialize(): void {
18
+ if (!isDevMode()) return;
19
+ if (!isPlatformBrowser(this.platformId)) return;
20
+ if (this.options.enabled === false) return;
21
+
22
+ this.canvas.attach(this.document);
23
+ }
24
+
25
+ destroy(): void {
26
+ this.canvas.detach();
27
+ for (const badge of this.badges.values()) badge.remove();
28
+ this.badges.clear();
29
+ }
30
+
31
+ onComponentChecked(stats: ComponentStats): void {
32
+ const durationMs = this.options.flashDurationMs ?? 500;
33
+ this.canvas.flash(stats.hostElement, stats.lastRenderKind, durationMs);
34
+
35
+ if (this.options.showBadges !== false) {
36
+ this.updateBadge(stats);
37
+ }
38
+ }
39
+
40
+ removeBadge(el: Element): void {
41
+ this.badges.get(el)?.remove();
42
+ this.badges.delete(el);
43
+ }
44
+
45
+ private updateBadge(stats: ComponentStats): void {
46
+ let badge = this.badges.get(stats.hostElement);
47
+
48
+ if (!badge) {
49
+ badge = this.document.createElement('div');
50
+ badge.setAttribute('aria-hidden', 'true');
51
+ badge.setAttribute('role', 'presentation');
52
+ badge.style.cssText = [
53
+ 'position:absolute',
54
+ 'top:2px',
55
+ 'right:2px',
56
+ 'z-index:2147483645',
57
+ 'pointer-events:none',
58
+ 'font:bold 9px/13px monospace',
59
+ 'padding:1px 4px',
60
+ 'border-radius:3px',
61
+ 'min-width:16px',
62
+ 'text-align:center',
63
+ 'color:#fff',
64
+ 'white-space:nowrap',
65
+ ].join(';');
66
+
67
+ // Host element must be non-static to contain an absolutely-positioned badge
68
+ this.ensurePositioned(stats.hostElement);
69
+ stats.hostElement.appendChild(badge);
70
+ this.badges.set(stats.hostElement, badge);
71
+ }
72
+
73
+ const isUnnecessary = stats.lastRenderKind === 'unnecessary';
74
+ badge.style.background = isUnnecessary ? '#f44336' : '#ff9800';
75
+ badge.textContent = String(stats.totalRenders);
76
+ badge.title = `${stats.componentName}: ${stats.totalRenders} renders, ${stats.unnecessaryRenders} unnecessary`;
77
+ }
78
+
79
+ private ensurePositioned(el: Element): void {
80
+ const htmlEl = el as HTMLElement;
81
+ if (getComputedStyle(htmlEl).position === 'static') {
82
+ htmlEl.style.position = 'relative';
83
+ }
84
+ }
85
+ }
@@ -0,0 +1,77 @@
1
+ import {
2
+ EnvironmentProviders,
3
+ makeEnvironmentProviders,
4
+ isDevMode,
5
+ provideEnvironmentInitializer,
6
+ inject,
7
+ ApplicationRef,
8
+ createComponent,
9
+ EnvironmentInjector,
10
+ } from '@angular/core';
11
+ import { DOCUMENT } from '@angular/common';
12
+ import { ScannerService } from './scanner.service';
13
+ import { OverlayService } from './overlay/overlay.service';
14
+ import { ToolbarComponent } from './toolbar/toolbar.component';
15
+ import { ANGULAR_SCAN_OPTIONS } from './tokens';
16
+ import type { AngularScanOptions } from './types';
17
+
18
+ /**
19
+ * Provides angular-scan for development-time render tracking.
20
+ *
21
+ * Add to your application providers:
22
+ * ```ts
23
+ * bootstrapApplication(AppComponent, {
24
+ * providers: [provideAngularScan()]
25
+ * });
26
+ * ```
27
+ *
28
+ * This is a complete no-op in production builds (`isDevMode() === false`),
29
+ * so it can safely be left in your app config.
30
+ */
31
+ export function provideAngularScan(options: AngularScanOptions = {}): EnvironmentProviders {
32
+ // Return empty providers in production — entire library is tree-shaken
33
+ if (!isDevMode()) {
34
+ return makeEnvironmentProviders([]);
35
+ }
36
+
37
+ const resolvedOptions: AngularScanOptions = {
38
+ enabled: true,
39
+ flashDurationMs: 500,
40
+ showBadges: true,
41
+ showToolbar: true,
42
+ ...options,
43
+ };
44
+
45
+ return makeEnvironmentProviders([
46
+ {
47
+ provide: ANGULAR_SCAN_OPTIONS,
48
+ useValue: resolvedOptions,
49
+ },
50
+ provideEnvironmentInitializer(() => {
51
+ const overlay = inject(OverlayService);
52
+ const scanner = inject(ScannerService);
53
+ const opts = inject(ANGULAR_SCAN_OPTIONS);
54
+
55
+ overlay.initialize();
56
+ scanner.initialize();
57
+
58
+ if (opts.showToolbar !== false) {
59
+ const injector = inject(EnvironmentInjector);
60
+ const doc = inject(DOCUMENT);
61
+ const appRef = inject(ApplicationRef);
62
+
63
+ // Create the toolbar outside Angular's component tree so it doesn't
64
+ // appear in CD tracking. Attach it to ApplicationRef so signals work.
65
+ const toolbarRef = createComponent(ToolbarComponent, {
66
+ environmentInjector: injector,
67
+ });
68
+
69
+ doc.body.appendChild(toolbarRef.location.nativeElement);
70
+ appRef.attachView(toolbarRef.hostView);
71
+
72
+ // Tell the scanner to ignore the toolbar's own renders
73
+ scanner.setToolbarInstance(toolbarRef.instance);
74
+ }
75
+ }),
76
+ ]);
77
+ }
@@ -0,0 +1,176 @@
1
+ import { isDevMode } from '@angular/core';
2
+ import { getNgDebugApi } from './ng-debug';
3
+ import { CanvasOverlay } from './overlay/canvas-overlay';
4
+ import type { AngularScanOptions, RenderKind } from './types';
5
+
6
+ // Profiler event constants
7
+ const TemplateUpdateStart = 2;
8
+ const ChangeDetectionStart = 12;
9
+ const ChangeDetectionEnd = 13;
10
+ const ChangeDetectionSyncStart = 14;
11
+ const ChangeDetectionSyncEnd = 15;
12
+
13
+ /**
14
+ * Imperative API for angular-scan.
15
+ *
16
+ * Use this when you cannot modify application providers (e.g. micro-frontends,
17
+ * third-party apps). Call before `bootstrapApplication()`.
18
+ *
19
+ * ```ts
20
+ * // main.ts
21
+ * import { scan } from 'angular-scan';
22
+ * scan();
23
+ * bootstrapApplication(AppComponent, appConfig);
24
+ * ```
25
+ *
26
+ * @returns A teardown function that stops scanning and removes the overlay.
27
+ */
28
+ export function scan(options: AngularScanOptions = {}): () => void {
29
+ if (!isDevMode()) return noop;
30
+ if (typeof window === 'undefined') return noop;
31
+ if (options.enabled === false) return noop;
32
+
33
+ const ng = getNgDebugApi();
34
+ if (!ng) {
35
+ console.warn(
36
+ '[angular-scan] Angular debug APIs (window.ng) not available. ' +
37
+ 'Ensure you are running in development mode.'
38
+ );
39
+ return noop;
40
+ }
41
+
42
+ const durationMs = options.flashDurationMs ?? 500;
43
+ const showBadges = options.showBadges !== false;
44
+
45
+ const overlay = new CanvasOverlay();
46
+ overlay.attach(document);
47
+
48
+ // State for the current tick
49
+ let inTick = false;
50
+ let inSyncPhase = false;
51
+ const mutatedNodes = new Set<Node>();
52
+ const tickInstances = new Set<object>();
53
+ const renderCounts = new WeakMap<object, number>();
54
+ const unnecessaryCounts = new WeakMap<object, number>();
55
+ const badges = new Map<Element, HTMLElement>();
56
+
57
+ const observer = new MutationObserver(records => {
58
+ for (const r of records) {
59
+ mutatedNodes.add(r.target);
60
+ r.addedNodes.forEach(n => mutatedNodes.add(n));
61
+ r.removedNodes.forEach(n => mutatedNodes.add(n));
62
+ }
63
+ });
64
+
65
+ const removeProfiler = ng.ɵsetProfiler((event, instance) => {
66
+ switch (event) {
67
+ case ChangeDetectionStart:
68
+ if (inTick) break;
69
+ inTick = true;
70
+ mutatedNodes.clear();
71
+ tickInstances.clear();
72
+ observer.observe(document.body, {
73
+ subtree: true, childList: true, attributes: true, characterData: true,
74
+ });
75
+ break;
76
+
77
+ case ChangeDetectionSyncStart:
78
+ inSyncPhase = true;
79
+ break;
80
+
81
+ case ChangeDetectionSyncEnd:
82
+ inSyncPhase = false;
83
+ break;
84
+
85
+ case TemplateUpdateStart:
86
+ if (inSyncPhase && instance) tickInstances.add(instance);
87
+ break;
88
+
89
+ case ChangeDetectionEnd: {
90
+ if (!inTick) break;
91
+ inTick = false;
92
+ inSyncPhase = false;
93
+
94
+ const pending = observer.takeRecords();
95
+ for (const r of pending) {
96
+ mutatedNodes.add(r.target);
97
+ r.addedNodes.forEach(n => mutatedNodes.add(n));
98
+ r.removedNodes.forEach(n => mutatedNodes.add(n));
99
+ }
100
+ observer.disconnect();
101
+
102
+ // Build mutated host set
103
+ const mutatedHosts = new Set<Element>();
104
+ for (const node of mutatedNodes) {
105
+ let current: Node | null = node;
106
+ while (current && current !== document.body) {
107
+ if (current instanceof Element) {
108
+ try {
109
+ if (ng.getComponent(current)) { mutatedHosts.add(current); break; }
110
+ } catch { /* ignore */ }
111
+ }
112
+ current = current.parentNode;
113
+ }
114
+ }
115
+
116
+ // Collect updates without writing to any signals
117
+ const updates: Array<{ instance: object; hostEl: Element; kind: RenderKind }> = [];
118
+ for (const inst of tickInstances) {
119
+ try {
120
+ const hostEl = ng.getHostElement(inst);
121
+ if (!hostEl) continue;
122
+ const kind: RenderKind = mutatedHosts.has(hostEl) ? 'render' : 'unnecessary';
123
+ updates.push({ instance: inst, hostEl, kind });
124
+ } catch { /* component destroyed */ }
125
+ }
126
+
127
+ queueMicrotask(() => {
128
+ for (const { instance, hostEl, kind } of updates) {
129
+ const count = (renderCounts.get(instance) ?? 0) + 1;
130
+ renderCounts.set(instance, count);
131
+
132
+ if (kind === 'unnecessary') {
133
+ unnecessaryCounts.set(instance, (unnecessaryCounts.get(instance) ?? 0) + 1);
134
+ }
135
+
136
+ overlay.flash(hostEl, kind, durationMs);
137
+
138
+ if (showBadges) updateBadge(hostEl, count, kind);
139
+ }
140
+ });
141
+ break;
142
+ }
143
+ }
144
+ });
145
+
146
+ function updateBadge(hostEl: Element, count: number, kind: RenderKind): void {
147
+ let badge = badges.get(hostEl);
148
+ if (!badge) {
149
+ badge = document.createElement('div');
150
+ badge.setAttribute('aria-hidden', 'true');
151
+ badge.setAttribute('role', 'presentation');
152
+ badge.style.cssText = [
153
+ 'position:absolute', 'top:2px', 'right:2px',
154
+ 'z-index:2147483645', 'pointer-events:none',
155
+ 'font:bold 9px/13px monospace', 'padding:1px 4px',
156
+ 'border-radius:3px', 'min-width:16px', 'text-align:center', 'color:#fff',
157
+ ].join(';');
158
+ const htmlEl = hostEl as HTMLElement;
159
+ if (getComputedStyle(htmlEl).position === 'static') htmlEl.style.position = 'relative';
160
+ hostEl.appendChild(badge);
161
+ badges.set(hostEl, badge);
162
+ }
163
+ badge.style.background = kind === 'unnecessary' ? '#f44336' : '#ff9800';
164
+ badge.textContent = String(count);
165
+ }
166
+
167
+ return () => {
168
+ removeProfiler();
169
+ observer.disconnect();
170
+ overlay.detach();
171
+ for (const b of badges.values()) b.remove();
172
+ badges.clear();
173
+ };
174
+ }
175
+
176
+ function noop(): void {}
@@ -0,0 +1,197 @@
1
+ import {
2
+ Injectable,
3
+ inject,
4
+ isDevMode,
5
+ OnDestroy,
6
+ PLATFORM_ID,
7
+ } from '@angular/core';
8
+ import { isPlatformBrowser } from '@angular/common';
9
+ import { ComponentTracker } from './component-tracker';
10
+ import { OverlayService } from './overlay/overlay.service';
11
+ import { ANGULAR_SCAN_OPTIONS } from './tokens';
12
+ import { getNgDebugApi } from './ng-debug';
13
+ import type { NgDebugApi, RenderKind } from './types';
14
+
15
+ // Angular profiler event IDs (stable contract in dev mode, used by Angular DevTools)
16
+ const TemplateUpdateStart = 2;
17
+ const ChangeDetectionStart = 12;
18
+ const ChangeDetectionEnd = 13;
19
+ const ChangeDetectionSyncStart = 14;
20
+ const ChangeDetectionSyncEnd = 15;
21
+
22
+ interface PendingUpdate {
23
+ instance: object;
24
+ hostElement: Element;
25
+ kind: RenderKind;
26
+ }
27
+
28
+ @Injectable({ providedIn: 'root' })
29
+ export class ScannerService implements OnDestroy {
30
+ private readonly tracker = inject(ComponentTracker);
31
+ private readonly overlay = inject(OverlayService);
32
+ private readonly options = inject(ANGULAR_SCAN_OPTIONS);
33
+ private readonly platformId = inject(PLATFORM_ID);
34
+
35
+ private removeProfiler: (() => void) | null = null;
36
+ private mutationObserver: MutationObserver | null = null;
37
+
38
+ // Accumulated during a single CD tick (raw DOM nodes, no signal writes)
39
+ private mutatedNodes = new Set<Node>();
40
+ // Component instances visited during the sync (template update) phase
41
+ private tickInstances = new Set<object>();
42
+
43
+ // Tracks which phase of CD we're in
44
+ private inSyncPhase = false;
45
+ private inTick = false;
46
+
47
+ // The toolbar component instance — excluded from tracking to avoid self-loops
48
+ private toolbarInstance: object | null = null;
49
+
50
+ initialize(): void {
51
+ if (!isDevMode()) return;
52
+ if (!isPlatformBrowser(this.platformId)) return;
53
+ if (this.options.enabled === false) return;
54
+
55
+ const ng = getNgDebugApi();
56
+ if (!ng) {
57
+ console.warn(
58
+ '[angular-scan] Angular debug APIs (window.ng) not available. ' +
59
+ 'Ensure you are running in development mode.'
60
+ );
61
+ return;
62
+ }
63
+
64
+ this.mutationObserver = new MutationObserver(records => {
65
+ for (const r of records) {
66
+ this.mutatedNodes.add(r.target);
67
+ r.addedNodes.forEach(n => this.mutatedNodes.add(n));
68
+ r.removedNodes.forEach(n => this.mutatedNodes.add(n));
69
+ }
70
+ });
71
+
72
+ this.removeProfiler = ng.ɵsetProfiler((event, instance) => {
73
+ switch (event) {
74
+ case ChangeDetectionStart:
75
+ this.onTickStart();
76
+ break;
77
+ case ChangeDetectionSyncStart:
78
+ this.inSyncPhase = true;
79
+ break;
80
+ case ChangeDetectionSyncEnd:
81
+ this.inSyncPhase = false;
82
+ break;
83
+ case TemplateUpdateStart:
84
+ // Only record during the real update phase, not the dev-mode checkNoChanges pass
85
+ if (this.inSyncPhase && instance && instance !== this.toolbarInstance) {
86
+ this.tickInstances.add(instance);
87
+ }
88
+ break;
89
+ case ChangeDetectionEnd:
90
+ this.onTickEnd(ng);
91
+ break;
92
+ }
93
+ });
94
+ }
95
+
96
+ /** Exclude a component instance from render tracking (used for the toolbar). */
97
+ setToolbarInstance(instance: object): void {
98
+ this.toolbarInstance = instance;
99
+ }
100
+
101
+ private onTickStart(): void {
102
+ if (this.inTick) return;
103
+ this.inTick = true;
104
+ this.mutatedNodes.clear();
105
+ this.tickInstances.clear();
106
+
107
+ this.mutationObserver?.observe(document.body, {
108
+ subtree: true,
109
+ childList: true,
110
+ attributes: true,
111
+ characterData: true,
112
+ });
113
+ }
114
+
115
+ private onTickEnd(ng: NgDebugApi): void {
116
+ if (!this.inTick) return;
117
+ this.inTick = false;
118
+ this.inSyncPhase = false;
119
+
120
+ // Synchronously flush pending mutation records before disconnecting
121
+ const pending = this.mutationObserver?.takeRecords() ?? [];
122
+ for (const r of pending) {
123
+ this.mutatedNodes.add(r.target);
124
+ r.addedNodes.forEach(n => this.mutatedNodes.add(n));
125
+ r.removedNodes.forEach(n => this.mutatedNodes.add(n));
126
+ }
127
+ this.mutationObserver?.disconnect();
128
+
129
+ // Build a set of component host elements that had DOM mutations
130
+ // Walk mutated nodes UP to find their owning component host element
131
+ const mutatedHosts = this.buildMutatedHosts(ng);
132
+
133
+ // Collect updates as pure data — NO signal writes here (would trigger CD)
134
+ const pendingUpdates: PendingUpdate[] = [];
135
+
136
+ for (const instance of this.tickInstances) {
137
+ try {
138
+ const hostElement = ng.getHostElement(instance);
139
+ if (!hostElement) continue;
140
+
141
+ const hadMutation = mutatedHosts.has(hostElement);
142
+ const kind: RenderKind = hadMutation ? 'render' : 'unnecessary';
143
+
144
+ pendingUpdates.push({ instance, hostElement, kind });
145
+ } catch {
146
+ // Component may have been destroyed during the tick
147
+ }
148
+ }
149
+
150
+ // Defer all signal writes out of the profiler callback to avoid triggering a new CD cycle
151
+ queueMicrotask(() => {
152
+ for (const update of pendingUpdates) {
153
+ const stats = this.tracker.recordRender(update.instance, update.hostElement, update.kind);
154
+ this.overlay.onComponentChecked(stats);
155
+ }
156
+ this.tracker.snapshotTrackedComponents();
157
+ });
158
+ }
159
+
160
+ /**
161
+ * Build a Set of host Elements by walking each mutated node up the DOM tree
162
+ * until an Angular component boundary is found.
163
+ * This is O(mutatedNodes × depth) but depth is typically < 20.
164
+ */
165
+ private buildMutatedHosts(ng: NgDebugApi): Set<Element> {
166
+ const hosts = new Set<Element>();
167
+
168
+ for (const node of this.mutatedNodes) {
169
+ let current: Node | null = node;
170
+
171
+ while (current && current !== document.body) {
172
+ if (current instanceof Element) {
173
+ try {
174
+ const component = ng.getComponent(current);
175
+ if (component) {
176
+ hosts.add(current);
177
+ break;
178
+ }
179
+ } catch {
180
+ // ignore
181
+ }
182
+ }
183
+ current = current.parentNode;
184
+ }
185
+ }
186
+
187
+ return hosts;
188
+ }
189
+
190
+ ngOnDestroy(): void {
191
+ this.removeProfiler?.();
192
+ this.removeProfiler = null;
193
+ this.mutationObserver?.disconnect();
194
+ this.mutationObserver = null;
195
+ this.overlay.destroy();
196
+ }
197
+ }
@@ -0,0 +1,7 @@
1
+ import { InjectionToken } from '@angular/core';
2
+ import type { AngularScanOptions } from './types';
3
+
4
+ export const ANGULAR_SCAN_OPTIONS = new InjectionToken<AngularScanOptions>(
5
+ 'ANGULAR_SCAN_OPTIONS',
6
+ { providedIn: 'root', factory: () => ({}) }
7
+ );
@@ -0,0 +1,66 @@
1
+ <div
2
+ class="toolbar"
3
+ role="complementary"
4
+ aria-label="Angular Scan DevTools"
5
+ >
6
+ <div class="toolbar__header">
7
+ <span class="toolbar__logo" aria-hidden="true">◉</span>
8
+ <span class="toolbar__title">angular-scan</span>
9
+
10
+ <button
11
+ class="toolbar__btn"
12
+ type="button"
13
+ [attr.aria-pressed]="enabled()"
14
+ [title]="enabled() ? 'Pause scanning' : 'Resume scanning'"
15
+ (click)="toggleEnabled()"
16
+ >{{ enabled() ? '⏸' : '▶' }}</button>
17
+
18
+ <button
19
+ class="toolbar__btn"
20
+ type="button"
21
+ [attr.aria-pressed]="expanded()"
22
+ [title]="expanded() ? 'Collapse inspector' : 'Expand inspector'"
23
+ (click)="toggleExpanded()"
24
+ >{{ expanded() ? '▲' : '▼' }}</button>
25
+ </div>
26
+
27
+ <div class="toolbar__stats">
28
+ <span class="toolbar__stat" title="Total change detection checks">
29
+ <span class="toolbar__stat-label">checks</span>
30
+ <strong class="toolbar__stat-value">{{ tracker.totalRenders() }}</strong>
31
+ </span>
32
+ <span
33
+ class="toolbar__stat"
34
+ [class.toolbar__stat--warn]="tracker.totalUnnecessary() > 0"
35
+ title="Unnecessary renders: component was checked but DOM did not change"
36
+ >
37
+ <span class="toolbar__stat-label">wasted</span>
38
+ <strong class="toolbar__stat-value">{{ tracker.totalUnnecessary() }}</strong>
39
+ </span>
40
+ </div>
41
+
42
+ @if (expanded()) {
43
+ <div
44
+ class="toolbar__inspector"
45
+ role="list"
46
+ aria-label="Component render counts"
47
+ >
48
+ @for (comp of tracker.trackedComponents(); track comp.hostElement) {
49
+ <div
50
+ class="toolbar__component"
51
+ role="listitem"
52
+ [class.toolbar__component--warn]="comp.lastRenderKind === 'unnecessary'"
53
+ [title]="comp.componentName + ' — ' + comp.totalRenders + ' checks, ' + comp.unnecessaryRenders + ' wasted'"
54
+ >
55
+ <span class="toolbar__component-name">{{ comp.componentName }}</span>
56
+ <span class="toolbar__component-count">{{ comp.totalRenders }}</span>
57
+ @if (comp.unnecessaryRenders > 0) {
58
+ <span class="toolbar__component-wasted" aria-label="wasted renders">
59
+ {{ comp.unnecessaryRenders }}W
60
+ </span>
61
+ }
62
+ </div>
63
+ }
64
+ </div>
65
+ }
66
+ </div>
@@ -0,0 +1,147 @@
1
+
2
+ :host {
3
+ position: fixed;
4
+ bottom: 0;
5
+ right: 0;
6
+ z-index: 2147483647;
7
+ font-family: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, monospace;
8
+ font-size: 11px;
9
+ line-height: 1.4;
10
+ }
11
+
12
+ .toolbar {
13
+ background: #0f1117;
14
+ color: #c9d1d9;
15
+ border: 1px solid #30363d;
16
+ border-bottom: none;
17
+ border-right: none;
18
+ border-radius: 6px 0 0 0;
19
+ min-width: 200px;
20
+ max-width: 300px;
21
+ box-shadow: -2px -2px 12px rgba(0, 0, 0, 0.5);
22
+ }
23
+
24
+ .toolbar__header {
25
+ display: flex;
26
+ align-items: center;
27
+ gap: 4px;
28
+ padding: 5px 8px;
29
+ border-bottom: 1px solid #21262d;
30
+ }
31
+
32
+ .toolbar__logo {
33
+ color: #58a6ff;
34
+ font-size: 12px;
35
+ }
36
+
37
+ .toolbar__title {
38
+ font-weight: 600;
39
+ color: #58a6ff;
40
+ flex: 1;
41
+ letter-spacing: 0.02em;
42
+ }
43
+
44
+ .toolbar__btn {
45
+ background: transparent;
46
+ border: 1px solid #30363d;
47
+ color: #8b949e;
48
+ border-radius: 4px;
49
+ cursor: pointer;
50
+ padding: 2px 6px;
51
+ font-size: 10px;
52
+ transition: color 0.15s, border-color 0.15s;
53
+ }
54
+
55
+ .toolbar__btn:hover {
56
+ color: #c9d1d9;
57
+ border-color: #58a6ff;
58
+ }
59
+
60
+ .toolbar__btn:focus-visible {
61
+ outline: 2px solid #58a6ff;
62
+ outline-offset: 2px;
63
+ }
64
+
65
+ .toolbar__stats {
66
+ display: flex;
67
+ gap: 12px;
68
+ padding: 5px 8px;
69
+ }
70
+
71
+ .toolbar__stat {
72
+ display: flex;
73
+ flex-direction: column;
74
+ gap: 1px;
75
+ }
76
+
77
+ .toolbar__stat-label {
78
+ color: #8b949e;
79
+ font-size: 9px;
80
+ text-transform: uppercase;
81
+ letter-spacing: 0.05em;
82
+ }
83
+
84
+ .toolbar__stat-value {
85
+ color: #c9d1d9;
86
+ }
87
+
88
+ .toolbar__stat--warn .toolbar__stat-value {
89
+ color: #f85149;
90
+ }
91
+
92
+ .toolbar__inspector {
93
+ max-height: 220px;
94
+ overflow-y: auto;
95
+ border-top: 1px solid #21262d;
96
+ }
97
+
98
+ .toolbar__inspector::-webkit-scrollbar {
99
+ width: 4px;
100
+ }
101
+
102
+ .toolbar__inspector::-webkit-scrollbar-track {
103
+ background: transparent;
104
+ }
105
+
106
+ .toolbar__inspector::-webkit-scrollbar-thumb {
107
+ background: #30363d;
108
+ border-radius: 2px;
109
+ }
110
+
111
+ .toolbar__component {
112
+ display: flex;
113
+ align-items: center;
114
+ gap: 6px;
115
+ padding: 3px 8px;
116
+ border-bottom: 1px solid #161b22;
117
+ transition: background 0.1s;
118
+ }
119
+
120
+ .toolbar__component:hover {
121
+ background: #161b22;
122
+ }
123
+
124
+ .toolbar__component--warn {
125
+ background: rgba(248, 81, 73, 0.06);
126
+ }
127
+
128
+ .toolbar__component-name {
129
+ flex: 1;
130
+ overflow: hidden;
131
+ text-overflow: ellipsis;
132
+ white-space: nowrap;
133
+ color: #8b949e;
134
+ }
135
+
136
+ .toolbar__component-count {
137
+ font-weight: 600;
138
+ color: #ffa657;
139
+ min-width: 20px;
140
+ text-align: right;
141
+ }
142
+
143
+ .toolbar__component-wasted {
144
+ color: #f85149;
145
+ font-size: 9px;
146
+ font-weight: 600;
147
+ }
@@ -0,0 +1,29 @@
1
+ import {
2
+ Component,
3
+ ChangeDetectionStrategy,
4
+ inject,
5
+ signal,
6
+ } from '@angular/core';
7
+ import { ComponentTracker } from '../component-tracker';
8
+
9
+ @Component({
10
+ selector: 'angular-scan-toolbar',
11
+ changeDetection: ChangeDetectionStrategy.OnPush,
12
+ templateUrl: './toolbar.component.html',
13
+ styleUrls: ['./toolbar.component.scss'],
14
+ host: { '[style.display]': '"block"' },
15
+ })
16
+ export class ToolbarComponent {
17
+ protected readonly tracker = inject(ComponentTracker);
18
+
19
+ protected readonly expanded = signal(false);
20
+ protected readonly enabled = signal(true);
21
+
22
+ protected toggleExpanded(): void {
23
+ this.expanded.update(v => !v);
24
+ }
25
+
26
+ protected toggleEnabled(): void {
27
+ this.enabled.update(v => !v);
28
+ }
29
+ }
@@ -0,0 +1,36 @@
1
+ /** Whether a render was meaningful (DOM changed) or wasted (DOM unchanged). */
2
+ export type RenderKind = 'render' | 'unnecessary';
3
+
4
+ /** Accumulated stats for a single component across its lifetime. */
5
+ export interface ComponentStats {
6
+ componentName: string;
7
+ hostElement: Element;
8
+ totalRenders: number;
9
+ unnecessaryRenders: number;
10
+ lastRenderKind: RenderKind;
11
+ lastRenderTimestamp: number;
12
+ }
13
+
14
+ /** Options for configuring angular-scan. */
15
+ export interface AngularScanOptions {
16
+ /** Set to false to disable scanning entirely. Default: true. */
17
+ enabled?: boolean;
18
+ /** Duration of the canvas flash in milliseconds. Default: 500. */
19
+ flashDurationMs?: number;
20
+ /** Show render count badges on component host elements. Default: true. */
21
+ showBadges?: boolean;
22
+ /** Show the floating toolbar HUD. Default: true. */
23
+ showToolbar?: boolean;
24
+ }
25
+
26
+ /** Shape of the window.ng Angular debug API (dev mode only). */
27
+ export interface NgDebugApi {
28
+ getComponent: (el: Element) => object | null;
29
+ getHostElement: (component: object) => Element | null;
30
+ getRootComponents: (el?: Element) => object[];
31
+ getOwningComponent: (el: Element) => object | null;
32
+ getDirectives: (el: Element) => object[];
33
+ ɵsetProfiler: (
34
+ callback: ((event: number, instance: object | null, context?: unknown) => void) | null
35
+ ) => () => void;
36
+ }
@@ -0,0 +1,7 @@
1
+ /*
2
+ * Public API Surface of angular-scan
3
+ */
4
+
5
+ export { provideAngularScan } from './lib/provide-angular-scan';
6
+ export { scan } from './lib/scan';
7
+ export type { AngularScanOptions, ComponentStats, RenderKind } from './lib/types';
@@ -0,0 +1,13 @@
1
+ /* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
2
+ /* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
3
+ {
4
+ "extends": "../../tsconfig.json",
5
+ "compilerOptions": {
6
+ "outDir": "../../out-tsc/lib",
7
+ "declaration": true,
8
+ "declarationMap": true,
9
+ "types": []
10
+ },
11
+ "include": ["src/**/*.ts"],
12
+ "exclude": ["**/*.spec.ts"]
13
+ }
@@ -0,0 +1,11 @@
1
+ /* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
2
+ /* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
3
+ {
4
+ "extends": "./tsconfig.lib.json",
5
+ "compilerOptions": {
6
+ "declarationMap": false
7
+ },
8
+ "angularCompilerOptions": {
9
+ "compilationMode": "partial"
10
+ }
11
+ }
@@ -0,0 +1,10 @@
1
+ /* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
2
+ /* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
3
+ {
4
+ "extends": "../../tsconfig.json",
5
+ "compilerOptions": {
6
+ "outDir": "../../out-tsc/spec",
7
+ "types": ["vitest/globals"]
8
+ },
9
+ "include": ["src/**/*.d.ts", "src/**/*.spec.ts"]
10
+ }