From 5bb5dd8a475fdb6898b3af395bbdddc7f8132c33 Mon Sep 17 00:00:00 2001 From: Manfred Steyer Date: Mon, 1 Sep 2025 16:15:50 +0200 Subject: [PATCH 1/6] feat: Make `rxMutation` standalone (#219) This makes `rxMutation` a fully autonomous function which can also be used entirely independently of the SignalStore. It also exposes the following additional properties: - `isSuccess` - `hasValue` - `value` --- apps/demo/src/app/app.component.html | 1 + .../counter-rx-mutation.css | 0 .../counter-rx-mutation.html | 20 + .../counter-rx-mutation.ts | 69 ++ apps/demo/src/app/lazy-routes.ts | 7 + libs/ngrx-toolkit/src/index.ts | 4 +- .../ngrx-toolkit/src/lib/mutation/mutation.ts | 26 + .../src/lib/mutation/rx-mutation.spec.ts | 594 ++++++++++++++++++ .../src/lib/{ => mutation}/rx-mutation.ts | 124 ++-- .../src/lib/with-mutations.spec.ts | 2 +- libs/ngrx-toolkit/src/lib/with-mutations.ts | 23 +- 11 files changed, 802 insertions(+), 68 deletions(-) create mode 100644 apps/demo/src/app/counter-rx-mutation/counter-rx-mutation.css create mode 100644 apps/demo/src/app/counter-rx-mutation/counter-rx-mutation.html create mode 100644 apps/demo/src/app/counter-rx-mutation/counter-rx-mutation.ts create mode 100644 libs/ngrx-toolkit/src/lib/mutation/mutation.ts create mode 100644 libs/ngrx-toolkit/src/lib/mutation/rx-mutation.spec.ts rename libs/ngrx-toolkit/src/lib/{ => mutation}/rx-mutation.ts (52%) diff --git a/apps/demo/src/app/app.component.html b/apps/demo/src/app/app.component.html index 16e00bb3..a18faa3d 100644 --- a/apps/demo/src/app/app.component.html +++ b/apps/demo/src/app/app.component.html @@ -27,6 +27,7 @@ withFeatureFactory withConditional withMutation + rxMutation (without Store) diff --git a/apps/demo/src/app/counter-rx-mutation/counter-rx-mutation.css b/apps/demo/src/app/counter-rx-mutation/counter-rx-mutation.css new file mode 100644 index 00000000..e69de29b diff --git a/apps/demo/src/app/counter-rx-mutation/counter-rx-mutation.html b/apps/demo/src/app/counter-rx-mutation/counter-rx-mutation.html new file mode 100644 index 00000000..ce6a4b82 --- /dev/null +++ b/apps/demo/src/app/counter-rx-mutation/counter-rx-mutation.html @@ -0,0 +1,20 @@ +

rxMutation (without Store)

+ +
{{ counter() }}
+ + + +
+ + +
diff --git a/apps/demo/src/app/counter-rx-mutation/counter-rx-mutation.ts b/apps/demo/src/app/counter-rx-mutation/counter-rx-mutation.ts new file mode 100644 index 00000000..1b25a92c --- /dev/null +++ b/apps/demo/src/app/counter-rx-mutation/counter-rx-mutation.ts @@ -0,0 +1,69 @@ +import { concatOp, rxMutation } from '@angular-architects/ngrx-toolkit'; +import { CommonModule } from '@angular/common'; +import { Component, signal } from '@angular/core'; +import { delay, Observable, of, throwError } from 'rxjs'; + +export type Params = { + value: number; +}; + +@Component({ + selector: 'demo-counter-rx-mutation', + imports: [CommonModule], + templateUrl: './counter-rx-mutation.html', + styleUrl: './counter-rx-mutation.css', +}) +export class CounterRxMutation { + private counterSignal = signal(0); + + private increment = rxMutation({ + operation: (params: Params) => { + return calcSum(this.counterSignal(), params.value); + }, + operator: concatOp, + onSuccess: (result) => { + this.counterSignal.set(result); + }, + onError: (error) => { + console.error('Error occurred:', error); + }, + }); + + // Expose signals for template + protected counter = this.counterSignal.asReadonly(); + protected error = this.increment.error; + protected isPending = this.increment.isPending; + protected status = this.increment.status; + protected value = this.increment.value; + protected hasValue = this.increment.hasValue; + + async incrementCounter() { + const result = await this.increment({ value: 1 }); + if (result.status === 'success') { + console.log('Success:', result.value); + } + if (result.status === 'error') { + console.log('Error:', result.error); + } + if (result.status === 'aborted') { + console.log('Operation aborted'); + } + } + + async incrementBy13() { + await this.increment({ value: 13 }); + } +} + +function calcSum(a: number, b: number): Observable { + const result = a + b; + if (b === 13) { + return throwError(() => ({ + message: 'error due to bad luck!', + a, + b, + result, + })); + } + return of(result).pipe(delay(500)); +} diff --git a/apps/demo/src/app/lazy-routes.ts b/apps/demo/src/app/lazy-routes.ts index d0ed7598..ba4c66e5 100644 --- a/apps/demo/src/app/lazy-routes.ts +++ b/apps/demo/src/app/lazy-routes.ts @@ -68,4 +68,11 @@ export const lazyRoutes: Route[] = [ (m) => m.CounterMutation, ), }, + { + path: 'rx-mutation', + loadComponent: () => + import('./counter-rx-mutation/counter-rx-mutation').then( + (m) => m.CounterRxMutation, + ), + }, ]; diff --git a/libs/ngrx-toolkit/src/index.ts b/libs/ngrx-toolkit/src/index.ts index 62c93eed..bae1b6d0 100644 --- a/libs/ngrx-toolkit/src/index.ts +++ b/libs/ngrx-toolkit/src/index.ts @@ -42,7 +42,7 @@ export { export { emptyFeature, withConditional } from './lib/with-conditional'; export { withFeatureFactory } from './lib/with-feature-factory'; -export * from './lib/rx-mutation'; +export * from './lib/mutation/rx-mutation'; export * from './lib/with-mutations'; export { mapToResource, withResource } from './lib/with-resource'; @@ -52,3 +52,5 @@ export { mergeOp, switchOp, } from './lib/flattening-operator'; + +export { rxMutation } from './lib/mutation/rx-mutation'; diff --git a/libs/ngrx-toolkit/src/lib/mutation/mutation.ts b/libs/ngrx-toolkit/src/lib/mutation/mutation.ts new file mode 100644 index 00000000..cb73a9a1 --- /dev/null +++ b/libs/ngrx-toolkit/src/lib/mutation/mutation.ts @@ -0,0 +1,26 @@ +import { Signal } from '@angular/core'; + +export type MutationResult = + | { + status: 'success'; + value: Result; + } + | { + status: 'error'; + error: unknown; + } + | { + status: 'aborted'; + }; + +export type MutationStatus = 'idle' | 'pending' | 'error' | 'success'; + +export type Mutation = { + (params: Parameter): Promise>; + status: Signal; + value: Signal; + isPending: Signal; + isSuccess: Signal; + error: Signal; + hasValue(): this is Mutation, Result>; +}; diff --git a/libs/ngrx-toolkit/src/lib/mutation/rx-mutation.spec.ts b/libs/ngrx-toolkit/src/lib/mutation/rx-mutation.spec.ts new file mode 100644 index 00000000..2a9e093b --- /dev/null +++ b/libs/ngrx-toolkit/src/lib/mutation/rx-mutation.spec.ts @@ -0,0 +1,594 @@ +import { fakeAsync, TestBed, tick } from '@angular/core/testing'; +import { delay, Observable, of, Subject, switchMap, throwError } from 'rxjs'; +import { concatOp, exhaustOp, mergeOp, switchOp } from '../flattening-operator'; +import { rxMutation } from './rx-mutation'; + +type Param = + | number + | { + value: number | Observable; + delay?: number; + fail?: boolean; + }; + +type NormalizedParam = { + value: number | Observable; + delay: number; + fail: boolean; +}; + +async function asyncTick(): Promise { + return new Promise((resolve) => { + setTimeout(() => { + resolve(); + }, 0); + }); +} + +function calcDouble(value: number, delayInMsec = 1000): Observable { + return of(value * 2).pipe(delay(delayInMsec)); +} + +function fail(_value: number, delayInMsec = 1000): Observable { + return of(null).pipe( + delay(delayInMsec), + switchMap(() => throwError(() => ({ error: 'Test-Error' }))), + ); +} + +function createTestSetup(flatteningOperator = concatOp) { + function normalizeParam(param: Param): NormalizedParam { + if (typeof param === 'number') { + return { + value: param, + delay: 1000, + fail: false, + }; + } + + return { + value: param.value, + delay: param.delay ?? 1000, + fail: param.fail ?? false, + }; + } + + type SuccessParam = { result: number; param: Param }; + type ErrorParam = { error: unknown; param: Param }; + + let onSuccessCalls = 0; + let onErrorCalls = 0; + let counter = 3; + + let lastOnSuccessParam: SuccessParam | undefined = undefined; + let lastOnErrorParam: ErrorParam | undefined = undefined; + + return TestBed.runInInjectionContext(() => { + const increment = rxMutation({ + operation: (param: Param) => { + const normalized = normalizeParam(param); + + if (normalized.value instanceof Observable) { + return normalized.value.pipe( + switchMap((value) => { + if (normalized.fail) { + return fail(value, normalized.delay); + } + return calcDouble(value, normalized.delay); + }), + ); + } + + if (normalized.fail) { + return fail(normalized.value, normalized.delay); + } + return calcDouble(normalized.value, normalized.delay); + }, + operator: flatteningOperator, + onSuccess: (result, param) => { + lastOnSuccessParam = { result, param: param }; + onSuccessCalls++; + counter = counter + result; + }, + onError: (error, param) => { + lastOnErrorParam = { error, param: param }; + onErrorCalls++; + }, + }); + + return { + increment, + getCounter: () => counter, + onSuccessCalls: () => onSuccessCalls, + onErrorCalls: () => onErrorCalls, + lastOnSuccessParam: () => lastOnSuccessParam, + lastOnErrorParam: () => lastOnErrorParam, + }; + }); +} + +describe('rxMutation', () => { + it('should update the state', fakeAsync(() => { + const testSetup = createTestSetup(); + const increment = testSetup.increment; + + expect(increment.status()).toEqual('idle'); + expect(increment.isPending()).toEqual(false); + + increment(2); + expect(increment.status()).toEqual('pending'); + expect(increment.isPending()).toEqual(true); + + tick(2000); + expect(increment.status()).toEqual('success'); + expect(increment.isPending()).toEqual(false); + expect(increment.isSuccess()).toEqual(true); + expect(increment.error()).toEqual(undefined); + + expect(testSetup.getCounter()).toEqual(7); + })); + + it('sets error', fakeAsync(() => { + const testSetup = createTestSetup(); + const increment = testSetup.increment; + + increment({ value: 2, fail: true }); + + tick(2000); + expect(increment.status()).toEqual('error'); + expect(increment.isPending()).toEqual(false); + expect(increment.isSuccess()).toEqual(false); + expect(increment.error()).toEqual({ + error: 'Test-Error', + }); + + expect(testSetup.getCounter()).toEqual(3); + })); + + it('starts two concurrent operations using concatMap: the first one fails and the second one succeeds', fakeAsync(() => { + const testSetup = createTestSetup(concatOp); + const increment = testSetup.increment; + + increment({ value: 1, delay: 100, fail: true }); + increment({ value: 2, delay: 200, fail: false }); + + tick(100); + + expect(increment.status()).toEqual('pending'); + expect(increment.isPending()).toEqual(true); + expect(increment.error()).toEqual({ + error: 'Test-Error', + }); + + tick(200); + + expect(increment.status()).toEqual('success'); + expect(increment.isPending()).toEqual(false); + expect(increment.error()).toEqual(undefined); + + expect(testSetup.getCounter()).toEqual(7); + })); + + it('starts two concurrent operations using mergeMap: the first one fails and the second one succeeds', fakeAsync(() => { + const testSetup = createTestSetup(mergeOp); + const increment = testSetup.increment; + + increment({ value: 1, delay: 100, fail: true }); + increment({ value: 2, delay: 200, fail: false }); + + tick(100); + + expect(increment.status()).toEqual('pending'); + expect(increment.isPending()).toEqual(true); + expect(increment.isSuccess()).toEqual(false); + + expect(increment.error()).toEqual({ + error: 'Test-Error', + }); + + tick(100); + + expect(increment.status()).toEqual('success'); + expect(increment.isPending()).toEqual(false); + expect(increment.isSuccess()).toEqual(true); + + expect(increment.error()).toEqual(undefined); + + expect(testSetup.getCounter()).toEqual(7); + })); + + it('deals with race conditions using switchMap', fakeAsync(() => { + const testSetup = createTestSetup(switchOp); + const increment = testSetup.increment; + + increment(1); + + tick(500); + expect(increment.status()).toEqual('pending'); + expect(increment.isPending()).toEqual(true); + + increment(2); + tick(1000); + + expect(increment.status()).toEqual('success'); + expect(increment.isPending()).toEqual(false); + expect(increment.error()).toEqual(undefined); + expect(increment.isSuccess()).toEqual(true); + + expect(testSetup.getCounter()).toEqual(7); + expect(testSetup.onSuccessCalls()).toEqual(1); + expect(testSetup.onErrorCalls()).toEqual(0); + + expect(testSetup.lastOnSuccessParam()).toEqual({ + param: 2, + result: 4, + }); + })); + + it('deals with race conditions using mergeMap', fakeAsync(() => { + const testSetup = createTestSetup(mergeOp); + const increment = testSetup.increment; + + increment(1); + tick(500); + increment(2); + tick(500); + + expect(increment.status()).toEqual('pending'); + expect(increment.isPending()).toEqual(true); + expect(increment.isSuccess()).toEqual(false); + + // expect(testSetup.getCounter()).toEqual(7); + expect(testSetup.onSuccessCalls()).toEqual(1); + expect(testSetup.onErrorCalls()).toEqual(0); + expect(testSetup.lastOnSuccessParam()).toEqual({ + param: 1, + result: 2, + }); + + tick(500); + + expect(increment.status()).toEqual('success'); + expect(increment.isPending()).toEqual(false); + expect(increment.error()).toEqual(undefined); + expect(increment.isSuccess()).toEqual(true); + + expect(testSetup.getCounter()).toEqual(9); + expect(testSetup.onSuccessCalls()).toEqual(2); + expect(testSetup.onErrorCalls()).toEqual(0); + expect(testSetup.lastOnSuccessParam()).toEqual({ + param: 2, + result: 4, + }); + })); + + it('deals with race conditions using mergeMap where the 2nd task starts after and finishes before the 1st one', fakeAsync(() => { + const testSetup = createTestSetup(mergeOp); + const increment = testSetup.increment; + + increment({ value: 1, delay: 1000 }); + tick(500); + + expect(increment.status()).toEqual('pending'); + expect(increment.isPending()).toEqual(true); + expect(increment.isSuccess()).toEqual(false); + + increment({ value: 2, delay: 100 }); + tick(500); + + expect(increment.status()).toEqual('success'); + expect(increment.isPending()).toEqual(false); + expect(increment.error()).toEqual(undefined); + expect(increment.isSuccess()).toEqual(true); + + expect(testSetup.getCounter()).toEqual(9); + expect(testSetup.onSuccessCalls()).toEqual(2); + expect(testSetup.onErrorCalls()).toEqual(0); + expect(testSetup.lastOnSuccessParam()).toEqual({ + param: { value: 1, delay: 1000 }, + result: 2, + }); + })); + + it('deals with race conditions using concatMap', fakeAsync(() => { + const testSetup = createTestSetup(concatOp); + const increment = testSetup.increment; + + increment({ value: 1, delay: 1000 }); + tick(500); + increment({ value: 2, delay: 100 }); + tick(500); + + expect(increment.status()).toEqual('pending'); + expect(increment.isPending()).toEqual(true); + expect(increment.isSuccess()).toEqual(false); + + expect(testSetup.getCounter()).toEqual(5); + expect(testSetup.onSuccessCalls()).toEqual(1); + expect(testSetup.onErrorCalls()).toEqual(0); + expect(testSetup.lastOnSuccessParam()).toEqual({ + param: { value: 1, delay: 1000 }, + result: 2, + }); + + tick(500); + + expect(increment.status()).toEqual('success'); + expect(increment.isPending()).toEqual(false); + expect(increment.error()).toEqual(undefined); + expect(increment.isSuccess()).toEqual(true); + + expect(testSetup.getCounter()).toEqual(9); + expect(testSetup.onSuccessCalls()).toEqual(2); + expect(testSetup.onErrorCalls()).toEqual(0); + expect(testSetup.lastOnSuccessParam()).toEqual({ + param: { value: 2, delay: 100 }, + result: 4, + }); + })); + + it('deals with race conditions using exhaustMap', fakeAsync(() => { + const testSetup = createTestSetup(exhaustOp); + const increment = testSetup.increment; + + increment({ value: 1, delay: 1000 }); + tick(500); + + expect(increment.status()).toEqual('pending'); + expect(increment.isPending()).toEqual(true); + expect(increment.isSuccess()).toEqual(false); + + increment({ value: 2, delay: 100 }); + tick(500); + + expect(increment.status()).toEqual('success'); + expect(increment.isPending()).toEqual(false); + expect(increment.error()).toEqual(undefined); + expect(increment.isSuccess()).toEqual(true); + + expect(testSetup.getCounter()).toEqual(5); + expect(testSetup.onSuccessCalls()).toEqual(1); + expect(testSetup.onErrorCalls()).toEqual(0); + expect(testSetup.lastOnSuccessParam()).toEqual({ + param: { value: 1, delay: 1000 }, + result: 2, + }); + + tick(500); + + expect(increment.status()).toEqual('success'); + expect(increment.isPending()).toEqual(false); + expect(increment.error()).toEqual(undefined); + expect(increment.isSuccess()).toEqual(true); + + expect(testSetup.getCounter()).toEqual(5); + expect(testSetup.onSuccessCalls()).toEqual(1); + expect(testSetup.onErrorCalls()).toEqual(0); + expect(testSetup.lastOnSuccessParam()).toEqual({ + param: { value: 1, delay: 1000 }, + result: 2, + }); + })); + + it('informs about failed operation via the returned promise', async () => { + const testSetup = createTestSetup(switchOp); + const increment = testSetup.increment; + + const p1 = increment({ value: 1, delay: 1, fail: false }); + const p2 = increment({ value: 2, delay: 2, fail: true }); + + expect(increment.status()).toEqual('pending'); + expect(increment.isPending()).toEqual(true); + expect(increment.isSuccess()).toEqual(false); + + await asyncTick(); + + const result1 = await p1; + const result2 = await p2; + + expect(result1.status).toEqual('aborted'); + expect(result2).toEqual({ + status: 'error', + error: { + error: 'Test-Error', + }, + }); + + expect(increment.isPending()).toEqual(false); + expect(increment.status()).toEqual('error'); + expect(increment.isSuccess()).toEqual(false); + + expect(increment.error()).toEqual({ + error: 'Test-Error', + }); + }); + + it('informs about successful operation via the returned promise', async () => { + const testSetup = createTestSetup(); + const increment = testSetup.increment; + + const resultPromise = increment({ value: 2, delay: 2, fail: false }); + + expect(increment.status()).toEqual('pending'); + expect(increment.isPending()).toEqual(true); + expect(increment.isSuccess()).toEqual(false); + + await asyncTick(); + + const result = await resultPromise; + + expect(result).toEqual({ + status: 'success', + value: 4, + }); + + expect(increment.isPending()).toEqual(false); + expect(increment.isSuccess()).toEqual(true); + + expect(increment.status()).toEqual('success'); + expect(increment.error()).toBeUndefined(); + }); + + it('informs about aborted operation when using switchMap', async () => { + const testSetup = createTestSetup(switchOp); + const increment = testSetup.increment; + + const p1 = increment({ value: 1, delay: 1, fail: false }); + const p2 = increment({ value: 2, delay: 2, fail: false }); + + expect(increment.status()).toEqual('pending'); + expect(increment.isPending()).toEqual(true); + expect(increment.isSuccess()).toEqual(false); + + await asyncTick(); + + const result1 = await p1; + const result2 = await p2; + + expect(result1.status).toEqual('aborted'); + expect(result2).toEqual({ + status: 'success', + value: 4, + }); + + expect(increment.isPending()).toEqual(false); + expect(increment.status()).toEqual('success'); + expect(increment.isSuccess()).toEqual(true); + + expect(increment.value()).toEqual(4); + expect(increment.hasValue()).toEqual(true); + expect(increment.error()).toBeUndefined(); + }); + + it('informs about aborted operation when using exhaustMap', async () => { + const testSetup = createTestSetup(exhaustOp); + const increment = testSetup.increment; + + const p1 = increment({ value: 1, delay: 1, fail: false }); + const p2 = increment({ value: 2, delay: 1, fail: false }); + + expect(increment.status()).toEqual('pending'); + expect(increment.isPending()).toEqual(true); + expect(increment.isSuccess()).toEqual(false); + + await asyncTick(); + + const result1 = await p1; + const result2 = await p2; + + expect(result1).toEqual({ + status: 'success', + value: 2, + }); + + expect(result2.status).toEqual('aborted'); + + expect(increment.isPending()).toEqual(false); + expect(increment.status()).toEqual('success'); + expect(increment.isSuccess()).toEqual(true); + expect(increment.error()).toBeUndefined(); + }); + + it('calls success handler per value in the stream and returns the final value via the promise', async () => { + const testSetup = createTestSetup(switchOp); + const increment = testSetup.increment; + + const input$ = new Subject(); + const resultPromise = increment({ + value: input$, + delay: 1, + fail: false, + }); + + expect(increment.status()).toEqual('pending'); + expect(increment.isPending()).toEqual(true); + expect(increment.isSuccess()).toEqual(false); + + input$.next(1); + input$.next(2); + input$.next(3); + input$.complete(); + + await asyncTick(); + + const result = await resultPromise; + + expect(result).toEqual({ + status: 'success', + value: 6, + }); + + expect(testSetup.getCounter()).toEqual(9); + expect(testSetup.lastOnSuccessParam()).toMatchObject({ + result: 6, + }); + + expect(increment.isPending()).toEqual(false); + expect(increment.status()).toEqual('success'); + expect(increment.isSuccess()).toEqual(true); + + expect(increment.error()).toBeUndefined(); + }); + + it('informs about failed operation via the returned promise', async () => { + const testSetup = createTestSetup(switchOp); + const increment = testSetup.increment; + + const p1 = increment({ value: 1, delay: 1, fail: false }); + const p2 = increment({ value: 2, delay: 2, fail: true }); + + expect(increment.status()).toEqual('pending'); + expect(increment.isPending()).toEqual(true); + expect(increment.isSuccess()).toEqual(false); + + await asyncTick(); + + const result1 = await p1; + const result2 = await p2; + + expect(result1.status).toEqual('aborted'); + expect(result2).toEqual({ + status: 'error', + error: { + error: 'Test-Error', + }, + }); + + expect(increment.isPending()).toEqual(false); + expect(increment.hasValue()).toEqual(false); + expect(increment.status()).toEqual('error'); + expect(increment.isSuccess()).toEqual(false); + expect(increment.error()).toEqual({ + error: 'Test-Error', + }); + }); + + it('can be called using an operation function', async () => { + const increment = TestBed.runInInjectionContext(() => + rxMutation((value: number) => { + return calcDouble(value).pipe(delay(1)); + }), + ); + + const resultPromise = increment(2); + + expect(increment.status()).toEqual('pending'); + expect(increment.isPending()).toEqual(true); + expect(increment.isSuccess()).toEqual(false); + + await asyncTick(); + + const result = await resultPromise; + + expect(result).toEqual({ + status: 'success', + value: 4, + }); + + expect(increment.isPending()).toEqual(false); + expect(increment.isSuccess()).toEqual(true); + + expect(increment.status()).toEqual('success'); + expect(increment.error()).toBeUndefined(); + }); +}); diff --git a/libs/ngrx-toolkit/src/lib/rx-mutation.ts b/libs/ngrx-toolkit/src/lib/mutation/rx-mutation.ts similarity index 52% rename from libs/ngrx-toolkit/src/lib/rx-mutation.ts rename to libs/ngrx-toolkit/src/lib/mutation/rx-mutation.ts index 55299619..cccf1772 100644 --- a/libs/ngrx-toolkit/src/lib/rx-mutation.ts +++ b/libs/ngrx-toolkit/src/lib/mutation/rx-mutation.ts @@ -10,15 +10,15 @@ import { tap, } from 'rxjs'; -import { concatOp, FlatteningOperator } from './flattening-operator'; -import { Mutation, MutationResult, MutationStatus } from './with-mutations'; +import { concatOp, FlatteningOperator } from '../flattening-operator'; +import { Mutation, MutationResult, MutationStatus } from './mutation'; -export type Func = (params: P) => R; +export type Operation = (param: Parameter) => Result; -export interface RxMutationOptions { - operation: Func>; - onSuccess?: (result: R, params: P) => void; - onError?: (error: unknown, params: P) => void; +export interface RxMutationOptions { + operation: Operation>; + onSuccess?: (result: Result, param: Parameter) => void; + onError?: (error: unknown, param: Parameter) => void; operator?: FlatteningOperator; injector?: Injector; } @@ -35,46 +35,72 @@ export interface RxMutationOptions { * * The `operation` is the only mandatory option. * + * The returned mutation can be called as an async function and returns a Promise. + * This promise informs about whether the mutation was successful, failed, or aborted + * (due to switchMap or exhaustMap semantics). + * + * The mutation also provides several Signals such as error, status or isPending (see below). + * + * Example usage without Store: + * * ```typescript - * export type Params = { - * value: number; - * }; + * const counterSignal = signal(0); * - * export const CounterStore = signalStore( - * { providedIn: 'root' }, - * withState({ counter: 0 }), - * withMutations((store) => ({ - * increment: rxMutation({ - * operation: (params: Params) => { - * return calcSum(store.counter(), params.value); - * }, - * operator: concatOp, - * onSuccess: (result) => { - * console.log('result', result); - * patchState(store, { counter: result }); - * }, - * onError: (error) => { - * console.error('Error occurred:', error); - * }, - * }), - * })), - * ); + * const increment = rxMutation({ + * operation: (param: Param) => { + * return calcSum(this.counterSignal(), param.value); + * }, + * operator: concatOp, + * onSuccess: (result) => { + * this.counterSignal.set(result); + * }, + * onError: (error) => { + * console.error('Error occurred:', error); + * }, + * }); + * + * const error = increment.error; + * const isPending = increment.isPending; + * const status = increment.status; + * const value = increment.value; + * const hasValue = increment.hasValue; + * + * async function incrementCounter() { + * const result = await increment({ value: 1 }); + * if (result.status === 'success') { + * console.log('Success:', result.value); + * } + * if (result.status === 'error') { + * console.log('Error:', result.error); + * } + * if (result.status === 'aborted') { + * console.log('Operation aborted'); + * } + * } * * function calcSum(a: number, b: number): Observable { - * return of(a + b); + * return of(result).pipe(delay(500)); * } * ``` * * @param options - * @returns + * @returns the actual mutation function along tracking data as properties/methods */ -export function rxMutation( - options: RxMutationOptions, -): Mutation { +export function rxMutation( + optionsOrOperation: + | RxMutationOptions + | Operation>, +): Mutation { const inputSubject = new Subject<{ - param: P; - resolve: (result: MutationResult) => void; + param: Parameter; + resolve: (result: MutationResult) => void; }>(); + + const options = + typeof optionsOrOperation === 'function' + ? { operation: optionsOrOperation } + : optionsOrOperation; + const flatteningOp = options.operator ?? concatOp; const destroyRef = options.injector?.get(DestroyRef) ?? inject(DestroyRef); @@ -83,6 +109,14 @@ export function rxMutation( const errorSignal = signal(undefined); const idle = signal(true); const isPending = computed(() => callCount() > 0); + const value = signal(undefined); + const isSuccess = computed(() => !idle() && !isPending() && !errorSignal()); + + const hasValue = function ( + this: Mutation, + ): this is Mutation, Result> { + return typeof value() !== 'undefined'; + }; const status = computed(() => { if (idle()) { @@ -99,7 +133,6 @@ export function rxMutation( const initialInnerStatus: MutationStatus = 'idle'; let innerStatus: MutationStatus = initialInnerStatus; - let lastResult: R; inputSubject .pipe( @@ -108,15 +141,16 @@ export function rxMutation( callCount.update((c) => c + 1); idle.set(false); return options.operation(input.param).pipe( - tap((result: R) => { + tap((result: Result) => { options.onSuccess?.(result, input.param); innerStatus = 'success'; errorSignal.set(undefined); - lastResult = result; + value.set(result); }), catchError((error: unknown) => { options.onError?.(error, input.param); errorSignal.set(error); + value.set(undefined); innerStatus = 'error'; return EMPTY; }), @@ -126,7 +160,7 @@ export function rxMutation( if (innerStatus === 'success') { input.resolve({ status: 'success', - value: lastResult, + value: value() as Result, }); } else if (innerStatus === 'error') { input.resolve({ @@ -148,8 +182,8 @@ export function rxMutation( ) .subscribe(); - const mutationFn = (param: P) => { - return new Promise>((resolve) => { + const mutationFn = (param: Parameter) => { + return new Promise>((resolve) => { if (callCount() > 0 && flatteningOp.exhaustSemantics) { resolve({ status: 'aborted', @@ -163,10 +197,12 @@ export function rxMutation( }); }; - const mutation = mutationFn as Mutation; + const mutation = mutationFn as Mutation; mutation.status = status; mutation.isPending = isPending; mutation.error = errorSignal; - + mutation.value = value; + mutation.hasValue = hasValue; + mutation.isSuccess = isSuccess; return mutation; } diff --git a/libs/ngrx-toolkit/src/lib/with-mutations.spec.ts b/libs/ngrx-toolkit/src/lib/with-mutations.spec.ts index 05e2175a..5bb3a80d 100644 --- a/libs/ngrx-toolkit/src/lib/with-mutations.spec.ts +++ b/libs/ngrx-toolkit/src/lib/with-mutations.spec.ts @@ -2,7 +2,7 @@ import { fakeAsync, TestBed, tick } from '@angular/core/testing'; import { patchState, signalStore, withState } from '@ngrx/signals'; import { delay, Observable, of, Subject, switchMap, throwError } from 'rxjs'; import { concatOp, exhaustOp, mergeOp, switchOp } from './flattening-operator'; -import { rxMutation } from './rx-mutation'; +import { rxMutation } from './mutation/rx-mutation'; import { withMutations } from './with-mutations'; type Param = diff --git a/libs/ngrx-toolkit/src/lib/with-mutations.ts b/libs/ngrx-toolkit/src/lib/with-mutations.ts index d8e050f3..5922307a 100644 --- a/libs/ngrx-toolkit/src/lib/with-mutations.ts +++ b/libs/ngrx-toolkit/src/lib/with-mutations.ts @@ -10,33 +10,12 @@ import { withMethods, WritableStateSource, } from '@ngrx/signals'; - -export type Mutation = { - (params: P): Promise>; - status: Signal; - isPending: Signal; - error: Signal; -}; +import { Mutation, MutationStatus } from './mutation/mutation'; // NamedMutationMethods below will infer the actual parameter and return types // eslint-disable-next-line @typescript-eslint/no-explicit-any type MutationsDictionary = Record>; -export type MutationResult = - | { - status: 'success'; - value: T; - } - | { - status: 'error'; - error: unknown; - } - | { - status: 'aborted'; - }; - -export type MutationStatus = 'idle' | 'pending' | 'error' | 'success'; - // withMethods uses Record internally // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type export type MethodsDictionary = Record; From ff508ca47e6f0334307015edc8628570b51af511 Mon Sep 17 00:00:00 2001 From: "J. Degand" <70610011+jdegand@users.noreply.github.com> Date: Tue, 2 Sep 2025 17:57:43 +0000 Subject: [PATCH 2/6] fix: devtools-syncer missing case for extension --- .../internal/devtools-syncer.service.ts | 140 ++++++++---------- .../src/lib/devtools/with-devtools.ts | 5 +- 2 files changed, 60 insertions(+), 85 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 a4a67b4a..3403423d 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 @@ -11,56 +11,28 @@ const dummyConnection: Connection = { send: () => void true, }; -/** - * A service provided by the root injector is - * required because the synchronization runs - * globally. - * - * The SignalStore could be provided in a component. - * If the effect starts in the injection - * context of the SignalStore, the complete sync - * process would shut down once the component gets - * destroyed. - */ @Injectable({ providedIn: 'root' }) export class DevtoolsSyncer implements OnDestroy { - /** - * Stores all SignalStores that are connected to the - * DevTools along their options, names and id. - */ #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 }), }; - /** - * Maintains the current states of all stores to avoid conflicts - * between glitch-free and glitched trackers when used simultaneously. - * - * The challenge lies in ensuring that glitched trackers do not - * interfere with the synchronization process of glitch-free trackers. - * Specifically, glitched trackers could cause the synchronization to - * read the current state of stores managed by glitch-free trackers. - * - * Therefore, the synchronization process doesn't read the state from - * each store, but relies on #currentState. - * - * Please note, that here the key is the name and not the id. - */ #currentState: Record = {}; #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) { + console.warn( + '[DevtoolsSyncer] Not running in browser. DevTools disabled.', + ); return; } } @@ -69,6 +41,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 +76,7 @@ export class DevtoolsSyncer implements OnDestroy { }, {} as Record, ); + this.#currentState = { ...this.#currentState, ...mappedChangedStatePerName, @@ -90,18 +89,10 @@ export class DevtoolsSyncer implements OnDestroy { this.#connection.send({ type }, this.#currentState); } - getNextId() { + getNextId(): string { return String(this.#currentId++); } - /** - * Consumer provides the id. That is because we can only start - * tracking the store in the init hook. - * Unfortunately, methods for renaming having the final id - * need to be defined already before. - * That's why `withDevtools` requests first the id and - * then registers itself later. - */ addStore( id: string, name: string, @@ -109,21 +100,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 +123,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 +147,28 @@ 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/with-devtools.ts b/libs/ngrx-toolkit/src/lib/devtools/with-devtools.ts index 5827e7ae..4ae6b05d 100644 --- a/libs/ngrx-toolkit/src/lib/devtools/with-devtools.ts +++ b/libs/ngrx-toolkit/src/lib/devtools/with-devtools.ts @@ -45,10 +45,8 @@ export function withDevtools(name: string, ...features: DevtoolsFeature[]) { return signalStoreFeature( withMethods(() => { const syncer = inject(DevtoolsSyncer); - const id = syncer.getNextId(); - // TODO: use withProps and symbols return { [renameDevtoolsMethodName]: (newName: string) => { syncer.renameStore(name, newName); @@ -58,7 +56,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 +71,7 @@ export function withDevtools(name: string, ...features: DevtoolsFeature[]) { syncer.addStore(id, name, store, finalOptions); }, onDestroy() { + const id = String(store[uniqueDevtoolsId]()); syncer.removeStore(id); }, }; From eda1e4d067c2714f7ea864a5a20e1a615082cde9 Mon Sep 17 00:00:00 2001 From: "J. Degand" <70610011+jdegand@users.noreply.github.com> Date: Tue, 2 Sep 2025 18:10:58 +0000 Subject: [PATCH 3/6] fix: add back stripped comments --- .../internal/devtools-syncer.service.ts | 39 +++++++++++++++++++ .../src/lib/devtools/with-devtools.ts | 1 + 2 files changed, 40 insertions(+) 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 3403423d..ac8f75a2 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 @@ -11,8 +11,23 @@ const dummyConnection: Connection = { send: () => void true, }; +/** + * A service provided by the root injector is + * required because the synchronization runs + * globally. + * + * The SignalStore could be provided in a component. + * If the effect starts in the injection + * context of the SignalStore, the complete sync + * process would shut down once the component gets + * destroyed. + */ @Injectable({ providedIn: 'root' }) export class DevtoolsSyncer implements OnDestroy { + /** + * Stores all SignalStores that are connected to the + * DevTools along their options, names and id. + */ #stores: StoreRegistry = {}; readonly #isBrowser = isPlatformBrowser(inject(PLATFORM_ID)); readonly #trackers: Tracker[] = []; @@ -21,6 +36,20 @@ export class DevtoolsSyncer implements OnDestroy { ...inject(REDUX_DEVTOOLS_CONFIG, { optional: true }), }; + /** + * Maintains the current states of all stores to avoid conflicts + * between glitch-free and glitched trackers when used simultaneously. + * + * The challenge lies in ensuring that glitched trackers do not + * interfere with the synchronization process of glitch-free trackers. + * Specifically, glitched trackers could cause the synchronization to + * read the current state of stores managed by glitch-free trackers. + * + * Therefore, the synchronization process doesn't read the state from + * each store, but relies on #currentState. + * + * Please note, that here the key is the name and not the id. + */ #currentState: Record = {}; #currentId = 1; @@ -93,6 +122,14 @@ export class DevtoolsSyncer implements OnDestroy { return String(this.#currentId++); } + /** + * Consumer provides the id. That is because we can only start + * tracking the store in the init hook. + * Unfortunately, methods for renaming having the final id + * need to be defined already before. + * That's why `withDevtools` requests first the id and + * then registers itself later. + */ addStore( id: string, name: string, @@ -133,6 +170,8 @@ Enable automatic indexing via withDevTools('${storeName}', { indexNames: true }) {} 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( (acc, [storeName, state]) => { if (storeName !== name) acc[storeName] = state; diff --git a/libs/ngrx-toolkit/src/lib/devtools/with-devtools.ts b/libs/ngrx-toolkit/src/lib/devtools/with-devtools.ts index 4ae6b05d..6166a62a 100644 --- a/libs/ngrx-toolkit/src/lib/devtools/with-devtools.ts +++ b/libs/ngrx-toolkit/src/lib/devtools/with-devtools.ts @@ -47,6 +47,7 @@ export function withDevtools(name: string, ...features: DevtoolsFeature[]) { const syncer = inject(DevtoolsSyncer); const id = syncer.getNextId(); + // TODO: use withProps and symbols return { [renameDevtoolsMethodName]: (newName: string) => { syncer.renameStore(name, newName); From 15cf4fa9d2de7e75343053e50cc4f40951db3932 Mon Sep 17 00:00:00 2001 From: "J. Degand" <70610011+jdegand@users.noreply.github.com> Date: Tue, 2 Sep 2025 18:13:18 +0000 Subject: [PATCH 4/6] fix: move comments to correct spot --- .../src/lib/devtools/internal/devtools-syncer.service.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 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 ac8f75a2..37a35a11 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 @@ -170,8 +170,6 @@ Enable automatic indexing via withDevTools('${storeName}', { indexNames: true }) {} 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( (acc, [storeName, state]) => { if (storeName !== name) acc[storeName] = state; @@ -204,6 +202,8 @@ Enable automatic indexing via withDevTools('${storeName}', { indexNames: true }) 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( (acc, [storeName, state]) => { if (storeName !== oldName) acc[storeName] = state; From cdbbad2f1571788f5566a98bf336ce07aa2e42d6 Mon Sep 17 00:00:00 2001 From: "J. Degand" <70610011+jdegand@users.noreply.github.com> Date: Wed, 3 Sep 2025 00:24:28 +0000 Subject: [PATCH 5/6] feat: add spec file for devtools-syncer --- .../internal/devtools-syncer.service.ts | 3 +- .../devtools/tests/devtools-syncer.spec.ts | 96 +++++++++++++++++++ 2 files changed, 97 insertions(+), 2 deletions(-) create mode 100644 libs/ngrx-toolkit/src/lib/devtools/tests/devtools-syncer.spec.ts 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 37a35a11..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, }; /** @@ -62,7 +62,6 @@ export class DevtoolsSyncer implements OnDestroy { console.warn( '[DevtoolsSyncer] Not running in browser. DevTools disabled.', ); - return; } } 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(); + }); +}); From cf8e5838a71bbf2de74d7215f19715ab6cbb9aea Mon Sep 17 00:00:00 2001 From: "J. Degand" <70610011+jdegand@users.noreply.github.com> Date: Wed, 3 Sep 2025 00:45:48 +0000 Subject: [PATCH 6/6] fix: remove exisiting_names --- libs/ngrx-toolkit/src/lib/devtools/with-devtools.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/libs/ngrx-toolkit/src/lib/devtools/with-devtools.ts b/libs/ngrx-toolkit/src/lib/devtools/with-devtools.ts index 6166a62a..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. *