Skip to content

Commit 2b86a46

Browse files
committed
feat(store): live NgRx Signal Store inspector
1 parent 6abd4b3 commit 2b86a46

26 files changed

Lines changed: 2607 additions & 246 deletions

‎app/src/pages/store-inspector.ts‎

Lines changed: 475 additions & 162 deletions
Large diffs are not rendered by default.

‎app/src/pages/store-types.ts‎

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,23 @@ export interface NgrxStoreEntry {
1818
types?: string[];
1919
}
2020

21+
/**
22+
* `selectedId` is set once the app's `selectedId` state field holds a non-null value.
23+
* `selected` is only set when that id resolves to an entity in the collection, so a
24+
* stale/dangling id shows `selectedId` without `selected`.
25+
*/
26+
export interface NgrxEntitiesInfo {
27+
collection?: string;
28+
idsKey: string;
29+
entityMapKey: string;
30+
entitiesKey?: string;
31+
ids: (string | number)[];
32+
count: number;
33+
selectedIdKey?: string;
34+
selectedId?: unknown;
35+
selected?: unknown;
36+
}
37+
2138
export interface NgrxSignalStoreInfo {
2239
id: string;
2340
kind: 'signal-store' | 'signal-state';
@@ -28,7 +45,21 @@ export interface NgrxSignalStoreInfo {
2845
stateKeys: string[];
2946
state: Record<string, unknown>;
3047
computed: Record<string, unknown>;
31-
methods: { name: string; calls: number; rx?: boolean }[];
48+
/** `withEntities()` collections found in `state`/`computed`. See the collector for details. */
49+
entities?: NgrxEntitiesInfo[];
50+
/**
51+
* `lastDurationMs`/`avgDurationMs` are the wall-clock time of the synchronous method call
52+
* only (see {@link NgrxLogEntry.durationMs}), present only once the method has been called
53+
* at least once with measurable timing.
54+
*/
55+
methods: {
56+
name: string;
57+
calls: number;
58+
rx?: boolean;
59+
signalMethod?: boolean;
60+
lastDurationMs?: number;
61+
avgDurationMs?: number;
62+
}[];
3263
references: string[];
3364
writable: boolean;
3465
}
@@ -51,7 +82,7 @@ export type NgrxActionOrigin = 'dispatch' | 'effect' | 'reactive';
5182

5283
export interface NgrxLogEntry {
5384
seq: number;
54-
source: 'signal-store' | 'store';
85+
source: 'signal-store' | 'store' | 'event';
5586
storeId: string;
5687
type: string;
5788
args?: unknown[];
@@ -60,6 +91,17 @@ export interface NgrxLogEntry {
6091
timestamp: number;
6192
diff: NgrxDiffEntry[];
6293
restorable: boolean;
94+
/**
95+
* Set only for an entry produced by a wrapped `signalStore`/`signalState` method call
96+
* (never a plain `patchState`/signal-write entry, a classic-store action entry or an
97+
* event entry). How long the synchronous call took to return.
98+
*/
99+
durationMs?: number;
100+
/** `source: 'event'` only: the dispatched `@ngrx/signals/events` event's type and payload. */
101+
eventType?: string;
102+
payload?: unknown;
103+
/** `source: 'signal-store'` only: the event that this state change was correlated with. */
104+
causedByEvent?: { type: string; payload?: unknown };
63105
unrestorable?: 'dropped' | 'not-recorded';
64106
}
65107

‎apps/docs/src/app/components/llm-actions.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ interface MenuItem {
6767
<button
6868
type="button"
6969
(click)="copyMarkdownAction($event)"
70-
class="inline-flex items-center justify-center gap-1.5 px-2.5 py-1 min-w-[7.5rem] hover:bg-zinc-100 dark:hover:bg-zinc-900 transition-colors"
70+
class="inline-flex items-center justify-center gap-1.5 px-2.5 py-1 min-w-30 hover:bg-zinc-100 dark:hover:bg-zinc-900 transition-colors"
7171
[attr.aria-label]="copied() ? 'Markdown copied' : 'Copy markdown to clipboard'"
7272
>
7373
<svg

‎apps/docs/src/content/agents/resources.md‎

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -33,14 +33,14 @@ Each one returns JSON.
3333

3434
## Available resources
3535

36-
| Resource | Name | Content |
37-
| ---------------------------- | ---------------------- | ------------------------------- |
38-
| `ng-devtools:component-tree` | Angular Component Tree | Live component hierarchy |
39-
| `ng-devtools:signal-graph` | Angular Signal Graph | Signal dependency graph |
40-
| `ng-devtools:injector-tree` | Angular Injector Tree | DI injector hierarchy |
41-
| `ng-devtools:ngrx-store` | NgRx Store State | Live NgRx stores and change log |
42-
| `ng-devtools:forms` | Angular Forms | Live forms and recent changes |
43-
| `ng-devtools:router` | Angular Router | Live route and navigations |
36+
| Resource | Name | Content |
37+
| ---------------------------- | ---------------------- | ------------------------------ |
38+
| `ng-devtools:component-tree` | Angular Component Tree | Live component hierarchy |
39+
| `ng-devtools:signal-graph` | Angular Signal Graph | Signal dependency graph |
40+
| `ng-devtools:injector-tree` | Angular Injector Tree | DI injector hierarchy |
41+
| `ng-devtools:ngrx-store` | NgRx Store State | Live NgRx state and change log |
42+
| `ng-devtools:forms` | Angular Forms | Live forms and recent changes |
43+
| `ng-devtools:router` | Angular Router | Live route and navigations |
4444

4545
### component-tree
4646

‎apps/docs/src/content/agents/tools.md‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,29 @@ The injectors a page reported. Element injectors list what each component inject
147147

148148
Without `selector` or `token`, the answer is the whole tree, cut off at 20,000 characters, and says which change detection mode the page runs. When the page has more than 2000 element injectors, the answer says that it holds only the first 2000.
149149

150+
## NgRx
151+
152+
Two tools read the live `@ngrx/signals` and `@ngrx/store` state a page reported. Reads: page.
153+
154+
### inspect-signal-store
155+
156+
With `storeId`, the full state of one store: state, computed values, `withEntities()` collections (a summary of state and computed already there), methods with call counts, scope, where it is provided, and which components or injectors reference it. Without it, every store discovered so far, and the classic `@ngrx/store` state if present.
157+
158+
| Argument | Required | Value |
159+
| --------- | -------- | -------------------------------------------------------------------------------------------------- |
160+
| `page` | no | The tab to read. Defaults to every connected page. |
161+
| `storeId` | no | A store id from a previous call, or the id shown on the [NgRx Store](/inspectors/ngrx-store) page. |
162+
163+
### signal-store-history
164+
165+
The change log for a page's stores, oldest first: `@ngrx/signals` state diffs (method calls and `patchState` writes, each with a per-key diff), classic `@ngrx/store` actions, and `@ngrx/signals/events` dispatched events. A signal-store entry carries the event that caused it when a `withReducer()` case reducer patched the state synchronously while handling that event.
166+
167+
| Argument | Required | Value |
168+
| --------- | -------- | --------------------------------------------------------------------- |
169+
| `page` | no | The tab to read. Defaults to every connected page. |
170+
| `storeId` | no | Only that store's entries. Without it, every store, action and event. |
171+
| `since` | no | A `seq` from a previous call. Returns only entries reported after it. |
172+
150173
## Router
151174

152175
All router tools read the page, except `explain-render-mode`, which also reads your `*.routes.server.ts` files.

‎apps/docs/src/content/guides/ngrx-signals-restore.md‎

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,11 @@ export const TravelStore = signalStore(
3636

3737
Without `registerNgrxSignals`, a restore changes the store but skips this listener. With it, the listener runs.
3838

39-
## Register patchState
39+
## Register patchState and watchState
4040

41-
Call `registerNgrxSignals({ patchState })` from `@santoshyadavdev/ng-devtools/overlay` once, after the app starts. Load both modules with dynamic imports in development only, so production bundles do not include the devtools.
41+
Call `registerNgrxSignals({ patchState, watchState })` from `@santoshyadavdev/ng-devtools/overlay` once, after the app starts. Load both modules with dynamic imports in development only, so production bundles do not include the devtools.
42+
43+
The overlay also tries to auto-import `@ngrx/signals` itself and register both functions. The explicit call stays worth doing: it runs synchronously (so no race against the first state push) and documents the dependency.
4244

4345
```ts group="register" name="Angular CLI" active
4446
// src/main.ts
@@ -54,7 +56,9 @@ bootstrapApplication(App, appConfig)
5456
.then(() =>
5557
Promise.all([import('@santoshyadavdev/ng-devtools/overlay'), import('@ngrx/signals')]),
5658
)
57-
.then(([devtools, {patchState}]) => devtools.registerNgrxSignals({patchState}));
59+
.then(([devtools, {patchState, watchState}]) =>
60+
devtools.registerNgrxSignals({patchState, watchState}),
61+
);
5862
}
5963
return undefined;
6064
})
@@ -69,16 +73,18 @@ import {appConfig} from './app/app.config';
6973

7074
bootstrapApplication(App, appConfig).then(async () => {
7175
if (import.meta.env.DEV) {
72-
const [devtools, {patchState}] = await Promise.all([
76+
const [devtools, {patchState, watchState}] = await Promise.all([
7377
import('@santoshyadavdev/ng-devtools/overlay'),
7478
import('@ngrx/signals'),
7579
]);
76-
devtools.registerNgrxSignals({patchState});
80+
devtools.registerNgrxSignals({patchState, watchState});
7781
}
7882
});
7983
```
8084

81-
The Angular CLI version is the demo app's `src/main.ts`. It loads the overlay and registers `patchState` in the same step.
85+
The Angular CLI version is the demo app's `src/main.ts`. It loads the overlay and registers both functions in the same step.
86+
87+
Registering `watchState` gives you one log entry per `patchState` call — including several in the same tick. Without it (and without the auto-import working), the overlay batches writes per microtask into one entry.
8288

8389
## Restore a state
8490

@@ -132,5 +138,5 @@ export const appConfig: ApplicationConfig = {
132138
<ngmd-pill-row>
133139
<ngmd-pill href="/inspectors/ngrx-store" title="NgRx Store inspector"></ngmd-pill>
134140
<ngmd-pill href="/getting-started/overlay" title="Browser overlay"></ngmd-pill>
135-
<ngmd-pill href="/agents/resources" title="ngrx-store resource"></ngmd-pill>
141+
<ngmd-pill href="/agents/tools" title="NgRx agent tools"></ngmd-pill>
136142
</ngmd-pill-row>

‎apps/docs/src/content/inspectors/ngrx-store.md‎

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
---
22
title: NgRx Store
3-
description: Live NgRx signal stores and @ngrx/store state, with change logs, diffs, restore and dispatch.
3+
description: Live NgRx signal stores and @ngrx/store state, with entities, dispatched events, change logs, diffs, restore and dispatch.
44
---
55

66
<ngmd-hero title="NgRx Store" logo="https://cdn.simpleicons.org/ngrx/BA2BD2" gradient>
7-
Your NgRx state as it changes. Signal stores and the classic Store, with a log of every change, a diff per entry, restore and dispatch.
7+
Your NgRx state as it changes. Signal stores and the classic Store, with entity collections, dispatched events, a log of every change, a diff per entry, restore and dispatch.
88
</ngmd-hero>
99

1010
# NgRx Store
@@ -32,19 +32,24 @@ Select a store to see:
3232
- Its kind, scope and declaring file. The file appears when the store's state keys match a `signalStore` or `signalState` in your source.
3333
- **Store DevTools on** or **Store DevTools off**, for the classic Store.
3434
- **Referenced by**: the component fields that hold it.
35-
- **State**, **Computed** and **Methods**, with a call count per method. The tab tags `rxMethod` members.
35+
- **State**, **Computed** and **Methods**, with a call count per method. The tab tags `signalMethod` and `rxMethod` members. Once a method has been called, its chip also shows the average and last call duration, in milliseconds.
36+
- **Entities**, for a `signalStore` that calls `withEntities()`. One group per collection, with the entity count and the ids as chips. A group past 30 ids shows the first 30 and a count of the rest.
3637

3738
### Change log
3839

39-
Signal stores get a **Change log**. The classic Store gets an **Action log**. Each entry shows its number, its type, the number of changes and the time. An action also shows a badge for where it came from:
40+
Signal stores get a **Change log**. The classic Store gets an **Action log**. Each entry shows its number, its type, the number of changes and the time. A method-call entry also shows how long the call itself took, and an entry whose change came from a dispatched event carries a tag with the event's type. An action also shows a badge for where it came from:
4041

4142
| Badge | Sent by |
4243
| ---------- | ----------------------------------------------------------------------------- |
4344
| `dispatch` | A `store.dispatch(action)` call, usually from a component or a service. |
4445
| `effect` | An NgRx effect. Effects send their actions through `Store.next`. |
4546
| `reactive` | `store.dispatch(() => action)`, which dispatches again when a signal changes. |
4647

47-
Open an entry to see its arguments, or the action and its **Origin** for the classic Store, and a **State diff** with the value before and after each change. The diff lists up to 50 changes. An action entry also has **Dispatch again**.
48+
Open an entry to see its arguments, or the action and its **Origin** for the classic Store, and a **State diff** with the value before and after each change. The diff lists up to 50 changes. A method-call entry also shows its **Duration**, in milliseconds. An entry caused by a dispatched event shows the event under **Caused by event**. An action entry also has **Dispatch again**.
49+
50+
### Events
51+
52+
When a page dispatches at least one `@ngrx/signals/events` event, an **Events** section lists them: the event type, its payload and the time. This section covers every store on the page, not just the selected one. Open an event to see its full payload in the same detail panel as the change log.
4853

4954
### Dispatch an action
5055

@@ -74,12 +79,14 @@ Filter by kind with the chips.
7479

7580
### How changes are recorded
7681

77-
The overlay wraps the state signals of each signal store and the store's methods. A method call becomes one log entry with its arguments. Nested method calls fold into the outer one. The overlay batches writes made outside a method and logs them as `patchState`.
82+
The overlay wraps the state signals of each signal store and the store's methods. A method call becomes one log entry with its arguments and how long the call itself took to return. That duration covers the synchronous call only, not any async work a `rxMethod` or an effect starts from it. Nested method calls fold into the outer one and do not get their own duration entry. The overlay batches writes made outside a method and logs them as `patchState`.
7883

7984
For the classic Store, the overlay listens to the dispatched actions. It also wraps `dispatch` and `next` on the Store to tag each action with its origin, and puts them back when the page disconnects.
8085

8186
The overlay diffs a copy of the state that keeps the first 100 items of each array or object. When a change is past that limit, it compares the live state instead, so the entry still lists the change.
8287

88+
For `@ngrx/signals/events`, the overlay finds the platform-wide `Dispatcher` and records every event it dispatches. When a `withReducer()` case changes a tracked store's state in response, that change's log entry is tagged with the event that caused it.
89+
8390
### Development builds
8491

8592
The overlay finds stores through Angular's debug API, so the live section needs a development build.
@@ -145,6 +152,8 @@ To try another payload, type the action in **Dispatch an action** instead.
145152
| `ng-devtools:get-ngrx-store` | tool | NgRx declarations from source, with the members of each `signalStore` and the type strings of each action. |
146153
| `ng-devtools:dispatch-ngrx-action` | tool | Dispatches an action to the classic Store, or an action from the log again, and returns the new log entry. |
147154
| `ng-devtools:ngrx-store` | resource | The live stores per page, with state, computeds, methods, references and the change log. Classic Store actions carry an `origin`. |
155+
| `ng-devtools:inspect-signal-store` | tool | The live state of one store, or a summary of every store discovered so far. |
156+
| `ng-devtools:signal-store-history` | tool | The live change log, oldest first: state diffs, classic `@ngrx/store` actions and `@ngrx/signals/events` events. |
148157

149158
No tool can restore a state. See [Dispatch an action](../agents/tools.md#dispatch-an-action) and [Resources](../agents/resources.md).
150159

@@ -178,6 +187,20 @@ Restore, **Back to latest**, **Dispatch** and **Dispatch again** need [`actions.
178187

179188
The log keeps the last 200 entries. Set the count with [`limits.changeLog`](../getting-started/configuration.md#limits). Once older entries are dropped, the log says how many. The overlay logs a method call that changes nothing at most once per second.
180189

190+
### The selected entity is a convention, not an API
191+
192+
`@ngrx/signals` has no built-in concept of a selected entity. The tab looks for a `selectedId` state key (or `<collection>SelectedId` for a named collection) next to a `withEntities()` collection, and shows it as **Selected** when it holds a value. A `null` or missing key shows no **Selected** row.
193+
194+
### Events: only the platform `Dispatcher`, only synchronous reducers are tagged
195+
196+
The overlay subscribes to the platform `Dispatcher` from `@ngrx/signals/events`. A scoped dispatcher created by a feature with its own `provideDispatcher()` is a different injectable and will not surface events here — only the root/platform dispatcher does. Switch to the platform dispatcher if you need to see those events.
197+
198+
Each entry is tagged with its originating event (`caused by`) only when the `withReducer()` case ran synchronously inside the `Dispatcher.dispatch()` call. A `withEffects` tap or an `rxMethod` that reacts to the same event later still shows up as a plain state change — the devtools cannot tell after the fact which event caused it.
199+
200+
### `signalMethod` vs `rxMethod`
201+
202+
Both wrap the returned callable with a `.destroy` function, so the overlay cannot tell them apart from the live store alone. The tab reads the source scan to decide which label to show. A store with no matching declaration file (because its state keys do not line up with any `signalStore` in the scanned source) will label every reactive method as `rxMethod` by default.
203+
181204
## FAQ
182205

183206
<ngmd-accordion>

extension/ui/assets/browser-agent-rpc-BXhoSh1z-BqZcKNTn.js renamed to extension/ui/assets/browser-agent-rpc-BXhoSh1z-ClgmuQMl.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)