From b729125a72a987218881ba573f58196e2d5f6952 Mon Sep 17 00:00:00 2001 From: Oleksandr Oleniuk Date: Fri, 3 Apr 2026 23:42:52 +0100 Subject: [PATCH 1/2] feat: implement event payload exposure on devtools messages --- apps/demo/e2e/events-sample.spec.ts | 63 +++++++++++++++++++ .../devtools/internal/current-action-names.ts | 4 ++ .../internal/devtools-syncer.service.ts | 23 +++++-- .../src/lib/devtools/internal/models.ts | 2 +- .../lib/devtools/tests/action-name.spec.ts | 30 ++++++++- .../lib/devtools/tests/update-state.spec.ts | 28 ++++++++- .../tests/with-tracked-reducer.spec.ts | 25 ++++++++ .../src/lib/devtools/update-state.ts | 33 +++++++++- .../src/lib/devtools/with-tracked-reducer.ts | 2 +- 9 files changed, 198 insertions(+), 12 deletions(-) create mode 100644 apps/demo/e2e/events-sample.spec.ts diff --git a/apps/demo/e2e/events-sample.spec.ts b/apps/demo/e2e/events-sample.spec.ts new file mode 100644 index 00000000..60efb42c --- /dev/null +++ b/apps/demo/e2e/events-sample.spec.ts @@ -0,0 +1,63 @@ +import { Action } from '@ngrx/store'; +import { expect, test } from '@playwright/test'; + +test.describe('events-sample + devtools', () => { + test('DevTools are syncing events and payloads', async ({ page }) => { + await page.goto(''); + + await page.evaluate(() => { + window['devtoolsSpy'] = []; + + window['__REDUX_DEVTOOLS_EXTENSION__'] = { + connect: () => { + return { + send: (data: Action) => { + window['devtoolsSpy'].push(data); + }, + }; + }, + }; + }); + + await page.getByRole('link', { name: 'Events + DevTools Sample' }).click(); + + // Filter by title + await page.getByPlaceholder('Filter by title or author...').fill('Hobbit'); + + // Add Book (Random) + await page.getByRole('button', { name: 'Add Book' }).click(); + + // Clear Selection + await page.getByRole('button', { name: 'Clear Selection' }).click(); + + const devtoolsActions = await page.evaluate(() => window['devtoolsSpy']); + + // Check if filterUpdated event was tracked with the expected payload + const filterEvent = devtoolsActions.find( + (a) => a.type === '[Book Store] filterUpdated', + ); + expect(filterEvent).toBeDefined(); + expect(filterEvent).toEqual({ + type: '[Book Store] filterUpdated', + payload: { filter: 'Hobbit' }, + }); + + // Check selectionCleared event + const clearSelectionEvent = devtoolsActions.find( + (a) => a.type === '[Book Store] selectionCleared', + ); + expect(clearSelectionEvent).toBeDefined(); + expect(clearSelectionEvent).toEqual({ + type: '[Book Store] selectionCleared', + }); + + // Check bookAdded event (payload will contain random book, so just check if payload has book object) + const bookAddedEvent = devtoolsActions.find( + (a) => a.type === '[Book Store] bookAdded', + ); + expect(bookAddedEvent).toBeDefined(); + expect(bookAddedEvent.payload).toBeDefined(); + expect(bookAddedEvent.payload.book).toBeDefined(); + expect(bookAddedEvent.payload.book).toHaveProperty('title'); + }); +}); 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..8886938a 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,5 @@ +import { EventInstance } from '@ngrx/signals/events'; + export const currentActionNames = new Set(); + +export const currentEvents = 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..4d3b7d61 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 @@ -8,9 +8,9 @@ import { } from '@angular/core'; import { StateSource } from '@ngrx/signals'; import { REDUX_DEVTOOLS_CONFIG } from '../provide-devtools-config'; -import { currentActionNames } from './current-action-names'; +import { currentActionNames, currentEvents } 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, @@ -74,6 +74,7 @@ export class DevtoolsSyncer implements OnDestroy { ngOnDestroy(): void { currentActionNames.clear(); + currentEvents.clear(); } syncToDevTools(changedStatePerId: Record) { @@ -90,11 +91,21 @@ export class DevtoolsSyncer implements OnDestroy { ...mappedChangedStatePerName, }; - const names = Array.from(currentActionNames); - const type = names.length ? names.join(', ') : 'Store Update'; - currentActionNames.clear(); + const action: Action = { type: 'Store Update' }; + + if (currentActionNames.size > 0) { + action.type = Array.from(currentActionNames).join(', '); + } else if (currentEvents.size > 0) { + const events = Array.from(currentEvents); + action.type = events.map((e) => e.type).join(', '); + action.payload = + events.length > 1 ? events.map((e) => e.payload) : events[0].payload; + } - this.#connection.send({ type }, this.#currentState); + this.#connection.send(action, this.#currentState); + + currentActionNames.clear(); + currentEvents.clear(); } 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..9c7dab2f 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; payload?: 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..40c7f346 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,5 +1,6 @@ import { TestBed } from '@angular/core/testing'; -import { signalStore, withMethods, withState } from '@ngrx/signals'; +import { signalStore, type, withMethods, withState } from '@ngrx/signals'; +import { event } from '@ngrx/signals/events'; import { updateState } from '../update-state'; import { withDevtools } from '../with-devtools'; import { setupExtensions } from './helpers.spec'; @@ -45,4 +46,31 @@ describe('updateState', () => { { shop: { name: 'i4' } }, ); }); + + it('should set the action name and payload using EventInstance', () => { + const { sendSpy } = setupExtensions(); + + const setNameEvent = event('Set Name', type<{ name: string }>()); + + const Store = signalStore( + { providedIn: 'root' }, + withDevtools('shop'), + withState({ name: 'Car' }), + withMethods((store) => ({ + setName(name: string) { + updateState(store, setNameEvent({ name }), { name }); + }, + })), + ); + const store = TestBed.inject(Store); + TestBed.flushEffects(); + + store.setName('i4'); + TestBed.flushEffects(); + + expect(sendSpy).toHaveBeenLastCalledWith( + { type: 'Set Name', payload: { name: 'i4' } }, + { shop: { name: 'i4' } }, + ); + }); }); diff --git a/libs/ngrx-toolkit/src/lib/devtools/tests/update-state.spec.ts b/libs/ngrx-toolkit/src/lib/devtools/tests/update-state.spec.ts index 6eb623b4..902368e6 100644 --- a/libs/ngrx-toolkit/src/lib/devtools/tests/update-state.spec.ts +++ b/libs/ngrx-toolkit/src/lib/devtools/tests/update-state.spec.ts @@ -1,10 +1,11 @@ import { signalStore, withMethods } from '@ngrx/signals'; import { setAllEntities, withEntities } from '@ngrx/signals/entities'; +import { event } from '@ngrx/signals/events'; import { setLoaded, withCallState } from '../../with-call-state'; import { updateState } from '../update-state'; describe('updateState', () => { - it('should work with multiple updaters', () => { + it('should work with multiple updaters via string action', () => { interface Item { id: string; name: string; @@ -26,4 +27,29 @@ describe('updateState', () => { })), ); }); + + it('should work with multiple updaters via EventInstance action', () => { + interface Item { + id: string; + name: string; + } + + const itemsLoaded = event('Items loaded successfully'); + + signalStore( + withEntities(), + withCallState({ collection: 'items' }), + withMethods((store) => ({ + loadItems() { + // This should not cause a type error + updateState( + store, + itemsLoaded(), + setAllEntities([] as Item[]), + setLoaded('items'), + ); + }, + })), + ); + }); }); 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..b2edd972 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(), + bumpWithPayload: type<{ amount: number }>(), }, }); @@ -52,6 +53,30 @@ describe('withTrackedReducer', () => { ); }); + it('should send a glitched update on event with payload', async () => { + const { sendSpy, withBasicStore } = setup(); + + const Store = signalStore( + { providedIn: 'root' }, + withBasicStore('store'), + withTrackedReducer( + on(testEvents.bumpWithPayload, (event, state) => ({ + count: state.count + event.payload.amount, + })), + ), + ); + TestBed.inject(Store); + + TestBed.inject(Dispatcher).dispatch( + testEvents.bumpWithPayload({ amount: 5 }), + ); + + expect(sendSpy).toHaveBeenLastCalledWith( + { type: '[Spec Store] bumpWithPayload', payload: { amount: 5 } }, + { store: { count: 5 } }, + ); + }); + it('should emit two glitched updates when two stores react to the same event', async () => { const { sendSpy, withBasicStore } = setup(); const StoreA = signalStore( diff --git a/libs/ngrx-toolkit/src/lib/devtools/update-state.ts b/libs/ngrx-toolkit/src/lib/devtools/update-state.ts index 6cda0d56..a4cff967 100644 --- a/libs/ngrx-toolkit/src/lib/devtools/update-state.ts +++ b/libs/ngrx-toolkit/src/lib/devtools/update-state.ts @@ -3,7 +3,11 @@ import { PartialStateUpdater, WritableStateSource, } from '@ngrx/signals'; -import { currentActionNames } from './internal/current-action-names'; +import { EventInstance } from '@ngrx/signals/events'; +import { + currentActionNames, + currentEvents, +} from './internal/current-action-names'; type PatchFn = typeof originalPatchState extends ( arg1: infer First, @@ -32,7 +36,32 @@ export function updateState( ...updaters: Array< Partial> | PartialStateUpdater> > +): void; +/** + * Wrapper of `patchState` for DevTools integration. Next to updating the state, + * it also sends the action to the DevTools. + * @param stateSource state of Signal Store + * @param eventInstance event instance which provides the action name for DevTools + * @param updaters updater functions or objects + */ +export function updateState( + stateSource: WritableStateSource, + eventInstance: EventInstance, + ...updaters: Array< + Partial> | PartialStateUpdater> + > +): void; +export function updateState( + stateSource: WritableStateSource, + eventOrEventType: string | EventInstance, + ...updaters: Array< + Partial> | PartialStateUpdater> + > ): void { - currentActionNames.add(action); + if (typeof eventOrEventType === 'string') { + currentActionNames.add(eventOrEventType); + } else { + currentEvents.add(eventOrEventType); + } return originalPatchState(stateSource, ...updaters); } 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 4652f27784fb6addffd3f694b11ce0dfc7175571 Mon Sep 17 00:00:00 2001 From: Oleksandr Oleniuk Date: Sat, 4 Apr 2026 00:13:52 +0100 Subject: [PATCH 2/2] fix(devtools): update docs --- docs/docs/with-devtools.md | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/docs/docs/with-devtools.md b/docs/docs/with-devtools.md index 73edf360..fc2798fb 100644 --- a/docs/docs/with-devtools.md +++ b/docs/docs/with-devtools.md @@ -50,6 +50,10 @@ 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 can also accept an EventInstance from @ngrx/signals/events. +// It will use the event type and payload for the DevTools. +updateState(this.store, bookEvents.addBook({ book }), { loading: false }); ``` ## `renameDevtoolsName()` @@ -188,6 +192,7 @@ export const bookEvents = eventGroup({ source: 'Book Store', events: { loadBooks: type(), + addBook: type<{ book: Book }>(), }, }); @@ -202,10 +207,16 @@ const Store = signalStore( on(bookEvents.loadBooks, () => ({ books: mockBooks, })), + // `[Book Store] addBook` will show up in the devtools along with its payload `{ book }` + on(bookEvents.addBook, (event, state) => ({ + books: [...state.books, event.book], + })), ), withHooks({ onInit() { - injectDispatch(bookEvents).loadBooks(); + const dispatch = injectDispatch(bookEvents); + dispatch.loadBooks(); + dispatch.addBook({ book: { id: 1, title: '1984' } }); }, }), );