From f39fa13b9692dd27d6a1a041f647e37eca467dad Mon Sep 17 00:00:00 2001 From: Rainer Hahnekamp Date: Sat, 19 Apr 2025 22:44:05 +0200 Subject: [PATCH] refactor!: split up state signal to multiple signals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This is a necessary refactoring to support a state which allows different types of Signals, i.e. `linkedSignal` & `signal` but still presents itself as a single unit to the outside. This commit splits the single Signal of `STATE_SOURCE`, which contains the state in the `SignalStore` and `signalState`, into multiple Signals. An example. Given the following type: ```typescript type User = { firstname: string; lastname: string; }; ``` Before, `STATE_SOURCE` would have been the following type: ```typescript WritableSignal; ``` With this change, it is: ```typescript { firstname: WritableSignal; lastname: WritableSignal; } ``` Most changes affect the tests which focus on the `STATE_SOURCE`. Except for one test in `signal-state.spec.ts` ('does not modify STATE_SOURCE'), all tests could be updated to assert the new behavior. ## Breaking Changes - Any code which accesses the hidden `STATE_SOURCE` will be impacted. - Breaking Changes to the public behavior are rare. ### different object reference for state of `STATE_SOURCE` `STATE_SOURCE` does not keep the object reference of the initial state upon initialization. ```typescript const initialObject = { ngrx: 'rocks', }; const state = signalState(initialObject); // before state() === initialObject; // ✅ // after state() === initialObject; // ❌ ``` ### no Signal change on empty patched state `patchState` created a clone of the state and applied the patches to the clone. That meant, if nothing was changed, the Signal still fired because of the shallow clone. Since the state Signal doesn't exist anymore, there will be no change in this case. ```typescript const state = signalState({ ngrx: 'rocks' }); let changeCount = 0; effect(() => { changeCount++; }); TestBed.flushEffects(); expect(changeCount).toBe(1); patchState(state, {}); // before expect(changeCount).toBe(2); // ✅ // after expect(changeCount).toBe(2); // ❌ changeCount is still 1 ``` ## Further Changes - `signalStoreFeature` had to be refactored because of typing issues with the change to `WritableStateSource`. - `patchState` get the `NoInfer` for `updaters` argument. Otherwise, with multiple updaters, the former updater would have defined the `State` for the next updater. --- modules/signals/spec/helpers.ts | 24 +++++++ modules/signals/spec/signal-state.spec.ts | 42 ++++++------ .../signals/spec/signal-store-feature.spec.ts | 11 +++- modules/signals/spec/signal-store.spec.ts | 65 +++++++++++++------ modules/signals/spec/state-source.spec.ts | 42 ++++++------ modules/signals/spec/with-state.spec.ts | 8 +-- modules/signals/src/signal-state.ts | 38 +++++++++-- modules/signals/src/signal-store-feature.ts | 14 ++-- modules/signals/src/signal-store-models.ts | 2 +- modules/signals/src/signal-store.ts | 2 +- modules/signals/src/state-source.ts | 65 +++++++++++++------ modules/signals/src/with-state.ts | 28 ++++---- .../spec/types/uprotected.types.spec.ts | 4 +- .../signals/testing/spec/unprotected.spec.ts | 2 +- 14 files changed, 231 insertions(+), 116 deletions(-) diff --git a/modules/signals/spec/helpers.ts b/modules/signals/spec/helpers.ts index af47db90f6..2a039be049 100644 --- a/modules/signals/spec/helpers.ts +++ b/modules/signals/spec/helpers.ts @@ -1,5 +1,6 @@ import { Component, inject, Type } from '@angular/core'; import { TestBed } from '@angular/core/testing'; +import { SignalsDictionary } from '../src/signal-store-models'; export function createLocalService>( serviceToken: Service @@ -30,3 +31,26 @@ export function createLocalService>( destroy: () => fixture.destroy(), }; } + +/** + * This could be done by using `getState`, but + * 1. We don't want to depend on the implementation of `getState` in the test. + * 2. We want to be able to provide the state in its actual type (with slice signals). + */ +export function assertStateSource( + state: SignalsDictionary, + expected: SignalsDictionary +): void { + const stateKeys = Reflect.ownKeys(state); + const expectedKeys = Reflect.ownKeys(expected); + + const currentState = stateKeys.reduce((acc, key) => { + acc[key] = state[key](); + return acc; + }, {} as Record); + const expectedState = expectedKeys.reduce((acc, key) => { + acc[key] = expected[key](); + return acc; + }, {} as Record); + expect(currentState).toEqual(expectedState); +} diff --git a/modules/signals/spec/signal-state.spec.ts b/modules/signals/spec/signal-state.spec.ts index 72ac857efc..2506c355ee 100644 --- a/modules/signals/spec/signal-state.spec.ts +++ b/modules/signals/spec/signal-state.spec.ts @@ -1,7 +1,7 @@ -import { computed } from '@angular/core'; -import { effect, isSignal } from '@angular/core'; +import { computed, effect, isSignal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { patchState, signalState } from '../src'; +import { SignalsDictionary } from '../src/signal-store-models'; import { STATE_SOURCE } from '../src/state-source'; vi.mock('@angular/core', { spy: true }); @@ -21,21 +21,30 @@ describe('signalState', () => { vi.clearAllMocks(); }); - it('has writable state source', () => { - const state = signalState({}); - const stateSource = state[STATE_SOURCE]; + it('creates its properites as Signals', () => { + const state = signalState({ foo: 'bar' }); + const stateSource: SignalsDictionary = state[STATE_SOURCE]; - expect(isSignal(stateSource)).toBe(true); - expect(typeof stateSource.update === 'function').toBe(true); + expect(isSignal(state)).toBe(true); + for (const key of Reflect.ownKeys(stateSource)) { + expect(isSignal(stateSource[key])).toBe(true); + expect(typeof stateSource[key].update === 'function').toBe(true); + } + }); + + it('does not keep the object reference of the initial state', () => { + const state = signalState(initialState); + expect(state()).not.toBe(initialState); + expect(state()).toEqual(initialState); }); it('creates signals for nested state slices', () => { const state = signalState(initialState); - expect(state()).toBe(initialState); + expect(state()).toEqual(initialState); expect(isSignal(state)).toBe(true); - expect(state.user()).toBe(initialState.user); + expect(state.user()).toEqual(initialState.user); expect(isSignal(state.user)).toBe(true); expect(state.user.firstName()).toBe(initialState.user.firstName); @@ -80,20 +89,11 @@ describe('signalState', () => { expect((state.user.firstName as any).y).toBe(undefined); }); - it('does not modify STATE_SOURCE', () => { - const state = signalState(initialState); - - expect((state[STATE_SOURCE] as any).user).toBe(undefined); - expect((state[STATE_SOURCE] as any).foo).toBe(undefined); - expect((state[STATE_SOURCE] as any).numbers).toBe(undefined); - expect((state[STATE_SOURCE] as any).ngrx).toBe(undefined); - }); - it('overrides Function properties if state keys have the same name', () => { const initialState = { name: { length: { length: 'ngrx' }, name: 20 } }; const state = signalState(initialState); - expect(state()).toBe(initialState); + expect(state()).toEqual(initialState); expect(state.name()).toBe(initialState.name); expect(isSignal(state.name)).toBe(true); @@ -190,12 +190,12 @@ describe('signalState', () => { patchState(state, {}); TestBed.flushEffects(); - expect(stateCounter).toBe(2); + expect(stateCounter).toBe(1); expect(userCounter).toBe(1); patchState(state, (state) => state); TestBed.flushEffects(); - expect(stateCounter).toBe(3); + expect(stateCounter).toBe(1); expect(userCounter).toBe(1); })); }); diff --git a/modules/signals/spec/signal-store-feature.spec.ts b/modules/signals/spec/signal-store-feature.spec.ts index b855d84733..7172f0d460 100644 --- a/modules/signals/spec/signal-store-feature.spec.ts +++ b/modules/signals/spec/signal-store-feature.spec.ts @@ -8,6 +8,7 @@ import { withState, } from '../src'; import { STATE_SOURCE } from '../src/state-source'; +import { assertStateSource } from './helpers'; describe('signalStoreFeature', () => { function withCustomFeature1() { @@ -50,7 +51,7 @@ describe('signalStoreFeature', () => { const store = new Store(); - expect(store[STATE_SOURCE]()).toEqual({ foo: 'foo' }); + assertStateSource(store[STATE_SOURCE], { foo: signal('foo') }); expect(store.foo()).toBe('foo'); expect(store.bar()).toBe('foo1'); expect(store.baz()).toBe('foofoo12'); @@ -65,7 +66,7 @@ describe('signalStoreFeature', () => { const store = new Store(); - expect(store[STATE_SOURCE]()).toEqual({ foo: 'foo' }); + assertStateSource(store[STATE_SOURCE], { foo: signal('foo') }); expect(store.foo()).toBe('foo'); expect(store.bar()).toBe('foo1'); expect(store.m()).toBe('foofoofoo123'); @@ -81,7 +82,11 @@ describe('signalStoreFeature', () => { const store = new Store(); - expect(store[STATE_SOURCE]()).toEqual({ foo: 'foo', foo1: 1, foo2: 2 }); + assertStateSource(store[STATE_SOURCE], { + foo: signal('foo'), + foo1: signal(1), + foo2: signal(2), + }); expect(store.foo()).toBe('foo'); expect(store.bar()).toBe('foo1'); expect(store.baz()).toBe('foofoo12'); diff --git a/modules/signals/spec/signal-store.spec.ts b/modules/signals/spec/signal-store.spec.ts index 837e76527a..db351f7e8a 100644 --- a/modules/signals/spec/signal-store.spec.ts +++ b/modules/signals/spec/signal-store.spec.ts @@ -16,7 +16,7 @@ import { withState, } from '../src'; import { STATE_SOURCE } from '../src/state-source'; -import { createLocalService } from './helpers'; +import { assertStateSource, createLocalService } from './helpers'; describe('signalStore', () => { describe('creation', () => { @@ -47,16 +47,20 @@ describe('signalStore', () => { expect(store1.foo()).toBe('bar'); }); - it('creates a store with readonly state source by default', () => { + it('creates a store with state source as Record holding slices as signals by default', () => { const Store = signalStore(withState({ foo: 'bar' })); const store = new Store(); const stateSource = store[STATE_SOURCE]; - expect(isSignal(stateSource)).toBe(true); - expect(stateSource()).toEqual({ foo: 'bar' }); + expect(isSignal(stateSource)).toBe(false); + expect(Object.keys(stateSource)).toEqual(['foo']); + expect(isSignal(stateSource.foo)).toBe(true); + assertStateSource(stateSource, { + foo: signal('bar'), + }); }); - it('creates a store with readonly state source when protectedState option is true', () => { + it('creates a store with state source as Record holding slices as signals when protectedState option is true', () => { const Store = signalStore( { protectedState: true }, withState({ foo: 'bar' }) @@ -64,11 +68,15 @@ describe('signalStore', () => { const store = new Store(); const stateSource = store[STATE_SOURCE]; - expect(isSignal(stateSource)).toBe(true); - expect(stateSource()).toEqual({ foo: 'bar' }); + expect(isSignal(stateSource)).toBe(false); + expect(Object.keys(stateSource)).toEqual(['foo']); + expect(isSignal(stateSource.foo)).toBe(true); + assertStateSource(stateSource, { + foo: signal('bar'), + }); }); - it('creates a store with writable state source when protectedState option is false', () => { + it('creates a store with state source as Record holding slices as writeable signals when protectedState option is false', () => { const Store = signalStore( { protectedState: false }, withState({ foo: 'bar' }) @@ -76,13 +84,19 @@ describe('signalStore', () => { const store = new Store(); const stateSource = store[STATE_SOURCE]; - expect(isSignal(stateSource)).toBe(true); - expect(stateSource()).toEqual({ foo: 'bar' }); - expect(typeof stateSource.update === 'function').toBe(true); + expect(isSignal(stateSource)).toBe(false); + expect(Object.keys(stateSource)).toEqual(['foo']); + expect(isSignal(stateSource.foo)).toBe(true); + assertStateSource(stateSource, { + foo: signal('bar'), + }); + expect(typeof stateSource.foo.update === 'function').toBe(true); patchState(store, { foo: 'baz' }); - expect(stateSource()).toEqual({ foo: 'baz' }); + assertStateSource(stateSource, { + foo: signal('baz'), + }); }); }); @@ -97,10 +111,11 @@ describe('signalStore', () => { const store = new Store(); - expect(store[STATE_SOURCE]()).toEqual({ - foo: 'foo', - x: { y: { z: 10 } }, + assertStateSource(store[STATE_SOURCE], { + foo: signal('foo'), + x: signal({ y: { z: 10 } }), }); + expect(store.foo()).toBe('foo'); expect(store.x()).toEqual({ y: { z: 10 } }); expect(store.x.y()).toEqual({ z: 10 }); @@ -178,7 +193,9 @@ describe('signalStore', () => { const store = new Store(); - expect(store[STATE_SOURCE]()).toEqual({ foo: 'foo' }); + assertStateSource(store[STATE_SOURCE], { + foo: signal('foo'), + }); expect(store.foo()).toBe('foo'); expect(store.bar()).toBe('bar'); expect(store.num).toBe(10); @@ -236,7 +253,9 @@ describe('signalStore', () => { const store = new Store(); - expect(store[STATE_SOURCE]()).toEqual({ foo: 'foo' }); + assertStateSource(store[STATE_SOURCE], { + foo: signal('foo'), + }); expect(store.foo()).toBe('foo'); expect(store.bar()).toBe('bar'); expect(store.num).toBe(10); @@ -279,7 +298,9 @@ describe('signalStore', () => { withMethods(() => ({ baz: () => 'baz' })), withProps(() => ({ num: 100 })), withMethods((store) => { - expect(store[STATE_SOURCE]()).toEqual({ foo: 'foo' }); + assertStateSource(store[STATE_SOURCE], { + foo: signal('foo'), + }); expect(store.foo()).toBe('foo'); expect(store.bar()).toBe('bar'); expect(store.baz()).toBe('baz'); @@ -291,7 +312,9 @@ describe('signalStore', () => { const store = new Store(); - expect(store[STATE_SOURCE]()).toEqual({ foo: 'foo' }); + assertStateSource(store[STATE_SOURCE], { + foo: signal('foo'), + }); expect(store.foo()).toBe('foo'); expect(store.bar()).toBe('bar'); expect(store.baz()).toBe('baz'); @@ -372,7 +395,9 @@ describe('signalStore', () => { withProps(() => ({ num: 10 })), withHooks({ onInit(store) { - expect(store[STATE_SOURCE]()).toEqual({ foo: 'foo' }); + assertStateSource(store[STATE_SOURCE], { + foo: signal('foo'), + }); expect(store.foo()).toBe('foo'); expect(store.bar()).toBe('bar'); expect(store.baz()).toBe('baz'); diff --git a/modules/signals/spec/state-source.spec.ts b/modules/signals/spec/state-source.spec.ts index 5f67f2a73d..d2a0ac8e5b 100644 --- a/modules/signals/spec/state-source.spec.ts +++ b/modules/signals/spec/state-source.spec.ts @@ -17,10 +17,9 @@ import { withHooks, withMethods, withState, - WritableStateSource, } from '../src'; import { STATE_SOURCE } from '../src/state-source'; -import { createLocalService } from './helpers'; +import { assertStateSource, createLocalService } from './helpers'; const SECRET = Symbol('SECRET'); @@ -38,16 +37,16 @@ describe('StateSource', () => { describe('isWritableStateSource', () => { it('returns true for a writable StateSource', () => { - const stateSource: StateSource = { - [STATE_SOURCE]: signal(initialState), + const stateSource: StateSource<{ value: typeof initialState }> = { + [STATE_SOURCE]: { value: signal(initialState) }, }; expect(isWritableStateSource(stateSource)).toBe(true); }); it('returns false for a readonly StateSource', () => { - const stateSource: StateSource = { - [STATE_SOURCE]: signal(initialState).asReadonly(), + const stateSource: StateSource<{ vaulue: typeof initialState }> = { + [STATE_SOURCE]: { value: signal(initialState).asReadonly() }, }; expect(isWritableStateSource(stateSource)).toBe(false); @@ -81,10 +80,12 @@ describe('StateSource', () => { foo: 'baz', }); - expect(state[STATE_SOURCE]()).toEqual({ - ...initialState, - user: { firstName: 'Johannes', lastName: 'Schmidt' }, - foo: 'baz', + assertStateSource(state[STATE_SOURCE], { + user: signal({ firstName: 'Johannes', lastName: 'Schmidt' }), + foo: signal('baz'), + numbers: signal([1, 2, 3]), + ngrx: signal('signals'), + [SECRET]: signal('secret'), }); }); @@ -96,10 +97,12 @@ describe('StateSource', () => { ngrx: 'rocks', })); - expect(state[STATE_SOURCE]()).toEqual({ - ...initialState, - numbers: [1, 2, 3, 4], - ngrx: 'rocks', + assertStateSource(state[STATE_SOURCE], { + user: signal({ firstName: 'John', lastName: 'Smith' }), + foo: signal('bar'), + numbers: signal([1, 2, 3, 4]), + ngrx: signal('rocks'), + [SECRET]: signal('secret'), }); }); @@ -121,11 +124,12 @@ describe('StateSource', () => { { foo: 'foo' } ); - expect(state[STATE_SOURCE]()).toEqual({ - ...initialState, - user: { firstName: 'Jovan', lastName: 'Schmidt' }, - foo: 'foo', - numbers: [1, 2, 3, 4], + assertStateSource(state[STATE_SOURCE], { + user: signal({ firstName: 'Jovan', lastName: 'Schmidt' }), + foo: signal('foo'), + numbers: signal([1, 2, 3, 4]), + ngrx: signal('signals'), + [SECRET]: signal('secret'), }); }); diff --git a/modules/signals/spec/with-state.spec.ts b/modules/signals/spec/with-state.spec.ts index b09ab24cfd..c1f9f60c6f 100644 --- a/modules/signals/spec/with-state.spec.ts +++ b/modules/signals/spec/with-state.spec.ts @@ -1,18 +1,18 @@ import { isSignal, signal } from '@angular/core'; import { withComputed, withMethods, withState } from '../src'; -import { STATE_SOURCE } from '../src/state-source'; import { getInitialInnerStore } from '../src/signal-store'; +import { getState } from '../src/state-source'; describe('withState', () => { it('patches state source and updates slices immutably', () => { const initialStore = getInitialInnerStore(); - const initialState = initialStore[STATE_SOURCE](); + const initialState = getState(initialStore); const store = withState({ foo: 'bar', x: { y: 'z' }, })(initialStore); - const state = store[STATE_SOURCE](); + const state = getState(store); expect(state).toEqual({ foo: 'bar', x: { y: 'z' } }); expect(initialState).toEqual({}); @@ -46,7 +46,7 @@ describe('withState', () => { foo: 'bar', x: { y: 'z' }, }))(initialStore); - const state = store[STATE_SOURCE](); + const state = getState(store); expect(state).toEqual({ foo: 'bar', x: { y: 'z' } }); expect(store.stateSignals.foo()).toBe('bar'); diff --git a/modules/signals/src/signal-state.ts b/modules/signals/src/signal-state.ts index d31521151e..cb16e38ef0 100644 --- a/modules/signals/src/signal-state.ts +++ b/modules/signals/src/signal-state.ts @@ -1,6 +1,7 @@ -import { signal } from '@angular/core'; -import { STATE_SOURCE, WritableStateSource } from './state-source'; +import { computed, signal } from '@angular/core'; import { DeepSignal, toDeepSignal } from './deep-signal'; +import { SignalsDictionary } from './signal-store-models'; +import { STATE_SOURCE, WritableStateSource } from './state-source'; export type SignalState = DeepSignal & WritableStateSource; @@ -8,11 +9,40 @@ export type SignalState = DeepSignal & export function signalState( initialState: State ): SignalState { - const stateSource = signal(initialState as State); - const signalState = toDeepSignal(stateSource.asReadonly()); + const stateKeys = Reflect.ownKeys(initialState); + const stateAsRecord = initialState as Record; + + // define STATE_SOURCE property + const stateSource = stateKeys.reduce( + (signalsDict, key) => ({ + ...signalsDict, + [key]: signal(stateAsRecord[key]), + }), + {} as SignalsDictionary + ); + + // define signalState as a computed signal of all STATE_SOURCE properties + const signalState = computed(() => + stateKeys.reduce( + (state, key) => ({ + ...state, + [key]: stateSource[key](), + }), + {} + ) + ); + + // append STATE_SOURCE property to the signalState Object.defineProperty(signalState, STATE_SOURCE, { value: stateSource, }); + // generate deep signals + for (const key of stateKeys) { + Object.defineProperty(signalState, key, { + value: toDeepSignal(stateSource[key]), + }); + } + return signalState as SignalState; } diff --git a/modules/signals/src/signal-store-feature.ts b/modules/signals/src/signal-store-feature.ts index cf3da5d0f5..4cf59bf88a 100644 --- a/modules/signals/src/signal-store-feature.ts +++ b/modules/signals/src/signal-store-feature.ts @@ -369,13 +369,13 @@ export function signalStoreFeature< >; export function signalStoreFeature( - featureOrInput: SignalStoreFeature | Partial, - ...restFeatures: SignalStoreFeature[] -): SignalStoreFeature { - const features = - typeof featureOrInput === 'function' - ? [featureOrInput, ...restFeatures] - : restFeatures; + ...args: + | [Partial, ...SignalStoreFeature[]] + | SignalStoreFeature[] +): SignalStoreFeature { + const features = ( + typeof args[0] === 'function' ? args : args.slice(1) + ) as SignalStoreFeature[]; return (inputStore) => features.reduce((store, feature) => feature(store), inputStore); diff --git a/modules/signals/src/signal-store-models.ts b/modules/signals/src/signal-store-models.ts index 451a03df47..0b9dc04df4 100644 --- a/modules/signals/src/signal-store-models.ts +++ b/modules/signals/src/signal-store-models.ts @@ -11,7 +11,7 @@ export type StateSignals = IsKnownRecord> extends true } : {}; -export type SignalsDictionary = Record>; +export type SignalsDictionary = Record>; export type MethodsDictionary = Record; diff --git a/modules/signals/src/signal-store.ts b/modules/signals/src/signal-store.ts index ce70ab8e46..692b14abb8 100644 --- a/modules/signals/src/signal-store.ts +++ b/modules/signals/src/signal-store.ts @@ -1384,7 +1384,7 @@ export function signalStore( export function getInitialInnerStore(): InnerSignalStore { return { - [STATE_SOURCE]: signal({}), + [STATE_SOURCE]: {}, stateSignals: {}, props: {}, methods: {}, diff --git a/modules/signals/src/state-source.ts b/modules/signals/src/state-source.ts index 98549e32a3..b0e687aed8 100644 --- a/modules/signals/src/state-source.ts +++ b/modules/signals/src/state-source.ts @@ -10,16 +10,18 @@ import { } from '@angular/core'; import { Prettify } from './ts-helpers'; -const STATE_WATCHERS = new WeakMap, Array>>(); +const STATE_WATCHERS = new WeakMap>>(); export const STATE_SOURCE = Symbol('STATE_SOURCE'); export type WritableStateSource = { - [STATE_SOURCE]: WritableSignal; + [STATE_SOURCE]: { + [Property in keyof State]: WritableSignal; + }; }; export type StateSource = { - [STATE_SOURCE]: Signal; + [STATE_SOURCE]: { [Property in keyof State]: Signal }; }; export type PartialStateUpdater = ( @@ -33,37 +35,62 @@ export type StateWatcher = ( export function isWritableStateSource( stateSource: StateSource ): stateSource is WritableStateSource { - return ( - 'set' in stateSource[STATE_SOURCE] && - 'update' in stateSource[STATE_SOURCE] && - typeof stateSource[STATE_SOURCE].set === 'function' && - typeof stateSource[STATE_SOURCE].update === 'function' - ); + const signals: Record = stateSource[STATE_SOURCE]; + return Reflect.ownKeys(stateSource[STATE_SOURCE]).every((key) => { + const stateSignal = signals[key]; + return ( + isSignal(stateSignal) && + 'set' in stateSignal && + 'update' in stateSignal && + typeof stateSignal.set === 'function' && + typeof stateSignal.update === 'function' + ); + }); } export function patchState( stateSource: WritableStateSource, ...updaters: Array< - Partial> | PartialStateUpdater> + | Partial>> + | PartialStateUpdater>> > ): void { - stateSource[STATE_SOURCE].update((currentState) => - updaters.reduce( - (nextState: State, updater) => ({ - ...nextState, - ...(typeof updater === 'function' ? updater(nextState) : updater), - }), - currentState - ) + const currentState = untracked(() => getState(stateSource)); + const newState = updaters.reduce( + (nextState: State, updater) => ({ + ...nextState, + ...(typeof updater === 'function' ? updater(nextState) : updater), + }), + currentState ); + const signals = stateSource[STATE_SOURCE]; + const stateKeys = Reflect.ownKeys(stateSource[STATE_SOURCE]); + for (const key of Reflect.ownKeys(newState)) { + if (!stateKeys.includes(key)) { + // TODO: Optional properties which don't exist in the initial state will not be added + continue; + } + const signalKey = key as keyof State; + signals[signalKey].set(newState[signalKey]); + } + notifyWatchers(stateSource); } export function getState( stateSource: StateSource ): State { - return stateSource[STATE_SOURCE](); + const signals: Record> = stateSource[ + STATE_SOURCE + ]; + return Reflect.ownKeys(stateSource[STATE_SOURCE]).reduce((state, key) => { + const value = signals[key](); + return { + ...state, + [key]: value, + }; + }, {} as State); } export function watchState( diff --git a/modules/signals/src/with-state.ts b/modules/signals/src/with-state.ts index e2ca7aede3..37b3be8025 100644 --- a/modules/signals/src/with-state.ts +++ b/modules/signals/src/with-state.ts @@ -1,7 +1,6 @@ -import { computed } from '@angular/core'; -import { assertUniqueStoreMembers } from './signal-store-assertions'; +import { Signal, signal } from '@angular/core'; import { toDeepSignal } from './deep-signal'; -import { STATE_SOURCE } from './state-source'; +import { assertUniqueStoreMembers } from './signal-store-assertions'; import { EmptyFeatureResult, InnerSignalStore, @@ -9,6 +8,7 @@ import { SignalStoreFeature, SignalStoreFeatureResult, } from './signal-store-models'; +import { STATE_SOURCE } from './state-source'; export function withState( stateFactory: () => State @@ -35,17 +35,17 @@ export function withState( assertUniqueStoreMembers(store, stateKeys); - store[STATE_SOURCE].update((currentState) => ({ - ...currentState, - ...state, - })); - - const stateSignals = stateKeys.reduce((acc, key) => { - const sliceSignal = computed( - () => (store[STATE_SOURCE]() as Record)[key] - ); - return { ...acc, [key]: toDeepSignal(sliceSignal) }; - }, {} as SignalsDictionary); + const stateAsRecord = state as Record; + const stateSource = store[STATE_SOURCE] as Record< + string | symbol, + Signal + >; + const stateSignals = {} as SignalsDictionary; + for (const key of stateKeys) { + const signalValue = stateAsRecord[key]; + stateSource[key] = signal(signalValue); + stateSignals[key] = toDeepSignal(stateSource[key]); + } return { ...store, diff --git a/modules/signals/testing/spec/types/uprotected.types.spec.ts b/modules/signals/testing/spec/types/uprotected.types.spec.ts index 57dea8f1f9..4106421637 100644 --- a/modules/signals/testing/spec/types/uprotected.types.spec.ts +++ b/modules/signals/testing/spec/types/uprotected.types.spec.ts @@ -29,7 +29,7 @@ describe('unprotected', () => { expectSnippet(snippet).toSucceed(); expectSnippet(snippet).toInfer( 'unprotectedStore', - '{ count: Signal; doubleCount: Signal; [STATE_SOURCE]: WritableSignal<{ count: number; }>; }' + '{ count: Signal; doubleCount: Signal; [STATE_SOURCE]: { count: WritableSignal; }; }' ); }); @@ -47,7 +47,7 @@ describe('unprotected', () => { expectSnippet(snippet).toSucceed(); expectSnippet(snippet).toInfer( 'unprotectedStore', - '{ count: Signal; [STATE_SOURCE]: WritableSignal<{ count: number; }>; }' + '{ count: Signal; [STATE_SOURCE]: { count: WritableSignal; }; }' ); }); }); diff --git a/modules/signals/testing/spec/unprotected.spec.ts b/modules/signals/testing/spec/unprotected.spec.ts index d289b270dd..750d7f0975 100644 --- a/modules/signals/testing/spec/unprotected.spec.ts +++ b/modules/signals/testing/spec/unprotected.spec.ts @@ -19,7 +19,7 @@ describe('unprotected', () => { it('throws error when provided state source is not writable', () => { const readonlySource: StateSource<{ count: number }> = { - [STATE_SOURCE]: signal({ count: 0 }).asReadonly(), + [STATE_SOURCE]: { count: signal(0).asReadonly() }, }; expect(() => unprotected(readonlySource)).toThrowError(