Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,14 @@ Angular Render Scan is a visual debugging overlay for Angular change detection.

[Live Demo](https://edisonaugusthy.github.io/angular-render-scan/) | [npm](https://www.npmjs.com/package/angular-render-scan)

## Versions

| Package | Version |
|---|---|
| Angular | `^22.0.0` |
| `angular-render-scan` | `0.1.8` |
| `angular-render-scan-cli` | `0.1.5` |

## What it shows

- Component render outlines and heatmap colors.
Expand Down
5 changes: 1 addition & 4 deletions e2e/demo.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,21 +195,18 @@ test('toolbar can toggle live CPU details panel', async ({ page }) => {
})).toBeNull();
});

test('wasted render counter and mutation details appear in toolbar and details panel', async ({ page }) => {
test('mutation details appear in toolbar and details panel', async ({ page }) => {
await page.goto('/');
const overlay = page.locator(overlaySelector);

await page.locator('app-product-card').first().getByRole('button', { name: 'Add to Cart' }).click();

await expect.poll(async () => overlay.evaluate((host) => host.shadowRoot?.textContent ?? '')).toContain('Wasted');

await overlay.evaluate((host) => {
host.shadowRoot?.querySelector<HTMLInputElement>('.details-checkbox')?.click();
});

await page.locator('app-shopping-cart').click({ force: true });

await expect.poll(async () => overlay.evaluate((host) => host.shadowRoot?.querySelector('.inspect-panel')?.textContent ?? '')).toContain('Wasted Renders');
await expect.poll(async () => overlay.evaluate((host) => host.shadowRoot?.querySelector('.inspect-panel')?.textContent ?? '')).toContain('DOM Mutation Type');
});

Expand Down
8 changes: 8 additions & 0 deletions packages/angular-render-scan/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,14 @@ Angular Render Scan is a visual debugging overlay for Angular change detection.

[Live Demo](https://edisonaugusthy.github.io/angular-render-scan/) | [npm](https://www.npmjs.com/package/angular-render-scan)

## Versions

| Package | Version |
|---|---|
| Angular | `^22.0.0` |
| `angular-render-scan` | `0.1.8` |
| `angular-render-scan-cli` | `0.1.5` |

## What it shows

- Component render outlines and heatmap colors.
Expand Down
7 changes: 5 additions & 2 deletions packages/angular-render-scan/src/application/runtime.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { AngularRenderScanOverlay } from '../infrastructure/ui/overlay';
import { getResolvedOptions, resolveOptions, setResolvedOptions } from '../domain/options';
import { getResolvedOptions, setResolvedOptions } from '../domain/options';
import {
finishCycle,
resetStats,
Expand Down Expand Up @@ -58,7 +58,10 @@ export function setTaskScheduler(scheduler: (fn: () => void) => void) {
}

export function scan(options?: AngularRenderScanOptions): void {
const resolved = setResolvedOptions(resolveOptions(options));
const resolved = setResolvedOptions(options ?? {}, {
persistEnabled: false,
preferStoredEnabled: true
});
if (!overlay && typeof document !== 'undefined') {
overlay = new AngularRenderScanOverlay(resolved, (enabled) => setOptions({ enabled }));
}
Expand Down
43 changes: 41 additions & 2 deletions packages/angular-render-scan/src/domain/options.spec.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { afterEach, describe, expect, it } from 'vitest';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { getResolvedOptions, resetOptionsForTest, setResolvedOptions } from './options';

describe('options', () => {
afterEach(() => resetOptionsForTest());
afterEach(() => {
resetOptionsForTest();
vi.unstubAllGlobals();
});

it('merges partial options with defaults', () => {
setResolvedOptions({ enabled: false, log: true });
Expand Down Expand Up @@ -76,4 +79,40 @@ describe('options', () => {
expect(resolved.editorProtocol).toBe('cursor');
expect(resolved.darkMode).toBe('dark');
});

it('persists enabled state and reuses it as the next default', () => {
const storage = new Map<string, string>();
vi.stubGlobal('localStorage', {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => storage.set(key, value),
removeItem: (key: string) => storage.delete(key),
});

setResolvedOptions({ enabled: false });
expect(globalThis.localStorage.getItem('angular-render-scan:enabled')).toBe('false');

setResolvedOptions({ enabled: true });
globalThis.localStorage.setItem('angular-render-scan:enabled', 'false');
setResolvedOptions({ log: true });

expect(getResolvedOptions().enabled).toBe(false);
});

it('can prefer saved enabled state during startup', () => {
const storage = new Map<string, string>();
vi.stubGlobal('localStorage', {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => storage.set(key, value),
removeItem: (key: string) => storage.delete(key),
});

globalThis.localStorage.setItem('angular-render-scan:enabled', 'false');
setResolvedOptions({ enabled: true }, {
persistEnabled: false,
preferStoredEnabled: true,
});

expect(getResolvedOptions().enabled).toBe(false);
expect(globalThis.localStorage.getItem('angular-render-scan:enabled')).toBe('false');
});
});
50 changes: 47 additions & 3 deletions packages/angular-render-scan/src/domain/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ const defaultBudgets: Required<AngularRenderScanBudgets> = {
maxRendersPerSecond: 20
};

const STORAGE_ENABLED_KEY = 'angular-render-scan:enabled';

const defaultOptions: AngularRenderScanResolvedOptions = {
enabled: true,
showToolbar: true,
Expand Down Expand Up @@ -43,8 +45,23 @@ const defaultOptions: AngularRenderScanResolvedOptions = {

let options: AngularRenderScanResolvedOptions = { ...defaultOptions };

export function resolveOptions(next?: AngularRenderScanOptions): AngularRenderScanResolvedOptions {
interface ResolveOptionsConfig {
preferStoredEnabled?: boolean;
}

interface SetResolvedOptionsConfig extends ResolveOptionsConfig {
persistEnabled?: boolean;
}

export function resolveOptions(next?: AngularRenderScanOptions, config: ResolveOptionsConfig = {}): AngularRenderScanResolvedOptions {
const merged = { ...options, ...next } as AngularRenderScanResolvedOptions;
const storedEnabled = readStoredEnabled();

merged.enabled = config.preferStoredEnabled
? storedEnabled ?? (typeof next?.enabled === 'boolean' ? next.enabled : merged.enabled)
: typeof next?.enabled === 'boolean'
? next.enabled
: storedEnabled ?? merged.enabled;

if (!['slow', 'fast', 'off'].includes(merged.animationSpeed)) {
merged.animationSpeed = defaultOptions.animationSpeed;
Expand Down Expand Up @@ -72,8 +89,11 @@ export function resolveOptions(next?: AngularRenderScanOptions): AngularRenderSc
return merged;
}

export function setResolvedOptions(next: Partial<AngularRenderScanOptions>): AngularRenderScanResolvedOptions {
options = resolveOptions(next);
export function setResolvedOptions(next: Partial<AngularRenderScanOptions>, config: SetResolvedOptionsConfig = {}): AngularRenderScanResolvedOptions {
options = resolveOptions(next, config);
if (config.persistEnabled !== false && typeof next.enabled === 'boolean') {
writeStoredEnabled(options.enabled);
}
return options;
}

Expand All @@ -83,6 +103,30 @@ export function getResolvedOptions(): AngularRenderScanResolvedOptions {

export function resetOptionsForTest(): void {
options = { ...defaultOptions };
try {
globalThis.localStorage?.removeItem(STORAGE_ENABLED_KEY);
} catch {
// localStorage can be unavailable in non-browser test environments.
}
}

function readStoredEnabled(): boolean | undefined {
try {
const value = globalThis.localStorage?.getItem(STORAGE_ENABLED_KEY);
if (value === 'true') return true;
if (value === 'false') return false;
} catch {
// localStorage can be unavailable or blocked by the host app.
}
return undefined;
}

function writeStoredEnabled(enabled: boolean): void {
try {
globalThis.localStorage?.setItem(STORAGE_ENABLED_KEY, String(enabled));
} catch {
// Persisting preference should never break instrumentation.
}
}

function normalizeNonNegative(value: number, fallback: number): number {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { afterEach, describe, expect, it } from "vitest";
import { getAngularDebugSummary } from "./angular-debug";

class DemoComponent {}
class OwnerComponent {}
class DemoDirective {}
class RootComponent {}

describe("getAngularDebugSummary", () => {
afterEach(() => {
delete (window as Window & { ng?: unknown }).ng;
});

it("falls back when Angular debug globals are unavailable", () => {
const element = document.createElement("app-demo");

expect(getAngularDebugSummary(element)).toEqual({
available: false,
directiveNames: [],
listenerNames: [],
});
});

it("returns sanitized Angular debug data", () => {
const element = document.createElement("app-demo");
(window as Window & { ng?: unknown }).ng = {
getComponent: () => new DemoComponent(),
getOwningComponent: () => new OwnerComponent(),
getDirectives: () => [new DemoDirective()],
getListeners: () => [
{ name: "click", callback: () => undefined },
{ name: "valueChange", type: "output" },
],
getRootComponents: () => [new RootComponent()],
};

expect(getAngularDebugSummary(element)).toEqual({
available: true,
componentName: "DemoComponent",
ownerName: "OwnerComponent",
directiveNames: ["DemoDirective"],
listenerNames: ["click", "valueChange"],
rootName: "RootComponent",
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
export interface AngularDebugSummary {
available: boolean;
componentName?: string;
ownerName?: string;
directiveNames: string[];
listenerNames: string[];
rootName?: string;
}

type AngularDebugGlobals = {
getComponent?: (element: Element) => unknown;
getOwningComponent?: (elementOrDir: Element | object) => unknown;
getDirectives?: (node: Node) => unknown[];
getListeners?: (element: Element) => Array<{ name?: string; type?: string }>;
getRootComponents?: (elementOrDir: Element | object) => unknown[];
};

export function getAngularDebugSummary(element: Element): AngularDebugSummary {
const ng = getAngularGlobals();
if (!ng) {
return { available: false, directiveNames: [], listenerNames: [] };
}

const component = safeCall(() => ng.getComponent?.(element));
const owner = safeCall(() => ng.getOwningComponent?.(element));
const directives = safeCall(() => ng.getDirectives?.(element)) ?? [];
const listeners = safeCall(() => ng.getListeners?.(element)) ?? [];
const roots = safeCall(() => ng.getRootComponents?.(element)) ?? [];

return {
available: true,
componentName: nameOf(component),
ownerName: nameOf(owner),
directiveNames: directives.map(nameOf).filter(isPresent),
listenerNames: listeners
.map((listener) => listener.name || listener.type)
.filter(isPresent),
rootName: nameOf(roots[0]),
};
}

function getAngularGlobals(): AngularDebugGlobals | undefined {
if (typeof window === "undefined") {
return undefined;
}

const ng = (window as Window & { ng?: AngularDebugGlobals }).ng;
if (!ng || typeof ng !== "object") {
return undefined;
}

return ng;
}

function safeCall<T>(read: () => T): T | undefined {
try {
return read();
} catch {
return undefined;
}
}

function nameOf(value: unknown): string | undefined {
if (!value || (typeof value !== "object" && typeof value !== "function")) {
return undefined;
}

const candidate = value as { constructor?: { name?: string }; name?: string };
return candidate.constructor?.name || candidate.name;
}

function isPresent(value: string | undefined): value is string {
return Boolean(value);
}
Loading
Loading