angular-scan 0.0.2 → 0.0.3

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.
@@ -0,0 +1,685 @@
1
+ import * as i0 from '@angular/core';
2
+ import { signal, Injectable, InjectionToken, inject, DOCUMENT, PLATFORM_ID, isDevMode, ChangeDetectionStrategy, Component, makeEnvironmentProviders, provideEnvironmentInitializer, EnvironmentInjector, ApplicationRef, createComponent } from '@angular/core';
3
+ import { isPlatformBrowser, DOCUMENT as DOCUMENT$1 } from '@angular/common';
4
+
5
+ class ComponentTracker {
6
+ // WeakMap: component instance → stats. GC-safe: entries are freed when
7
+ // the component instance is garbage collected after destruction.
8
+ byInstance = new WeakMap();
9
+ // Strong map: host element → component instance. Used for mutation-to-component
10
+ // lookup. Entries must be manually removed when components are destroyed.
11
+ byElement = new Map();
12
+ _totalRenders = signal(0, ...(ngDevMode ? [{ debugName: "_totalRenders" }] : []));
13
+ _totalUnnecessary = signal(0, ...(ngDevMode ? [{ debugName: "_totalUnnecessary" }] : []));
14
+ _trackedComponents = signal([], ...(ngDevMode ? [{ debugName: "_trackedComponents" }] : []));
15
+ totalRenders = this._totalRenders.asReadonly();
16
+ totalUnnecessary = this._totalUnnecessary.asReadonly();
17
+ trackedComponents = this._trackedComponents.asReadonly();
18
+ /**
19
+ * Record a render event for a component.
20
+ * NOTE: Must be called outside of a profiler callback to avoid triggering CD.
21
+ * Use queueMicrotask() to defer calls from profiler events.
22
+ */
23
+ recordRender(instance, hostElement, kind) {
24
+ let stats = this.byInstance.get(instance);
25
+ if (!stats) {
26
+ stats = {
27
+ componentName: instance.constructor.name,
28
+ hostElement,
29
+ totalRenders: 0,
30
+ unnecessaryRenders: 0,
31
+ lastRenderKind: kind,
32
+ lastRenderTimestamp: 0,
33
+ };
34
+ this.byInstance.set(instance, stats);
35
+ this.byElement.set(hostElement, instance);
36
+ }
37
+ stats.totalRenders++;
38
+ stats.lastRenderKind = kind;
39
+ stats.lastRenderTimestamp = Date.now();
40
+ if (kind === 'unnecessary') {
41
+ stats.unnecessaryRenders++;
42
+ this._totalUnnecessary.update(n => n + 1);
43
+ }
44
+ this._totalRenders.update(n => n + 1);
45
+ return stats;
46
+ }
47
+ /**
48
+ * Rebuild the trackedComponents signal from the current element map.
49
+ * Call once per tick after all recordRender() calls.
50
+ */
51
+ snapshotTrackedComponents() {
52
+ const list = [];
53
+ for (const [, instance] of this.byElement) {
54
+ const stats = this.byInstance.get(instance);
55
+ if (stats)
56
+ list.push(stats);
57
+ }
58
+ this._trackedComponents.set(list);
59
+ }
60
+ /** Remove a component from tracking (call when its host element is removed). */
61
+ unregister(el) {
62
+ this.byElement.delete(el);
63
+ }
64
+ reset() {
65
+ this.byElement.clear();
66
+ this._totalRenders.set(0);
67
+ this._totalUnnecessary.set(0);
68
+ this._trackedComponents.set([]);
69
+ }
70
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.1", ngImport: i0, type: ComponentTracker, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
71
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.1", ngImport: i0, type: ComponentTracker, providedIn: 'root' });
72
+ }
73
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.1", ngImport: i0, type: ComponentTracker, decorators: [{
74
+ type: Injectable,
75
+ args: [{ providedIn: 'root' }]
76
+ }] });
77
+
78
+ // rgb(...) strings for fill and stroke
79
+ const RENDER_COLOR = '255, 200, 0'; // yellow — normal re-render
80
+ const UNNECESSARY_COLOR = '255, 60, 60'; // red — unnecessary render
81
+ class CanvasOverlay {
82
+ canvas = null;
83
+ ctx = null;
84
+ rects = [];
85
+ rafId = 0;
86
+ attached = false;
87
+ attach(doc) {
88
+ if (this.attached)
89
+ return;
90
+ const canvas = doc.createElement('canvas');
91
+ canvas.setAttribute('aria-hidden', 'true');
92
+ canvas.setAttribute('role', 'presentation');
93
+ canvas.style.cssText = [
94
+ 'position:fixed',
95
+ 'top:0',
96
+ 'left:0',
97
+ 'width:100%',
98
+ 'height:100%',
99
+ 'pointer-events:none',
100
+ `z-index:2147483646`,
101
+ ].join(';');
102
+ doc.body.appendChild(canvas);
103
+ this.canvas = canvas;
104
+ this.ctx = canvas.getContext('2d');
105
+ this.attached = true;
106
+ this.resize();
107
+ window.addEventListener('resize', this.onResize);
108
+ this.scheduleFrame();
109
+ }
110
+ detach() {
111
+ if (!this.attached)
112
+ return;
113
+ this.attached = false;
114
+ cancelAnimationFrame(this.rafId);
115
+ window.removeEventListener('resize', this.onResize);
116
+ this.canvas?.remove();
117
+ this.canvas = null;
118
+ this.ctx = null;
119
+ this.rects = [];
120
+ }
121
+ /** Queue a flash animation over an element's bounding rect. */
122
+ flash(element, kind, durationMs) {
123
+ if (!this.attached)
124
+ return;
125
+ const rect = element.getBoundingClientRect();
126
+ if (rect.width === 0 || rect.height === 0)
127
+ return;
128
+ this.rects.push({
129
+ x: rect.left,
130
+ y: rect.top,
131
+ width: rect.width,
132
+ height: rect.height,
133
+ kind,
134
+ startTime: performance.now(),
135
+ durationMs,
136
+ });
137
+ }
138
+ onResize = () => this.resize();
139
+ resize() {
140
+ if (!this.canvas)
141
+ return;
142
+ this.canvas.width = window.innerWidth;
143
+ this.canvas.height = window.innerHeight;
144
+ }
145
+ scheduleFrame() {
146
+ if (!this.attached)
147
+ return;
148
+ this.rafId = requestAnimationFrame(this.drawFrame);
149
+ }
150
+ drawFrame = (now) => {
151
+ if (!this.ctx || !this.canvas || !this.attached)
152
+ return;
153
+ this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
154
+ // Decay alphas and remove expired rects
155
+ this.rects = this.rects.filter(r => {
156
+ const elapsed = now - r.startTime;
157
+ return elapsed < r.durationMs;
158
+ });
159
+ for (const r of this.rects) {
160
+ const elapsed = now - r.startTime;
161
+ const alpha = Math.max(0, 1 - elapsed / r.durationMs);
162
+ const color = r.kind === 'render' ? RENDER_COLOR : UNNECESSARY_COLOR;
163
+ // Semi-transparent fill
164
+ this.ctx.fillStyle = `rgba(${color}, ${alpha * 0.1})`;
165
+ this.ctx.fillRect(r.x, r.y, r.width, r.height);
166
+ // Solid border
167
+ this.ctx.strokeStyle = `rgba(${color}, ${alpha * 0.9})`;
168
+ this.ctx.lineWidth = 2;
169
+ this.ctx.strokeRect(r.x + 1, r.y + 1, r.width - 2, r.height - 2);
170
+ }
171
+ this.scheduleFrame();
172
+ };
173
+ }
174
+
175
+ const ANGULAR_SCAN_OPTIONS = new InjectionToken('ANGULAR_SCAN_OPTIONS', { providedIn: 'root', factory: () => ({}) });
176
+
177
+ class OverlayService {
178
+ document = inject(DOCUMENT);
179
+ platformId = inject(PLATFORM_ID);
180
+ options = inject(ANGULAR_SCAN_OPTIONS);
181
+ canvas = new CanvasOverlay();
182
+ // Badge elements keyed by host element
183
+ badges = new Map();
184
+ initialize() {
185
+ if (!isDevMode())
186
+ return;
187
+ if (!isPlatformBrowser(this.platformId))
188
+ return;
189
+ if (this.options.enabled === false)
190
+ return;
191
+ this.canvas.attach(this.document);
192
+ }
193
+ destroy() {
194
+ this.canvas.detach();
195
+ for (const badge of this.badges.values())
196
+ badge.remove();
197
+ this.badges.clear();
198
+ }
199
+ onComponentChecked(stats) {
200
+ const durationMs = this.options.flashDurationMs ?? 500;
201
+ this.canvas.flash(stats.hostElement, stats.lastRenderKind, durationMs);
202
+ if (this.options.showBadges !== false) {
203
+ this.updateBadge(stats);
204
+ }
205
+ }
206
+ removeBadge(el) {
207
+ this.badges.get(el)?.remove();
208
+ this.badges.delete(el);
209
+ }
210
+ updateBadge(stats) {
211
+ let badge = this.badges.get(stats.hostElement);
212
+ if (!badge) {
213
+ badge = this.document.createElement('div');
214
+ badge.setAttribute('aria-hidden', 'true');
215
+ badge.setAttribute('role', 'presentation');
216
+ badge.style.cssText = [
217
+ 'position:absolute',
218
+ 'top:2px',
219
+ 'right:2px',
220
+ 'z-index:2147483645',
221
+ 'pointer-events:none',
222
+ 'font:bold 9px/13px monospace',
223
+ 'padding:1px 4px',
224
+ 'border-radius:3px',
225
+ 'min-width:16px',
226
+ 'text-align:center',
227
+ 'color:#fff',
228
+ 'white-space:nowrap',
229
+ ].join(';');
230
+ // Host element must be non-static to contain an absolutely-positioned badge
231
+ this.ensurePositioned(stats.hostElement);
232
+ stats.hostElement.appendChild(badge);
233
+ this.badges.set(stats.hostElement, badge);
234
+ }
235
+ const isUnnecessary = stats.lastRenderKind === 'unnecessary';
236
+ badge.style.background = isUnnecessary ? '#f44336' : '#ff9800';
237
+ badge.textContent = String(stats.totalRenders);
238
+ badge.title = `${stats.componentName}: ${stats.totalRenders} renders, ${stats.unnecessaryRenders} unnecessary`;
239
+ }
240
+ ensurePositioned(el) {
241
+ const htmlEl = el;
242
+ if (getComputedStyle(htmlEl).position === 'static') {
243
+ htmlEl.style.position = 'relative';
244
+ }
245
+ }
246
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.1", ngImport: i0, type: OverlayService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
247
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.1", ngImport: i0, type: OverlayService, providedIn: 'root' });
248
+ }
249
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.1", ngImport: i0, type: OverlayService, decorators: [{
250
+ type: Injectable,
251
+ args: [{ providedIn: 'root' }]
252
+ }] });
253
+
254
+ /**
255
+ * Returns the Angular debug API (window.ng) if available.
256
+ * Returns null in SSR, production builds, or when Angular DevTools APIs are absent.
257
+ *
258
+ * Safe to call before Angular is bootstrapped — the API is attached to window
259
+ * by Angular's dev mode runtime.
260
+ */
261
+ function getNgDebugApi() {
262
+ if (typeof window === 'undefined')
263
+ return null;
264
+ const ng = window['ng'];
265
+ if (!ng || typeof ng !== 'object')
266
+ return null;
267
+ const api = ng;
268
+ // ɵsetProfiler is the sentinel for dev mode — absent in production builds
269
+ if (typeof api.ɵsetProfiler !== 'function')
270
+ return null;
271
+ if (typeof api.getHostElement !== 'function')
272
+ return null;
273
+ return api;
274
+ }
275
+
276
+ // Angular profiler event IDs (stable contract in dev mode, used by Angular DevTools)
277
+ const TemplateUpdateStart$1 = 2;
278
+ const ChangeDetectionStart$1 = 12;
279
+ const ChangeDetectionEnd$1 = 13;
280
+ const ChangeDetectionSyncStart$1 = 14;
281
+ const ChangeDetectionSyncEnd$1 = 15;
282
+ class ScannerService {
283
+ tracker = inject(ComponentTracker);
284
+ overlay = inject(OverlayService);
285
+ options = inject(ANGULAR_SCAN_OPTIONS);
286
+ platformId = inject(PLATFORM_ID);
287
+ removeProfiler = null;
288
+ mutationObserver = null;
289
+ // Accumulated during a single CD tick (raw DOM nodes, no signal writes)
290
+ mutatedNodes = new Set();
291
+ // Component instances visited during the sync (template update) phase
292
+ tickInstances = new Set();
293
+ // Tracks which phase of CD we're in
294
+ inSyncPhase = false;
295
+ inTick = false;
296
+ // The toolbar component instance — excluded from tracking to avoid self-loops
297
+ toolbarInstance = null;
298
+ initialize() {
299
+ if (!isDevMode())
300
+ return;
301
+ if (!isPlatformBrowser(this.platformId))
302
+ return;
303
+ if (this.options.enabled === false)
304
+ return;
305
+ const ng = getNgDebugApi();
306
+ if (!ng) {
307
+ console.warn('[angular-scan] Angular debug APIs (window.ng) not available. ' +
308
+ 'Ensure you are running in development mode.');
309
+ return;
310
+ }
311
+ this.mutationObserver = new MutationObserver(records => {
312
+ for (const r of records) {
313
+ this.mutatedNodes.add(r.target);
314
+ r.addedNodes.forEach(n => this.mutatedNodes.add(n));
315
+ r.removedNodes.forEach(n => this.mutatedNodes.add(n));
316
+ }
317
+ });
318
+ this.removeProfiler = ng.ɵsetProfiler((event, instance) => {
319
+ switch (event) {
320
+ case ChangeDetectionStart$1:
321
+ this.onTickStart();
322
+ break;
323
+ case ChangeDetectionSyncStart$1:
324
+ this.inSyncPhase = true;
325
+ break;
326
+ case ChangeDetectionSyncEnd$1:
327
+ this.inSyncPhase = false;
328
+ break;
329
+ case TemplateUpdateStart$1:
330
+ // Only record during the real update phase, not the dev-mode checkNoChanges pass
331
+ if (this.inSyncPhase && instance && instance !== this.toolbarInstance) {
332
+ this.tickInstances.add(instance);
333
+ }
334
+ break;
335
+ case ChangeDetectionEnd$1:
336
+ this.onTickEnd(ng);
337
+ break;
338
+ }
339
+ });
340
+ }
341
+ /** Exclude a component instance from render tracking (used for the toolbar). */
342
+ setToolbarInstance(instance) {
343
+ this.toolbarInstance = instance;
344
+ }
345
+ onTickStart() {
346
+ if (this.inTick)
347
+ return;
348
+ this.inTick = true;
349
+ this.mutatedNodes.clear();
350
+ this.tickInstances.clear();
351
+ this.mutationObserver?.observe(document.body, {
352
+ subtree: true,
353
+ childList: true,
354
+ attributes: true,
355
+ characterData: true,
356
+ });
357
+ }
358
+ onTickEnd(ng) {
359
+ if (!this.inTick)
360
+ return;
361
+ this.inTick = false;
362
+ this.inSyncPhase = false;
363
+ // Synchronously flush pending mutation records before disconnecting
364
+ const pending = this.mutationObserver?.takeRecords() ?? [];
365
+ for (const r of pending) {
366
+ this.mutatedNodes.add(r.target);
367
+ r.addedNodes.forEach(n => this.mutatedNodes.add(n));
368
+ r.removedNodes.forEach(n => this.mutatedNodes.add(n));
369
+ }
370
+ this.mutationObserver?.disconnect();
371
+ // Build a set of component host elements that had DOM mutations
372
+ // Walk mutated nodes UP to find their owning component host element
373
+ const mutatedHosts = this.buildMutatedHosts(ng);
374
+ // Collect updates as pure data — NO signal writes here (would trigger CD)
375
+ const pendingUpdates = [];
376
+ for (const instance of this.tickInstances) {
377
+ try {
378
+ const hostElement = ng.getHostElement(instance);
379
+ if (!hostElement)
380
+ continue;
381
+ const hadMutation = mutatedHosts.has(hostElement);
382
+ const kind = hadMutation ? 'render' : 'unnecessary';
383
+ pendingUpdates.push({ instance, hostElement, kind });
384
+ }
385
+ catch {
386
+ // Component may have been destroyed during the tick
387
+ }
388
+ }
389
+ // Defer all signal writes out of the profiler callback to avoid triggering a new CD cycle
390
+ queueMicrotask(() => {
391
+ for (const update of pendingUpdates) {
392
+ const stats = this.tracker.recordRender(update.instance, update.hostElement, update.kind);
393
+ this.overlay.onComponentChecked(stats);
394
+ }
395
+ this.tracker.snapshotTrackedComponents();
396
+ });
397
+ }
398
+ /**
399
+ * Build a Set of host Elements by walking each mutated node up the DOM tree
400
+ * until an Angular component boundary is found.
401
+ * This is O(mutatedNodes × depth) but depth is typically < 20.
402
+ */
403
+ buildMutatedHosts(ng) {
404
+ const hosts = new Set();
405
+ for (const node of this.mutatedNodes) {
406
+ let current = node;
407
+ while (current && current !== document.body) {
408
+ if (current instanceof Element) {
409
+ try {
410
+ const component = ng.getComponent(current);
411
+ if (component) {
412
+ hosts.add(current);
413
+ break;
414
+ }
415
+ }
416
+ catch {
417
+ // ignore
418
+ }
419
+ }
420
+ current = current.parentNode;
421
+ }
422
+ }
423
+ return hosts;
424
+ }
425
+ ngOnDestroy() {
426
+ this.removeProfiler?.();
427
+ this.removeProfiler = null;
428
+ this.mutationObserver?.disconnect();
429
+ this.mutationObserver = null;
430
+ this.overlay.destroy();
431
+ }
432
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.1", ngImport: i0, type: ScannerService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
433
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.1", ngImport: i0, type: ScannerService, providedIn: 'root' });
434
+ }
435
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.1", ngImport: i0, type: ScannerService, decorators: [{
436
+ type: Injectable,
437
+ args: [{ providedIn: 'root' }]
438
+ }] });
439
+
440
+ class ToolbarComponent {
441
+ tracker = inject(ComponentTracker);
442
+ expanded = signal(false, ...(ngDevMode ? [{ debugName: "expanded" }] : []));
443
+ enabled = signal(true, ...(ngDevMode ? [{ debugName: "enabled" }] : []));
444
+ toggleExpanded() {
445
+ this.expanded.update(v => !v);
446
+ }
447
+ toggleEnabled() {
448
+ this.enabled.update(v => !v);
449
+ }
450
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.1", ngImport: i0, type: ToolbarComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
451
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.1", type: ToolbarComponent, isStandalone: true, selector: "angular-scan-toolbar", host: { properties: { "style.display": "\"block\"" } }, ngImport: i0, template: "<div\nclass=\"toolbar\"\nrole=\"complementary\"\naria-label=\"Angular Scan DevTools\"\n>\n<div class=\"toolbar__header\">\n <span class=\"toolbar__logo\" aria-hidden=\"true\">\u25C9</span>\n <span class=\"toolbar__title\">angular-scan</span>\n\n <button\n class=\"toolbar__btn\"\n type=\"button\"\n [attr.aria-pressed]=\"enabled()\"\n [title]=\"enabled() ? 'Pause scanning' : 'Resume scanning'\"\n (click)=\"toggleEnabled()\"\n >{{ enabled() ? '\u23F8' : '\u25B6' }}</button>\n\n <button\n class=\"toolbar__btn\"\n type=\"button\"\n [attr.aria-pressed]=\"expanded()\"\n [title]=\"expanded() ? 'Collapse inspector' : 'Expand inspector'\"\n (click)=\"toggleExpanded()\"\n >{{ expanded() ? '\u25B2' : '\u25BC' }}</button>\n</div>\n\n<div class=\"toolbar__stats\">\n <span class=\"toolbar__stat\" title=\"Total change detection checks\">\n <span class=\"toolbar__stat-label\">checks</span>\n <strong class=\"toolbar__stat-value\">{{ tracker.totalRenders() }}</strong>\n </span>\n <span\n class=\"toolbar__stat\"\n [class.toolbar__stat--warn]=\"tracker.totalUnnecessary() > 0\"\n title=\"Unnecessary renders: component was checked but DOM did not change\"\n >\n <span class=\"toolbar__stat-label\">wasted</span>\n <strong class=\"toolbar__stat-value\">{{ tracker.totalUnnecessary() }}</strong>\n </span>\n</div>\n\n@if (expanded()) {\n <div\n class=\"toolbar__inspector\"\n role=\"list\"\n aria-label=\"Component render counts\"\n >\n @for (comp of tracker.trackedComponents(); track comp.hostElement) {\n <div\n class=\"toolbar__component\"\n role=\"listitem\"\n [class.toolbar__component--warn]=\"comp.lastRenderKind === 'unnecessary'\"\n [title]=\"comp.componentName + ' \u2014 ' + comp.totalRenders + ' checks, ' + comp.unnecessaryRenders + ' wasted'\"\n >\n <span class=\"toolbar__component-name\">{{ comp.componentName }}</span>\n <span class=\"toolbar__component-count\">{{ comp.totalRenders }}</span>\n @if (comp.unnecessaryRenders > 0) {\n <span class=\"toolbar__component-wasted\" aria-label=\"wasted renders\">\n {{ comp.unnecessaryRenders }}W\n </span>\n }\n </div>\n }\n </div>\n}\n</div>", styles: [":host{position:fixed;bottom:0;right:0;z-index:2147483647;font-family:ui-monospace,Cascadia Code,Source Code Pro,Menlo,Consolas,monospace;font-size:11px;line-height:1.4}.toolbar{background:#0f1117;color:#c9d1d9;border:1px solid #30363d;border-bottom:none;border-right:none;border-radius:6px 0 0;min-width:200px;max-width:300px;box-shadow:-2px -2px 12px #00000080}.toolbar__header{display:flex;align-items:center;gap:4px;padding:5px 8px;border-bottom:1px solid #21262d}.toolbar__logo{color:#58a6ff;font-size:12px}.toolbar__title{font-weight:600;color:#58a6ff;flex:1;letter-spacing:.02em}.toolbar__btn{background:transparent;border:1px solid #30363d;color:#8b949e;border-radius:4px;cursor:pointer;padding:2px 6px;font-size:10px;transition:color .15s,border-color .15s}.toolbar__btn:hover{color:#c9d1d9;border-color:#58a6ff}.toolbar__btn:focus-visible{outline:2px solid #58a6ff;outline-offset:2px}.toolbar__stats{display:flex;gap:12px;padding:5px 8px}.toolbar__stat{display:flex;flex-direction:column;gap:1px}.toolbar__stat-label{color:#8b949e;font-size:9px;text-transform:uppercase;letter-spacing:.05em}.toolbar__stat-value{color:#c9d1d9}.toolbar__stat--warn .toolbar__stat-value{color:#f85149}.toolbar__inspector{max-height:220px;overflow-y:auto;border-top:1px solid #21262d}.toolbar__inspector::-webkit-scrollbar{width:4px}.toolbar__inspector::-webkit-scrollbar-track{background:transparent}.toolbar__inspector::-webkit-scrollbar-thumb{background:#30363d;border-radius:2px}.toolbar__component{display:flex;align-items:center;gap:6px;padding:3px 8px;border-bottom:1px solid #161b22;transition:background .1s}.toolbar__component:hover{background:#161b22}.toolbar__component--warn{background:#f851490f}.toolbar__component-name{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#8b949e}.toolbar__component-count{font-weight:600;color:#ffa657;min-width:20px;text-align:right}.toolbar__component-wasted{color:#f85149;font-size:9px;font-weight:600}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush });
452
+ }
453
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.1", ngImport: i0, type: ToolbarComponent, decorators: [{
454
+ type: Component,
455
+ args: [{ selector: 'angular-scan-toolbar', changeDetection: ChangeDetectionStrategy.OnPush, host: { '[style.display]': '"block"' }, template: "<div\nclass=\"toolbar\"\nrole=\"complementary\"\naria-label=\"Angular Scan DevTools\"\n>\n<div class=\"toolbar__header\">\n <span class=\"toolbar__logo\" aria-hidden=\"true\">\u25C9</span>\n <span class=\"toolbar__title\">angular-scan</span>\n\n <button\n class=\"toolbar__btn\"\n type=\"button\"\n [attr.aria-pressed]=\"enabled()\"\n [title]=\"enabled() ? 'Pause scanning' : 'Resume scanning'\"\n (click)=\"toggleEnabled()\"\n >{{ enabled() ? '\u23F8' : '\u25B6' }}</button>\n\n <button\n class=\"toolbar__btn\"\n type=\"button\"\n [attr.aria-pressed]=\"expanded()\"\n [title]=\"expanded() ? 'Collapse inspector' : 'Expand inspector'\"\n (click)=\"toggleExpanded()\"\n >{{ expanded() ? '\u25B2' : '\u25BC' }}</button>\n</div>\n\n<div class=\"toolbar__stats\">\n <span class=\"toolbar__stat\" title=\"Total change detection checks\">\n <span class=\"toolbar__stat-label\">checks</span>\n <strong class=\"toolbar__stat-value\">{{ tracker.totalRenders() }}</strong>\n </span>\n <span\n class=\"toolbar__stat\"\n [class.toolbar__stat--warn]=\"tracker.totalUnnecessary() > 0\"\n title=\"Unnecessary renders: component was checked but DOM did not change\"\n >\n <span class=\"toolbar__stat-label\">wasted</span>\n <strong class=\"toolbar__stat-value\">{{ tracker.totalUnnecessary() }}</strong>\n </span>\n</div>\n\n@if (expanded()) {\n <div\n class=\"toolbar__inspector\"\n role=\"list\"\n aria-label=\"Component render counts\"\n >\n @for (comp of tracker.trackedComponents(); track comp.hostElement) {\n <div\n class=\"toolbar__component\"\n role=\"listitem\"\n [class.toolbar__component--warn]=\"comp.lastRenderKind === 'unnecessary'\"\n [title]=\"comp.componentName + ' \u2014 ' + comp.totalRenders + ' checks, ' + comp.unnecessaryRenders + ' wasted'\"\n >\n <span class=\"toolbar__component-name\">{{ comp.componentName }}</span>\n <span class=\"toolbar__component-count\">{{ comp.totalRenders }}</span>\n @if (comp.unnecessaryRenders > 0) {\n <span class=\"toolbar__component-wasted\" aria-label=\"wasted renders\">\n {{ comp.unnecessaryRenders }}W\n </span>\n }\n </div>\n }\n </div>\n}\n</div>", styles: [":host{position:fixed;bottom:0;right:0;z-index:2147483647;font-family:ui-monospace,Cascadia Code,Source Code Pro,Menlo,Consolas,monospace;font-size:11px;line-height:1.4}.toolbar{background:#0f1117;color:#c9d1d9;border:1px solid #30363d;border-bottom:none;border-right:none;border-radius:6px 0 0;min-width:200px;max-width:300px;box-shadow:-2px -2px 12px #00000080}.toolbar__header{display:flex;align-items:center;gap:4px;padding:5px 8px;border-bottom:1px solid #21262d}.toolbar__logo{color:#58a6ff;font-size:12px}.toolbar__title{font-weight:600;color:#58a6ff;flex:1;letter-spacing:.02em}.toolbar__btn{background:transparent;border:1px solid #30363d;color:#8b949e;border-radius:4px;cursor:pointer;padding:2px 6px;font-size:10px;transition:color .15s,border-color .15s}.toolbar__btn:hover{color:#c9d1d9;border-color:#58a6ff}.toolbar__btn:focus-visible{outline:2px solid #58a6ff;outline-offset:2px}.toolbar__stats{display:flex;gap:12px;padding:5px 8px}.toolbar__stat{display:flex;flex-direction:column;gap:1px}.toolbar__stat-label{color:#8b949e;font-size:9px;text-transform:uppercase;letter-spacing:.05em}.toolbar__stat-value{color:#c9d1d9}.toolbar__stat--warn .toolbar__stat-value{color:#f85149}.toolbar__inspector{max-height:220px;overflow-y:auto;border-top:1px solid #21262d}.toolbar__inspector::-webkit-scrollbar{width:4px}.toolbar__inspector::-webkit-scrollbar-track{background:transparent}.toolbar__inspector::-webkit-scrollbar-thumb{background:#30363d;border-radius:2px}.toolbar__component{display:flex;align-items:center;gap:6px;padding:3px 8px;border-bottom:1px solid #161b22;transition:background .1s}.toolbar__component:hover{background:#161b22}.toolbar__component--warn{background:#f851490f}.toolbar__component-name{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#8b949e}.toolbar__component-count{font-weight:600;color:#ffa657;min-width:20px;text-align:right}.toolbar__component-wasted{color:#f85149;font-size:9px;font-weight:600}\n"] }]
456
+ }] });
457
+
458
+ /**
459
+ * Provides angular-scan for development-time render tracking.
460
+ *
461
+ * Add to your application providers:
462
+ * ```ts
463
+ * bootstrapApplication(AppComponent, {
464
+ * providers: [provideAngularScan()]
465
+ * });
466
+ * ```
467
+ *
468
+ * This is a complete no-op in production builds (`isDevMode() === false`),
469
+ * so it can safely be left in your app config.
470
+ */
471
+ function provideAngularScan(options = {}) {
472
+ // Return empty providers in production — entire library is tree-shaken
473
+ if (!isDevMode()) {
474
+ return makeEnvironmentProviders([]);
475
+ }
476
+ const resolvedOptions = {
477
+ enabled: true,
478
+ flashDurationMs: 500,
479
+ showBadges: true,
480
+ showToolbar: true,
481
+ ...options,
482
+ };
483
+ return makeEnvironmentProviders([
484
+ {
485
+ provide: ANGULAR_SCAN_OPTIONS,
486
+ useValue: resolvedOptions,
487
+ },
488
+ provideEnvironmentInitializer(() => {
489
+ const overlay = inject(OverlayService);
490
+ const scanner = inject(ScannerService);
491
+ const opts = inject(ANGULAR_SCAN_OPTIONS);
492
+ overlay.initialize();
493
+ scanner.initialize();
494
+ if (opts.showToolbar !== false) {
495
+ const injector = inject(EnvironmentInjector);
496
+ const doc = inject(DOCUMENT$1);
497
+ const appRef = inject(ApplicationRef);
498
+ // Create the toolbar outside Angular's component tree so it doesn't
499
+ // appear in CD tracking. Attach it to ApplicationRef so signals work.
500
+ const toolbarRef = createComponent(ToolbarComponent, {
501
+ environmentInjector: injector,
502
+ });
503
+ doc.body.appendChild(toolbarRef.location.nativeElement);
504
+ appRef.attachView(toolbarRef.hostView);
505
+ // Tell the scanner to ignore the toolbar's own renders
506
+ scanner.setToolbarInstance(toolbarRef.instance);
507
+ }
508
+ }),
509
+ ]);
510
+ }
511
+
512
+ // Profiler event constants
513
+ const TemplateUpdateStart = 2;
514
+ const ChangeDetectionStart = 12;
515
+ const ChangeDetectionEnd = 13;
516
+ const ChangeDetectionSyncStart = 14;
517
+ const ChangeDetectionSyncEnd = 15;
518
+ /**
519
+ * Imperative API for angular-scan.
520
+ *
521
+ * Use this when you cannot modify application providers (e.g. micro-frontends,
522
+ * third-party apps). Call before `bootstrapApplication()`.
523
+ *
524
+ * ```ts
525
+ * // main.ts
526
+ * import { scan } from 'angular-scan';
527
+ * scan();
528
+ * bootstrapApplication(AppComponent, appConfig);
529
+ * ```
530
+ *
531
+ * @returns A teardown function that stops scanning and removes the overlay.
532
+ */
533
+ function scan(options = {}) {
534
+ if (!isDevMode())
535
+ return noop;
536
+ if (typeof window === 'undefined')
537
+ return noop;
538
+ if (options.enabled === false)
539
+ return noop;
540
+ const ng = getNgDebugApi();
541
+ if (!ng) {
542
+ console.warn('[angular-scan] Angular debug APIs (window.ng) not available. ' +
543
+ 'Ensure you are running in development mode.');
544
+ return noop;
545
+ }
546
+ const durationMs = options.flashDurationMs ?? 500;
547
+ const showBadges = options.showBadges !== false;
548
+ const overlay = new CanvasOverlay();
549
+ overlay.attach(document);
550
+ // State for the current tick
551
+ let inTick = false;
552
+ let inSyncPhase = false;
553
+ const mutatedNodes = new Set();
554
+ const tickInstances = new Set();
555
+ const renderCounts = new WeakMap();
556
+ const unnecessaryCounts = new WeakMap();
557
+ const badges = new Map();
558
+ const observer = new MutationObserver(records => {
559
+ for (const r of records) {
560
+ mutatedNodes.add(r.target);
561
+ r.addedNodes.forEach(n => mutatedNodes.add(n));
562
+ r.removedNodes.forEach(n => mutatedNodes.add(n));
563
+ }
564
+ });
565
+ const removeProfiler = ng.ɵsetProfiler((event, instance) => {
566
+ switch (event) {
567
+ case ChangeDetectionStart:
568
+ if (inTick)
569
+ break;
570
+ inTick = true;
571
+ mutatedNodes.clear();
572
+ tickInstances.clear();
573
+ observer.observe(document.body, {
574
+ subtree: true, childList: true, attributes: true, characterData: true,
575
+ });
576
+ break;
577
+ case ChangeDetectionSyncStart:
578
+ inSyncPhase = true;
579
+ break;
580
+ case ChangeDetectionSyncEnd:
581
+ inSyncPhase = false;
582
+ break;
583
+ case TemplateUpdateStart:
584
+ if (inSyncPhase && instance)
585
+ tickInstances.add(instance);
586
+ break;
587
+ case ChangeDetectionEnd: {
588
+ if (!inTick)
589
+ break;
590
+ inTick = false;
591
+ inSyncPhase = false;
592
+ const pending = observer.takeRecords();
593
+ for (const r of pending) {
594
+ mutatedNodes.add(r.target);
595
+ r.addedNodes.forEach(n => mutatedNodes.add(n));
596
+ r.removedNodes.forEach(n => mutatedNodes.add(n));
597
+ }
598
+ observer.disconnect();
599
+ // Build mutated host set
600
+ const mutatedHosts = new Set();
601
+ for (const node of mutatedNodes) {
602
+ let current = node;
603
+ while (current && current !== document.body) {
604
+ if (current instanceof Element) {
605
+ try {
606
+ if (ng.getComponent(current)) {
607
+ mutatedHosts.add(current);
608
+ break;
609
+ }
610
+ }
611
+ catch { /* ignore */ }
612
+ }
613
+ current = current.parentNode;
614
+ }
615
+ }
616
+ // Collect updates without writing to any signals
617
+ const updates = [];
618
+ for (const inst of tickInstances) {
619
+ try {
620
+ const hostEl = ng.getHostElement(inst);
621
+ if (!hostEl)
622
+ continue;
623
+ const kind = mutatedHosts.has(hostEl) ? 'render' : 'unnecessary';
624
+ updates.push({ instance: inst, hostEl, kind });
625
+ }
626
+ catch { /* component destroyed */ }
627
+ }
628
+ queueMicrotask(() => {
629
+ for (const { instance, hostEl, kind } of updates) {
630
+ const count = (renderCounts.get(instance) ?? 0) + 1;
631
+ renderCounts.set(instance, count);
632
+ if (kind === 'unnecessary') {
633
+ unnecessaryCounts.set(instance, (unnecessaryCounts.get(instance) ?? 0) + 1);
634
+ }
635
+ overlay.flash(hostEl, kind, durationMs);
636
+ if (showBadges)
637
+ updateBadge(hostEl, count, kind);
638
+ }
639
+ });
640
+ break;
641
+ }
642
+ }
643
+ });
644
+ function updateBadge(hostEl, count, kind) {
645
+ let badge = badges.get(hostEl);
646
+ if (!badge) {
647
+ badge = document.createElement('div');
648
+ badge.setAttribute('aria-hidden', 'true');
649
+ badge.setAttribute('role', 'presentation');
650
+ badge.style.cssText = [
651
+ 'position:absolute', 'top:2px', 'right:2px',
652
+ 'z-index:2147483645', 'pointer-events:none',
653
+ 'font:bold 9px/13px monospace', 'padding:1px 4px',
654
+ 'border-radius:3px', 'min-width:16px', 'text-align:center', 'color:#fff',
655
+ ].join(';');
656
+ const htmlEl = hostEl;
657
+ if (getComputedStyle(htmlEl).position === 'static')
658
+ htmlEl.style.position = 'relative';
659
+ hostEl.appendChild(badge);
660
+ badges.set(hostEl, badge);
661
+ }
662
+ badge.style.background = kind === 'unnecessary' ? '#f44336' : '#ff9800';
663
+ badge.textContent = String(count);
664
+ }
665
+ return () => {
666
+ removeProfiler();
667
+ observer.disconnect();
668
+ overlay.detach();
669
+ for (const b of badges.values())
670
+ b.remove();
671
+ badges.clear();
672
+ };
673
+ }
674
+ function noop() { }
675
+
676
+ /*
677
+ * Public API Surface of angular-scan
678
+ */
679
+
680
+ /**
681
+ * Generated bundle index. Do not edit.
682
+ */
683
+
684
+ export { provideAngularScan, scan };
685
+ //# sourceMappingURL=angular-scan.mjs.map