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 a4a67b4a..54baad9b 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,7 +8,7 @@ import { DevtoolsInnerOptions } from './devtools-feature'; import { Connection, StoreRegistry, Tracker } from './models'; const dummyConnection: Connection = { - send: () => void true, + send: () => true, }; /** @@ -30,7 +30,7 @@ export class DevtoolsSyncer implements OnDestroy { */ #stores: StoreRegistry = {}; readonly #isBrowser = isPlatformBrowser(inject(PLATFORM_ID)); - readonly #trackers = [] as Tracker[]; + readonly #trackers: Tracker[] = []; readonly #devtoolsConfig = { name: 'NgRx SignalStore', ...inject(REDUX_DEVTOOLS_CONFIG, { optional: true }), @@ -54,14 +54,14 @@ export class DevtoolsSyncer implements OnDestroy { #currentId = 1; readonly #connection: Connection = this.#isBrowser - ? window.__REDUX_DEVTOOLS_EXTENSION__ - ? window.__REDUX_DEVTOOLS_EXTENSION__.connect(this.#devtoolsConfig) - : dummyConnection + ? this.#initDevtoolsConnection() : dummyConnection; constructor() { if (!this.#isBrowser) { - return; + console.warn( + '[DevtoolsSyncer] Not running in browser. DevTools disabled.', + ); } } @@ -69,6 +69,32 @@ export class DevtoolsSyncer implements OnDestroy { currentActionNames.clear(); } + #initDevtoolsConnection(): Connection { + const extension = window.__REDUX_DEVTOOLS_EXTENSION__; + if (!extension) { + console.warn('[DevtoolsSyncer] Redux DevTools extension not found.'); + return dummyConnection; + } + + try { + if (typeof extension.connect === 'function') { + return extension.connect(this.#devtoolsConfig); + } else { + console.warn( + '[DevtoolsSyncer] Redux DevTools extension does not support .connect()', + ); + } + } catch (error) { + console.error( + '[DevtoolsSyncer] Error connecting to Redux DevTools:', + error, + ); + return dummyConnection; + } + + return dummyConnection; + } + syncToDevTools(changedStatePerId: Record) { const mappedChangedStatePerName = Object.entries(changedStatePerId).reduce( (acc, [id, store]) => { @@ -78,6 +104,7 @@ export class DevtoolsSyncer implements OnDestroy { }, {} as Record, ); + this.#currentState = { ...this.#currentState, ...mappedChangedStatePerName, @@ -90,7 +117,7 @@ export class DevtoolsSyncer implements OnDestroy { this.#connection.send({ type }, this.#currentState); } - getNextId() { + getNextId(): string { return String(this.#currentId++); } @@ -109,21 +136,17 @@ export class DevtoolsSyncer implements OnDestroy { options: DevtoolsInnerOptions, ) { let storeName = name; - const names = Object.values(this.#stores).map((store) => store.name); - - if (names.includes(storeName)) { - // const { options } = throwIfNull( - // Object.values(this.#stores).find((store) => store.name === storeName) - // ); - if (!options.indexNames) { - throw new Error(`An instance of the store ${storeName} already exists. \ + const names = Object.values(this.#stores).map((s) => s.name); + + if (names.includes(storeName) && !options.indexNames) { + throw new Error(`An instance of the store ${storeName} already exists. \ Enable automatic indexing via withDevTools('${storeName}', { indexNames: true }), or rename it upon instantiation.`); - } } for (let i = 1; names.includes(storeName); i++) { storeName = `${name}-${i}`; } + this.#stores[id] = { name: storeName, options }; const tracker = options.tracker; @@ -136,23 +159,20 @@ Enable automatic indexing via withDevTools('${storeName}', { indexNames: true }) } removeStore(id: string) { - const name = this.#stores[id].name; + const name = this.#stores[id]?.name; + this.#stores = Object.entries(this.#stores).reduce( - (newStore, [storeId, value]) => { - if (storeId !== id) { - newStore[storeId] = value; - } - return newStore; + (acc, [storeId, value]) => { + if (storeId !== id) acc[storeId] = value; + return acc; }, {} as StoreRegistry, ); this.#currentState = Object.entries(this.#currentState).reduce( - (newState, [storeName, state]) => { - if (storeName !== name) { - newState[name] = state; - } - return newState; + (acc, [storeName, state]) => { + if (storeName !== name) acc[storeName] = state; + return acc; }, {} as Record, ); @@ -163,36 +183,30 @@ Enable automatic indexing via withDevTools('${storeName}', { indexNames: true }) } renameStore(oldName: string, newName: string) { - const storeNames = Object.values(this.#stores).map((store) => store.name); + const storeNames = Object.values(this.#stores).map((s) => s.name); const id = throwIfNull( - Object.keys(this.#stores).find((id) => this.#stores[id].name === oldName), + Object.keys(this.#stores).find( + (key) => this.#stores[key].name === oldName, + ), ); + if (storeNames.includes(newName)) { throw new Error( `NgRx Toolkit/DevTools: cannot rename from ${oldName} to ${newName}. ${newName} is already assigned to another SignalStore instance.`, ); } - this.#stores = Object.entries(this.#stores).reduce( - (newStore, [id, value]) => { - if (value.name === oldName) { - newStore[id] = { ...value, name: newName }; - } else { - newStore[id] = value; - } - return newStore; - }, - {} as StoreRegistry, - ); + this.#stores = Object.entries(this.#stores).reduce((acc, [key, value]) => { + acc[key] = value.name === oldName ? { ...value, name: newName } : value; + return acc; + }, {} as StoreRegistry); // we don't rename in #currentState but wait for tracker to notify // us with a changed state that contains that name. this.#currentState = Object.entries(this.#currentState).reduce( - (newState, [storeName, state]) => { - if (storeName !== oldName) { - newState[storeName] = state; - } - return newState; + (acc, [storeName, state]) => { + if (storeName !== oldName) acc[storeName] = state; + return acc; }, {} as Record, ); diff --git a/libs/ngrx-toolkit/src/lib/devtools/tests/devtools-syncer.spec.ts b/libs/ngrx-toolkit/src/lib/devtools/tests/devtools-syncer.spec.ts new file mode 100644 index 00000000..37b26d4a --- /dev/null +++ b/libs/ngrx-toolkit/src/lib/devtools/tests/devtools-syncer.spec.ts @@ -0,0 +1,96 @@ +import { PLATFORM_ID } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { DevtoolsSyncer } from '../internal/devtools-syncer.service'; +import { Tracker } from '../internal/models'; + +describe('DevtoolsSyncer integration with Redux DevTools', () => { + let connectSpy: jest.Mock; + let sendSpy: jest.Mock; + + beforeEach(() => { + sendSpy = jest.fn(); + connectSpy = jest.fn(() => ({ send: sendSpy })); + (window as any).__REDUX_DEVTOOLS_EXTENSION__ = { connect: connectSpy }; + + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [{ provide: PLATFORM_ID, useValue: 'browser' }], + }); + }); + + function createTrackerMock(): Tracker { + const onChangeMock = jest.fn(); + + return { + onChange: onChangeMock, + track: jest.fn(), + removeStore: jest.fn(), + notifyRenamedStore: jest.fn(), + get stores() { + return {}; // Return an empty object or mock stores as needed + }, + }; + } + + it('should send valid state and action type to DevTools', () => { + const syncer = TestBed.inject(DevtoolsSyncer); + const id = syncer.getNextId(); + const tracker = createTrackerMock(); + + (tracker.onChange as jest.Mock).mockImplementation((cb) => { + cb({ [id]: { count: 42 } }); + }); + + syncer.addStore(id, 'CounterStore', {} as any, { + map: (s: object) => s, + tracker, + indexNames: false, + }); + + expect(sendSpy).toHaveBeenCalledWith( + expect.objectContaining({ type: 'Store Update' }), + expect.objectContaining({ CounterStore: { count: 42 } }), + ); + }); + + it('should not send empty state or type', () => { + const syncer = TestBed.inject(DevtoolsSyncer); + const id = syncer.getNextId(); + const tracker = createTrackerMock(); + + (tracker.onChange as jest.Mock).mockImplementation((cb) => { + cb({ [id]: {} }); + }); + + syncer.addStore(id, 'EmptyStore', {} as any, { + map: (s: object) => s, + tracker, + indexNames: false, + }); + + const [action, state] = sendSpy.mock.calls[0]; + expect(action.type).toBe('Store Update'); + expect(state.EmptyStore).toEqual({}); + }); + + it('should handle extension absence gracefully', () => { + delete (window as any).__REDUX_DEVTOOLS_EXTENSION__; + const warnSpy = jest.spyOn(console, 'warn'); + TestBed.inject(DevtoolsSyncer); + + expect(warnSpy).toHaveBeenCalledWith( + '[DevtoolsSyncer] Redux DevTools extension not found.', + ); + warnSpy.mockRestore(); + }); + + it('should not send if not in browser', () => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [{ provide: PLATFORM_ID, useValue: 'server' }], + }); + + TestBed.inject(DevtoolsSyncer); + expect(connectSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/libs/ngrx-toolkit/src/lib/devtools/with-devtools.ts b/libs/ngrx-toolkit/src/lib/devtools/with-devtools.ts index 5827e7ae..cd6447d5 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, @@ -23,11 +23,6 @@ declare global { export const renameDevtoolsMethodName = '___renameDevtoolsName'; export const uniqueDevtoolsId = '___uniqueDevtoolsId'; -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. * @@ -45,7 +40,6 @@ export function withDevtools(name: string, ...features: DevtoolsFeature[]) { return signalStoreFeature( withMethods(() => { const syncer = inject(DevtoolsSyncer); - const id = syncer.getNextId(); // TODO: use withProps and symbols @@ -58,7 +52,7 @@ export function withDevtools(name: string, ...features: DevtoolsFeature[]) { }), withHooks((store) => { const syncer = inject(DevtoolsSyncer); - const id = String(store[uniqueDevtoolsId]()); + return { onInit() { const id = String(store[uniqueDevtoolsId]()); @@ -73,6 +67,7 @@ export function withDevtools(name: string, ...features: DevtoolsFeature[]) { syncer.addStore(id, name, store, finalOptions); }, onDestroy() { + const id = String(store[uniqueDevtoolsId]()); syncer.removeStore(id); }, };