diff --git a/src/lib/deep-freeze.ts b/src/lib/deep-freeze.ts new file mode 100644 index 0000000..bd0fd42 --- /dev/null +++ b/src/lib/deep-freeze.ts @@ -0,0 +1,41 @@ +import { DeepReadonly } from './deep-readonly'; + +export function deepFreeze(target: T): DeepReadonly { + Object.freeze(target); + + const targetIsFunction = isFunction(target); + + for (const prop of Object.getOwnPropertyNames(target)) { + // Do not freeze Ivy properties (e.g. router state) + // https://github.com/ngrx/platform/issues/2109#issuecomment-582689060 + if (prop.startsWith('ɵ')) { + continue; + } + + if ( + Object.hasOwn(target, prop) && + (targetIsFunction + ? prop !== 'caller' && prop !== 'callee' && prop !== 'arguments' + : true) + ) { + const propValue = (target as Record)[prop]; + + if ( + (isObjectLike(propValue) || isFunction(propValue)) && + !Object.isFrozen(propValue) + ) { + deepFreeze(propValue); + } + } + } + + return target as DeepReadonly; +} + +function isFunction(target: unknown): target is () => unknown { + return typeof target === 'function'; +} + +function isObjectLike(target: unknown): target is object { + return typeof target === 'object' && target !== null; +} diff --git a/src/lib/deep-readonly.ts b/src/lib/deep-readonly.ts new file mode 100644 index 0000000..2f58038 --- /dev/null +++ b/src/lib/deep-readonly.ts @@ -0,0 +1,5 @@ +export type DeepReadonly = T extends Array + ? ReadonlyArray + : T extends Record + ? Readonly<{ [Key in keyof T]: DeepReadonly }> + : Readonly; diff --git a/src/lib/signal-state.prod.spec.ts b/src/lib/signal-state.prod.spec.ts new file mode 100644 index 0000000..b9f1046 --- /dev/null +++ b/src/lib/signal-state.prod.spec.ts @@ -0,0 +1,24 @@ +import { testEffects } from '../tests/test-effects'; +import { enableProdMode } from '@angular/core'; +import { signalState } from './signal-state'; + +// needs a separate file because we can't revert the prod mode. +it( + 'should not freeze state with immutability check in prod mode', + testEffects(() => { + enableProdMode(); + const state = signalState( + { + person: { firstName: 'John', lastName: 'Smith' }, + }, + { immutabilityCheck: true } + ); + + expect(() => + state.$update((state) => { + (state as any).person.firstName = 'Johannes'; + return state; + }) + ).not.toThrow(); + }) +); diff --git a/src/lib/signal-state.spec.ts b/src/lib/signal-state.spec.ts index 15c7730..7567a5d 100644 --- a/src/lib/signal-state.spec.ts +++ b/src/lib/signal-state.spec.ts @@ -1,92 +1,211 @@ +import { effect, isDevMode } from '@angular/core'; +import * as angularCore from '@angular/core'; +import { testEffects } from '../tests/test-effects'; import { signalState } from './signal-state'; -import { ApplicationRef, Component, effect } from '@angular/core'; -import { fakeAsync, TestBed, waitForAsync } from '@angular/core/testing'; -import { state } from '@angular/animations'; +import { selectSignal } from './select-signal'; describe('Signal State', () => { - const initialState = { + interface TestState { + user: { firstName: string; lastName: string }; + foo: string; + numbers: number[]; + } + + const getInitialState = () => ({ user: { firstName: 'John', lastName: 'Smith', }, foo: 'bar', numbers: [1, 2, 3], - }; + }); - const setup = () => signalState(initialState); + describe('Basics', () => { + it('should support nested signals', () => { + const initialState = getInitialState(); + const state = signalState(initialState); - it('should support nested signals', () => { - const state = setup(); + expect(state()).toBe(initialState); + expect(state.user()).toBe(initialState.user); + expect(state.user.firstName()).toBe('John'); + }); - expect(state()).toBe(initialState); - expect(state.user()).toBe(initialState.user); - expect(state.user.firstName()).toBe(initialState.user.firstName); - }); + it('should allow updates', () => { + const initialState = getInitialState(); + const state = signalState(initialState); + state.$update((state) => ({ + ...state, + user: { firstName: 'Johannes', lastName: 'Schmidt' }, + })); + expect(state()).toEqual({ + ...initialState, + user: { firstName: 'Johannes', lastName: 'Schmidt' }, + }); + }); - it('should allow updates', () => { - const state = setup(); - state.$update((state) => ({ - ...state, - user: { firstName: 'Johannes', lastName: 'Schmidt' }, - })); - expect(state()).toEqual({ - ...initialState, - user: { firstName: 'Johannes', lastName: 'Schmidt' }, + it('should update immutably', () => { + const initialState = getInitialState(); + const state = signalState(initialState); + state.$update((state) => ({ + ...state, + foo: 'bar', + numbers: [3, 2, 1], + })); + expect(state.user()).toBe(initialState.user); + expect(state.foo()).toBe(initialState.foo); + expect(state.numbers()).not.toBe(initialState.numbers); }); }); - it('should update immutably', () => { - const state = setup(); - state.$update((state) => ({ - ...state, - foo: 'bar', - numbers: [3, 2, 1], - })); - expect(state.user()).toBe(initialState.user); - expect(state.foo()).toBe(initialState.foo); - expect(state.numbers()).not.toBe(initialState.numbers); + describe('equal checks', () => { + const setup = () => signalState(getInitialState()); + + it( + 'should not fire unchanged signals on update', + testEffects((runEffects) => { + const state = setup(); + + const numberEffect = jest.fn(() => state.numbers()); + effect(numberEffect); + + const userEffect = jest.fn(() => state.user()); + effect(userEffect); + + expect(numberEffect).toHaveBeenCalledTimes(0); + expect(userEffect).toHaveBeenCalledTimes(0); + + // run effects for the first time + runEffects(); + expect(numberEffect).toHaveBeenCalledTimes(1); + expect(userEffect).toHaveBeenCalledTimes(1); + + // update state with effect run + state.$update((state) => ({ ...state, numbers: [4, 5, 6] })); + runEffects(); + expect(numberEffect).toHaveBeenCalledTimes(2); + expect(userEffect).toHaveBeenCalledTimes(1); + }) + ); + + it( + 'should not fire for unchanged derived signals', + testEffects((runEffects) => { + const state = setup(); + + const numberCount = selectSignal( + state, + (state) => state.numbers.length + ); + + const numberEffect = jest.fn(() => numberCount()); + effect(numberEffect); + + // run effects for the first time + runEffects(); + expect(numberEffect).toHaveBeenCalledTimes(1); + + // update user + state.$update({ + user: { firstName: 'Susanne', lastName: 'Taylor' }, + }); + runEffects(); + expect(numberEffect).toHaveBeenCalledTimes(1); + + // update numbers + state.$update({ numbers: [1] }); + runEffects(); + expect(numberEffect).toHaveBeenCalledTimes(2); + expect(numberCount()).toBe(1); + }) + ); }); - // TODO: Find a better way to execute effects - describe('Effects in Signals', () => { - @Component({ - template: '', - }) - class TestComponent { - userFired = 0; - numbersFired = 0; - state = setup(); - - constructor() { - effect(() => { - this.userFired++; - this.state.user(); + describe('immutability', () => { + const setup = (immutabilityCheck = false) => + signalState(getInitialState(), { immutabilityCheck }); + + it( + 'should run nested-based effects on mutable updates', + testEffects((runEffects) => { + let numberCounter = 0; + const state = setup(); + const effectFn = jest.fn(() => state.user()); + effect(effectFn); + runEffects(); + + // mutable update + state.$update((state) => { + (state as any).user = { firstName: 'John', lastName: 'Smith' }; + return state; }); - const numbersListener = effect(() => { - this.numbersFired++; - this.state.numbers(); + runEffects(); + expect(effectFn).toHaveBeenCalledTimes(2); + }) + ); + + it( + 'should run selectSignal-based effects on mutable updates', + testEffects((runEffects) => { + let numberCounter = 0; + const state = setup(); + const userSignal = selectSignal(state, (state) => state.user); + const effectFn = jest.fn(() => userSignal()); + effect(effectFn); + runEffects(); + + // mutable update + state.$update((state) => { + (state as any).user = { firstName: 'John', lastName: 'Smith' }; + return state; }); - } - } + runEffects(); + expect(effectFn).toHaveBeenCalledTimes(2); + }) + ); + + it( + 'should not freeze on mutable updates', + testEffects(() => { + const state = setup(); - it('should not fire all signals on update', fakeAsync(() => { - const fixture = TestBed.createComponent(TestComponent); - const component = fixture.componentInstance; + expect(() => + state.$update((state) => { + (state as any).foo = 'bar'; + return { ...state }; + }) + ).not.toThrow(); + }) + ); - expect(component.numbersFired).toBe(0); - expect(component.userFired).toBe(0); + it( + 'should freeze on mutable updates with immutability check', + testEffects(() => { + const state = setup(true); - fixture.detectChanges(); + expect(() => + state.$update((state) => { + (state as any).foo = 'bar'; + return state; + }) + ).toThrow(); + }) + ); - expect(component.numbersFired).toBe(1); - expect(component.userFired).toBe(1); + it( + 'should freeze on consecutive mutable updates with immutability check', + testEffects(() => { + const state = setup(true); - component.state.$update((state) => ({ ...state, numbers: [4, 5, 6] })); - fixture.detectChanges(); + state.$update((state) => ({ ...state, foo: 'foobar' })); - expect(component.numbersFired).toBe(2); - expect(component.userFired).toBe(1); - })); + expect(() => + state.$update((state) => { + (state as any).foo = 'barfoo'; + return state; + }) + ).toThrow(); + }) + ); }); }); diff --git a/src/lib/signal-state.ts b/src/lib/signal-state.ts index 0e6061d..afc7230 100644 --- a/src/lib/signal-state.ts +++ b/src/lib/signal-state.ts @@ -1,40 +1,65 @@ -import { signal, WritableSignal } from '@angular/core'; +import { isDevMode, signal, WritableSignal } from '@angular/core'; import { DeepSignal, toDeepSignal } from './deep-signal'; import { defaultEqualityFn } from './select-signal'; +import { DeepReadonly } from './deep-readonly'; +import { deepFreeze } from './deep-freeze'; export type SignalState> = DeepSignal & SignalStateUpdate; +export type UpdatedState = T extends ReadonlyArray + ? Array | ReadonlyArray + : T extends Record + ? { [Key in keyof T]: UpdatedState } + : T; + export type SignalStateUpdater> = | Partial - | ((state: State) => Partial); + | ((state: DeepReadonly) => UpdatedState); export type SignalStateUpdate> = { $update: (...updaters: SignalStateUpdater[]) => void; }; export function signalState>( - initialState: State + initialState: State, + options: { immutabilityCheck: boolean } = { immutabilityCheck: false } ): SignalState { + const freezeState = isDevMode() && options.immutabilityCheck; + if (freezeState) { + deepFreeze(initialState); + } const stateSignal = signal(initialState, { equal: defaultEqualityFn }); const deepSignal = toDeepSignal(stateSignal.asReadonly()); - (deepSignal as SignalState).$update = - signalStateUpdateFactory(stateSignal); + (deepSignal as SignalState).$update = signalStateUpdateFactory( + stateSignal, + freezeState + ); return deepSignal as SignalState; } export function signalStateUpdateFactory>( - stateSignal: WritableSignal + stateSignal: WritableSignal, + freezeState: boolean ): SignalStateUpdate['$update'] { - return (...updaters) => - stateSignal.update((state) => - updaters.reduce( + return (...updaters) => { + stateSignal.update((state) => { + const updatedState = updaters.reduce( (currentState: State, updater) => ({ ...currentState, - ...(typeof updater === 'function' ? updater(currentState) : updater), + ...(typeof updater === 'function' + ? updater(currentState as DeepReadonly) + : updater), }), state - ) - ); + ); + + if (freezeState) { + deepFreeze(updatedState); + } + + return updatedState; + }); + }; } diff --git a/src/tests/test-effects.ts b/src/tests/test-effects.ts new file mode 100644 index 0000000..d8912c2 --- /dev/null +++ b/src/tests/test-effects.ts @@ -0,0 +1,26 @@ +import { Component } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; + +/** + * Testing function which executes inside of an injectionContext and provides Change Detection trigger. + * + * It looks like, we need to have at least one component to execute effects. + * AppRef::tick() does not work. Only ComponentFixture::detectChanges() seems to do the job. + * withEffects renders a TestComponent and executes the tests inside an injectionContext. + * This allows us to generate effects very easily. + */ +export const testEffects = + (testFn: (runEffects: () => void) => void): (() => void) => + () => { + const fixture = TestBed.configureTestingModule({ + imports: [TestComponent], + }).createComponent(TestComponent); + + TestBed.runInInjectionContext(() => testFn(() => fixture.detectChanges())); + }; + +@Component({ + template: '', + standalone: true, +}) +class TestComponent {} diff --git a/src/tests/test2.ts b/src/tests/test2.ts index ac61cc6..b2a57c4 100644 --- a/src/tests/test2.ts +++ b/src/tests/test2.ts @@ -1,5 +1,4 @@ import { - selectSignal, signalStore, signalStoreFeature, type,