From 2b86a46fbcdb2760951e191e69d795d7e679cef3 Mon Sep 17 00:00:00 2001 From: abiramcodes Date: Thu, 1 Oct 2026 20:32:28 +0530 Subject: [PATCH] feat(store): live NgRx Signal Store inspector --- app/src/pages/store-inspector.ts | 637 ++++++++++++----- 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 | 35 +- ...=> browser-agent-rpc-BXhoSh1z-ClgmuQMl.js} | 2 +- .../{index-aQsUflnN.js => index-DD3MO5-M.js} | 145 +++- extension/ui/index.html | 2 +- .../src/__tests__/ngrx-collector.test.ts | 664 +++++++++++++++++- packages/ng-devtools/src/config.ts | 2 + packages/ng-devtools/src/devframe.ts | 61 ++ packages/ng-devtools/src/ngrx-collector.ts | 450 +++++++++++- packages/ng-devtools/src/ngrx-overlay.ts | 29 + packages/ng-devtools/src/ngrx-register.ts | 58 +- packages/ng-devtools/src/ngrx-shared.ts | 61 +- .../src/rpc/__tests__/ngrx-live-tools.test.ts | 244 +++++++ .../ng-devtools/src/rpc/get-ngrx-store.ts | 12 + .../ng-devtools/src/rpc/ngrx-live-tools.ts | 227 ++++++ 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 +- 26 files changed, 2607 insertions(+), 246 deletions(-) rename extension/ui/assets/{browser-agent-rpc-BXhoSh1z-BqZcKNTn.js => browser-agent-rpc-BXhoSh1z-ClgmuQMl.js} (93%) rename extension/ui/assets/{index-aQsUflnN.js => index-DD3MO5-M.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/pages/store-inspector.ts b/app/src/pages/store-inspector.ts index a775069..232947a 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,50 @@ 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.ids.length > 30) { +
  • + +{{ col.ids.length - 30 }} more +
  • + } +
+ @if (col.selectedId !== undefined) { +
+
Selected
+
+ @if (col.selected !== undefined) { + {{ + shortText(col.selected) + }} + } @else { + None selected + } +
+
+ } +
+ } +
+
+ } }
@@ -297,12 +347,24 @@ const CLASSIC_KINDS = new Set([ } @empty { @@ -332,145 +397,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 +469,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 +791,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 +1035,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 +1170,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 +1186,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 +1289,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 +1312,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 +1628,13 @@ 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); 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 +1726,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 +1813,11 @@ export class StoreInspector { this.focusConfirm.set(null); button.nativeElement.focus(); }); + effect(() => { + if (this.selectedEventSeq() === null) return; + const heading = this.eventDetailHeading()?.nativeElement; + heading?.focus(); + }); this.destroyRef.onDestroy(() => this.unsubscribe?.()); } @@ -1568,20 +1855,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 +1908,10 @@ export class StoreInspector { } } + selectEvent(seq: number) { + this.selectedEventSeq.set(this.selectedEventSeq() === seq ? null : seq); + } + async backToLatest() { const page = this.page(); if (!page) return; @@ -1668,7 +1960,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 +1978,14 @@ 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)[]): string { + return ids.slice(30).join(', '); + } + kindColor(kind: string) { return KIND_COLORS[kind] ?? 'var(--text-3)'; } @@ -1711,4 +2011,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 {