From 07548e3750be5bb6eea4efb2828e8d8710c30052 Mon Sep 17 00:00:00 2001 From: Murat Sari Date: Sat, 6 Sep 2025 11:18:51 +0200 Subject: [PATCH 1/3] feat: add withEventsTracking (#231) --- apps/demo/e2e/devtools.spec.ts | 4 +- apps/demo/src/app/app.component.html | 1 + .../demo/src/app/events-sample/book-events.ts | 16 ++ apps/demo/src/app/events-sample/book.model.ts | 51 +++++ apps/demo/src/app/events-sample/book.store.ts | 81 ++++++++ .../events-sample/events-sample.component.ts | 196 ++++++++++++++++++ apps/demo/src/app/lazy-routes.ts | 2 + libs/ngrx-toolkit/src/index.ts | 1 + .../devtools/features/with-events-tracking.ts | 13 ++ .../lib/devtools/internal/devtools-feature.ts | 2 + .../src/lib/devtools/with-devtools.ts | 21 ++ 11 files changed, 386 insertions(+), 2 deletions(-) create mode 100644 apps/demo/src/app/events-sample/book-events.ts create mode 100644 apps/demo/src/app/events-sample/book.model.ts create mode 100644 apps/demo/src/app/events-sample/book.store.ts create mode 100644 apps/demo/src/app/events-sample/events-sample.component.ts create mode 100644 libs/ngrx-toolkit/src/lib/devtools/features/with-events-tracking.ts diff --git a/apps/demo/e2e/devtools.spec.ts b/apps/demo/e2e/devtools.spec.ts index dc8265ca..26f29766 100644 --- a/apps/demo/e2e/devtools.spec.ts +++ b/apps/demo/e2e/devtools.spec.ts @@ -8,7 +8,7 @@ test.describe('DevTools', () => { await page.goto(''); const errors = []; page.on('pageerror', (error) => errors.push(error)); - await page.getByRole('link', { name: 'DevTools' }).click(); + await page.getByRole('link', { name: 'DevTools', exact: true }).click(); await expect( page.getByRole('row', { name: 'Go for a walk' }), ).toBeVisible(); @@ -30,7 +30,7 @@ test.describe('DevTools', () => { }, }; }); - await page.getByRole('link', { name: 'DevTools' }).click(); + await page.getByRole('link', { name: 'DevTools', exact: true }).click(); await page .getByRole('row', { name: 'Go for a walk' }) .getByRole('checkbox') diff --git a/apps/demo/src/app/app.component.html b/apps/demo/src/app/app.component.html index a18faa3d..1f8fd63b 100644 --- a/apps/demo/src/app/app.component.html +++ b/apps/demo/src/app/app.component.html @@ -5,6 +5,7 @@ DevTools + Events + DevTools Sample withRedux withDataService (Simple)(), + bookSelected: type<{ bookId: string }>(), + selectionCleared: type(), + filterUpdated: type<{ filter: string }>(), + stockToggled: type<{ bookId: string }>(), + bookAdded: type<{ book: Book }>(), + bookRemoved: type<{ bookId: string }>(), + }, +}); diff --git a/apps/demo/src/app/events-sample/book.model.ts b/apps/demo/src/app/events-sample/book.model.ts new file mode 100644 index 00000000..3a950cf5 --- /dev/null +++ b/apps/demo/src/app/events-sample/book.model.ts @@ -0,0 +1,51 @@ +export interface Book { + id: string; + title: string; + author: string; + year: number; + isbn: string; + inStock: boolean; +} + +export const mockBooks: Book[] = [ + { + id: '1', + title: 'The Great Gatsby', + author: 'F. Scott Fitzgerald', + year: 1925, + isbn: '978-0-7432-7356-5', + inStock: true, + }, + { + id: '2', + title: '1984', + author: 'George Orwell', + year: 1949, + isbn: '978-0-452-28423-4', + inStock: true, + }, + { + id: '3', + title: 'To Kill a Mockingbird', + author: 'Harper Lee', + year: 1960, + isbn: '978-0-06-112008-4', + inStock: false, + }, + { + id: '4', + title: 'Pride and Prejudice', + author: 'Jane Austen', + year: 1813, + isbn: '978-0-14-143951-8', + inStock: true, + }, + { + id: '5', + title: 'The Catcher in the Rye', + author: 'J.D. Salinger', + year: 1951, + isbn: '978-0-316-76948-0', + inStock: false, + }, +]; diff --git a/apps/demo/src/app/events-sample/book.store.ts b/apps/demo/src/app/events-sample/book.store.ts new file mode 100644 index 00000000..12b478bb --- /dev/null +++ b/apps/demo/src/app/events-sample/book.store.ts @@ -0,0 +1,81 @@ +import { + withDevtools, + withEventsTracking, +} from '@angular-architects/ngrx-toolkit'; +import { signalStore, withComputed, withHooks, withState } from '@ngrx/signals'; +import { injectDispatch, on, withReducer } from '@ngrx/signals/events'; +import { bookEvents } from './book-events'; +import { Book, mockBooks } from './book.model'; + +export const BookStore = signalStore( + { providedIn: 'root' }, + withDevtools('book-store-events', withEventsTracking()), + withState({ + books: [] as Book[], + selectedBookId: null as string | null, + filter: '', + }), + + withComputed((store) => ({ + selectedBook: () => { + const id = store.selectedBookId(); + return id ? store.books().find((b) => b.id === id) || null : null; + }, + + filteredBooks: () => { + const filter = store.filter().toLowerCase(); + if (!filter) return store.books(); + + return store + .books() + .filter( + (book) => + book.title.toLowerCase().includes(filter) || + book.author.toLowerCase().includes(filter), + ); + }, + + totalBooks: () => store.books().length, + + availableBooks: () => store.books().filter((book) => book.inStock).length, + })), + + withReducer( + on(bookEvents.loadBooks, () => ({ + books: mockBooks, + })), + + on(bookEvents.bookSelected, ({ payload }) => ({ + selectedBookId: payload.bookId, + })), + + on(bookEvents.selectionCleared, () => ({ + selectedBookId: null, + })), + + on(bookEvents.filterUpdated, ({ payload }) => ({ + filter: payload.filter, + })), + + on(bookEvents.stockToggled, (event, state) => ({ + books: state.books.map((book) => + book.id === event.payload.bookId + ? { ...book, inStock: !book.inStock } + : book, + ), + })), + + on(bookEvents.bookAdded, (event, state) => ({ + books: [...state.books, event.payload.book], + })), + + on(bookEvents.bookRemoved, (event, state) => ({ + books: state.books.filter((book) => book.id !== event.payload.bookId), + })), + ), + withHooks({ + onInit() { + injectDispatch(bookEvents).loadBooks(); + }, + }), +); diff --git a/apps/demo/src/app/events-sample/events-sample.component.ts b/apps/demo/src/app/events-sample/events-sample.component.ts new file mode 100644 index 00000000..5c15e6a2 --- /dev/null +++ b/apps/demo/src/app/events-sample/events-sample.component.ts @@ -0,0 +1,196 @@ +import { CommonModule } from '@angular/common'; +import { Component, inject } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { MatCardModule } from '@angular/material/card'; +import { MatChipsModule } from '@angular/material/chips'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatGridListModule } from '@angular/material/grid-list'; +import { MatIconModule } from '@angular/material/icon'; +import { MatInputModule } from '@angular/material/input'; +import { MatToolbarModule } from '@angular/material/toolbar'; +import { injectDispatch } from '@ngrx/signals/events'; +import { bookEvents } from './book-events'; +import { BookStore } from './book.store'; + +@Component({ + selector: 'demo-events-sample', + standalone: true, + imports: [ + CommonModule, + FormsModule, + MatCardModule, + MatButtonModule, + MatInputModule, + MatFormFieldModule, + MatIconModule, + MatChipsModule, + MatGridListModule, + MatToolbarModule, + ], + template: ` + + Book Store with Event Tracking + + + + + + Search books + + search + + + + + + + + + + + Total: {{ store.totalBooks() }} + In Stock: {{ store.availableBooks() }} + Filtered: {{ store.filteredBooks().length }} + + + + + + @for (book of store.filteredBooks(); track book.id) { + + + + {{ book.title }} + {{ book.author }} ({{ book.year }}) + + +

ISBN: {{ book.isbn }}

+ + {{ book.inStock ? 'In Stock' : 'Out of Stock' }} + +
+ + + + +
+
+ } @empty { + +

+ @if (store.filter()) { + No books found matching "{{ store.filter() }}" + } @else { + No books available + } +

+
+ } +
+ + @if (store.selectedBook(); as book) { + + + Selected: {{ book.title }} + + +

Author: {{ book.author }}

+

Year: {{ book.year }}

+

ISBN: {{ book.isbn }}

+

Status: {{ book.inStock ? 'In Stock' : 'Out of Stock' }}

+
+
+ } + `, + styles: [ + ` + mat-card { + margin: 16px; + } + + mat-form-field { + margin-right: 16px; + } + + button { + margin-right: 8px; + } + + mat-grid-tile mat-card { + width: 100%; + cursor: pointer; + } + `, + ], +}) +export class EventsSampleComponent { + readonly store = inject(BookStore); + readonly dispatch = injectDispatch(bookEvents); + filterText = ''; + + toggleStock(bookId: string, event: Event) { + event.stopPropagation(); + this.dispatch.stockToggled({ bookId }); + } + + removeBook(bookId: string, event: Event) { + event.stopPropagation(); + this.dispatch.bookRemoved({ bookId }); + } + + addRandomBook() { + const titles = [ + 'The Hobbit', + 'Brave New World', + 'Fahrenheit 451', + 'The Road', + 'Dune', + ]; + const authors = [ + 'J.R.R. Tolkien', + 'Aldous Huxley', + 'Ray Bradbury', + 'Cormac McCarthy', + 'Frank Herbert', + ]; + const randomIndex = Math.floor(Math.random() * titles.length); + + this.dispatch.bookAdded({ + book: { + id: crypto.randomUUID(), + title: titles[randomIndex], + author: authors[randomIndex], + year: 1950 + Math.floor(Math.random() * 70), + isbn: `978-${Math.floor(Math.random() * 10)}-${Math.floor(Math.random() * 100000)}`, + inStock: Math.random() > 0.5, + }, + }); + } +} diff --git a/apps/demo/src/app/lazy-routes.ts b/apps/demo/src/app/lazy-routes.ts index ba4c66e5..f4f93da0 100644 --- a/apps/demo/src/app/lazy-routes.ts +++ b/apps/demo/src/app/lazy-routes.ts @@ -1,5 +1,6 @@ import { Route } from '@angular/router'; import { TodoComponent } from './devtools/todo.component'; +import { EventsSampleComponent } from './events-sample/events-sample.component'; import { FlightEditDynamicComponent } from './flight-search-data-service-dynamic/flight-edit.component'; import { FlightSearchDynamicComponent } from './flight-search-data-service-dynamic/flight-search.component'; import { FlightEditSimpleComponent } from './flight-search-data-service-simple/flight-edit-simple.component'; @@ -13,6 +14,7 @@ import { TodoStorageSyncComponent } from './todo-storage-sync/todo-storage-sync. export const lazyRoutes: Route[] = [ { path: 'todo', component: TodoComponent }, + { path: 'events-sample', component: EventsSampleComponent }, { path: 'flight-search', component: FlightSearchComponent }, { path: 'flight-search-data-service-simple', diff --git a/libs/ngrx-toolkit/src/index.ts b/libs/ngrx-toolkit/src/index.ts index 8b8bba85..287d95ae 100644 --- a/libs/ngrx-toolkit/src/index.ts +++ b/libs/ngrx-toolkit/src/index.ts @@ -1,4 +1,5 @@ export { withDisabledNameIndices } from './lib/devtools/features/with-disabled-name-indicies'; +export { withEventsTracking } from './lib/devtools/features/with-events-tracking'; export { withGlitchTracking } from './lib/devtools/features/with-glitch-tracking'; export { withMapper } from './lib/devtools/features/with-mapper'; export { diff --git a/libs/ngrx-toolkit/src/lib/devtools/features/with-events-tracking.ts b/libs/ngrx-toolkit/src/lib/devtools/features/with-events-tracking.ts new file mode 100644 index 00000000..a16f9f2b --- /dev/null +++ b/libs/ngrx-toolkit/src/lib/devtools/features/with-events-tracking.ts @@ -0,0 +1,13 @@ +import { createDevtoolsFeature } from '../internal/devtools-feature'; + +/** + * Automatically infers DevTools action names from NgRx SignalStore events. + * + * It listens to all dispatched events via the Events stream and enqueues + * the event's type as the upcoming DevTools action name. When the corresponding + * reducer mutates state, the DevTools sync will use that name instead of + * the default "Store Update". + */ +export function withEventsTracking() { + return createDevtoolsFeature({ eventsTracking: true }); +} diff --git a/libs/ngrx-toolkit/src/lib/devtools/internal/devtools-feature.ts b/libs/ngrx-toolkit/src/lib/devtools/internal/devtools-feature.ts index 6477f376..600c1163 100644 --- a/libs/ngrx-toolkit/src/lib/devtools/internal/devtools-feature.ts +++ b/libs/ngrx-toolkit/src/lib/devtools/internal/devtools-feature.ts @@ -8,12 +8,14 @@ export type DevtoolsOptions = { indexNames?: boolean; // defines if names should be indexed. map?: Mapper; // defines a mapper for the state. tracker?: new () => Tracker; // defines a tracker for the state + eventsTracking?: boolean; // enables @ngrx/signals/events → DevTools action name tracking }; export type DevtoolsInnerOptions = { indexNames: boolean; map: Mapper; tracker: Tracker; + eventsTracking: boolean; }; /** diff --git a/libs/ngrx-toolkit/src/lib/devtools/with-devtools.ts b/libs/ngrx-toolkit/src/lib/devtools/with-devtools.ts index 5827e7ae..49345721 100644 --- a/libs/ngrx-toolkit/src/lib/devtools/with-devtools.ts +++ b/libs/ngrx-toolkit/src/lib/devtools/with-devtools.ts @@ -6,6 +6,10 @@ import { withHooks, withMethods, } from '@ngrx/signals'; +import { EventInstance, Events } from '@ngrx/signals/events'; +import { rxMethod } from '@ngrx/signals/rxjs-interop'; +import { tap } from 'rxjs'; +import { currentActionNames } from './internal/current-action-names'; import { DefaultTracker } from './internal/default-tracker'; import { DevtoolsFeature, @@ -22,6 +26,7 @@ declare global { export const renameDevtoolsMethodName = '___renameDevtoolsName'; export const uniqueDevtoolsId = '___uniqueDevtoolsId'; +export const devtoolsEventsTracker = '___devtoolsEventsTracker'; const EXISTING_NAMES = new InjectionToken( 'Array contain existing names for the signal stores', @@ -54,6 +59,16 @@ export function withDevtools(name: string, ...features: DevtoolsFeature[]) { syncer.renameStore(name, newName); }, [uniqueDevtoolsId]: () => id, + [devtoolsEventsTracker]: rxMethod>( + (c$) => + c$.pipe( + tap((ev) => { + if (ev && typeof ev.type === 'string' && ev.type.length > 0) { + currentActionNames.add(ev.type); + } + }), + ), + ), } as Record unknown>; }), withHooks((store) => { @@ -68,9 +83,15 @@ export function withDevtools(name: string, ...features: DevtoolsFeature[]) { tracker: inject( features.find((f) => f.tracker)?.tracker || DefaultTracker, ), + eventsTracking: features.some((f) => f.eventsTracking === true), }; syncer.addStore(id, name, store, finalOptions); + + if (finalOptions.eventsTracking) { + const events = inject(Events); + store[devtoolsEventsTracker](events.on()); + } }, onDestroy() { syncer.removeStore(id); From 92a49be9ebf17f8e5f1d373f39cc9632c29bb889 Mon Sep 17 00:00:00 2001 From: Murat Sari Date: Mon, 29 Sep 2025 22:12:08 +0200 Subject: [PATCH 2/3] feat: rework import mechanic (#231) --- .../devtools/features/with-events-tracking.ts | 10 +++++- .../lib/devtools/internal/devtools-feature.ts | 8 +++++ .../src/lib/devtools/with-devtools.ts | 36 +++++++++---------- 3 files changed, 34 insertions(+), 20 deletions(-) diff --git a/libs/ngrx-toolkit/src/lib/devtools/features/with-events-tracking.ts b/libs/ngrx-toolkit/src/lib/devtools/features/with-events-tracking.ts index a16f9f2b..285db70e 100644 --- a/libs/ngrx-toolkit/src/lib/devtools/features/with-events-tracking.ts +++ b/libs/ngrx-toolkit/src/lib/devtools/features/with-events-tracking.ts @@ -1,3 +1,5 @@ +import { inject } from '@angular/core'; +import { Events } from '@ngrx/signals/events'; import { createDevtoolsFeature } from '../internal/devtools-feature'; /** @@ -9,5 +11,11 @@ import { createDevtoolsFeature } from '../internal/devtools-feature'; * the default "Store Update". */ export function withEventsTracking() { - return createDevtoolsFeature({ eventsTracking: true }); + return createDevtoolsFeature({ + eventsTracking: true, + onInit: ({ trackEvents }) => { + const events = inject(Events); + trackEvents(events.on()); + }, + }); } diff --git a/libs/ngrx-toolkit/src/lib/devtools/internal/devtools-feature.ts b/libs/ngrx-toolkit/src/lib/devtools/internal/devtools-feature.ts index 600c1163..201dce76 100644 --- a/libs/ngrx-toolkit/src/lib/devtools/internal/devtools-feature.ts +++ b/libs/ngrx-toolkit/src/lib/devtools/internal/devtools-feature.ts @@ -1,14 +1,22 @@ +import { Observable } from 'rxjs'; import { Tracker } from './models'; export const DEVTOOLS_FEATURE = Symbol('DEVTOOLS_FEATURE'); export type Mapper = (state: object) => object; +export type DevtoolsHookContext = { + id: string; + name: string; + trackEvents: (source: Observable<{ type: string }>) => void; +}; + export type DevtoolsOptions = { indexNames?: boolean; // defines if names should be indexed. map?: Mapper; // defines a mapper for the state. tracker?: new () => Tracker; // defines a tracker for the state eventsTracking?: boolean; // enables @ngrx/signals/events → DevTools action name tracking + onInit?: (context: DevtoolsHookContext) => void; // lifecycle hook executed during devtools init }; export type DevtoolsInnerOptions = { diff --git a/libs/ngrx-toolkit/src/lib/devtools/with-devtools.ts b/libs/ngrx-toolkit/src/lib/devtools/with-devtools.ts index 49345721..3e0df3d1 100644 --- a/libs/ngrx-toolkit/src/lib/devtools/with-devtools.ts +++ b/libs/ngrx-toolkit/src/lib/devtools/with-devtools.ts @@ -1,4 +1,4 @@ -import { inject, InjectionToken } from '@angular/core'; +import { inject } from '@angular/core'; import { EmptyFeatureResult, SignalStoreFeature, @@ -6,7 +6,6 @@ import { withHooks, withMethods, } from '@ngrx/signals'; -import { EventInstance, Events } from '@ngrx/signals/events'; import { rxMethod } from '@ngrx/signals/rxjs-interop'; import { tap } from 'rxjs'; import { currentActionNames } from './internal/current-action-names'; @@ -28,11 +27,6 @@ export const renameDevtoolsMethodName = '___renameDevtoolsName'; export const uniqueDevtoolsId = '___uniqueDevtoolsId'; export const devtoolsEventsTracker = '___devtoolsEventsTracker'; -const EXISTING_NAMES = new InjectionToken( - 'Array contain existing names for the signal stores', - { factory: () => [] as string[], providedIn: 'root' }, -); - /** * Adds this store as a feature state to the Redux DevTools. * @@ -59,15 +53,14 @@ export function withDevtools(name: string, ...features: DevtoolsFeature[]) { syncer.renameStore(name, newName); }, [uniqueDevtoolsId]: () => id, - [devtoolsEventsTracker]: rxMethod>( - (c$) => - c$.pipe( - tap((ev) => { - if (ev && typeof ev.type === 'string' && ev.type.length > 0) { - currentActionNames.add(ev.type); - } - }), - ), + [devtoolsEventsTracker]: rxMethod<{ type: string }>((c$) => + c$.pipe( + tap((ev) => { + if (ev && typeof ev.type === 'string' && ev.type.length > 0) { + currentActionNames.add(ev.type); + } + }), + ), ), } as Record unknown>; }), @@ -88,9 +81,14 @@ export function withDevtools(name: string, ...features: DevtoolsFeature[]) { syncer.addStore(id, name, store, finalOptions); - if (finalOptions.eventsTracking) { - const events = inject(Events); - store[devtoolsEventsTracker](events.on()); + for (const feature of features) { + if (typeof feature.onInit === 'function') { + feature.onInit({ + id, + name, + trackEvents: (source$) => store[devtoolsEventsTracker](source$), + }); + } } }, onDestroy() { From 90794239e137275ca992620bb06347999bb5a6e8 Mon Sep 17 00:00:00 2001 From: Murat Sari Date: Wed, 29 Oct 2025 13:01:44 +0100 Subject: [PATCH 3/3] feat(devtools): enhance `withEventsTracking` to support glitch-free tracking and add tests --- .../devtools/features/with-events-tracking.ts | 58 +++++++++++++- .../tests/with-events-tracking.spec.ts | 79 +++++++++++++++++++ 2 files changed, 133 insertions(+), 4 deletions(-) create mode 100644 libs/ngrx-toolkit/src/lib/devtools/tests/with-events-tracking.spec.ts diff --git a/libs/ngrx-toolkit/src/lib/devtools/features/with-events-tracking.ts b/libs/ngrx-toolkit/src/lib/devtools/features/with-events-tracking.ts index 285db70e..ac6b2446 100644 --- a/libs/ngrx-toolkit/src/lib/devtools/features/with-events-tracking.ts +++ b/libs/ngrx-toolkit/src/lib/devtools/features/with-events-tracking.ts @@ -1,6 +1,7 @@ import { inject } from '@angular/core'; -import { Events } from '@ngrx/signals/events'; +import { Dispatcher, Events } from '@ngrx/signals/events'; import { createDevtoolsFeature } from '../internal/devtools-feature'; +import { GlitchTrackerService } from '../internal/glitch-tracker.service'; /** * Automatically infers DevTools action names from NgRx SignalStore events. @@ -9,13 +10,62 @@ import { createDevtoolsFeature } from '../internal/devtools-feature'; * the event's type as the upcoming DevTools action name. When the corresponding * reducer mutates state, the DevTools sync will use that name instead of * the default "Store Update". + * + * By default (withGlitchTracking = true), the `GlitchTrackerService` is used to capture + * all intermediate updates (glitched states). To use the default, glitch-free tracker + * and synchronize only stable state transitions, set `withGlitchTracking` to `false`. + * + * @param {{ withGlitchTracking?: boolean }} [options] Options to configure tracking behavior. + * @param {boolean} [options.withGlitchTracking=true] Enable capturing intermediate (glitched) state updates. + * @returns Devtools feature enabling events-based action naming; glitched tracking is enabled by default. + * Set `withGlitchTracking: false` to use glitch-free tracking instead. + * @example + * // Capture intermediate updates (default) + * withDevtools('counter', withEventsTracking()); + * @example + * // Glitch-free tracking (only stable transitions) + * withDevtools('counter', withEventsTracking({ withGlitchTracking: false })); + * @see withGlitchTracking */ -export function withEventsTracking() { +export function withEventsTracking( + options: { withGlitchTracking: boolean } = { withGlitchTracking: true }, +) { + const useGlitchTracking = options.withGlitchTracking === true; return createDevtoolsFeature({ + tracker: useGlitchTracking ? GlitchTrackerService : undefined, eventsTracking: true, onInit: ({ trackEvents }) => { - const events = inject(Events); - trackEvents(events.on()); + if (useGlitchTracking) { + trackEvents(getReducerEvents().on()); + } else { + trackEvents(inject(Events).on()); + } }, }); } + +/** + * Returns the synchronous reducer event stream exposed by the dispatcher. + * + * NgRx's `Dispatcher` delivers events to `ReducerEvents` immediately but feeds + * the public `Events` stream via `queueScheduler`, which keeps work in a FIFO + * queue and executes scheduled tasks only after the current task completes + * ([rxjs.dev](https://rxjs.dev/api/index/const/queueScheduler)). When + * `GlitchTrackerService` captures the state change synchronously, that queued + * `Events` emission is processed afterward and DevTools records the update as + * `Store Update`. Tapping into the reducer stream keeps event names and state + * changes aligned. + * + * TODO(@ngrx): expose a synchronous events API (similar to what `withReducer` uses) + * so consumers do not need to reach into dispatcher internals. + */ +function getReducerEvents() { + type ReducerEventsLike = { + on(): ReturnType; + }; + + const dispatcher = inject(Dispatcher) as unknown as { + reducerEvents: ReducerEventsLike; + }; + return dispatcher.reducerEvents; +} diff --git a/libs/ngrx-toolkit/src/lib/devtools/tests/with-events-tracking.spec.ts b/libs/ngrx-toolkit/src/lib/devtools/tests/with-events-tracking.spec.ts new file mode 100644 index 00000000..56d71e5b --- /dev/null +++ b/libs/ngrx-toolkit/src/lib/devtools/tests/with-events-tracking.spec.ts @@ -0,0 +1,79 @@ +import { EnvironmentInjector, runInInjectionContext } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { signalStore, type, withState } from '@ngrx/signals'; +import { + eventGroup, + injectDispatch, + on, + withReducer, +} from '@ngrx/signals/events'; +import { withEventsTracking } from '../features/with-events-tracking'; +import { withDevtools } from '../with-devtools'; +import { setupExtensions } from './helpers.spec'; + +const testEvents = eventGroup({ + source: 'Spec Store', + events: { + bump: type(), + }, +}); + +describe('withEventsTracking', () => { + it('should send a glitched update on event', async () => { + const { sendSpy } = setupExtensions(); + + const Store = signalStore( + { providedIn: 'root' }, + withDevtools('store-a', withEventsTracking({ withGlitchTracking: true })), + withState({ count: 0 }), + withReducer( + on(testEvents.bump, (_event, state) => ({ count: state.count + 1 })), + ), + ); + + TestBed.inject(Store); + + runInInjectionContext(TestBed.inject(EnvironmentInjector), () => { + injectDispatch(testEvents).bump(); + }); + + expect(sendSpy).toHaveBeenLastCalledWith( + { type: '[Spec Store] bump' }, + { 'store-a': { count: 1 } }, + ); + }); + + it('should emit two glitched updates when two stores react to the same event', async () => { + const { sendSpy } = setupExtensions(); + + const StoreA = signalStore( + { providedIn: 'root' }, + withDevtools('store-a', withEventsTracking({ withGlitchTracking: true })), + withState({ count: 0 }), + withReducer( + on(testEvents.bump, (_event, state) => ({ count: state.count + 1 })), + ), + ); + + const StoreB = signalStore( + { providedIn: 'root' }, + withDevtools('store-b', withEventsTracking({ withGlitchTracking: true })), + withState({ count: 0 }), + withReducer( + on(testEvents.bump, (_event, state) => ({ count: state.count + 1 })), + ), + ); + + TestBed.inject(StoreA); + TestBed.inject(StoreB); + + runInInjectionContext(TestBed.inject(EnvironmentInjector), () => { + injectDispatch(testEvents).bump(); + }); + + expect(sendSpy).toHaveBeenLastCalledWith( + { type: '[Spec Store] bump' }, + { 'store-a': { count: 1 }, 'store-b': { count: 1 } }, + ); + }); +});