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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
637 changes: 475 additions & 162 deletions app/src/pages/store-inspector.ts

Large diffs are not rendered by default.

46 changes: 44 additions & 2 deletions app/src/pages/store-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -28,7 +45,21 @@ export interface NgrxSignalStoreInfo {
stateKeys: string[];
state: Record<string, unknown>;
computed: Record<string, unknown>;
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;
}
Expand All @@ -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[];
Expand All @@ -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';
}

Expand Down
2 changes: 1 addition & 1 deletion apps/docs/src/app/components/llm-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ interface MenuItem {
<button
type="button"
(click)="copyMarkdownAction($event)"
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"
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"
[attr.aria-label]="copied() ? 'Markdown copied' : 'Copy markdown to clipboard'"
>
<svg
Expand Down
16 changes: 8 additions & 8 deletions apps/docs/src/content/agents/resources.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,14 +33,14 @@ Each one returns JSON.

## Available resources

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

### component-tree

Expand Down
23 changes: 23 additions & 0 deletions apps/docs/src/content/agents/tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,29 @@ The injectors a page reported. Element injectors list what each component inject

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.

## NgRx

Two tools read the live `@ngrx/signals` and `@ngrx/store` state a page reported. Reads: page.

### inspect-signal-store

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.

| Argument | Required | Value |
| --------- | -------- | -------------------------------------------------------------------------------------------------- |
| `page` | no | The tab to read. Defaults to every connected page. |
| `storeId` | no | A store id from a previous call, or the id shown on the [NgRx Store](/inspectors/ngrx-store) page. |

### signal-store-history

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.

| Argument | Required | Value |
| --------- | -------- | --------------------------------------------------------------------- |
| `page` | no | The tab to read. Defaults to every connected page. |
| `storeId` | no | Only that store's entries. Without it, every store, action and event. |
| `since` | no | A `seq` from a previous call. Returns only entries reported after it. |

## Router

All router tools read the page, except `explain-render-mode`, which also reads your `*.routes.server.ts` files.
Expand Down
20 changes: 13 additions & 7 deletions apps/docs/src/content/guides/ngrx-signals-restore.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,11 @@ export const TravelStore = signalStore(

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

## Register patchState
## Register patchState and watchState

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.
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.

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.

```ts group="register" name="Angular CLI" active
// src/main.ts
Expand All @@ -54,7 +56,9 @@ bootstrapApplication(App, appConfig)
.then(() =>
Promise.all([import('@santoshyadavdev/ng-devtools/overlay'), import('@ngrx/signals')]),
)
.then(([devtools, {patchState}]) => devtools.registerNgrxSignals({patchState}));
.then(([devtools, {patchState, watchState}]) =>
devtools.registerNgrxSignals({patchState, watchState}),
);
}
return undefined;
})
Expand All @@ -69,16 +73,18 @@ import {appConfig} from './app/app.config';

bootstrapApplication(App, appConfig).then(async () => {
if (import.meta.env.DEV) {
const [devtools, {patchState}] = await Promise.all([
const [devtools, {patchState, watchState}] = await Promise.all([
import('@santoshyadavdev/ng-devtools/overlay'),
import('@ngrx/signals'),
]);
devtools.registerNgrxSignals({patchState});
devtools.registerNgrxSignals({patchState, watchState});
}
});
```

The Angular CLI version is the demo app's `src/main.ts`. It loads the overlay and registers `patchState` in the same step.
The Angular CLI version is the demo app's `src/main.ts`. It loads the overlay and registers both functions in the same step.

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.

## Restore a state

Expand Down Expand Up @@ -132,5 +138,5 @@ export const appConfig: ApplicationConfig = {
<ngmd-pill-row>
<ngmd-pill href="/inspectors/ngrx-store" title="NgRx Store inspector"></ngmd-pill>
<ngmd-pill href="/getting-started/overlay" title="Browser overlay"></ngmd-pill>
<ngmd-pill href="/agents/resources" title="ngrx-store resource"></ngmd-pill>
<ngmd-pill href="/agents/tools" title="NgRx agent tools"></ngmd-pill>
</ngmd-pill-row>
35 changes: 29 additions & 6 deletions apps/docs/src/content/inspectors/ngrx-store.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
---
title: NgRx Store
description: Live NgRx signal stores and @ngrx/store state, with change logs, diffs, restore and dispatch.
description: Live NgRx signal stores and @ngrx/store state, with entities, dispatched events, change logs, diffs, restore and dispatch.
---

<ngmd-hero title="NgRx Store" logo="https://cdn.simpleicons.org/ngrx/BA2BD2" gradient>
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.
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.
</ngmd-hero>

# NgRx Store
Expand Down Expand Up @@ -32,19 +32,24 @@ Select a store to see:
- Its kind, scope and declaring file. The file appears when the store's state keys match a `signalStore` or `signalState` in your source.
- **Store DevTools on** or **Store DevTools off**, for the classic Store.
- **Referenced by**: the component fields that hold it.
- **State**, **Computed** and **Methods**, with a call count per method. The tab tags `rxMethod` members.
- **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.
- **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.

### Change log

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:
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:

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

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**.
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**.

### Events

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.

### Dispatch an action

Expand Down Expand Up @@ -74,12 +79,14 @@ Filter by kind with the chips.

### How changes are recorded

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`.
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`.

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.

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.

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.

### Development builds

The overlay finds stores through Angular's debug API, so the live section needs a development build.
Expand Down Expand Up @@ -145,6 +152,8 @@ To try another payload, type the action in **Dispatch an action** instead.
| `ng-devtools:get-ngrx-store` | tool | NgRx declarations from source, with the members of each `signalStore` and the type strings of each action. |
| `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. |
| `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`. |
| `ng-devtools:inspect-signal-store` | tool | The live state of one store, or a summary of every store discovered so far. |
| `ng-devtools:signal-store-history` | tool | The live change log, oldest first: state diffs, classic `@ngrx/store` actions and `@ngrx/signals/events` events. |

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

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

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.

### The selected entity is a convention, not an API

`@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.

### Events: only the platform `Dispatcher`, only synchronous reducers are tagged

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.

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.

### `signalMethod` vs `rxMethod`

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.

## FAQ

<ngmd-accordion>
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading