From 537d1e7a9800702f23f65a2198a2fe1e93e8ce4d Mon Sep 17 00:00:00 2001 From: abiramcodes Date: Fri, 2 Oct 2026 17:36:54 +0530 Subject: [PATCH] feat(store): live NgRx Signal Store inspector Adds a real-time inspector for @ngrx/signals stores and the classic @ngrx/store: state, computed values, per-method call counts and durations, withEntities() summaries, a per-patchState change log with diffs and event correlation, restore to any logged state (with watchState notification when registerNgrxSignals is called), dispatch-again for @ngrx/store actions, and MCP/agent tools that expose the same data. Key implementation details: - Subscribes to reducerEvents$ only; dropping the events$ subscription removes the withEventHandlers chain double-log and the dispatchDedup WeakSet entirely - currentDispatchedEvent variable (set synchronously during dispatch) correlates watchState-recorded patchState calls with their event - Component-scoped Dispatchers from provideDispatcher() are picked up in discover() and released when their element is gone - Restore entries skip args and durationMs; "not notified" message now says registerNgrxSignals({ patchState, watchState }) - watchState entries carry durationMs: elapsed from method start to that specific patchState call --- .../__tests__/store-inspector-focus.test.ts | 102 ++- app/src/__tests__/store-inspector.test.ts | 92 +- app/src/pages/store-inspector.ts | 646 ++++++++++---- app/src/pages/store-types.ts | 46 +- apps/docs/src/app/components/llm-actions.ts | 2 +- apps/docs/src/content/agents/resources.md | 16 +- apps/docs/src/content/agents/tools.md | 23 + .../content/guides/ngrx-signals-restore.md | 20 +- .../docs/src/content/inspectors/ngrx-store.md | 39 +- ...=> browser-agent-rpc-BXhoSh1z-pINc-dzb.js} | 2 +- .../{index-aQsUflnN.js => index-BgK1NvA1.js} | 145 +++- extension/ui/index.html | 2 +- .../src/__tests__/ngrx-collector.test.ts | 794 +++++++++++++++++- packages/ng-devtools/src/config.ts | 4 + packages/ng-devtools/src/devframe.ts | 61 ++ packages/ng-devtools/src/ngrx-collector.ts | 503 ++++++++++- packages/ng-devtools/src/ngrx-register.ts | 56 +- packages/ng-devtools/src/ngrx-shared.ts | 61 +- .../src/rpc/__tests__/ngrx-live-tools.test.ts | 275 ++++++ .../ng-devtools/src/rpc/get-ngrx-store.ts | 12 + .../ng-devtools/src/rpc/ngrx-live-tools.ts | 247 ++++++ packages/ng-devtools/src/rpc/ngrx-tools.ts | 29 +- src/app/pages/booking.ts | 6 +- src/app/pages/destinations.ts | 4 +- src/app/pages/trips.ts | 6 +- src/app/travel/travel.store.ts | 68 +- src/main.ts | 4 +- 27 files changed, 3006 insertions(+), 259 deletions(-) rename extension/ui/assets/{browser-agent-rpc-BXhoSh1z-BqZcKNTn.js => browser-agent-rpc-BXhoSh1z-pINc-dzb.js} (93%) rename extension/ui/assets/{index-aQsUflnN.js => index-BgK1NvA1.js} (91%) create mode 100644 packages/ng-devtools/src/rpc/__tests__/ngrx-live-tools.test.ts create mode 100644 packages/ng-devtools/src/rpc/ngrx-live-tools.ts diff --git a/app/src/__tests__/store-inspector-focus.test.ts b/app/src/__tests__/store-inspector-focus.test.ts index 8bfb400..ac54ab4 100644 --- a/app/src/__tests__/store-inspector-focus.test.ts +++ b/app/src/__tests__/store-inspector-focus.test.ts @@ -2,7 +2,7 @@ import { TestBed } from '@angular/core/testing'; import type { DevframeRpcClient } from 'devframe/client'; import { afterEach, describe, expect, it } from 'vitest'; import { StoreInspector } from '../pages/store-inspector'; -import type { NgrxPage, NgrxState } from '../pages/store-types'; +import type { NgrxLogEntry, NgrxPage, NgrxState } from '../pages/store-types'; afterEach(() => { TestBed.resetTestingModule(); @@ -69,6 +69,106 @@ async function restoreNewest(result: { ok: boolean; paused?: boolean; message: s return fixture.nativeElement as HTMLElement; } +function eventPage(events: NgrxLogEntry[]): NgrxPage { + return { + pageId: 'p1', + url: 'http://localhost/', + title: 'Shop', + stores: [], + classic: null, + log: events, + reportedAt: 1, + }; +} + +function eventEntry(seq: number, type = 'bookingCancelled'): NgrxLogEntry { + return { + seq, + source: 'event', + storeId: 'event', + type, + eventType: type, + payload: { id: seq }, + timestamp: seq, + diff: [], + restorable: false, + }; +} + +function fakeEventClient(events: NgrxLogEntry[]): DevframeRpcClient { + const ep = eventPage(events); + return { + connectionMeta: {}, + scope: () => ({ + rpc: { + call: async (name: string) => (name === 'request-ngrx-action' ? { ok: true } : []), + sharedState: async () => ({ + value: () => ({ pages: [ep] }) as NgrxState, + on: () => () => undefined, + }), + }, + }), + } as unknown as DevframeRpcClient; +} + +describe('StoreInspector events', () => { + it('shows events in a dedicated section and selects one on click', async () => { + const fixture = TestBed.createComponent(StoreInspector); + document.body.appendChild(fixture.nativeElement); + fixture.componentRef.setInput('rpc', fakeEventClient([eventEntry(1), eventEntry(2)])); + await fixture.whenStable(); + fixture.detectChanges(); + + const host = fixture.nativeElement as HTMLElement; + const evtButtons = host.querySelectorAll('.event-item'); + expect(evtButtons.length).toBe(2); + expect(host.querySelector('.event-detail-focus')).toBeNull(); + + evtButtons[0].click(); + await fixture.whenStable(); + fixture.detectChanges(); + expect(host.querySelector('.event-detail-focus')).not.toBeNull(); + expect(evtButtons[0].getAttribute('aria-pressed')).toBe('true'); + expect(evtButtons[1].getAttribute('aria-pressed')).toBe('false'); + }); + + it('moves focus to the event detail panel when an event is clicked', async () => { + const fixture = TestBed.createComponent(StoreInspector); + document.body.appendChild(fixture.nativeElement); + fixture.componentRef.setInput('rpc', fakeEventClient([eventEntry(1)])); + await fixture.whenStable(); + fixture.detectChanges(); + + const host = fixture.nativeElement as HTMLElement; + host.querySelector('.event-item')!.click(); + await fixture.whenStable(); + fixture.detectChanges(); + expect(document.activeElement).toBe(host.querySelector('.event-detail-focus')); + }); + + it('does not steal focus from the filter when typing after an event is selected', async () => { + const fixture = TestBed.createComponent(StoreInspector); + document.body.appendChild(fixture.nativeElement); + fixture.componentRef.setInput('rpc', fakeEventClient([eventEntry(1)])); + await fixture.whenStable(); + fixture.detectChanges(); + + const host = fixture.nativeElement as HTMLElement; + host.querySelector('.event-item')!.click(); + await fixture.whenStable(); + fixture.detectChanges(); + + // Now type in the filter — this causes a re-render but must not steal focus + const input = host.querySelector('input[type="search"]')!; + input.focus(); + input.value = 'book'; + input.dispatchEvent(new Event('input')); + await fixture.whenStable(); + fixture.detectChanges(); + expect(document.activeElement).toBe(input); + }); +}); + describe('StoreInspector restore focus', () => { it('focuses the state when restoring the newest action does not pause the store', async () => { const host = await restoreNewest({ ok: true, paused: false, message: 'Jumped.' }); diff --git a/app/src/__tests__/store-inspector.test.ts b/app/src/__tests__/store-inspector.test.ts index 46e2b0e..5dc6cda 100644 --- a/app/src/__tests__/store-inspector.test.ts +++ b/app/src/__tests__/store-inspector.test.ts @@ -2,7 +2,12 @@ import { TestBed, type ComponentFixture } from '@angular/core/testing'; import type { DevframeRpcClient } from 'devframe/client'; import { afterEach, describe, expect, it } from 'vitest'; import { StoreInspector } from '../pages/store-inspector'; -import type { NgrxLogEntry, NgrxPage, NgrxStoreEntry } from '../pages/store-types'; +import type { + NgrxLogEntry, + NgrxPage, + NgrxSignalStoreInfo, + NgrxStoreEntry, +} from '../pages/store-types'; type Call = (name: string, arg?: Record) => Promise; @@ -190,3 +195,88 @@ describe('StoreInspector restore', () => { expect(root(fixture).textContent).toContain('actionsBlocklist'); }); }); + +describe('StoreInspector signal store', () => { + function signalStore(overrides: Partial = {}): NgrxSignalStoreInfo { + return { + id: 'ngrx-1', + kind: 'signal-store', + className: 'ProductStore', + scope: 'root', + stateKeys: ['count'], + state: { count: 0 }, + computed: {}, + methods: [], + references: [], + writable: true, + ...overrides, + }; + } + + function signalPage(stores: NgrxSignalStoreInfo[], log: NgrxLogEntry[] = []): NgrxPage { + return { pageId: 'p1', url: '/', title: 'App', stores, classic: null, log, reportedAt: 0 }; + } + + async function mountSignal(stores: NgrxSignalStoreInfo[], log: NgrxLogEntry[] = []) { + const fixture = TestBed.createComponent(StoreInspector); + fixture.componentRef.setInput( + 'rpc', + fakeClient( + (name) => (name === 'get-ngrx-store' ? Promise.resolve([]) : Promise.resolve({})), + signalPage(stores, log), + ), + ); + await settle(fixture); + return fixture; + } + + it('shows entity count and ids for a withEntities() store', async () => { + const fixture = await mountSignal([ + signalStore({ + stateKeys: ['entityMap', 'ids'], + state: { entityMap: {}, ids: [] }, + entities: [{ idsKey: 'ids', entityMapKey: 'entityMap', ids: ['a', 'b', 'c'], count: 3 }], + }), + ]); + const host = root(fixture); + expect(host.textContent).toMatch(/3\s+entities/); + expect(host.querySelector('.entity-ids')).not.toBeNull(); + const chips = Array.from(host.querySelectorAll('.entity-ids .chip')); + expect(chips.map((c) => c.textContent?.trim())).toEqual(['a', 'b', 'c']); + }); + + it('truncates ids past 30 and shows a +N more chip', async () => { + const ids = Array.from({ length: 32 }, (_, i) => String(i)); + const fixture = await mountSignal([ + signalStore({ + stateKeys: ['entityMap', 'ids'], + state: { entityMap: {}, ids: [] }, + entities: [{ idsKey: 'ids', entityMapKey: 'entityMap', ids, count: ids.length }], + }), + ]); + const host = root(fixture); + const more = host.querySelector('.chip.more'); + expect(more).not.toBeNull(); + expect(more?.textContent?.trim()).toBe('+2 more'); + }); + + it('shows avg and last duration for timed methods and omits them for untimed ones', async () => { + const fixture = await mountSignal([ + signalStore({ + methods: [ + { name: 'load', calls: 4, avgDurationMs: 10, lastDurationMs: 8 }, + { name: 'reset', calls: 1 }, + ], + }), + ]); + const host = root(fixture); + const methods = host.querySelector('.methods'); + expect(methods?.textContent).toContain('avg 10ms'); + expect(methods?.textContent).toContain('last 8ms'); + // reset has no duration numbers + const resetChip = Array.from(host.querySelectorAll('.chip.mono')).find((c) => + c.textContent?.includes('reset'), + ); + expect(resetChip?.textContent).not.toContain('ms'); + }); +}); diff --git a/app/src/pages/store-inspector.ts b/app/src/pages/store-inspector.ts index a775069..4d89085 100644 --- a/app/src/pages/store-inspector.ts +++ b/app/src/pages/store-inspector.ts @@ -1,3 +1,4 @@ +import { NgTemplateOutlet } from '@angular/common'; import { afterRenderEffect, Component, @@ -39,6 +40,7 @@ const KIND_COLORS: Record = { 'signal-state': '#22d3ee', 'signal-method': '#fb7185', store: '#a78bfa', + event: 'var(--warn)', }; const KIND_LABELS: Record = { @@ -47,6 +49,7 @@ const KIND_LABELS: Record = { 'signal-method': 'signalMethod', 'store-setup': 'store setup', store: '@ngrx/store', + event: 'event', }; const ORIGIN_TEXT: Record = { @@ -85,7 +88,7 @@ const CLASSIC_KINDS = new Set([ @Component({ selector: 'app-store-inspector', - imports: [LimitNote, Select], + imports: [LimitNote, Select, NgTemplateOutlet], template: `
{{ method.name }} - @if (method.rx) { + @if (method.signalMethod) { + signalMethod + } @else if (method.rx) { rxMethod } {{ method.calls }} {{ method.calls === 1 ? 'call' : 'calls' }}{{ method.calls }} {{ method.calls === 1 ? 'call' : 'calls' + }}{{ methodDuration(method) }} } @@ -276,6 +282,53 @@ const CLASSIC_KINDS = new Set([

No methods.

} + @if (info.entities?.length) { +
+

Entities

+
+ @for (col of info.entities!; track col.idsKey) { +
+
+ {{ col.collection ?? 'entities' }} + {{ col.count }} {{ col.count === 1 ? 'entity' : 'entities' }} +
+
    + @for (id of entityIds(col.ids); track id) { +
  • {{ id }}
  • + } + @if (col.count > 30) { +
  • + +{{ col.count - 30 }} more +
  • + } +
+ @if (col.selectedId !== undefined) { +
+
Selected
+
+ @if (col.selected !== undefined) { + {{ + shortText(col.selected) + }} + } @else { + None selected + } +
+
+ } +
+ } +
+
+ } }
@@ -297,12 +350,24 @@ const CLASSIC_KINDS = new Set([ } @empty { @@ -332,145 +400,17 @@ const CLASSIC_KINDS = new Set([ } - @if (entry(); as selected) { -
-
#{{ selected.seq }} {{ selected.type }}
-
-
Time
-
{{ formatTime(selected.timestamp) }}
- @if (selected.origin) { -
Origin
-
{{ originText(selected.origin) }}
- } - @if (selected.action !== undefined) { -
Action
-
-
{{ prettyText(selected.action) }}
-
- } - @if (selected.args?.length) { -
Arguments
-
-
{{ prettyText(selected.args) }}
-
- } -
-
State diff
- @if (selected.diff.length) { -
    - @for (change of selected.diff; track change.path) { -
  • - {{ opLabel(change.op) }} - {{ change.path }} - - @if (change.op !== 'add') { - {{ shortText(change.before) }} - } - @if (change.op === 'change') { - became - } - @if (change.op !== 'remove') { - {{ shortText(change.after) }} - } - -
  • - } -
- } @else { -

The state did not change.

- } - - @if (selected.restorable && confirmSeq() === selected.seq) { -
-

- @if (selected.source === 'store') { - Store DevTools jumps the app state to the state right after action #{{ - selected.seq - }}. Until you go back to the latest state, new actions are logged but - do not change the state. - } @else { - This sets every state key of {{ current.label }} back to its value - right after change #{{ selected.seq }}. Components that read the store - update at once, and a new "Restore" entry is added to the log. - } -

-
- - -
-
- } - @if ( - confirmSeq() !== selected.seq && (selected.restorable || canAgain(selected)) - ) { -
- @if (selected.restorable) { - - } - @if (canAgain(selected)) { - - } -
- @if (!canRestore()) { -

{{ restoreOff }}

- } - } - @if (!selected.restorable && selected.source === 'store') { -

- @if (selected.unrestorable === 'dropped') { - Store DevTools no longer holds this action, so this state cannot be - restored. It was dropped past maxAge, or the Store DevTools - history was committed, reset or imported. - } @else if (selected.unrestorable === 'not-recorded') { - Store DevTools never recorded this action, so this state cannot be - restored. An actionsBlocklist, - actionsSafelist or predicate option filtered - it out, or recording was paused. - } @else { - Time travel for @ngrx/store needs - provideStoreDevtools(). Without it, entries cannot be - restored. - } -

- } -
+ @if (changeEntry(); as selected) { +
+ +
} @@ -532,6 +472,236 @@ const CLASSIC_KINDS = new Set([

{{ message() }}

+ @if (hasEvents()) { +
+
+

Events

+ {{ pageEvents().length }} +
+

+ Events dispatched through @ngrx/signals/events, across every store on this page. +

+
    + @for (evt of pageEvents(); track evt.seq) { +
  • + +
  • + } @empty { +
  • No events match this filter.
  • + } +
+ @if (eventEntry(); as selected) { +
+ +
+ } +
+ } + + + +
+
+ #{{ selected.seq }} {{ selected.type }} +
+
+
Time
+
{{ formatTime(selected.timestamp) }}
+ @if (selected.origin) { +
Origin
+
{{ originText(selected.origin) }}
+ } + @if (selected.durationMs !== undefined) { +
Duration
+
{{ formatDuration(selected.durationMs) }}
+ } + @if (selected.action !== undefined) { +
Action
+
+
{{ prettyText(selected.action) }}
+
+ } + @if (selected.args?.length) { +
Arguments
+
+
{{ prettyText(selected.args) }}
+
+ } + @if (selected.source === 'event' && selected.payload !== undefined) { +
Payload
+
+
{{ prettyText(selected.payload) }}
+
+ } + @if (selected.causedByEvent; as caused) { +
Caused by event
+
+ {{ caused.type }} + @if (caused.payload !== undefined) { +
{{ prettyText(caused.payload) }}
+ } +
+ } +
+ @if (selected.source !== 'event') { +
State diff
+ @if (selected.diff.length) { +
    + @for (change of selected.diff; track change.path) { +
  • + {{ opLabel(change.op) }} + {{ change.path }} + + @if (change.op !== 'add') { + {{ shortText(change.before) }} + } + @if (change.op === 'change') { + became + } + @if (change.op !== 'remove') { + {{ shortText(change.after) }} + } + +
  • + } +
+ } @else { +

The state did not change.

+ } + } + + @if (selected.restorable && confirmSeq() === selected.seq) { +
+

+ @if (selected.source === 'store') { + Store DevTools jumps the app state to the state right after action #{{ + selected.seq + }}. Until you go back to the latest state, new actions are logged but do not change + the state. + } @else { + @if (store(); as current) { + This sets every state key of {{ current.label }} back to its value right after + change #{{ selected.seq }}. Components that read the store update at once, and a + new "Restore" entry is added to the log. + } @else { + This sets every state key of the store back to its value right after change #{{ + selected.seq + }}. Components that read the store update at once, and a new "Restore" entry is + added to the log. + } + } +

+
+ + +
+
+ } + @if (confirmSeq() !== selected.seq && (selected.restorable || canAgain(selected))) { +
+ @if (selected.restorable) { + + } + @if (canAgain(selected)) { + + } +
+ @if (!canRestore()) { +

{{ restoreOff }}

+ } + } + @if (!selected.restorable && selected.source === 'store') { +

+ @if (selected.unrestorable === 'dropped') { + Store DevTools no longer holds this action, so this state cannot be restored. It was + dropped past maxAge, or the Store DevTools history was committed, reset + or imported. + } @else if (selected.unrestorable === 'not-recorded') { + Store DevTools never recorded this action, so this state cannot be restored. An + actionsBlocklist, actionsSafelist or + predicate option filtered it out, or recording was paused. + } @else { + Time travel for @ngrx/store needs provideStoreDevtools(). Without it, + entries cannot be restored. + } +

+ } +
+
+

Source declarations

@@ -624,6 +794,12 @@ const CLASSIC_KINDS = new Set([ color: var(--text); font-size: 13px; } + .intro { + max-width: 720px; + margin: 0; + color: var(--text-2); + line-height: 1.5; + } .toolbar { position: sticky; top: 0; @@ -862,15 +1038,18 @@ const CLASSIC_KINDS = new Set([ transition: background-color 0.15s var(--ease); } .store-item:hover, - .log-item:hover { + .log-item:hover, + .event-item:hover { background: var(--surface-3); } .store-item:focus-visible, - .log-item:focus-visible { + .log-item:focus-visible, + .event-item:focus-visible { @include m.focus-ring(-2px); } .store-item.selected, - .log-item.selected { + .log-item.selected, + .event-item.selected { background: var(--accent-soft); box-shadow: inset 2px 0 0 var(--accent); } @@ -994,6 +1173,10 @@ const CLASSIC_KINDS = new Set([ flex-wrap: wrap; gap: 6px; } + .methods li { + flex-wrap: wrap; + row-gap: 2px; + } .tag { padding: 0 5px; border-radius: 4px; @@ -1006,6 +1189,79 @@ const CLASSIC_KINDS = new Set([ font-family: var(--font-sans, inherit); font-size: 11px; } + .entities { + display: grid; + gap: 14px; + } + .entity-group { + display: grid; + gap: 8px; + } + .entity-head { + display: flex; + align-items: center; + gap: 8px; + } + .entity-count { + color: var(--text-2); + font-size: 11.5px; + font-variant-numeric: tabular-nums; + } + .entity-ids { + margin: 0; + } + .chip.more { + border-style: dashed; + color: var(--text-3); + } + .events-list { + display: flex; + flex-direction: column; + gap: 2px; + max-height: 260px; + overflow: auto; + padding: 6px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + @include m.enter; + } + .event-item { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px 10px; + width: 100%; + min-width: 0; + padding: 7px 10px; + border: none; + border-radius: var(--radius-sm); + background: transparent; + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; + transition: background-color 0.15s var(--ease); + } + .event-type { + flex: none; + font-weight: 600; + color: var(--text-strong); + font-size: 12.5px; + } + .event-payload { + flex: 1 1 160px; + min-width: 0; + @include m.truncate; + color: var(--text-2); + } + .event-time { + flex: none; + margin-left: auto; + color: var(--text-3); + font-size: 11.5px; + font-variant-numeric: tabular-nums; + } .log { min-width: 0; padding-top: 16px; @@ -1036,9 +1292,15 @@ const CLASSIC_KINDS = new Set([ font-size: 11.5px; font-variant-numeric: tabular-nums; } + .log-type-row { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; + } .log-type { @include m.truncate; - max-width: 100%; + min-width: 0; font-family: var(--font-mono); font-size: 12.5px; color: var(--text); @@ -1053,7 +1315,7 @@ const CLASSIC_KINDS = new Set([ border-radius: var(--radius-sm); @include m.enter(0.25s); } - .entry h5 { + .entry .entry-heading { margin: 0 0 12px; font-family: var(--font-mono); font-size: 13px; @@ -1369,8 +1631,14 @@ export class StoreInspector { private readonly hostPageId = hostPageId(); readonly maxLog = computed(() => panelConfig(this.rpc()).limits.changeLog); readonly selectedStoreId = signal(null); - readonly selectedSeq = signal(null); + // Two selection cursors so clicking an event never swaps out the per-store + // change-log detail: the change log keeps its own highlighted row, the + // Events section keeps its own, and each shows its detail in-place. + readonly selectedChangeSeq = signal(null); + readonly selectedEventSeq = signal(null); + private readonly focusEventSeq = signal(null); readonly confirmSeq = signal(null); + private readonly eventDetailHeading = viewChild>('eventDetailHeading'); readonly busy = signal(false); private readonly focusLatest = signal(false); private readonly focusConfirm = signal<'cancel' | 'restore' | null>(null); @@ -1462,9 +1730,27 @@ export class StoreInspector { .reverse(); }); - readonly entry = computed( - () => this.storeLog().find((e) => e.seq === this.selectedSeq()) ?? null, - ); + readonly hasEvents = computed(() => (this.page()?.log ?? []).some((e) => e.source === 'event')); + + readonly pageEvents = computed(() => { + const f = this.filter().trim().toLowerCase(); + return (this.page()?.log ?? []) + .filter((e) => e.source === 'event' && (!f || e.type.toLowerCase().includes(f))) + .slice() + .reverse(); + }); + + readonly changeEntry = computed(() => { + const seq = this.selectedChangeSeq(); + if (seq === null) return null; + return this.storeLog().find((e) => e.seq === seq) ?? null; + }); + + readonly eventEntry = computed(() => { + const seq = this.selectedEventSeq(); + if (seq === null) return null; + return this.pageEvents().find((e) => e.seq === seq) ?? null; + }); readonly dispatchType = signal(''); readonly dispatchPayload = signal(''); @@ -1531,6 +1817,13 @@ export class StoreInspector { this.focusConfirm.set(null); button.nativeElement.focus(); }); + afterRenderEffect(() => { + const seq = this.focusEventSeq(); + const detail = this.eventDetailHeading(); + if (seq === null || !detail) return; + this.focusEventSeq.set(null); + detail.nativeElement.focus(); + }); this.destroyRef.onDestroy(() => this.unsubscribe?.()); } @@ -1568,20 +1861,21 @@ export class StoreInspector { selectPage(pageId: string | null) { this.selectedPageId.set(pageId); this.selectedStoreId.set(null); - this.selectedSeq.set(null); + this.selectedChangeSeq.set(null); + this.selectedEventSeq.set(null); this.confirmSeq.set(null); this.message.set(''); } selectStore(id: string) { this.selectedStoreId.set(id); - this.selectedSeq.set(null); + this.selectedChangeSeq.set(null); this.confirmSeq.set(null); this.message.set(''); } - selectEntry(seq: number) { - this.selectedSeq.set(this.selectedSeq() === seq ? null : seq); + selectChange(seq: number) { + this.selectedChangeSeq.set(this.selectedChangeSeq() === seq ? null : seq); this.confirmSeq.set(null); this.message.set(''); } @@ -1620,6 +1914,12 @@ export class StoreInspector { } } + selectEvent(seq: number) { + const newSeq = this.selectedEventSeq() === seq ? null : seq; + this.selectedEventSeq.set(newSeq); + if (newSeq !== null) this.focusEventSeq.set(newSeq); + } + async backToLatest() { const page = this.page(); if (!page) return; @@ -1668,7 +1968,7 @@ export class StoreInspector { this.message.set(result?.error ?? result?.message ?? 'Dispatched.'); if (result?.entry) { this.selectedStoreId.set('store'); - this.selectedSeq.set(result.entry.seq); + this.selectedChangeSeq.set(result.entry.seq); } } catch { this.message.set(offline); @@ -1686,6 +1986,15 @@ export class StoreInspector { return (this.page()?.log ?? []).filter((e) => e.storeId === id).length; } + entityIds(ids: (string | number)[]): (string | number)[] { + return ids.slice(0, 30); + } + + entityOverflowTitle(ids: (string | number)[], count: number): string { + const overflow = ids.slice(30).join(', '); + return count > ids.length ? `${overflow} (first ${ids.length} of ${count} shown)` : overflow; + } + kindColor(kind: string) { return KIND_COLORS[kind] ?? 'var(--text-3)'; } @@ -1711,4 +2020,17 @@ export class StoreInspector { } readonly formatTime = time; + + formatDuration(ms: number): string { + return ms <= 0 ? '<1ms' : `${Math.round(ms)}ms`; + } + + methodDuration(method: { lastDurationMs?: number; avgDurationMs?: number }): string { + const parts: string[] = []; + if (method.avgDurationMs !== undefined) + parts.push(`avg ${this.formatDuration(method.avgDurationMs)}`); + if (method.lastDurationMs !== undefined) + parts.push(`last ${this.formatDuration(method.lastDurationMs)}`); + return parts.length ? ` · ${parts.join(' · ')}` : ''; + } } diff --git a/app/src/pages/store-types.ts b/app/src/pages/store-types.ts index 2f970a3..203e9a8 100644 --- a/app/src/pages/store-types.ts +++ b/app/src/pages/store-types.ts @@ -18,6 +18,23 @@ export interface NgrxStoreEntry { types?: string[]; } +/** + * `selectedId` is set once the app's `selectedId` state field holds a non-null value. + * `selected` is only set when that id resolves to an entity in the collection, so a + * stale/dangling id shows `selectedId` without `selected`. + */ +export interface NgrxEntitiesInfo { + collection?: string; + idsKey: string; + entityMapKey: string; + entitiesKey?: string; + ids: (string | number)[]; + count: number; + selectedIdKey?: string; + selectedId?: unknown; + selected?: unknown; +} + export interface NgrxSignalStoreInfo { id: string; kind: 'signal-store' | 'signal-state'; @@ -28,7 +45,21 @@ export interface NgrxSignalStoreInfo { stateKeys: string[]; state: Record; computed: Record; - methods: { name: string; calls: number; rx?: boolean }[]; + /** `withEntities()` collections found in `state`/`computed`. See the collector for details. */ + entities?: NgrxEntitiesInfo[]; + /** + * `lastDurationMs`/`avgDurationMs` are the wall-clock time of the synchronous method call + * only (see {@link NgrxLogEntry.durationMs}), present only once the method has been called + * at least once with measurable timing. + */ + methods: { + name: string; + calls: number; + rx?: boolean; + signalMethod?: boolean; + lastDurationMs?: number; + avgDurationMs?: number; + }[]; references: string[]; writable: boolean; } @@ -51,7 +82,7 @@ export type NgrxActionOrigin = 'dispatch' | 'effect' | 'reactive'; export interface NgrxLogEntry { seq: number; - source: 'signal-store' | 'store'; + source: 'signal-store' | 'store' | 'event'; storeId: string; type: string; args?: unknown[]; @@ -60,6 +91,17 @@ export interface NgrxLogEntry { timestamp: number; diff: NgrxDiffEntry[]; restorable: boolean; + /** + * Set only for an entry produced by a wrapped `signalStore`/`signalState` method call + * (never a plain `patchState`/signal-write entry, a classic-store action entry or an + * event entry). How long the synchronous call took to return. + */ + durationMs?: number; + /** `source: 'event'` only: the dispatched `@ngrx/signals/events` event's type and payload. */ + eventType?: string; + payload?: unknown; + /** `source: 'signal-store'` only: the event that this state change was correlated with. */ + causedByEvent?: { type: string; payload?: unknown }; unrestorable?: 'dropped' | 'not-recorded'; } diff --git a/apps/docs/src/app/components/llm-actions.ts b/apps/docs/src/app/components/llm-actions.ts index bb0136c..e1998fd 100644 --- a/apps/docs/src/app/components/llm-actions.ts +++ b/apps/docs/src/app/components/llm-actions.ts @@ -67,7 +67,7 @@ interface MenuItem {