From 0495dbb80c73bf6d5d891b26b0c978f05b5b642f Mon Sep 17 00:00:00 2001 From: markostanimirovic Date: Tue, 5 Sep 2023 00:20:39 +0200 Subject: [PATCH 1/2] feat(signals): add `patchState` function and remove `$update` method --- modules/signals/spec/signal-state.spec.ts | 19 +++---- modules/signals/src/index.ts | 3 +- modules/signals/src/signal-state-models.ts | 21 -------- modules/signals/src/signal-state.ts | 60 ++++++++++++++-------- 4 files changed, 50 insertions(+), 53 deletions(-) delete mode 100644 modules/signals/src/signal-state-models.ts diff --git a/modules/signals/spec/signal-state.spec.ts b/modules/signals/spec/signal-state.spec.ts index f20524ed14..8c01fa6cca 100644 --- a/modules/signals/spec/signal-state.spec.ts +++ b/modules/signals/spec/signal-state.spec.ts @@ -1,5 +1,5 @@ import { effect, isSignal } from '@angular/core'; -import { signalState } from '../src'; +import { patchState, signalState } from '../src'; import { testEffects } from './helpers'; describe('signalState', () => { @@ -13,11 +13,11 @@ describe('signalState', () => { ngrx: 'signals', }; - describe('$update', () => { + describe('patch', () => { it('updates state via partial state object', () => { const state = signalState(initialState); - state.$update({ + patchState(state, { user: { firstName: 'Johannes', lastName: 'Schmidt' }, foo: 'baz', }); @@ -32,7 +32,7 @@ describe('signalState', () => { it('updates state via updater function', () => { const state = signalState(initialState); - state.$update((state) => ({ + patchState(state, (state) => ({ numbers: [...state.numbers, 4], ngrx: 'rocks', })); @@ -47,7 +47,8 @@ describe('signalState', () => { it('updates state via sequence of partial state objects and updater functions', () => { const state = signalState(initialState); - state.$update( + patchState( + state, { user: { firstName: 'Johannes', lastName: 'Schmidt' } }, (state) => ({ numbers: [...state.numbers, 4], foo: 'baz' }), (state) => ({ user: { ...state.user, firstName: 'Jovan' } }), @@ -65,7 +66,7 @@ describe('signalState', () => { it('updates state immutably', () => { const state = signalState(initialState); - state.$update({ + patchState(state, { foo: 'bar', numbers: [3, 2, 1], ngrx: 'rocks', @@ -149,14 +150,14 @@ describe('signalState', () => { expect(userEmitted).toBe(1); expect(firstNameEmitted).toBe(1); - state.$update({ numbers: [1, 2, 3] }); + patchState(state, { numbers: [1, 2, 3] }); tick(); expect(numbersEmitted).toBe(2); expect(userEmitted).toBe(1); expect(firstNameEmitted).toBe(1); - state.$update((state) => ({ + patchState(state, (state) => ({ user: { ...state.user, lastName: 'Schmidt' }, })); tick(); @@ -165,7 +166,7 @@ describe('signalState', () => { expect(userEmitted).toBe(2); expect(firstNameEmitted).toBe(1); - state.$update((state) => ({ + patchState(state, (state) => ({ user: { ...state.user, firstName: 'Johannes' }, })); tick(); diff --git a/modules/signals/src/index.ts b/modules/signals/src/index.ts index 735ffeaf91..e5bd6486f4 100644 --- a/modules/signals/src/index.ts +++ b/modules/signals/src/index.ts @@ -1,3 +1,2 @@ export { selectSignal } from './select-signal'; -export { signalState } from './signal-state'; -export { SignalStateUpdater } from './signal-state-models'; +export { patchState, signalState, PartialStateUpdater } from './signal-state'; diff --git a/modules/signals/src/signal-state-models.ts b/modules/signals/src/signal-state-models.ts deleted file mode 100644 index ad8a2c3277..0000000000 --- a/modules/signals/src/signal-state-models.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { DeepSignal } from './deep-signal'; - -export type SignalState> = - DeepSignal & SignalStateUpdate; - -export type SignalStateUpdater> = - | Partial - | ((state: State) => Partial); - -export type SignalStateUpdate> = { - $update: (...updaters: SignalStateUpdater[]) => void; -}; - -/** - * Signal state cannot contain optional properties. - */ -export type NotAllowedStateCheck = State extends Required - ? State extends Record - ? { [K in keyof State]: State[K] & NotAllowedStateCheck } - : unknown - : never; diff --git a/modules/signals/src/signal-state.ts b/modules/signals/src/signal-state.ts index a29c3ed49c..b37f4500e7 100644 --- a/modules/signals/src/signal-state.ts +++ b/modules/signals/src/signal-state.ts @@ -1,34 +1,52 @@ import { signal, WritableSignal } from '@angular/core'; -import { toDeepSignal } from './deep-signal'; +import { DeepSignal, toDeepSignal } from './deep-signal'; import { defaultEqual } from './select-signal'; -import { - NotAllowedStateCheck, - SignalState, - SignalStateUpdate, -} from './signal-state-models'; + +type SignalState> = DeepSignal & + SignalStateMeta; + +const SIGNAL_STATE_META_KEY = Symbol('SIGNAL_STATE_META_KEY'); + +type SignalStateMeta> = { + [SIGNAL_STATE_META_KEY]: WritableSignal; +}; + +/** + * Signal state cannot contain optional properties. + */ +type NotAllowedStateCheck = State extends Required + ? State extends Record + ? { [K in keyof State]: State[K] & NotAllowedStateCheck } + : unknown + : never; + +export type PartialStateUpdater> = + | Partial + | ((state: State) => Partial); export function signalState>( initialState: State & NotAllowedStateCheck ): SignalState { const stateSignal = signal(initialState as State, { equal: defaultEqual }); const deepSignal = toDeepSignal(stateSignal.asReadonly()); - (deepSignal as SignalState).$update = - signalStateUpdateFactory(stateSignal); + Object.defineProperty(deepSignal, SIGNAL_STATE_META_KEY, { + value: stateSignal, + }); return deepSignal as SignalState; } -export function signalStateUpdateFactory>( - stateSignal: WritableSignal -): SignalStateUpdate['$update'] { - return (...updaters) => - stateSignal.update((state) => - updaters.reduce( - (currentState: State, updater) => ({ - ...currentState, - ...(typeof updater === 'function' ? updater(currentState) : updater), - }), - state - ) - ); +export function patchState>( + signalState: SignalStateMeta, + ...updaters: PartialStateUpdater[] +): void { + signalState[SIGNAL_STATE_META_KEY].update((currentState) => + updaters.reduce( + (nextState: State, updater) => ({ + ...nextState, + ...(typeof updater === 'function' ? updater(nextState) : updater), + }), + currentState + ) + ); } From ee76f5180106e825812914e3c4ae05564b85c13e Mon Sep 17 00:00:00 2001 From: markostanimirovic Date: Tue, 5 Sep 2023 18:58:25 +0200 Subject: [PATCH 2/2] refactor(signals): move patchState to separate file --- modules/signals/spec/patch-state.spec.ts | 77 ++++++++ modules/signals/spec/signal-state.spec.ts | 218 ++++++++-------------- modules/signals/src/index.ts | 3 +- modules/signals/src/patch-state.ts | 20 ++ modules/signals/src/signal-state.ts | 29 +-- 5 files changed, 179 insertions(+), 168 deletions(-) create mode 100644 modules/signals/spec/patch-state.spec.ts create mode 100644 modules/signals/src/patch-state.ts diff --git a/modules/signals/spec/patch-state.spec.ts b/modules/signals/spec/patch-state.spec.ts new file mode 100644 index 0000000000..c3e6e0e9d7 --- /dev/null +++ b/modules/signals/spec/patch-state.spec.ts @@ -0,0 +1,77 @@ +import { patchState, signalState } from '../src'; + +describe('patchState', () => { + const initialState = { + user: { + firstName: 'John', + lastName: 'Smith', + }, + foo: 'bar', + numbers: [1, 2, 3], + ngrx: 'signals', + }; + + it('patches state via partial state object', () => { + const state = signalState(initialState); + + patchState(state, { + user: { firstName: 'Johannes', lastName: 'Schmidt' }, + foo: 'baz', + }); + + expect(state()).toEqual({ + ...initialState, + user: { firstName: 'Johannes', lastName: 'Schmidt' }, + foo: 'baz', + }); + }); + + it('patches state via updater function', () => { + const state = signalState(initialState); + + patchState(state, (state) => ({ + numbers: [...state.numbers, 4], + ngrx: 'rocks', + })); + + expect(state()).toEqual({ + ...initialState, + numbers: [1, 2, 3, 4], + ngrx: 'rocks', + }); + }); + + it('patches state via sequence of partial state objects and updater functions', () => { + const state = signalState(initialState); + + patchState( + state, + { user: { firstName: 'Johannes', lastName: 'Schmidt' } }, + (state) => ({ numbers: [...state.numbers, 4], foo: 'baz' }), + (state) => ({ user: { ...state.user, firstName: 'Jovan' } }), + { foo: 'foo' } + ); + + expect(state()).toEqual({ + ...initialState, + user: { firstName: 'Jovan', lastName: 'Schmidt' }, + foo: 'foo', + numbers: [1, 2, 3, 4], + }); + }); + + it('patches state immutably', () => { + const state = signalState(initialState); + + patchState(state, { + foo: 'bar', + numbers: [3, 2, 1], + ngrx: 'rocks', + }); + + expect(state.user()).toBe(initialState.user); + expect(state.foo()).toBe(initialState.foo); + expect(state.numbers()).not.toBe(initialState.numbers); + expect(state.ngrx()).not.toBe(initialState.ngrx); + }); +}); diff --git a/modules/signals/spec/signal-state.spec.ts b/modules/signals/spec/signal-state.spec.ts index 8c01fa6cca..edf93e896e 100644 --- a/modules/signals/spec/signal-state.spec.ts +++ b/modules/signals/spec/signal-state.spec.ts @@ -13,168 +13,100 @@ describe('signalState', () => { ngrx: 'signals', }; - describe('patch', () => { - it('updates state via partial state object', () => { - const state = signalState(initialState); + it('creates signals for nested state slices', () => { + const state = signalState(initialState); - patchState(state, { - user: { firstName: 'Johannes', lastName: 'Schmidt' }, - foo: 'baz', - }); + expect(state()).toBe(initialState); + expect(isSignal(state)).toBe(true); - expect(state()).toEqual({ - ...initialState, - user: { firstName: 'Johannes', lastName: 'Schmidt' }, - foo: 'baz', - }); - }); + expect(state.user()).toBe(initialState.user); + expect(isSignal(state.user)).toBe(true); - it('updates state via updater function', () => { - const state = signalState(initialState); + expect(state.user.firstName()).toBe(initialState.user.firstName); + expect(isSignal(state.user.firstName)).toBe(true); - patchState(state, (state) => ({ - numbers: [...state.numbers, 4], - ngrx: 'rocks', - })); + expect(state.foo()).toBe(initialState.foo); + expect(isSignal(state.foo)).toBe(true); - expect(state()).toEqual({ - ...initialState, - numbers: [1, 2, 3, 4], - ngrx: 'rocks', - }); - }); + expect(state.numbers()).toBe(initialState.numbers); + expect(isSignal(state.numbers)).toBe(true); + + expect(state.ngrx()).toBe(initialState.ngrx); + expect(isSignal(state.ngrx)).toBe(true); + }); - it('updates state via sequence of partial state objects and updater functions', () => { + it('does not modify props that are not state slices', () => { + const state = signalState(initialState); + (state as any).x = 1; + (state.user as any).x = 2; + (state.user.firstName as any).x = 3; + + expect((state as any).x).toBe(1); + expect((state.user as any).x).toBe(2); + expect((state.user.firstName as any).x).toBe(3); + + expect((state as any).y).toBe(undefined); + expect((state.user as any).y).toBe(undefined); + expect((state.user.firstName as any).y).toBe(undefined); + }); + + it( + 'emits new values only for affected signals', + testEffects((tick) => { const state = signalState(initialState); + let numbersEmitted = 0; + let userEmitted = 0; + let firstNameEmitted = 0; - patchState( - state, - { user: { firstName: 'Johannes', lastName: 'Schmidt' } }, - (state) => ({ numbers: [...state.numbers, 4], foo: 'baz' }), - (state) => ({ user: { ...state.user, firstName: 'Jovan' } }), - { foo: 'foo' } - ); - - expect(state()).toEqual({ - ...initialState, - user: { firstName: 'Jovan', lastName: 'Schmidt' }, - foo: 'foo', - numbers: [1, 2, 3, 4], + effect(() => { + state.numbers(); + numbersEmitted++; }); - }); - it('updates state immutably', () => { - const state = signalState(initialState); + effect(() => { + state.user(); + userEmitted++; + }); - patchState(state, { - foo: 'bar', - numbers: [3, 2, 1], - ngrx: 'rocks', + effect(() => { + state.user.firstName(); + firstNameEmitted++; }); - expect(state.user()).toBe(initialState.user); - expect(state.foo()).toBe(initialState.foo); - expect(state.numbers()).not.toBe(initialState.numbers); - expect(state.ngrx()).not.toBe(initialState.ngrx); - }); - }); + expect(numbersEmitted).toBe(0); + expect(userEmitted).toBe(0); + expect(firstNameEmitted).toBe(0); - describe('nested signals', () => { - it('creates signals for nested state slices', () => { - const state = signalState(initialState); + tick(); - expect(state()).toBe(initialState); - expect(isSignal(state)).toBe(true); + expect(numbersEmitted).toBe(1); + expect(userEmitted).toBe(1); + expect(firstNameEmitted).toBe(1); - expect(state.user()).toBe(initialState.user); - expect(isSignal(state.user)).toBe(true); + patchState(state, { numbers: [1, 2, 3] }); + tick(); - expect(state.user.firstName()).toBe(initialState.user.firstName); - expect(isSignal(state.user.firstName)).toBe(true); + expect(numbersEmitted).toBe(2); + expect(userEmitted).toBe(1); + expect(firstNameEmitted).toBe(1); - expect(state.foo()).toBe(initialState.foo); - expect(isSignal(state.foo)).toBe(true); + patchState(state, (state) => ({ + user: { ...state.user, lastName: 'Schmidt' }, + })); + tick(); - expect(state.numbers()).toBe(initialState.numbers); - expect(isSignal(state.numbers)).toBe(true); + expect(numbersEmitted).toBe(2); + expect(userEmitted).toBe(2); + expect(firstNameEmitted).toBe(1); - expect(state.ngrx()).toBe(initialState.ngrx); - expect(isSignal(state.ngrx)).toBe(true); - }); + patchState(state, (state) => ({ + user: { ...state.user, firstName: 'Johannes' }, + })); + tick(); - it('does not modify props that are not state slices', () => { - const state = signalState(initialState); - (state as any).x = 1; - (state.user as any).x = 2; - (state.user.firstName as any).x = 3; - - expect((state as any).x).toBe(1); - expect((state.user as any).x).toBe(2); - expect((state.user.firstName as any).x).toBe(3); - - expect((state as any).y).toBe(undefined); - expect((state.user as any).y).toBe(undefined); - expect((state.user.firstName as any).y).toBe(undefined); - }); - - it( - 'emits new values only for affected signals', - testEffects((tick) => { - const state = signalState(initialState); - let numbersEmitted = 0; - let userEmitted = 0; - let firstNameEmitted = 0; - - effect(() => { - state.numbers(); - numbersEmitted++; - }); - - effect(() => { - state.user(); - userEmitted++; - }); - - effect(() => { - state.user.firstName(); - firstNameEmitted++; - }); - - expect(numbersEmitted).toBe(0); - expect(userEmitted).toBe(0); - expect(firstNameEmitted).toBe(0); - - tick(); - - expect(numbersEmitted).toBe(1); - expect(userEmitted).toBe(1); - expect(firstNameEmitted).toBe(1); - - patchState(state, { numbers: [1, 2, 3] }); - tick(); - - expect(numbersEmitted).toBe(2); - expect(userEmitted).toBe(1); - expect(firstNameEmitted).toBe(1); - - patchState(state, (state) => ({ - user: { ...state.user, lastName: 'Schmidt' }, - })); - tick(); - - expect(numbersEmitted).toBe(2); - expect(userEmitted).toBe(2); - expect(firstNameEmitted).toBe(1); - - patchState(state, (state) => ({ - user: { ...state.user, firstName: 'Johannes' }, - })); - tick(); - - expect(numbersEmitted).toBe(2); - expect(userEmitted).toBe(3); - expect(firstNameEmitted).toBe(2); - }) - ); - }); + expect(numbersEmitted).toBe(2); + expect(userEmitted).toBe(3); + expect(firstNameEmitted).toBe(2); + }) + ); }); diff --git a/modules/signals/src/index.ts b/modules/signals/src/index.ts index e5bd6486f4..3a4891adce 100644 --- a/modules/signals/src/index.ts +++ b/modules/signals/src/index.ts @@ -1,2 +1,3 @@ +export { PartialStateUpdater, patchState } from './patch-state'; export { selectSignal } from './select-signal'; -export { patchState, signalState, PartialStateUpdater } from './signal-state'; +export { signalState } from './signal-state'; diff --git a/modules/signals/src/patch-state.ts b/modules/signals/src/patch-state.ts new file mode 100644 index 0000000000..ce96a16b69 --- /dev/null +++ b/modules/signals/src/patch-state.ts @@ -0,0 +1,20 @@ +import { SIGNAL_STATE_META_KEY, SignalStateMeta } from './signal-state'; + +export type PartialStateUpdater> = + | Partial + | ((state: State) => Partial); + +export function patchState>( + signalState: SignalStateMeta, + ...updaters: PartialStateUpdater[] +): void { + signalState[SIGNAL_STATE_META_KEY].update((currentState) => + updaters.reduce( + (nextState: State, updater) => ({ + ...nextState, + ...(typeof updater === 'function' ? updater(nextState) : updater), + }), + currentState + ) + ); +} diff --git a/modules/signals/src/signal-state.ts b/modules/signals/src/signal-state.ts index b37f4500e7..c16eefe3ce 100644 --- a/modules/signals/src/signal-state.ts +++ b/modules/signals/src/signal-state.ts @@ -2,15 +2,15 @@ import { signal, WritableSignal } from '@angular/core'; import { DeepSignal, toDeepSignal } from './deep-signal'; import { defaultEqual } from './select-signal'; -type SignalState> = DeepSignal & - SignalStateMeta; - -const SIGNAL_STATE_META_KEY = Symbol('SIGNAL_STATE_META_KEY'); +export const SIGNAL_STATE_META_KEY = Symbol('SIGNAL_STATE_META_KEY'); -type SignalStateMeta> = { +export type SignalStateMeta> = { [SIGNAL_STATE_META_KEY]: WritableSignal; }; +type SignalState> = DeepSignal & + SignalStateMeta; + /** * Signal state cannot contain optional properties. */ @@ -20,10 +20,6 @@ type NotAllowedStateCheck = State extends Required : unknown : never; -export type PartialStateUpdater> = - | Partial - | ((state: State) => Partial); - export function signalState>( initialState: State & NotAllowedStateCheck ): SignalState { @@ -35,18 +31,3 @@ export function signalState>( return deepSignal as SignalState; } - -export function patchState>( - signalState: SignalStateMeta, - ...updaters: PartialStateUpdater[] -): void { - signalState[SIGNAL_STATE_META_KEY].update((currentState) => - updaters.reduce( - (nextState: State, updater) => ({ - ...nextState, - ...(typeof updater === 'function' ? updater(nextState) : updater), - }), - currentState - ) - ); -}