From 2df83750b8f26dbee1c2ff7cffcbedb1cb3b7cb1 Mon Sep 17 00:00:00 2001 From: Serhii Siryk Date: Thu, 7 May 2026 21:44:25 +0100 Subject: [PATCH 1/3] feat(devtools): expose tracked event payloads from withTrackedReducer withTrackedReducer now forwards full event objects to updateState so Redux DevTools can show payload data, not only action names. --- .../feature-factory.component.ts | 2 +- docs/docs/with-devtools.md | 12 ++++++-- .../devtools/internal/current-action-names.ts | 4 ++- .../internal/devtools-syncer.service.ts | 21 +++++++++++--- .../src/lib/devtools/internal/models.ts | 2 +- .../lib/devtools/tests/action-name.spec.ts | 25 +++++++++++++++++ .../tests/with-tracked-reducer.spec.ts | 28 +++++++++++++++++++ .../src/lib/devtools/update-state.ts | 3 +- .../src/lib/devtools/with-tracked-reducer.ts | 2 +- 9 files changed, 88 insertions(+), 11 deletions(-) diff --git a/apps/demo/src/app/feature-factory/feature-factory.component.ts b/apps/demo/src/app/feature-factory/feature-factory.component.ts index 86a484aa..709f7a03 100644 --- a/apps/demo/src/app/feature-factory/feature-factory.component.ts +++ b/apps/demo/src/app/feature-factory/feature-factory.component.ts @@ -34,7 +34,7 @@ function withMyEntity(loadMethod: (id: number) => Promise) { const UserStore = signalStore( { providedIn: 'root' }, withMethods(() => ({ - findById(id: number) { + findById(_id: number) { return of({ id: 1, name: 'Konrad' }); }, })), diff --git a/docs/docs/with-devtools.md b/docs/docs/with-devtools.md index 73edf360..7d37f657 100644 --- a/docs/docs/with-devtools.md +++ b/docs/docs/with-devtools.md @@ -41,7 +41,7 @@ The extensions don't activate during app initialization (as it is with `@ngrx/st import { updateState } from '@angular-architects/ngrx-toolkit'; ``` -The Signal Store does not use the Redux pattern, so there are no action names involved by default. Instead, every action is referred to as a "Store Update". If you want to customize the action name for better clarity, you can use the `updateState()` function instead of `patchState()`: +The Signal Store does not use the Redux pattern, so there are no action names involved by default. Instead, every action is referred to as a "Store Update". If you want to customize the action entry for better clarity, you can use the `updateState()` function instead of `patchState()`: ```typescript import { updateState } from '@angular-architects/ngrx-toolkit'; @@ -50,6 +50,9 @@ patchState(this.store, { loading: false }); // updateState is a wrapper around patchState and has an action name as second parameter updateState(this.store, 'update loading', { loading: false }); + +// updateState also accepts an action object with a mandatory type field +updateState(this.store, { type: '[Book Store] bookSelected', payload: { bookId: '1' } }, { selectedBookId: '1' }); ``` ## `renameDevtoolsName()` @@ -172,7 +175,7 @@ const Store = signalStore( ## Events tracking: `withTrackedReducer` `withTrackedReducer` tracks state changes within the events -plugin. This utility automatically derives the event name, streamlining +plugin. This utility automatically derives the event entry, streamlining the tracking process. To use it @@ -188,6 +191,7 @@ export const bookEvents = eventGroup({ source: 'Book Store', events: { loadBooks: type(), + bookSelected: type<{ bookId: string }>(), }, }); @@ -202,6 +206,10 @@ const Store = signalStore( on(bookEvents.loadBooks, () => ({ books: mockBooks, })), + // DevTools action will include payload: { bookId: string } + on(bookEvents.bookSelected, ({ payload }) => ({ + selectedBookId: payload.bookId, + })), ), withHooks({ onInit() { diff --git a/libs/ngrx-toolkit/src/lib/devtools/internal/current-action-names.ts b/libs/ngrx-toolkit/src/lib/devtools/internal/current-action-names.ts index 3be2be44..8c198f68 100644 --- a/libs/ngrx-toolkit/src/lib/devtools/internal/current-action-names.ts +++ b/libs/ngrx-toolkit/src/lib/devtools/internal/current-action-names.ts @@ -1 +1,3 @@ -export const currentActionNames = new Set(); +import { Action } from './models'; + +export const currentActionNames = new Set(); diff --git a/libs/ngrx-toolkit/src/lib/devtools/internal/devtools-syncer.service.ts b/libs/ngrx-toolkit/src/lib/devtools/internal/devtools-syncer.service.ts index 29c84b52..e6695b7d 100644 --- a/libs/ngrx-toolkit/src/lib/devtools/internal/devtools-syncer.service.ts +++ b/libs/ngrx-toolkit/src/lib/devtools/internal/devtools-syncer.service.ts @@ -10,12 +10,25 @@ import { StateSource } from '@ngrx/signals'; import { REDUX_DEVTOOLS_CONFIG } from '../provide-devtools-config'; import { currentActionNames } from './current-action-names'; import { DevtoolsInnerOptions } from './devtools-feature'; -import { Connection, StoreRegistry, Tracker } from './models'; +import { Action, Connection, StoreRegistry, Tracker } from './models'; const dummyConnection: Connection = { send: () => void true, }; +function toDevtoolsAction(actions: (string | Action)[]): Action { + if (!actions.length) { + return { type: 'Store Update' }; + } + + const objects = actions.filter((a): a is Action => typeof a === 'object'); + const type = [ + ...new Set(actions.map((a) => (typeof a === 'string' ? a : a.type))), + ].join(', '); + + return objects.length ? { ...objects[0], type } : { type }; +} + /** * A service provided by the root injector is * required because the synchronization runs @@ -90,11 +103,11 @@ export class DevtoolsSyncer implements OnDestroy { ...mappedChangedStatePerName, }; - const names = Array.from(currentActionNames); - const type = names.length ? names.join(', ') : 'Store Update'; + const actions = Array.from(currentActionNames); + const action = toDevtoolsAction(actions); currentActionNames.clear(); - this.#connection.send({ type }, this.#currentState); + this.#connection.send(action, this.#currentState); } getNextId() { diff --git a/libs/ngrx-toolkit/src/lib/devtools/internal/models.ts b/libs/ngrx-toolkit/src/lib/devtools/internal/models.ts index d82a6fc7..0fd6b33b 100644 --- a/libs/ngrx-toolkit/src/lib/devtools/internal/models.ts +++ b/libs/ngrx-toolkit/src/lib/devtools/internal/models.ts @@ -2,7 +2,7 @@ import { StateSource } from '@ngrx/signals'; import { ReduxDevtoolsConfig } from '../provide-devtools-config'; import { DevtoolsInnerOptions } from './devtools-feature'; -export type Action = { type: string }; +export type Action = { type: string; [key: string]: unknown }; export type Connection = { send: (action: Action, state: Record) => void; }; diff --git a/libs/ngrx-toolkit/src/lib/devtools/tests/action-name.spec.ts b/libs/ngrx-toolkit/src/lib/devtools/tests/action-name.spec.ts index e059797c..a6a4592c 100644 --- a/libs/ngrx-toolkit/src/lib/devtools/tests/action-name.spec.ts +++ b/libs/ngrx-toolkit/src/lib/devtools/tests/action-name.spec.ts @@ -45,4 +45,29 @@ describe('updateState', () => { { shop: { name: 'i4' } }, ); }); + + it('should set and send an action object', () => { + const { sendSpy } = setupExtensions(); + + const Store = signalStore( + { providedIn: 'root' }, + withDevtools('shop'), + withState({ name: 'Car' }), + withMethods((store) => ({ + setName(name: string) { + updateState(store, { type: 'Set Name', name }, { name }); + }, + })), + ); + const store = TestBed.inject(Store); + TestBed.flushEffects(); + + store.setName('i4'); + TestBed.flushEffects(); + + expect(sendSpy).toHaveBeenLastCalledWith( + { type: 'Set Name', name: 'i4' }, + { shop: { name: 'i4' } }, + ); + }); }); diff --git a/libs/ngrx-toolkit/src/lib/devtools/tests/with-tracked-reducer.spec.ts b/libs/ngrx-toolkit/src/lib/devtools/tests/with-tracked-reducer.spec.ts index 185de0c4..5fb8d082 100644 --- a/libs/ngrx-toolkit/src/lib/devtools/tests/with-tracked-reducer.spec.ts +++ b/libs/ngrx-toolkit/src/lib/devtools/tests/with-tracked-reducer.spec.ts @@ -28,6 +28,7 @@ const testEvents = eventGroup({ source: 'Spec Store', events: { bump: type(), + bookSelected: type<{ bookId: string }>(), }, }); @@ -79,6 +80,29 @@ describe('withTrackedReducer', () => { ); }); + it('should send event payload to devtools when using tracked reducer', () => { + const { sendSpy, withBasicStore } = setup(); + + const Store = signalStore( + { providedIn: 'root' }, + withBasicStore('store'), + withTrackedReducer( + on(testEvents.bookSelected, ({ payload }) => ({ + count: Number(payload.bookId), + })), + ), + ); + + TestBed.inject(Store); + + dispatchBookSelectedEvent('42'); + + expect(sendSpy).toHaveBeenLastCalledWith( + { type: '[Spec Store] bookSelected', payload: { bookId: '42' } }, + { store: { count: 42 } }, + ); + }); + it('should distinguish between two synchronous state changes in reducer and normal patchState', () => { const { sendSpy, withBasicStore } = setup(); @@ -260,6 +284,10 @@ function dispatchBumpEvent() { TestBed.inject(Dispatcher).dispatch(testEvents.bump()); } +function dispatchBookSelectedEvent(bookId: string) { + TestBed.inject(Dispatcher).dispatch(testEvents.bookSelected({ bookId })); +} + function setup() { const { sendSpy } = setupExtensions(); diff --git a/libs/ngrx-toolkit/src/lib/devtools/update-state.ts b/libs/ngrx-toolkit/src/lib/devtools/update-state.ts index 6cda0d56..99a940cf 100644 --- a/libs/ngrx-toolkit/src/lib/devtools/update-state.ts +++ b/libs/ngrx-toolkit/src/lib/devtools/update-state.ts @@ -4,6 +4,7 @@ import { WritableStateSource, } from '@ngrx/signals'; import { currentActionNames } from './internal/current-action-names'; +import { Action } from './internal/models'; type PatchFn = typeof originalPatchState extends ( arg1: infer First, @@ -28,7 +29,7 @@ export const patchState: PatchFn = (state, action, ...rest) => { */ export function updateState( stateSource: WritableStateSource, - action: string, + action: string | Action, ...updaters: Array< Partial> | PartialStateUpdater> > diff --git a/libs/ngrx-toolkit/src/lib/devtools/with-tracked-reducer.ts b/libs/ngrx-toolkit/src/lib/devtools/with-tracked-reducer.ts index 8da4d970..ed650bd0 100644 --- a/libs/ngrx-toolkit/src/lib/devtools/with-tracked-reducer.ts +++ b/libs/ngrx-toolkit/src/lib/devtools/with-tracked-reducer.ts @@ -39,7 +39,7 @@ export function withTrackedReducer( const result = caseReducer.reducer(event, state); const updaters = Array.isArray(result) ? result : [result]; - updateState(store, event.type, ...updaters); + updateState(store, event, ...updaters); }), ), ), From 83cb10c16c127b728050705430f9536c7c2ff1f0 Mon Sep 17 00:00:00 2001 From: Serhii Siryk Date: Sat, 30 May 2026 23:12:36 +0100 Subject: [PATCH 2/3] feat(devtools): forward tracked event payloads without breaking changs to updateState --- docs/docs/with-devtools.md | 7 ++---- .../lib/devtools/tests/action-name.spec.ts | 25 ------------------- .../src/lib/devtools/update-state.ts | 3 +-- .../src/lib/devtools/with-tracked-reducer.ts | 6 +++-- 4 files changed, 7 insertions(+), 34 deletions(-) diff --git a/docs/docs/with-devtools.md b/docs/docs/with-devtools.md index 7d37f657..ebc527d8 100644 --- a/docs/docs/with-devtools.md +++ b/docs/docs/with-devtools.md @@ -41,7 +41,7 @@ The extensions don't activate during app initialization (as it is with `@ngrx/st import { updateState } from '@angular-architects/ngrx-toolkit'; ``` -The Signal Store does not use the Redux pattern, so there are no action names involved by default. Instead, every action is referred to as a "Store Update". If you want to customize the action entry for better clarity, you can use the `updateState()` function instead of `patchState()`: +The Signal Store does not use the Redux pattern, so there are no action names involved by default. Instead, every action is referred to as a "Store Update". If you want to customize the action name for better clarity, you can use the `updateState()` function instead of `patchState()`: ```typescript import { updateState } from '@angular-architects/ngrx-toolkit'; @@ -50,9 +50,6 @@ patchState(this.store, { loading: false }); // updateState is a wrapper around patchState and has an action name as second parameter updateState(this.store, 'update loading', { loading: false }); - -// updateState also accepts an action object with a mandatory type field -updateState(this.store, { type: '[Book Store] bookSelected', payload: { bookId: '1' } }, { selectedBookId: '1' }); ``` ## `renameDevtoolsName()` @@ -175,7 +172,7 @@ const Store = signalStore( ## Events tracking: `withTrackedReducer` `withTrackedReducer` tracks state changes within the events -plugin. This utility automatically derives the event entry, streamlining +plugin. This utility automatically derives the event name, streamlining the tracking process. To use it diff --git a/libs/ngrx-toolkit/src/lib/devtools/tests/action-name.spec.ts b/libs/ngrx-toolkit/src/lib/devtools/tests/action-name.spec.ts index a6a4592c..e059797c 100644 --- a/libs/ngrx-toolkit/src/lib/devtools/tests/action-name.spec.ts +++ b/libs/ngrx-toolkit/src/lib/devtools/tests/action-name.spec.ts @@ -45,29 +45,4 @@ describe('updateState', () => { { shop: { name: 'i4' } }, ); }); - - it('should set and send an action object', () => { - const { sendSpy } = setupExtensions(); - - const Store = signalStore( - { providedIn: 'root' }, - withDevtools('shop'), - withState({ name: 'Car' }), - withMethods((store) => ({ - setName(name: string) { - updateState(store, { type: 'Set Name', name }, { name }); - }, - })), - ); - const store = TestBed.inject(Store); - TestBed.flushEffects(); - - store.setName('i4'); - TestBed.flushEffects(); - - expect(sendSpy).toHaveBeenLastCalledWith( - { type: 'Set Name', name: 'i4' }, - { shop: { name: 'i4' } }, - ); - }); }); diff --git a/libs/ngrx-toolkit/src/lib/devtools/update-state.ts b/libs/ngrx-toolkit/src/lib/devtools/update-state.ts index 99a940cf..6cda0d56 100644 --- a/libs/ngrx-toolkit/src/lib/devtools/update-state.ts +++ b/libs/ngrx-toolkit/src/lib/devtools/update-state.ts @@ -4,7 +4,6 @@ import { WritableStateSource, } from '@ngrx/signals'; import { currentActionNames } from './internal/current-action-names'; -import { Action } from './internal/models'; type PatchFn = typeof originalPatchState extends ( arg1: infer First, @@ -29,7 +28,7 @@ export const patchState: PatchFn = (state, action, ...rest) => { */ export function updateState( stateSource: WritableStateSource, - action: string | Action, + action: string, ...updaters: Array< Partial> | PartialStateUpdater> > diff --git a/libs/ngrx-toolkit/src/lib/devtools/with-tracked-reducer.ts b/libs/ngrx-toolkit/src/lib/devtools/with-tracked-reducer.ts index ed650bd0..c1e72203 100644 --- a/libs/ngrx-toolkit/src/lib/devtools/with-tracked-reducer.ts +++ b/libs/ngrx-toolkit/src/lib/devtools/with-tracked-reducer.ts @@ -3,6 +3,7 @@ import { EmptyFeatureResult, getState, PartialStateUpdater, + patchState, SignalStoreFeature, signalStoreFeature, type, @@ -15,7 +16,7 @@ import { } from '@ngrx/signals/events'; import { tap } from 'rxjs/operators'; import { GLITCH_TRACKING_FEATURE } from './features/with-glitch-tracking'; -import { updateState } from './update-state'; +import { currentActionNames } from './internal/current-action-names'; import { DEVTOOL_FEATURE_NAMES } from './with-devtools'; export function withTrackedReducer( @@ -39,7 +40,8 @@ export function withTrackedReducer( const result = caseReducer.reducer(event, state); const updaters = Array.isArray(result) ? result : [result]; - updateState(store, event, ...updaters); + currentActionNames.add(event); + patchState(store, ...updaters); }), ), ), From 83f6b129b712f91f2daad66fcc29d4ce487e7e3a Mon Sep 17 00:00:00 2001 From: Serhii Siryk Date: Tue, 2 Jun 2026 12:33:15 +0100 Subject: [PATCH 3/3] feat(devtools): add branded Action type and asAction helper --- .../internal/devtools-syncer.service.ts | 6 +++-- .../src/lib/devtools/internal/models.ts | 5 +++- .../lib/devtools/tests/action-name.spec.ts | 27 ++++++++++++++++++- .../src/lib/devtools/update-state.ts | 11 +++++++- .../src/lib/devtools/with-tracked-reducer.ts | 6 ++--- 5 files changed, 46 insertions(+), 9 deletions(-) diff --git a/libs/ngrx-toolkit/src/lib/devtools/internal/devtools-syncer.service.ts b/libs/ngrx-toolkit/src/lib/devtools/internal/devtools-syncer.service.ts index e6695b7d..ff2ec27d 100644 --- a/libs/ngrx-toolkit/src/lib/devtools/internal/devtools-syncer.service.ts +++ b/libs/ngrx-toolkit/src/lib/devtools/internal/devtools-syncer.service.ts @@ -18,7 +18,7 @@ const dummyConnection: Connection = { function toDevtoolsAction(actions: (string | Action)[]): Action { if (!actions.length) { - return { type: 'Store Update' }; + return { type: 'Store Update' } as Action; } const objects = actions.filter((a): a is Action => typeof a === 'object'); @@ -26,7 +26,9 @@ function toDevtoolsAction(actions: (string | Action)[]): Action { ...new Set(actions.map((a) => (typeof a === 'string' ? a : a.type))), ].join(', '); - return objects.length ? { ...objects[0], type } : { type }; + return objects.length + ? ({ ...objects[0], type } as Action) + : ({ type } as Action); } /** diff --git a/libs/ngrx-toolkit/src/lib/devtools/internal/models.ts b/libs/ngrx-toolkit/src/lib/devtools/internal/models.ts index 0fd6b33b..bdc31c45 100644 --- a/libs/ngrx-toolkit/src/lib/devtools/internal/models.ts +++ b/libs/ngrx-toolkit/src/lib/devtools/internal/models.ts @@ -2,7 +2,10 @@ import { StateSource } from '@ngrx/signals'; import { ReduxDevtoolsConfig } from '../provide-devtools-config'; import { DevtoolsInnerOptions } from './devtools-feature'; -export type Action = { type: string; [key: string]: unknown }; +declare const __actionBrand: unique symbol; +export type Action = { type: string; [key: string]: unknown } & { + readonly [__actionBrand]: true; +}; export type Connection = { send: (action: Action, state: Record) => void; }; diff --git a/libs/ngrx-toolkit/src/lib/devtools/tests/action-name.spec.ts b/libs/ngrx-toolkit/src/lib/devtools/tests/action-name.spec.ts index e059797c..f04cb31a 100644 --- a/libs/ngrx-toolkit/src/lib/devtools/tests/action-name.spec.ts +++ b/libs/ngrx-toolkit/src/lib/devtools/tests/action-name.spec.ts @@ -1,6 +1,6 @@ import { TestBed } from '@angular/core/testing'; import { signalStore, withMethods, withState } from '@ngrx/signals'; -import { updateState } from '../update-state'; +import { asAction, updateState } from '../update-state'; import { withDevtools } from '../with-devtools'; import { setupExtensions } from './helpers.spec'; @@ -45,4 +45,29 @@ describe('updateState', () => { { shop: { name: 'i4' } }, ); }); + + it('should set and send an action object', () => { + const { sendSpy } = setupExtensions(); + + const Store = signalStore( + { providedIn: 'root' }, + withDevtools('shop'), + withState({ name: 'Car' }), + withMethods((store) => ({ + setName(name: string) { + updateState(store, asAction({ type: 'Set Name', name }), { name }); + }, + })), + ); + const store = TestBed.inject(Store); + TestBed.flushEffects(); + + store.setName('i4'); + TestBed.flushEffects(); + + expect(sendSpy).toHaveBeenLastCalledWith( + { type: 'Set Name', name: 'i4' }, + { shop: { name: 'i4' } }, + ); + }); }); diff --git a/libs/ngrx-toolkit/src/lib/devtools/update-state.ts b/libs/ngrx-toolkit/src/lib/devtools/update-state.ts index 6cda0d56..d2af8ce3 100644 --- a/libs/ngrx-toolkit/src/lib/devtools/update-state.ts +++ b/libs/ngrx-toolkit/src/lib/devtools/update-state.ts @@ -4,6 +4,7 @@ import { WritableStateSource, } from '@ngrx/signals'; import { currentActionNames } from './internal/current-action-names'; +import { Action } from './internal/models'; type PatchFn = typeof originalPatchState extends ( arg1: infer First, @@ -19,6 +20,14 @@ export const patchState: PatchFn = (state, action, ...rest) => { updateState(state, action, ...rest); }; +/** + * Casts an object with a `type` property to an {@link Action}. + * Use this when you need to pass a structured action object to {@link updateState}. + */ +export function asAction(value: T): Action { + return value as unknown as Action; +} + /** * Wrapper of `patchState` for DevTools integration. Next to updating the state, * it also sends the action to the DevTools. @@ -28,7 +37,7 @@ export const patchState: PatchFn = (state, action, ...rest) => { */ export function updateState( stateSource: WritableStateSource, - action: string, + action: string | Action, ...updaters: Array< Partial> | PartialStateUpdater> > diff --git a/libs/ngrx-toolkit/src/lib/devtools/with-tracked-reducer.ts b/libs/ngrx-toolkit/src/lib/devtools/with-tracked-reducer.ts index c1e72203..92087146 100644 --- a/libs/ngrx-toolkit/src/lib/devtools/with-tracked-reducer.ts +++ b/libs/ngrx-toolkit/src/lib/devtools/with-tracked-reducer.ts @@ -3,7 +3,6 @@ import { EmptyFeatureResult, getState, PartialStateUpdater, - patchState, SignalStoreFeature, signalStoreFeature, type, @@ -16,7 +15,7 @@ import { } from '@ngrx/signals/events'; import { tap } from 'rxjs/operators'; import { GLITCH_TRACKING_FEATURE } from './features/with-glitch-tracking'; -import { currentActionNames } from './internal/current-action-names'; +import { updateState, asAction } from './update-state'; import { DEVTOOL_FEATURE_NAMES } from './with-devtools'; export function withTrackedReducer( @@ -40,8 +39,7 @@ export function withTrackedReducer( const result = caseReducer.reducer(event, state); const updaters = Array.isArray(result) ? result : [result]; - currentActionNames.add(event); - patchState(store, ...updaters); + updateState(store, asAction(event), ...updaters); }), ), ),