From c428401913decb35ed2ef6f2d8c8f1881e6940d0 Mon Sep 17 00:00:00 2001 From: erKam Date: Sat, 7 Nov 2026 13:46:47 +0000 Subject: [PATCH 1/5] fix(overlay): ignore-list the console tap in shipped source maps The console tap wraps console.*, so browser DevTools named the wrapper as the caller of every console call in the app. The browser build now ships source maps whose x_google_ignoreList holds console-tap.ts (and node_modules, Rolldown's default), so the wrapper frame is skipped. A build test checks the emitted maps. Fixes #369 --- .../src/__tests__/build-entries.test.ts | 35 ++++++++++++++++++- packages/devtools/tsdown.config.ts | 12 +++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/packages/devtools/src/__tests__/build-entries.test.ts b/packages/devtools/src/__tests__/build-entries.test.ts index 08e3a8b2..9e8303ba 100644 --- a/packages/devtools/src/__tests__/build-entries.test.ts +++ b/packages/devtools/src/__tests__/build-entries.test.ts @@ -1,4 +1,4 @@ -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { pathToFileURL } from 'node:url'; @@ -131,6 +131,39 @@ describe('the real build', () => { expect(chunks.some((chunk) => chunk.fileName.startsWith('devframe'))).toBe(false); }, 60_000); + it('ignore-lists the console tap in the browser source maps, and nothing of ours besides', async () => { + const { outDir } = await buildInto(browserConfig, 'browser-maps'); + const maps = readdirSync(outDir) + .filter((file) => file.endsWith('.mjs.map')) + .map((file) => ({ + file, + map: JSON.parse(readFileSync(join(outDir, file), 'utf8')) as { + sources: string[]; + x_google_ignoreList?: number[]; + }, + })); + // Each chunk points to the map written next to it. + for (const { file } of maps) { + const chunk = file.slice(0, -'.map'.length); + expect(readFileSync(join(outDir, chunk), 'utf8'), chunk).toContain( + `//# sourceMappingURL=${file}`, + ); + } + const ignored = maps.flatMap(({ map }) => + (map.x_google_ignoreList ?? []).map((index) => map.sources[index]!), + ); + const tap = maps.filter(({ map }) => map.sources.some((s) => s.endsWith('/console-tap.ts'))); + expect(tap.length).toBeGreaterThan(0); + for (const { map } of tap) { + const index = map.sources.findIndex((s) => s.endsWith('/console-tap.ts')); + expect(map.x_google_ignoreList).toContain(index); + } + // The overlay's own frames stay visible. + expect(ignored.filter((s) => !s.includes('node_modules'))).toEqual( + tap.map(({ map }) => map.sources.find((s) => s.endsWith('/console-tap.ts'))), + ); + }, 60_000); + it('fails the browser build when an entry imports a Node builtin', async () => { const fixture = join(work, 'uses-node.ts'); writeFileSync( diff --git a/packages/devtools/tsdown.config.ts b/packages/devtools/tsdown.config.ts index e6e92305..aa65438d 100644 --- a/packages/devtools/tsdown.config.ts +++ b/packages/devtools/tsdown.config.ts @@ -18,6 +18,14 @@ const shared = { outExtensions: () => ({ js: '.mjs', dts: '.d.mts' }), } satisfies UserConfig; +/** + * Sources the browser source maps put in their ignore list, so DevTools skips + * their frames in stack traces and console call sites. The console tap wraps + * `console.*`, and without this every `console.log` in the app names the + * wrapper as its caller (#369). `node_modules` is Rolldown's default. + */ +export const IGNORED_SOURCES = /node_modules|[\\/]console-tap\.ts$/; + const nodeExternal = [/^@angular\/(?!compiler$)/, /^@nativescript\//, /^rxjs/]; /** @@ -29,6 +37,10 @@ export const browserConfig = { name: 'browser', entry: BROWSER_ENTRIES, platform: 'browser', + // Source maps ship with the browser entries, so the browser, or the app's + // bundler, reads the ignore list. + sourcemap: true, + outputOptions: { sourcemapIgnoreList: IGNORED_SOURCES }, hooks: { 'build:done': ({ chunks }) => assertBrowserSafe(chunks), }, From 3228063ebc5a1b8af773d0977c173073279b3bd9 Mon Sep 17 00:00:00 2001 From: erKam Date: Sat, 7 Nov 2026 13:46:58 +0000 Subject: [PATCH 2/5] fix(overlay): gate the injector profiler for Angular Native Angular wires its injector profiler only when window exists as the platform is created. The NativeScript overlay defined window until Angular published its utilities; the Angular Native overlay did not. The gate moves to injector-profiler-gate.ts, and the Angular Native module runs it as it loads in a React Native development build (__DEV__ true), since the overlay itself starts after mount(). It does nothing when window or ng already exists. The module is listed in sideEffects. Fixes #371 --- .../content/getting-started/angular-native.md | 2 +- packages/devtools/package.json | 4 +- .../__tests__/injector-profiler-gate.test.ts | 119 ++++++++++++++++++ .../devtools/src/injector-profiler-gate.ts | 64 ++++++++++ .../devtools/src/overlay-angular-native.ts | 4 + packages/devtools/src/overlay-nativescript.ts | 50 +------- 6 files changed, 192 insertions(+), 51 deletions(-) create mode 100644 packages/devtools/src/__tests__/injector-profiler-gate.test.ts create mode 100644 packages/devtools/src/injector-profiler-gate.ts diff --git a/apps/docs/src/content/getting-started/angular-native.md b/apps/docs/src/content/getting-started/angular-native.md index c9644537..681ddee9 100644 --- a/apps/docs/src/content/getting-started/angular-native.md +++ b/apps/docs/src/content/getting-started/angular-native.md @@ -108,7 +108,7 @@ The hook does nothing when `ngDevMode` is off. Without it, only stores created a Check the Metro log for a [pangular] line. A warning about --no-auth means the server asked for a code. A warning about reaching the server means the address is wrong for where the app runs. See where the overlay connects. A release build has no ng global, so nothing is collected there. - Angular records providers only when a window global exists as mount() creates the platform. If the list stays empty, check that window is defined before mount() runs. + Angular records providers only when a window global exists as mount() creates the platform. When __DEV__ is true and no window exists, importing @pangular-inspector/devtools/overlay-angular-native defines one until Angular publishes its debug utilities, then removes it. Keep the import at the top of src/main.ts, so it runs before mount(). If the list stays empty, check that window is defined before mount() runs. diff --git a/packages/devtools/package.json b/packages/devtools/package.json index 80624dea..c3d6fad5 100644 --- a/packages/devtools/package.json +++ b/packages/devtools/package.json @@ -19,11 +19,13 @@ "./dist/capacitor-hook.mjs", "./dist/store-hook.mjs", "./dist/startup-hook.mjs", + "./dist/overlay-angular-native.mjs", "./src/overlay-auto.ts", "./src/popup.ts", "./src/capacitor-hook.ts", "./src/store-hook.ts", - "./src/startup-hook.ts" + "./src/startup-hook.ts", + "./src/overlay-angular-native.ts" ], "bin": { "pangular": "./bin.mjs" diff --git a/packages/devtools/src/__tests__/injector-profiler-gate.test.ts b/packages/devtools/src/__tests__/injector-profiler-gate.test.ts new file mode 100644 index 00000000..02f55be1 --- /dev/null +++ b/packages/devtools/src/__tests__/injector-profiler-gate.test.ts @@ -0,0 +1,119 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + gateInjectorProfiler, + gateInjectorProfilerInReactNativeDev, +} from '../injector-profiler-gate.ts'; + +const g = globalThis as Record; + +/** What `publishDefaultGlobalUtils` and `publishUtil` in `@angular/core` do, in that order. */ +function bootAngular(): { profiler: boolean } { + const profiler = typeof g['window'] !== 'undefined'; + const ng = (g['ng'] ??= {}) as Record; + ng['getComponent'] = () => null; + ng['getInjector'] = () => null; + return { profiler }; +} + +function clean() { + delete g['window']; + delete g['ng']; + delete g['__DEV__']; +} + +describe('gateInjectorProfiler', () => { + beforeEach(() => { + // The tests run in Node, which has no `window`, like a native runtime. + expect('window' in g).toBe(false); + clean(); + }); + afterEach(clean); + + it('defines window until Angular publishes getComponent, then removes it', () => { + gateInjectorProfiler(); + expect(g['window']).toBe(globalThis); + expect(bootAngular().profiler).toBe(true); + expect('window' in g).toBe(false); + expect(typeof (g['ng'] as Record)['getComponent']).toBe('function'); + expect(typeof (g['ng'] as Record)['getInjector']).toBe('function'); + }); + + it('keeps window while the router publishes onto ng first', () => { + gateInjectorProfiler(); + g['ng'] ??= {}; + (g['ng'] as Record)['ɵgetLoadedRoutes'] = () => []; + expect(g['window']).toBe(globalThis); + expect(bootAngular().profiler).toBe(true); + expect('window' in g).toBe(false); + expect((g['ng'] as Record)['ɵgetLoadedRoutes']).toBeTypeOf('function'); + }); + + it('removes window when ng arrives with the utilities already on it', () => { + gateInjectorProfiler(); + const getComponent = () => null; + g['ng'] = { getComponent }; + expect('window' in g).toBe(false); + expect((g['ng'] as Record)['getComponent']).toBe(getComponent); + }); + + it('does nothing when window already exists', () => { + const window = { own: true }; + g['window'] = window; + gateInjectorProfiler(); + expect(Object.getOwnPropertyDescriptor(g, 'ng')).toBeUndefined(); + bootAngular(); + expect(g['window']).toBe(window); + }); + + it('does nothing once Angular has published ng', () => { + g['ng'] = { getComponent: () => null }; + gateInjectorProfiler(); + expect('window' in g).toBe(false); + }); +}); + +describe('gateInjectorProfilerInReactNativeDev', () => { + beforeEach(clean); + afterEach(clean); + + it('gates in a React Native development build', () => { + g['__DEV__'] = true; + gateInjectorProfilerInReactNativeDev(); + expect(bootAngular().profiler).toBe(true); + expect('window' in g).toBe(false); + }); + + it('leaves a release build and other runtimes alone', () => { + g['__DEV__'] = false; + gateInjectorProfilerInReactNativeDev(); + delete g['__DEV__']; + gateInjectorProfilerInReactNativeDev(); + expect('window' in g).toBe(false); + expect(bootAngular().profiler).toBe(false); + }); +}); + +describe('the Angular Native overlay', () => { + beforeEach(() => { + clean(); + vi.resetModules(); + }); + afterEach(clean); + + it('gates the injector profiler as its module loads, before mount()', async () => { + g['__DEV__'] = true; + await import('../overlay-angular-native.ts'); + expect(g['window']).toBe(globalThis); + expect(bootAngular().profiler).toBe(true); + expect('window' in g).toBe(false); + }); + + it('is a no-op when window exists', async () => { + g['__DEV__'] = true; + const window = { own: true }; + g['window'] = window; + await import('../overlay-angular-native.ts'); + expect(Object.getOwnPropertyDescriptor(g, 'ng')).toBeUndefined(); + expect(g['window']).toBe(window); + }); +}); diff --git a/packages/devtools/src/injector-profiler-gate.ts b/packages/devtools/src/injector-profiler-gate.ts new file mode 100644 index 00000000..ff60d629 --- /dev/null +++ b/packages/devtools/src/injector-profiler-gate.ts @@ -0,0 +1,64 @@ +/** + * Angular wires its injector profiler, which backs the provider lists the DI + * inspector shows, only when `window` exists as the platform is created + * (`publishDefaultGlobalUtils` in `@angular/core`). A native runtime may have + * no `window`, so one is defined here and removed once the profiler is in + * place. The moment is read off the `ng` global: core publishes its utilities + * right after wiring the profiler, and `getComponent` is the sentinel rather + * than the `ng` object itself because the router publishes its own utilities + * onto that object earlier, while `provideRouter()` evaluates. + * + * Does nothing when `window` or `ng` already exists: the profiler is wired, or + * the platform was created without it and it is too late. + */ +export function gateInjectorProfiler(): void { + const g = globalThis as Record; + if ('window' in g || 'ng' in g) return; + Object.defineProperty(g, 'window', { configurable: true, get: () => g }); + const removeWindow = () => { + if (Object.getOwnPropertyDescriptor(g, 'window')?.get) delete g['window']; + }; + Object.defineProperty(g, 'ng', { + configurable: true, + enumerable: true, + get: () => undefined, + set(value: unknown) { + Object.defineProperty(g, 'ng', { + configurable: true, + enumerable: true, + writable: true, + value, + }); + // Not Angular's empty object, or one that already holds the core utilities. + if (!value || typeof value !== 'object' || 'getComponent' in value) { + removeWindow(); + return; + } + Object.defineProperty(value, 'getComponent', { + configurable: true, + enumerable: true, + get: () => undefined, + set(fn: unknown) { + Object.defineProperty(value, 'getComponent', { + configurable: true, + enumerable: true, + writable: true, + value: fn, + }); + removeWindow(); + }, + }); + }, + }); +} + +/** + * The gate for Angular Native, run as its overlay module loads. The overlay + * itself starts after `mount()`, once the platform exists, but `src/main.ts` + * imports the module before `mount()` runs. Only in a React Native development + * build (`__DEV__` is `true`): a release build never publishes `ng`, so the + * stand-in `window` would stay. + */ +export function gateInjectorProfilerInReactNativeDev(): void { + if ((globalThis as { __DEV__?: unknown }).__DEV__ === true) gateInjectorProfiler(); +} diff --git a/packages/devtools/src/overlay-angular-native.ts b/packages/devtools/src/overlay-angular-native.ts index cc10a845..d56cacee 100644 --- a/packages/devtools/src/overlay-angular-native.ts +++ b/packages/devtools/src/overlay-angular-native.ts @@ -9,6 +9,7 @@ import { setRedaction } from './forms-privacy.ts'; import { legacyRedaction, serverRedaction } from './overlay-redaction.ts'; import { hostBySelector } from './host-tree.ts'; import { collectInjectorTree } from './injector-tree.ts'; +import { gateInjectorProfilerInReactNativeDev } from './injector-profiler-gate.ts'; import { attachNativeDiagnostics, errorUtilsTarget, nativeDebugApi } from './native-diagnostics.ts'; import { attachNgrx } from './ngrx-overlay.ts'; import { installReduxDevtoolsHook, uninstallReduxDevtoolsHook } from './redux-devtools-hook.ts'; @@ -36,6 +37,9 @@ import { claimPageOnServer, newPageId, newPageKey } from './page-id.ts'; export type { AngularNativeNode } from './overlay-angular-native-views.ts'; +// Before `mount()` creates the platform, so Angular wires its injector profiler. +gateInjectorProfilerInReactNativeDev(); + type DebugNg = SignalDebugNg & ComponentDebugNg; type Rpc = Awaited>; diff --git a/packages/devtools/src/overlay-nativescript.ts b/packages/devtools/src/overlay-nativescript.ts index 21a7ad9c..1eaaf780 100644 --- a/packages/devtools/src/overlay-nativescript.ts +++ b/packages/devtools/src/overlay-nativescript.ts @@ -18,6 +18,7 @@ import { setRedaction } from './forms-privacy.ts'; import { legacyRedaction, serverRedaction } from './overlay-redaction.ts'; import { hostBySelector } from './host-tree.ts'; import { collectInjectorTree } from './injector-tree.ts'; +import { gateInjectorProfiler } from './injector-profiler-gate.ts'; import { applicationErrorsTarget, attachNativeDiagnostics, @@ -498,55 +499,6 @@ function clearFlash() { view.borderColor = borderColor; } -/** - * Angular wires its injector profiler, which backs the provider lists the DI - * inspector shows, only when `window` exists as the platform is created, so a - * `window` is defined here and removed once the profiler is in place. The - * moment is read off the `ng` global: core publishes `getComponent` right - * after wiring the profiler, and `getComponent` is the sentinel rather than - * the `ng` object itself because the router publishes its own utilities onto - * that object earlier, while `provideRouter()` evaluates. - */ -function gateInjectorProfiler() { - const g = globalThis as Record; - if ('window' in g || 'ng' in g) return; - Object.defineProperty(g, 'window', { configurable: true, get: () => g }); - const removeWindow = () => { - if (Object.getOwnPropertyDescriptor(g, 'window')?.get) delete g['window']; - }; - Object.defineProperty(g, 'ng', { - configurable: true, - enumerable: true, - get: () => undefined, - set(value: unknown) { - Object.defineProperty(g, 'ng', { - configurable: true, - enumerable: true, - writable: true, - value, - }); - if (!value || typeof value !== 'object') { - removeWindow(); - return; - } - Object.defineProperty(value, 'getComponent', { - configurable: true, - enumerable: true, - get: () => undefined, - set(fn: unknown) { - Object.defineProperty(value, 'getComponent', { - configurable: true, - enumerable: true, - writable: true, - value: fn, - }); - removeWindow(); - }, - }); - }, - }); -} - /** * `devframe/client` reads `location` and `navigator` as bare globals when it * resolves the socket URL and identifies the client. A NativeScript runtime has From f5db025f0a829b0a8b5ba8376e6c34b647a23e39 Mon Sep 17 00:00:00 2001 From: erKam Date: Sat, 7 Nov 2026 13:47:11 +0000 Subject: [PATCH 3/5] perf(store): serialize only the NgRx stores that changed attachNgrx serialized every store and ran JSON.stringify over the whole report on each check to find out nothing changed. The collector now keeps each signal store's and the classic Store's report with the raw values it came from (state slices, computed values, method stats, references, the redaction epoch) and rebuilds it only when one changed. collect() returns a version that goes up when the report changed, and the overlay compares it with the version it last sent. The report itself is unchanged. Adds the changelog lines for #368, #369 and #371. Fixes #368 --- packages/devtools/CHANGELOG.md | 3 + .../src/__tests__/ngrx-report-memo.test.ts | 193 ++++++++++++++++++ packages/devtools/src/ngrx-collector.ts | 131 +++++++----- packages/devtools/src/ngrx-overlay.ts | 16 +- packages/devtools/src/ngrx-report-memo.ts | 60 ++++++ 5 files changed, 349 insertions(+), 54 deletions(-) create mode 100644 packages/devtools/src/__tests__/ngrx-report-memo.test.ts create mode 100644 packages/devtools/src/ngrx-report-memo.ts diff --git a/packages/devtools/CHANGELOG.md b/packages/devtools/CHANGELOG.md index 30d35eca..bd855c00 100644 --- a/packages/devtools/CHANGELOG.md +++ b/packages/devtools/CHANGELOG.md @@ -181,6 +181,7 @@ What remains: `/config` exports `DEFAULT_POPUP_HOTKEY`, `PANGULAR_ACTIONS`, `PAN - Pages push deltas instead of full snapshots, at most about once a second, and idle pages send only a small heartbeat. - NgRx store updates no longer slow down as the store grows (#432). +- The NgRx collector serializes a signal store or the classic `Store` only when its state, computed values or method stats changed, and the overlay compares a report version instead of the serialized report, so an unchanged store costs no serialization on each check (#368). - Collectors schedule their work outside the Angular zone, so an app that writes a signal or logs during change detection settles again on zone.js. - Long lists and trees in the panel render only the visible rows. - The agent tool list is much smaller: shorter descriptions, and library tools only for apps that use them. @@ -221,6 +222,8 @@ What remains: `/config` exports `DEFAULT_POPUP_HOTKEY`, `PANGULAR_ACTIONS`, `PAN - The **Forms** tab shows the forms setup errors Angular logs on the page, such as `NG01203`, in a notice above the list (#368). - The **Forms** timeline tags a change as **agent** only when a WebMCP call wrote it, or when it is in the call's own form while the call runs (#368). Typing in any form while a long WebMCP submit is pending is tagged **user**, not **agent**. - The **Pipes** tab no longer marks two `| async` usages of a Promise, a Subject, an `asObservable()` view of one or an NgRx store select as duplicates, since their subscribers share one execution (#369). Any other source that two `| async` usages subscribe to shows as **possible duplicate subscription**, and `explain-pipe` says **Possible duplicate subscriptions**, because the page can't see `share()` or `shareReplay()` in a pipe. +- Browser DevTools show the app's own line as the caller of a `console` call again. The browser entries ship source maps that list the console tap in `x_google_ignoreList`, so its wrapper frame is skipped (#369). +- The Angular Native overlay gates the injector profiler like the NativeScript overlay: in a development build with no `window`, importing it defines one until Angular publishes its debug utilities, so the **Providers** lists fill (#371). ### Docs diff --git a/packages/devtools/src/__tests__/ngrx-report-memo.test.ts b/packages/devtools/src/__tests__/ngrx-report-memo.test.ts new file mode 100644 index 00000000..73072265 --- /dev/null +++ b/packages/devtools/src/__tests__/ngrx-report-memo.test.ts @@ -0,0 +1,193 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it } from 'vitest'; +import { setRedaction } from '../forms-privacy.ts'; +import { domTree } from '../host-tree.ts'; +import { createNgrxCollector } from '../ngrx-collector.ts'; +import { attachNgrx } from '../ngrx-overlay.ts'; +import type { NgrxPageReport } from '../ngrx-shared.ts'; + +const SIGNAL = Symbol('SIGNAL'); +const STATE_SOURCE = Symbol('STATE_SOURCE'); + +function writable(initial: T) { + const node = { value: initial }; + const getter = (() => node.value) as (() => T) & { set(v: T): void }; + (getter as unknown as Record)[SIGNAL] = node; + getter.set = (v: T) => { + node.value = v; + }; + return getter; +} + +function computedOf(fn: () => T) { + const getter = () => fn(); + (getter as unknown as Record)[SIGNAL] = { computation: fn }; + return getter; +} + +/** Counts how often serialization reads the item, which only serializing it does. */ +let reads = 0; +function item(label: string) { + return { + get label() { + reads++; + return label; + }, + }; +} + +class Store { + [STATE_SOURCE] = { query: writable(''), items: writable([item('a')]) }; + query = computedOf(() => this[STATE_SOURCE].query()); + items = computedOf(() => this[STATE_SOURCE].items()); + count = computedOf(() => this[STATE_SOURCE].items().length); + setQuery = (query: string) => this[STATE_SOURCE].query.set(query); +} + +/** A page with a signal store on its root component and a classic Store in the root injector. */ +function setup() { + document.body.innerHTML = ''; + const root = document.querySelector('app-root')!; + const store = new Store(); + let state: Record = { todos: [item('t')] }; + class _Store { + source = { getValue: () => state }; + dispatch() {} + select() {} + } + class ScannedActionsSubject { + subscribe() { + return { unsubscribe: () => {} }; + } + } + const values = new Map([ + [_Store, new _Store()], + [ScannedActionsSubject, new ScannedActionsSubject()], + [Store, store], + ]); + const rootEnv = { + scopes: new Set(['root']), + records: new Map([...values.keys()].map((k) => [k, { value: values.get(k) }])), + get: (token: unknown) => values.get(token) ?? null, + }; + const node = { get: (token: unknown) => values.get(token) ?? null }; + const app = { store }; + const ng = { + getInjector: () => node, + getComponent: (el: Element) => (el === root ? app : null), + ɵgetInjectorMetadata: (inj: unknown) => + inj === rootEnv ? { type: 'environment', source: 'R3Injector' } : { type: 'element' }, + ɵgetInjectorResolutionPath: () => [node, rootEnv], + ɵgetInjectorProviders: () => [], + }; + return { + ng, + store, + setState: (next: Record) => (state = next), + }; +} + +afterEach(() => setRedaction()); + +describe('NgRx report change tracking', () => { + it('reuses an unchanged signal store and classic store without serializing them', () => { + const { ng } = setup(); + const collector = createNgrxCollector( + () => ng as never, + () => {}, + domTree(), + ); + const first = collector.collect(); + expect(first.stores).toHaveLength(1); + expect(first.stores[0]!.state).toEqual({ query: '', items: [{ label: 'a' }] }); + expect(first.stores[0]!.computed).toEqual({ count: 1 }); + expect(first.classic).toEqual({ + state: { todos: [{ label: 't' }] }, + devtools: false, + scope: 'root', + }); + const readsAfterFirst = reads; + expect(readsAfterFirst).toBeGreaterThan(0); + + const second = collector.collect(); + expect(reads).toBe(readsAfterFirst); + expect(second.version).toBe(first.version); + expect(second.stores[0]).toBe(first.stores[0]); + expect(second.classic).toBe(first.classic); + }); + + it('rebuilds only the store that changed, and moves the version', () => { + const { ng, store, setState } = setup(); + const collector = createNgrxCollector( + () => ng as never, + () => {}, + domTree(), + ); + const first = collector.collect(); + + store.setQuery('b'); + const second = collector.collect(); + expect(second.version).toBeGreaterThan(first.version); + expect(second.stores[0]!.state).toMatchObject({ query: 'b' }); + expect(second.classic).toBe(first.classic); + + setState({ todos: [] }); + const third = collector.collect(); + expect(third.version).toBeGreaterThan(second.version); + expect(third.classic!.state).toEqual({ todos: [] }); + expect(third.stores[0]).toBe(second.stores[0]); + }); + + it('rebuilds when the redaction config changes what the report shows', () => { + const { ng } = setup(); + const collector = createNgrxCollector( + () => ng as never, + () => {}, + domTree(), + ); + const first = collector.collect(); + expect(first.stores[0]!.state['query']).toBe(''); + setRedaction({ secretNames: ['query'] }); + const second = collector.collect(); + expect(second.version).toBeGreaterThan(first.version); + expect(second.stores[0]).not.toBe(first.stores[0]); + expect(second.stores[0]!.state['query']).not.toBe(''); + }); + + it('skips the push of an unchanged page without serializing it, and pushes a change', async () => { + const { ng, store } = setup(); + const reports: NgrxPageReport[] = []; + const my = { + rpc: { + call: async (name: string, report: unknown) => { + if (name !== 'push-ngrx-state') return undefined; + reports.push(report as NgrxPageReport); + return { seq: 0 }; + }, + register: () => {}, + }, + }; + const overlay = attachNgrx(my, 'p1', () => ng as never, undefined, { + describe: () => ({ url: '/', title: 'Test' }), + }); + try { + await overlay.push(); + expect(reports).toHaveLength(1); + expect(reports[0]).toMatchObject({ pageId: 'p1', url: '/', title: 'Test' }); + const readsAfterFirst = reads; + + await overlay.push(); + await overlay.push(); + expect(reports).toHaveLength(1); + expect(reads).toBe(readsAfterFirst); + + store.setQuery('changed'); + await overlay.push(); + expect(reports).toHaveLength(2); + expect(reports[1]!.stores[0]!.state).toMatchObject({ query: 'changed' }); + expect(reports[1]!.classic).toEqual(reports[0]!.classic); + } finally { + overlay.stop(); + } + }); +}); diff --git a/packages/devtools/src/ngrx-collector.ts b/packages/devtools/src/ngrx-collector.ts index a9ca35e3..6f38517f 100644 --- a/packages/devtools/src/ngrx-collector.ts +++ b/packages/devtools/src/ngrx-collector.ts @@ -24,6 +24,7 @@ import { registeredPatchState, registeredWatchState } from './ngrx-register.ts'; import { historyRequest, type HistorySource } from './ngrx-history.ts'; import { markSecretWriter, safeArgs, touchesSecret } from './ngrx-args.ts'; import { SliceCache } from './ngrx-slice-cache.ts'; +import { ReportMemo, ReportVersion } from './ngrx-report-memo.ts'; /** * Angular's `untracked` (16.0+), read at runtime: a named import of it fails @@ -147,6 +148,9 @@ const MAX_LOG = 200; const MAX_DIFF = 50; const NOOP_GAP_MS = 1000; const SMALL = { depth: 4, maxKeys: 20, maxString: 300, budget: 400 }; +/** A computed that threw when read, and what the report shows for it. */ +const UNREADABLE = Symbol('unreadable'); +const COULD_NOT_READ = { '@type': 'Error', message: 'Could not read' } as const; // Entity ids are typically short primitives; allow more of them through than SMALL // so `count` (from the raw array) and the sampled `ids` stay close for most stores. const ENTITY_IDS_SMALL = { depth: 1, maxKeys: 500, maxString: 200, budget: 3000 }; @@ -293,6 +297,8 @@ export interface NgrxCollector { collect(rediscover?: boolean): { stores: NgrxSignalStoreInfo[]; classic: NgrxClassicStoreInfo | null; + /** Goes up when `stores` or `classic` changed since the previous collect. */ + version: number; }; logSince(seq: number): NgrxLogEntry[]; lastSeq(): number; @@ -332,6 +338,9 @@ export function createNgrxCollector( const saved = new Map(); let seq = 0; let classic: Classic | null = null; + const storeReports = new ReportMemo(); + const classicReports = new ReportMemo(); + const reportVersion = new ReportVersion(); let classicMisses = 0; /** Environment injectors already searched for a classic Store. */ const classicEnvs = new WeakSet(); @@ -909,15 +918,6 @@ export function createNgrxCollector( return fromSource !== undefined ? fromSource : syncValue(store); }; - /** - * The serialized classic state. `last` already holds `serialize(lastRaw)`, so the - * state is serialized again only when the store emitted a new object since then. - */ - const classicSnapshot = (c: Classic): unknown => { - const raw = classicState(c.store); - return Object.is(raw, c.lastRaw) ? c.last : serialize(raw); - }; - const logClassic = ( c: Classic, type: string, @@ -1304,27 +1304,53 @@ export function createNgrxCollector( return out.length ? out : undefined; }; - const report = () => { - const stores = [...tracked.values()].map((t): NgrxSignalStoreInfo => { - const state: Record = {}; - for (const key of t.stateKeys) { - state[String(key)] = serializeSlice(key, peek(t.source[key])); + /** + * One signal store's report, rebuilt only when a raw value it is made of + * changed (`ngrx-report-memo.ts`), so an unchanged store is not serialized. + */ + const storeInfo = (t: Tracked): NgrxSignalStoreInfo => { + const stateRaw = t.stateKeys.map((key) => peek(t.source[key])); + const computedRaw: [string, unknown][] = []; + if (t.kind === 'signal-store') { + const stateNames = new Set(t.stateKeys.map(String)); + for (const key of Object.keys(t.instance)) { + if (stateNames.has(key)) continue; + const member = (t.instance as AnyRecord)[key]; + if (!isSignal(member)) continue; + computedRaw.push([key, read(() => untracked(member as () => unknown), UNREADABLE)]); } + } + const methods = [...t.methods].map(([name, info]) => ({ + name, + calls: info.calls, + ...(info.rx ? { rx: true } : {}), + ...(info.lastDurationMs !== undefined ? { lastDurationMs: info.lastDurationMs } : {}), + ...(info.timedCalls > 0 + ? { avgDurationMs: Math.round(info.totalDurationMs / info.timedCalls) } + : {}), + })); + const references = [...t.references].sort(); + const key = [ + t.stateKeys, + t.scope, + t.className, + t.writable, + JSON.stringify(methods), + references.join('\n'), + ...stateRaw, + ...computedRaw.flat(), + ]; + return storeReports.get(t, key, () => { + const state: Record = {}; + t.stateKeys.forEach((name, i) => { + state[String(name)] = serializeSlice(name, stateRaw[i]); + }); const computed: Record = {}; - if (t.kind === 'signal-store') { - const stateNames = new Set(t.stateKeys.map(String)); - for (const key of Object.keys(t.instance)) { - if (stateNames.has(key)) continue; - const member = (t.instance as AnyRecord)[key]; - if (!isSignal(member)) continue; - computed[key] = read( - () => serializeSlice(key, untracked(member as () => unknown), { budget: 5000 }), - { - '@type': 'Error', - message: 'Could not read', - }, - ); - } + for (const [name, raw] of computedRaw) { + computed[name] = + raw === UNREADABLE + ? { ...COULD_NOT_READ } + : read(() => serializeSlice(name, raw, { budget: 5000 }), { ...COULD_NOT_READ }); } const entities = t.kind === 'signal-store' ? entitiesOf(t, new Set(Object.keys(computed))) : undefined; @@ -1337,36 +1363,47 @@ export function createNgrxCollector( state, computed, ...(entities ? { entities } : {}), - methods: [...t.methods].map(([name, info]) => ({ - name, - calls: info.calls, - ...(info.rx ? { rx: true } : {}), - ...(info.lastDurationMs !== undefined ? { lastDurationMs: info.lastDurationMs } : {}), - ...(info.timedCalls > 0 - ? { avgDurationMs: Math.round(info.totalDurationMs / info.timedCalls) } - : {}), - })), - references: [...t.references].sort(), + methods, + references, writable: t.writable, }; }); + }; + + /** The classic store's report, rebuilt only when its state or flags changed. */ + const classicInfo = (c: Classic): NgrxClassicStoreInfo => { + const raw = classicState(c.store); + const paused = isPaused(c.devtools); + const key = [raw, c.lastRaw, c.last, !!c.devtools, c.scope, paused]; + return classicReports.get(c, key, () => ({ + // `last` already holds `serialize(lastRaw)`, so the state is serialized + // again only when the store emitted a new object since then. + state: Object.is(raw, c.lastRaw) ? c.last : serialize(raw), + devtools: !!c.devtools, + scope: c.scope, + ...(paused ? { paused: true } : {}), + })); + }; + const report = () => { + const stores = [...tracked.values()].map(storeInfo); + const classicReport = classic ? classicInfo(classic) : null; return { stores, - classic: classic - ? { - state: classicSnapshot(classic), - devtools: !!classic.devtools, - scope: classic.scope, - ...(isPaused(classic.devtools) ? { paused: true } : {}), - } - : null, + classic: classicReport, + version: reportVersion.stamp([...stores, classicReport]), }; }; const collect = (rediscover = true) => { const ng = getNg(); - if (!ng?.getInjector) return { stores: [] as NgrxSignalStoreInfo[], classic: null }; + if (!ng?.getInjector) { + return { + stores: [] as NgrxSignalStoreInfo[], + classic: null, + version: reportVersion.stamp([]), + }; + } if (rediscover || !discovered) { discover(ng); discovered = true; diff --git a/packages/devtools/src/ngrx-overlay.ts b/packages/devtools/src/ngrx-overlay.ts index e1df42df..59263d18 100644 --- a/packages/devtools/src/ngrx-overlay.ts +++ b/packages/devtools/src/ngrx-overlay.ts @@ -36,7 +36,8 @@ export function attachNgrx( let sentSeq = 0; let sentLost = 0; let sentMasked = 0; - let lastBody = ''; + /** The collector's report version last sent, or null when nothing was (or the send failed). */ + let sentVersion: number | null = null; let lastPushAt = 0; let timer: ReturnType | undefined; let pushing = false; @@ -63,15 +64,16 @@ export function attachNgrx( if (pushing) return schedule(rediscover); pushing = true; try { - const { stores, classic } = collector.collect(rediscover); + const { stores, classic, version } = collector.collect(rediscover); const log = collector.logSince(sentSeq); const lost = collector.unrestorableSince(sentLost); const masked = collector.argsRedactedSince(sentMasked); - if (!stores.length && !classic && !log.length && !lastBody) return; - const body = JSON.stringify({ stores, classic }); + if (!stores.length && !classic && !log.length && sentVersion === null) return; const quiet = !log.length && !lost.updates.length && !masked.seqs.length; - if (quiet && body === lastBody && !keepaliveAfter(lastPushAt, HEARTBEAT_MS)) return; - lastBody = body; + // The version goes up only when a store's report changed, so an unchanged + // page costs no serialization here (#368). + if (quiet && version === sentVersion && !keepaliveAfter(lastPushAt, HEARTBEAT_MS)) return; + sentVersion = version; lastPushAt = Date.now(); const page = options.describe?.() ?? { url: redactUrl(location.pathname + location.search), @@ -102,7 +104,7 @@ export function attachNgrx( } } catch { // Not sent: the next push sends it again, as the heartbeat leaves no keepalive. - lastBody = ''; + sentVersion = null; return; } finally { pushing = false; diff --git a/packages/devtools/src/ngrx-report-memo.ts b/packages/devtools/src/ngrx-report-memo.ts new file mode 100644 index 00000000..2dcedf0a --- /dev/null +++ b/packages/devtools/src/ngrx-report-memo.ts @@ -0,0 +1,60 @@ +import { redactionEpoch } from './forms-privacy.ts'; +import { knownSecretsVersion } from './text-redact.ts'; + +/** + * Change tracking for the NgRx report, so a store whose state did not change + * is not serialized again on every check (#368). + * + * Each store's report is kept with the raw values it was built from: the + * state slices, the computed values, and the method stats and references. NgRx + * state is immutable (`@ngrx/signals` freezes it in development), so a new + * slice is a new object, and an unchanged store gives the same raw values, + * compared with `Object.is`. The redaction config and the known secret values + * are part of the key, since they change what the serialized report holds. + */ +export class ReportMemo { + private readonly entries = new WeakMap(); + + /** The report of `owner` built from `key`, built again only when `key` changed. */ + get(owner: object, key: readonly unknown[], build: () => T): T { + const epoch = serializationEpoch(); + // Outside `withRedactionScope` the epoch is unknown, so nothing is kept. + if (epoch === null) return build(); + const full = [epoch, ...key]; + const hit = this.entries.get(owner); + if (hit && sameKey(hit.key, full)) return hit.value; + const value = build(); + this.entries.set(owner, { key: full, value }); + return value; + } +} + +/** + * A number that goes up whenever the report changes: a store's report was + * rebuilt, a store came or went, or the classic store's report changed. The + * overlay compares it with the version it last sent instead of comparing the + * serialized report. + */ +export class ReportVersion { + private version = 0; + private parts: readonly unknown[] = []; + + stamp(parts: readonly unknown[]): number { + if (!sameKey(this.parts, parts)) { + this.version++; + this.parts = parts; + } + return this.version; + } +} + +function serializationEpoch(): string | null { + const epoch = redactionEpoch(); + return epoch === null ? null : `${epoch}/${knownSecretsVersion()}`; +} + +function sameKey(a: readonly unknown[], b: readonly unknown[]): boolean { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) if (!Object.is(a[i], b[i])) return false; + return true; +} From 8f633cd5e1067ba5fa4141152436f954eb855cb5 Mon Sep 17 00:00:00 2001 From: erKam Date: Sat, 7 Nov 2026 14:32:03 +0000 Subject: [PATCH 4/5] fix(overlay): gate the injector profiler after the router publishes ng An app.config.ts imported above the Angular Native overlay runs provideRouter() first, which creates ng without getComponent. The gate returned early on any ng, so the platform was created without the injector profiler. It now gates on that ng too and waits for getComponent on the same object. --- .../docs/src/content/inspectors/ngrx-store.md | 4 ++ packages/devtools/CHANGELOG.md | 2 +- .../__tests__/injector-profiler-gate.test.ts | 11 ++++ .../devtools/src/injector-profiler-gate.ts | 52 ++++++++++++------- 4 files changed, 50 insertions(+), 19 deletions(-) diff --git a/apps/docs/src/content/inspectors/ngrx-store.md b/apps/docs/src/content/inspectors/ngrx-store.md index ae9b68ae..f66506dd 100644 --- a/apps/docs/src/content/inspectors/ngrx-store.md +++ b/apps/docs/src/content/inspectors/ngrx-store.md @@ -288,6 +288,10 @@ Angular creates a `signalStore` the first time something injects it. Open a page Use `provideStore()` or `StoreModule.forRoot()`. The overlay stops looking after a few tries, so if you provide the Store late, reload the page. +### State changed in place + +The tab rereads a store only when its state object is a new one, as NgRx creates on every change. Code that changes a nested object in place, without `patchState` or a reducer, leaves the tab on the old values until the next real change. + ### Redaction The devtools replace state keys with secret-looking names with `[redacted]`, at any depth. A signal store method call that writes such a key shows every argument as `[redacted]` in the change log, and so do later calls of that method. An argument whose parameter name looks secret, such as `password` in `login(email, password)`, is masked too. See [what the devtools redact](../security.md#components-signals-ngrx-and-pipes). diff --git a/packages/devtools/CHANGELOG.md b/packages/devtools/CHANGELOG.md index bd855c00..0c5dfe76 100644 --- a/packages/devtools/CHANGELOG.md +++ b/packages/devtools/CHANGELOG.md @@ -181,7 +181,7 @@ What remains: `/config` exports `DEFAULT_POPUP_HOTKEY`, `PANGULAR_ACTIONS`, `PAN - Pages push deltas instead of full snapshots, at most about once a second, and idle pages send only a small heartbeat. - NgRx store updates no longer slow down as the store grows (#432). -- The NgRx collector serializes a signal store or the classic `Store` only when its state, computed values or method stats changed, and the overlay compares a report version instead of the serialized report, so an unchanged store costs no serialization on each check (#368). +- The NgRx collector serializes a signal store or the classic `Store` only when its state, computed values or method stats changed, and the overlay compares a report version instead of the serialized report, so an unchanged store costs no serialization on each check. A change made in place, without a new state object, shows at the next real change (#368). - Collectors schedule their work outside the Angular zone, so an app that writes a signal or logs during change detection settles again on zone.js. - Long lists and trees in the panel render only the visible rows. - The agent tool list is much smaller: shorter descriptions, and library tools only for apps that use them. diff --git a/packages/devtools/src/__tests__/injector-profiler-gate.test.ts b/packages/devtools/src/__tests__/injector-profiler-gate.test.ts index 02f55be1..a95305ac 100644 --- a/packages/devtools/src/__tests__/injector-profiler-gate.test.ts +++ b/packages/devtools/src/__tests__/injector-profiler-gate.test.ts @@ -48,6 +48,17 @@ describe('gateInjectorProfiler', () => { expect((g['ng'] as Record)['ɵgetLoadedRoutes']).toBeTypeOf('function'); }); + it('gates when the router published onto ng before the gate ran', () => { + const ng: Record = { ɵgetLoadedRoutes: () => [] }; + g['ng'] = ng; + gateInjectorProfiler(); + expect(g['window']).toBe(globalThis); + expect(bootAngular().profiler).toBe(true); + expect('window' in g).toBe(false); + expect(g['ng']).toBe(ng); + expect(ng['getComponent']).toBeTypeOf('function'); + }); + it('removes window when ng arrives with the utilities already on it', () => { gateInjectorProfiler(); const getComponent = () => null; diff --git a/packages/devtools/src/injector-profiler-gate.ts b/packages/devtools/src/injector-profiler-gate.ts index ff60d629..d5018e9e 100644 --- a/packages/devtools/src/injector-profiler-gate.ts +++ b/packages/devtools/src/injector-profiler-gate.ts @@ -8,16 +8,41 @@ * than the `ng` object itself because the router publishes its own utilities * onto that object earlier, while `provideRouter()` evaluates. * - * Does nothing when `window` or `ng` already exists: the profiler is wired, or - * the platform was created without it and it is too late. + * Does nothing when `window` exists, or when `ng` already holds the core + * utilities: the profiler is wired, or the platform was created without it and + * it is too late. An `ng` that holds only the router's utilities still gates. */ export function gateInjectorProfiler(): void { const g = globalThis as Record; - if ('window' in g || 'ng' in g) return; + if ('window' in g) return; + const existing = g['ng']; + // Core has published its utilities, or something else owns `ng`: too late. + if (existing !== undefined && (!isObject(existing) || 'getComponent' in existing)) return; Object.defineProperty(g, 'window', { configurable: true, get: () => g }); const removeWindow = () => { if (Object.getOwnPropertyDescriptor(g, 'window')?.get) delete g['window']; }; + const awaitCoreUtils = (ng: Record) => { + Object.defineProperty(ng, 'getComponent', { + configurable: true, + enumerable: true, + get: () => undefined, + set(fn: unknown) { + Object.defineProperty(ng, 'getComponent', { + configurable: true, + enumerable: true, + writable: true, + value: fn, + }); + removeWindow(); + }, + }); + }; + // The router ran first (an `app.config.ts` imported before this module). + if (isObject(existing)) { + awaitCoreUtils(existing); + return; + } Object.defineProperty(g, 'ng', { configurable: true, enumerable: true, @@ -30,28 +55,19 @@ export function gateInjectorProfiler(): void { value, }); // Not Angular's empty object, or one that already holds the core utilities. - if (!value || typeof value !== 'object' || 'getComponent' in value) { + if (!isObject(value) || 'getComponent' in value) { removeWindow(); return; } - Object.defineProperty(value, 'getComponent', { - configurable: true, - enumerable: true, - get: () => undefined, - set(fn: unknown) { - Object.defineProperty(value, 'getComponent', { - configurable: true, - enumerable: true, - writable: true, - value: fn, - }); - removeWindow(); - }, - }); + awaitCoreUtils(value); }, }); } +function isObject(value: unknown): value is Record { + return !!value && typeof value === 'object'; +} + /** * The gate for Angular Native, run as its overlay module loads. The overlay * itself starts after `mount()`, once the platform exists, but `src/main.ts` From 8b1c38d938fe6bf7881342ece7b574d3355050ff Mon Sep 17 00:00:00 2001 From: erKam Date: Sat, 7 Nov 2026 14:44:24 +0000 Subject: [PATCH 5/5] docs(store): say an in-place NgRx change can show late, not when --- apps/docs/src/content/inspectors/ngrx-store.md | 2 +- packages/devtools/CHANGELOG.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/docs/src/content/inspectors/ngrx-store.md b/apps/docs/src/content/inspectors/ngrx-store.md index f66506dd..4c5546fa 100644 --- a/apps/docs/src/content/inspectors/ngrx-store.md +++ b/apps/docs/src/content/inspectors/ngrx-store.md @@ -290,7 +290,7 @@ Use `provideStore()` or `StoreModule.forRoot()`. The overlay stops looking after ### State changed in place -The tab rereads a store only when its state object is a new one, as NgRx creates on every change. Code that changes a nested object in place, without `patchState` or a reducer, leaves the tab on the old values until the next real change. +The tab can miss a change made in place to a nested object, without `patchState` or a reducer. It may show the old values until something else rebuilds the report, such as the next real change or a recorded action. Use `patchState`, or a reducer that returns new objects. ### Redaction diff --git a/packages/devtools/CHANGELOG.md b/packages/devtools/CHANGELOG.md index 0c5dfe76..6904ca0a 100644 --- a/packages/devtools/CHANGELOG.md +++ b/packages/devtools/CHANGELOG.md @@ -181,7 +181,7 @@ What remains: `/config` exports `DEFAULT_POPUP_HOTKEY`, `PANGULAR_ACTIONS`, `PAN - Pages push deltas instead of full snapshots, at most about once a second, and idle pages send only a small heartbeat. - NgRx store updates no longer slow down as the store grows (#432). -- The NgRx collector serializes a signal store or the classic `Store` only when its state, computed values or method stats changed, and the overlay compares a report version instead of the serialized report, so an unchanged store costs no serialization on each check. A change made in place, without a new state object, shows at the next real change (#368). +- The NgRx collector serializes a signal store or the classic `Store` only when its state, computed values or method stats changed, and the overlay compares a report version instead of the serialized report, so an unchanged store costs no serialization on each check. A change made in place, without a new state object, can show late (#368). - Collectors schedule their work outside the Angular zone, so an app that writes a signal or logs during change detection settles again on zone.js. - Long lists and trees in the panel render only the visible rows. - The agent tool list is much smaller: shorter descriptions, and library tools only for apps that use them.