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..ebc527d8 100644 --- a/docs/docs/with-devtools.md +++ b/docs/docs/with-devtools.md @@ -188,6 +188,7 @@ export const bookEvents = eventGroup({ source: 'Book Store', events: { loadBooks: type(), + bookSelected: type<{ bookId: string }>(), }, }); @@ -202,6 +203,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..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 @@ -10,12 +10,27 @@ 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' } as Action; + } + + 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 } as Action) + : ({ type } as Action); +} + /** * A service provided by the root injector is * required because the synchronization runs @@ -90,11 +105,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..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 }; +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/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..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 8da4d970..17a1e836 100644 --- a/libs/ngrx-toolkit/src/lib/devtools/with-tracked-reducer.ts +++ b/libs/ngrx-toolkit/src/lib/devtools/with-tracked-reducer.ts @@ -15,7 +15,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 { asAction, updateState } from './update-state'; import { DEVTOOL_FEATURE_NAMES } from './with-devtools'; export function withTrackedReducer( @@ -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, asAction(event), ...updaters); }), ), ),