diff --git a/modules/signals/spec/helpers.ts b/modules/signals/spec/helpers.ts index 847c5e689b..690964bdb4 100644 --- a/modules/signals/spec/helpers.ts +++ b/modules/signals/spec/helpers.ts @@ -1,4 +1,4 @@ -import { Component } from '@angular/core'; +import { Component, inject, Type } from '@angular/core'; import { TestBed } from '@angular/core/testing'; export function testEffects(testFn: (tick: () => void) => void): () => void { @@ -13,3 +13,28 @@ export function testEffects(testFn: (tick: () => void) => void): () => void { TestBed.runInInjectionContext(() => testFn(() => fixture.detectChanges())); }; } + +export function initializeLocalStore>( + Store: StoreClass +): { + store: InstanceType; + destroy: () => void; +} { + @Component({ + standalone: true, + template: '', + providers: [Store], + }) + class TestComponent { + store = inject(Store); + } + + const fixture = TestBed.configureTestingModule({ + imports: [TestComponent], + }).createComponent(TestComponent); + + return { + store: fixture.componentInstance.store, + destroy: () => fixture.destroy(), + }; +} diff --git a/modules/signals/spec/patch-state.spec.ts b/modules/signals/spec/patch-state.spec.ts index c3e6e0e9d7..c5fca6031d 100644 --- a/modules/signals/spec/patch-state.spec.ts +++ b/modules/signals/spec/patch-state.spec.ts @@ -1,4 +1,5 @@ -import { patchState, signalState } from '../src'; +import { patchState, signalState, signalStore, withState } from '../src'; +import { STATE_SIGNAL } from '../src/signal-state'; describe('patchState', () => { const initialState = { @@ -11,67 +12,83 @@ describe('patchState', () => { ngrx: 'signals', }; - it('patches state via partial state object', () => { - const state = signalState(initialState); + [ + { + name: 'with signalState', + stateFactory: () => signalState(initialState), + }, + { + name: 'with signalStore', + stateFactory: () => { + const SignalStore = signalStore(withState(initialState)); + return new SignalStore(); + }, + }, + ].forEach(({ name, stateFactory }) => { + describe(name, () => { + it('patches state via partial state object', () => { + const state = stateFactory(); - patchState(state, { - user: { firstName: 'Johannes', lastName: 'Schmidt' }, - foo: 'baz', - }); + patchState(state, { + user: { firstName: 'Johannes', lastName: 'Schmidt' }, + foo: 'baz', + }); - expect(state()).toEqual({ - ...initialState, - user: { firstName: 'Johannes', lastName: 'Schmidt' }, - foo: 'baz', - }); - }); + expect(state[STATE_SIGNAL]()).toEqual({ + ...initialState, + user: { firstName: 'Johannes', lastName: 'Schmidt' }, + foo: 'baz', + }); + }); - it('patches state via updater function', () => { - const state = signalState(initialState); + it('patches state via updater function', () => { + const state = stateFactory(); - patchState(state, (state) => ({ - numbers: [...state.numbers, 4], - ngrx: 'rocks', - })); + patchState(state, (state) => ({ + numbers: [...state.numbers, 4], + ngrx: 'rocks', + })); - expect(state()).toEqual({ - ...initialState, - numbers: [1, 2, 3, 4], - ngrx: 'rocks', - }); - }); + expect(state[STATE_SIGNAL]()).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); + it('patches state via sequence of partial state objects and updater functions', () => { + const state = stateFactory(); - patchState( - state, - { user: { firstName: 'Johannes', lastName: 'Schmidt' } }, - (state) => ({ numbers: [...state.numbers, 4], foo: 'baz' }), - (state) => ({ user: { ...state.user, firstName: 'Jovan' } }), - { foo: 'foo' } - ); + 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], - }); - }); + expect(state[STATE_SIGNAL]()).toEqual({ + ...initialState, + user: { firstName: 'Jovan', lastName: 'Schmidt' }, + foo: 'foo', + numbers: [1, 2, 3, 4], + }); + }); - it('patches state immutably', () => { - const state = signalState(initialState); + it('patches state immutably', () => { + const state = stateFactory(); - patchState(state, { - foo: 'bar', - numbers: [3, 2, 1], - ngrx: 'rocks', - }); + 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); + 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 edf93e896e..70cb52678c 100644 --- a/modules/signals/spec/signal-state.spec.ts +++ b/modules/signals/spec/signal-state.spec.ts @@ -1,5 +1,6 @@ import { effect, isSignal } from '@angular/core'; import { patchState, signalState } from '../src'; +import { STATE_SIGNAL } from '../src/signal-state'; import { testEffects } from './helpers'; describe('signalState', () => { @@ -13,6 +14,14 @@ describe('signalState', () => { ngrx: 'signals', }; + it('has state signal', () => { + const state = signalState({}); + const stateSignal = state[STATE_SIGNAL]; + + expect(isSignal(stateSignal)).toBe(true); + expect(typeof stateSignal.update === 'function').toBe(true); + }); + it('creates signals for nested state slices', () => { const state = signalState(initialState); diff --git a/modules/signals/spec/signal-store-feature.spec.ts b/modules/signals/spec/signal-store-feature.spec.ts new file mode 100644 index 0000000000..3b642e1fe3 --- /dev/null +++ b/modules/signals/spec/signal-store-feature.spec.ts @@ -0,0 +1,94 @@ +import { Signal, signal } from '@angular/core'; +import { + selectSignal, + signalStore, + signalStoreFeature, + type, + withMethods, + withSignals, + withState, +} from '../src'; +import { STATE_SIGNAL } from '../src/signal-state'; + +describe('signalStoreFeature', () => { + function withCustomFeature1() { + return signalStoreFeature( + withState({ foo: 'foo' }), + withSignals(({ foo }) => ({ bar: selectSignal(() => foo() + 1) })), + withMethods(({ foo, bar }) => ({ + baz: () => foo() + bar() + 2, + })) + ); + } + + function withCustomFeature2() { + return signalStoreFeature( + withCustomFeature1(), + withMethods(({ foo, baz }) => ({ + bar: (value: number) => value, + m: () => foo() + baz() + 3, + })) + ); + } + + function withCustomFeatureWithInput<_>() { + return signalStoreFeature( + { + state: type<{ foo: string }>(), + signals: type<{ s: Signal }>(), + }, + withState({ foo1: 1 }), + withState({ foo2: 2 }) + ); + } + + it('creates a custom feature by combining base features', () => { + const Store = signalStore( + withCustomFeature1(), + withSignals(({ bar }) => ({ + s: selectSignal(() => bar() + 's'), + })) + ); + + const store = new Store(); + + expect(store[STATE_SIGNAL]()).toEqual({ foo: 'foo' }); + expect(store.foo()).toBe('foo'); + expect(store.bar()).toBe('foo1'); + expect(store.baz()).toBe('foofoo12'); + expect(store.s()).toBe('foo1s'); + }); + + it('creates a custom feature by combining base and custom features', () => { + const Store = signalStore( + withCustomFeature2(), + withMethods(({ foo }) => ({ m1: () => foo() + 10 })) + ); + + const store = new Store(); + + expect(store[STATE_SIGNAL]()).toEqual({ foo: 'foo' }); + expect(store.foo()).toBe('foo'); + expect(store.bar(10)).toBe(10); + expect(store.m()).toBe('foofoofoo123'); + expect(store.m1()).toBe('foo10'); + }); + + it('creates a custom feature with input', () => { + const Store = signalStore( + withCustomFeature1(), + withSignals(() => ({ s: signal(1).asReadonly() })), + withCustomFeatureWithInput() + ); + + const store = new Store(); + + expect(store[STATE_SIGNAL]()).toEqual({ foo: 'foo', foo1: 1, foo2: 2 }); + expect(store.foo()).toBe('foo'); + expect(store.bar()).toBe('foo1'); + expect(store.baz()).toBe('foofoo12'); + expect(store.s()).toBe(1); + expect(store.foo1()).toBe(1); + expect(store.foo2()).toBe(2); + }); +}); diff --git a/modules/signals/spec/signal-store.spec.ts b/modules/signals/spec/signal-store.spec.ts new file mode 100644 index 0000000000..da1844e03d --- /dev/null +++ b/modules/signals/spec/signal-store.spec.ts @@ -0,0 +1,308 @@ +import { inject, InjectionToken, isSignal, signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { + signalStore, + withHooks, + withMethods, + withSignals, + withState, +} from '../src'; +import { STATE_SIGNAL } from '../src/signal-state'; +import { initializeLocalStore } from './helpers'; + +describe('signalStore', () => { + describe('creation', () => { + it('creates a store via new operator', () => { + const Store = signalStore(withState({})); + + const store = new Store(); + const stateSignal = store[STATE_SIGNAL]; + + expect(isSignal(stateSignal)).toBe(true); + expect(typeof stateSignal.update === 'function').toBe(true); + expect(stateSignal()).toEqual({}); + }); + + it('creates a store as injectable service', () => { + const Store = signalStore(withState({})); + + TestBed.configureTestingModule({ providers: [Store] }); + const store = TestBed.inject(Store); + const stateSignal = store[STATE_SIGNAL]; + + expect(isSignal(stateSignal)).toBe(true); + expect(typeof stateSignal.update === 'function').toBe(true); + expect(stateSignal()).toEqual({}); + }); + + it('creates a store that is provided in root when providedIn option is specified', () => { + const Store = signalStore({ providedIn: 'root' }, withState({})); + + const store1 = TestBed.inject(Store); + const store2 = TestBed.inject(Store); + const stateSignal = store1[STATE_SIGNAL]; + + expect(store1).toBe(store2); + expect(isSignal(stateSignal)).toBe(true); + expect(typeof stateSignal.update === 'function').toBe(true); + expect(stateSignal()).toEqual({}); + }); + }); + + describe('withState', () => { + it('adds deep signals to the store for each state slice', () => { + const Store = signalStore( + withState({ + foo: 'foo', + x: { y: { z: 10 } }, + }) + ); + + const store = new Store(); + + expect(store[STATE_SIGNAL]()).toEqual({ + foo: 'foo', + x: { y: { z: 10 } }, + }); + expect(store.foo()).toBe('foo'); + expect(store.x()).toEqual({ y: { z: 10 } }); + expect(store.x.y()).toEqual({ z: 10 }); + expect(store.x.y.z()).toBe(10); + }); + + it('executes withState factory in injection context', () => { + const TOKEN = new InjectionToken('TOKEN', { + providedIn: 'root', + factory: () => ({ foo: 'foo' }), + }); + const Store = signalStore(withState(() => inject(TOKEN))); + + TestBed.configureTestingModule({ providers: [Store] }); + const store = TestBed.inject(Store); + + expect(store.foo()).toBe('foo'); + }); + }); + + describe('withSignals', () => { + it('provides previously defined state slices and computed signals as input argument', () => { + const Store = signalStore( + withState(() => ({ foo: 'foo' })), + withSignals(() => ({ bar: signal('bar').asReadonly() })), + withSignals(({ foo, bar }) => { + expect(foo()).toBe('foo'); + expect(bar()).toBe('bar'); + + return { baz: signal('baz').asReadonly() }; + }) + ); + + const store = new Store(); + + expect(store[STATE_SIGNAL]()).toEqual({ foo: 'foo' }); + expect(store.foo()).toBe('foo'); + expect(store.bar()).toBe('bar'); + expect(store.baz()).toBe('baz'); + }); + + it('executes withSignals factory in injection context', () => { + const TOKEN = new InjectionToken('TOKEN', { + providedIn: 'root', + factory: () => ({ bar: signal('bar').asReadonly() }), + }); + const Store = signalStore(withSignals(() => inject(TOKEN))); + + TestBed.configureTestingModule({ providers: [Store] }); + const store = TestBed.inject(Store); + + expect(store.bar()).toBe('bar'); + }); + }); + + describe('withMethods', () => { + it('provides previously defined store properties as an input argument', () => { + const Store = signalStore( + withState(() => ({ foo: 'foo' })), + withSignals(() => ({ bar: signal('bar').asReadonly() })), + withMethods(() => ({ baz: () => 'baz' })), + withMethods((store) => { + expect(store[STATE_SIGNAL]()).toEqual({ foo: 'foo' }); + expect(store.foo()).toBe('foo'); + expect(store.bar()).toBe('bar'); + expect(store.baz()).toBe('baz'); + + return { m: () => 'm' }; + }) + ); + + const store = new Store(); + + expect(store[STATE_SIGNAL]()).toEqual({ foo: 'foo' }); + expect(store.foo()).toBe('foo'); + expect(store.bar()).toBe('bar'); + expect(store.baz()).toBe('baz'); + expect(store.m()).toBe('m'); + }); + + it('executes withMethods factory in injection context', () => { + const TOKEN = new InjectionToken('TOKEN', { + providedIn: 'root', + factory: () => ({ baz: () => 'baz' }), + }); + const Store = signalStore(withMethods(() => inject(TOKEN))); + + TestBed.configureTestingModule({ providers: [Store] }); + const store = TestBed.inject(Store); + + expect(store.baz()).toBe('baz'); + }); + }); + + describe('withHooks', () => { + it('calls onInit hook on store init', () => { + let message = ''; + const Store = signalStore( + withHooks({ + onInit() { + message = 'onInit'; + }, + }) + ); + + new Store(); + + expect(message).toBe('onInit'); + + message = ''; + TestBed.configureTestingModule({ providers: [Store] }); + TestBed.inject(Store); + + expect(message).toBe('onInit'); + }); + + it('calls onDestroy hook on store destroy', () => { + let message = ''; + const Store = signalStore( + withHooks({ + onDestroy() { + message = 'onDestroy'; + }, + }) + ); + + initializeLocalStore(Store).destroy(); + + expect(message).toBe('onDestroy'); + }); + + it('provides previously defined store properties as onInit input argument', () => { + let message = ''; + const Store = signalStore( + withState(() => ({ foo: 'foo' })), + withSignals(() => ({ bar: signal('bar').asReadonly() })), + withMethods(() => ({ baz: () => 'baz' })), + withHooks({ + onInit(store) { + expect(store[STATE_SIGNAL]()).toEqual({ foo: 'foo' }); + expect(store.foo()).toBe('foo'); + expect(store.bar()).toBe('bar'); + expect(store.baz()).toBe('baz'); + message = 'onInit'; + }, + }) + ); + + new Store(); + + expect(message).toBe('onInit'); + }); + + it('provides previously defined store properties as onDestroy input argument', () => { + let message = ''; + const Store = signalStore( + withState(() => ({ foo: 'foo' })), + withSignals(() => ({ bar: signal('bar').asReadonly() })), + withMethods(() => ({ baz: () => 'baz' })), + withHooks({ + onDestroy(store) { + expect(store.foo()).toBe('foo'); + expect(store.bar()).toBe('bar'); + expect(store.baz()).toBe('baz'); + message = 'onDestroy'; + }, + }) + ); + + initializeLocalStore(Store).destroy(); + + expect(message).toBe('onDestroy'); + }); + + it('executes hooks in injection context', () => { + const messages: string[] = []; + const TOKEN = new InjectionToken('TOKEN', { + providedIn: 'root', + factory: () => 'ngrx', + }); + const Store = signalStore( + withHooks({ + onInit() { + inject(TOKEN); + messages.push('onInit'); + }, + onDestroy() { + inject(TOKEN); + messages.push('onDestroy'); + }, + }) + ); + const { destroy } = initializeLocalStore(Store); + + expect(messages).toEqual(['onInit']); + + destroy(); + expect(messages).toEqual(['onInit', 'onDestroy']); + }); + }); + + describe('composition', () => { + it('overrides previously defined store properties immutably', () => { + const Store = signalStore( + withState({ i: 1, j: 2, k: 3, l: 4 }), + withSignals(({ i, j, k, l }) => { + expect(i()).toBe(1); + expect(j()).toBe(2); + expect(k()).toBe(3); + expect(l()).toBe(4); + + return { + l: signal('l').asReadonly(), + m: signal('m').asReadonly(), + }; + }), + withMethods((store) => { + expect(store.i()).toBe(1); + expect(store.j()).toBe(2); + expect(store.k()).toBe(3); + expect(store.l()).toBe('l'); + expect(store.m()).toBe('m'); + + return { + j: () => 'j', + m: () => true, + n: (value: number) => value, + }; + }) + ); + + const store = new Store(); + + expect(store.i()).toBe(1); + expect(store.j()).toBe('j'); + expect(store.k()).toBe(3); + expect(store.l()).toBe('l'); + expect(store.m()).toBe(true); + expect(store.n(10)).toBe(10); + }); + }); +}); diff --git a/modules/signals/spec/with-hooks.spec.ts b/modules/signals/spec/with-hooks.spec.ts new file mode 100644 index 0000000000..de530cd3ad --- /dev/null +++ b/modules/signals/spec/with-hooks.spec.ts @@ -0,0 +1,68 @@ +import { withHooks } from '../src'; +import { getInitialInnerStore } from '../src/signal-store'; + +describe('withHooks', () => { + it('adds onInit hook to the store', () => { + const initialStore = getInitialInnerStore(); + let message = ''; + + const store = withHooks({ + onInit() { + message = 'onInit'; + }, + })(initialStore); + store.hooks.onInit?.(); + + expect(message).toBe('onInit'); + }); + + it('executes new onInit hook after previously defined one', () => { + const messages: string[] = []; + const initialStore = withHooks({ + onInit() { + messages.push('onInit1'); + }, + })(getInitialInnerStore()); + + const store = withHooks({ + onInit() { + messages.push('onInit2'); + }, + })(initialStore); + store.hooks.onInit?.(); + + expect(messages).toEqual(['onInit1', 'onInit2']); + }); + + it('adds onDestroy hook to the store', () => { + const initialStore = getInitialInnerStore(); + let message = ''; + + const store = withHooks({ + onDestroy() { + message = 'onDestroy'; + }, + })(initialStore); + store.hooks.onDestroy?.(); + + expect(message).toBe('onDestroy'); + }); + + it('executes new onDestroy hook after previously defined one', () => { + const messages: string[] = []; + const initialStore = withHooks({ + onDestroy() { + messages.push('onDestroy1'); + }, + })(getInitialInnerStore()); + + const store = withHooks({ + onDestroy() { + messages.push('onDestroy2'); + }, + })(initialStore); + store.hooks.onDestroy?.(); + + expect(messages).toEqual(['onDestroy1', 'onDestroy2']); + }); +}); diff --git a/modules/signals/spec/with-methods.spec.ts b/modules/signals/spec/with-methods.spec.ts new file mode 100644 index 0000000000..83f828a551 --- /dev/null +++ b/modules/signals/spec/with-methods.spec.ts @@ -0,0 +1,51 @@ +import { signal } from '@angular/core'; +import { withMethods, withSignals, withState } from '../src'; +import { getInitialInnerStore } from '../src/signal-store'; + +describe('withMethods', () => { + it('adds methods to the store immutably', () => { + const initialStore = getInitialInnerStore(); + + const m1 = () => 100; + const m2 = () => 'm2'; + + const store = withMethods(() => ({ m1, m2 }))(initialStore); + + expect(Object.keys(store.methods)).toEqual(['m1', 'm2']); + expect(Object.keys(initialStore.methods)).toEqual([]); + + expect(store.methods.m1).toBe(m1); + expect(store.methods.m2).toBe(m2); + }); + + it('overrides previously defined slices, signals, and methods with the same name', () => { + const initialStore = [ + withState({ + p1: 'p1', + p2: false, + }), + withSignals(() => ({ + s1: signal(true).asReadonly(), + s2: signal({ s: 2 }).asReadonly(), + })), + withMethods(() => ({ + m1() {}, + m2() {}, + })), + ].reduce((acc, feature) => feature(acc), getInitialInnerStore()); + + const m2 = () => 10; + const store = withMethods(() => ({ + p2() {}, + s1: () => 100, + m2, + m3: () => 'm3', + }))(initialStore); + + expect(Object.keys(store.methods)).toEqual(['m1', 'm2', 'p2', 's1', 'm3']); + expect(store.methods.m2).toBe(m2); + + expect(Object.keys(store.slices)).toEqual(['p1']); + expect(Object.keys(store.signals)).toEqual(['s2']); + }); +}); diff --git a/modules/signals/spec/with-signals.spec.ts b/modules/signals/spec/with-signals.spec.ts new file mode 100644 index 0000000000..051660a508 --- /dev/null +++ b/modules/signals/spec/with-signals.spec.ts @@ -0,0 +1,51 @@ +import { signal } from '@angular/core'; +import { withMethods, withSignals, withState } from '../src'; +import { getInitialInnerStore } from '../src/signal-store'; + +describe('withSignals', () => { + it('adds signals to the store immutably', () => { + const initialStore = getInitialInnerStore(); + + const s1 = signal('s1').asReadonly(); + const s2 = signal(10).asReadonly(); + + const store = withSignals(() => ({ s1, s2 }))(initialStore); + + expect(Object.keys(store.signals)).toEqual(['s1', 's2']); + expect(Object.keys(initialStore.signals)).toEqual([]); + + expect(store.signals.s1).toBe(s1); + expect(store.signals.s2).toBe(s2); + }); + + it('overrides previously defined slices, signals, and methods with the same name', () => { + const initialStore = [ + withState({ + p1: 10, + p2: 'p2', + }), + withSignals(() => ({ + s1: signal('s1').asReadonly(), + s2: signal({ s: 2 }).asReadonly(), + })), + withMethods(() => ({ + m1() {}, + m2() {}, + })), + ].reduce((acc, feature) => feature(acc), getInitialInnerStore()); + + const s2 = signal(10).asReadonly(); + const store = withSignals(() => ({ + p1: signal('p1').asReadonly(), + s2, + m1: signal({ m: 1 }).asReadonly(), + s3: signal({ s: 3 }).asReadonly(), + }))(initialStore); + + expect(Object.keys(store.signals)).toEqual(['s1', 's2', 'p1', 'm1', 's3']); + expect(store.signals.s2).toBe(s2); + + expect(Object.keys(store.slices)).toEqual(['p2']); + expect(Object.keys(store.methods)).toEqual(['m2']); + }); +}); diff --git a/modules/signals/spec/with-state.spec.ts b/modules/signals/spec/with-state.spec.ts new file mode 100644 index 0000000000..5fc4a7b953 --- /dev/null +++ b/modules/signals/spec/with-state.spec.ts @@ -0,0 +1,86 @@ +import { isSignal, signal } from '@angular/core'; +import { withMethods, withSignals, withState } from '../src'; +import { STATE_SIGNAL } from '../src/signal-state'; +import { getInitialInnerStore } from '../src/signal-store'; + +describe('withState', () => { + it('patches state signal and updates slices immutably', () => { + const initialStore = getInitialInnerStore(); + const initialState = initialStore[STATE_SIGNAL](); + + const store = withState({ + foo: 'bar', + x: { y: 'z' }, + })(initialStore); + const state = store[STATE_SIGNAL](); + + expect(state).toEqual({ foo: 'bar', x: { y: 'z' } }); + expect(initialState).toEqual({}); + + expect(Object.keys(store.slices)).toEqual(['foo', 'x']); + expect(Object.keys(initialStore.slices)).toEqual([]); + }); + + it('creates deep signals for each state slice', () => { + const initialStore = getInitialInnerStore(); + + const store = withState({ + foo: 'bar', + x: { y: 'z' }, + })(initialStore); + + expect(store.slices.foo()).toBe('bar'); + expect(isSignal(store.slices.foo)).toBe(true); + + expect(store.slices.x()).toEqual({ y: 'z' }); + expect(isSignal(store.slices.x)).toBe(true); + + expect(store.slices.x.y()).toBe('z'); + expect(isSignal(store.slices.x.y)).toBe(true); + }); + + it('patches state signal and creates deep signals for state slices provided via factory', () => { + const initialStore = getInitialInnerStore(); + + const store = withState(() => ({ + foo: 'bar', + x: { y: 'z' }, + }))(initialStore); + const state = store[STATE_SIGNAL](); + + expect(state).toEqual({ foo: 'bar', x: { y: 'z' } }); + expect(store.slices.foo()).toBe('bar'); + expect(store.slices.x()).toEqual({ y: 'z' }); + expect(store.slices.x.y()).toBe('z'); + }); + + it('overrides previously defined state slices, signals, and methods with the same name', () => { + const initialStore = [ + withState({ + p1: 10, + p2: 'p2', + }), + withSignals(() => ({ + s1: signal('s1').asReadonly(), + s2: signal({ s: 2 }).asReadonly(), + })), + withMethods(() => ({ + m1() {}, + m2() {}, + })), + ].reduce((acc, feature) => feature(acc), getInitialInnerStore()); + + const store = withState(() => ({ + p2: 100, + s2: 's2', + m2: { m: 2 }, + p3: 'p3', + }))(initialStore); + + expect(Object.keys(store.slices)).toEqual(['p1', 'p2', 's2', 'm2', 'p3']); + expect(store.slices.p2()).toBe(100); + + expect(Object.keys(store.signals)).toEqual(['s1']); + expect(Object.keys(store.methods)).toEqual(['m1']); + }); +}); diff --git a/modules/signals/src/helpers.ts b/modules/signals/src/helpers.ts new file mode 100644 index 0000000000..65142603ec --- /dev/null +++ b/modules/signals/src/helpers.ts @@ -0,0 +1,9 @@ +export function excludeKeys< + Obj extends Record, + Keys extends string[] +>(obj: Obj, keys: Keys): Omit { + return Object.keys(obj).reduce( + (acc, key) => (keys.includes(key) ? acc : { ...acc, [key]: obj[key] }), + {} + ) as Omit; +} diff --git a/modules/signals/src/index.ts b/modules/signals/src/index.ts index 3a4891adce..1761d811a0 100644 --- a/modules/signals/src/index.ts +++ b/modules/signals/src/index.ts @@ -1,3 +1,9 @@ export { PartialStateUpdater, patchState } from './patch-state'; export { selectSignal } from './select-signal'; export { signalState } from './signal-state'; +export { signalStore } from './signal-store'; +export { signalStoreFeature, type } from './signal-store-feature'; +export { withHooks } from './with-hooks'; +export { withMethods } from './with-methods'; +export { withSignals } from './with-signals'; +export { withState } from './with-state'; diff --git a/modules/signals/src/patch-state.ts b/modules/signals/src/patch-state.ts index ce96a16b69..9c1e3f1632 100644 --- a/modules/signals/src/patch-state.ts +++ b/modules/signals/src/patch-state.ts @@ -1,4 +1,4 @@ -import { SIGNAL_STATE_META_KEY, SignalStateMeta } from './signal-state'; +import { STATE_SIGNAL, SignalStateMeta } from './signal-state'; export type PartialStateUpdater> = | Partial @@ -8,7 +8,7 @@ export function patchState>( signalState: SignalStateMeta, ...updaters: PartialStateUpdater[] ): void { - signalState[SIGNAL_STATE_META_KEY].update((currentState) => + signalState[STATE_SIGNAL].update((currentState) => updaters.reduce( (nextState: State, updater) => ({ ...nextState, diff --git a/modules/signals/src/signal-state.ts b/modules/signals/src/signal-state.ts index c16eefe3ce..2fb5360e28 100644 --- a/modules/signals/src/signal-state.ts +++ b/modules/signals/src/signal-state.ts @@ -2,30 +2,30 @@ import { signal, WritableSignal } from '@angular/core'; import { DeepSignal, toDeepSignal } from './deep-signal'; import { defaultEqual } from './select-signal'; -export const SIGNAL_STATE_META_KEY = Symbol('SIGNAL_STATE_META_KEY'); +export const STATE_SIGNAL = Symbol('STATE_SIGNAL'); export type SignalStateMeta> = { - [SIGNAL_STATE_META_KEY]: WritableSignal; + [STATE_SIGNAL]: WritableSignal; }; -type SignalState> = DeepSignal & - SignalStateMeta; - /** * Signal state cannot contain optional properties. */ -type NotAllowedStateCheck = State extends Required +export type NotAllowedStateCheck = State extends Required ? State extends Record ? { [K in keyof State]: State[K] & NotAllowedStateCheck } : unknown : never; +type SignalState> = DeepSignal & + SignalStateMeta; + export function signalState>( initialState: State & NotAllowedStateCheck ): SignalState { const stateSignal = signal(initialState as State, { equal: defaultEqual }); const deepSignal = toDeepSignal(stateSignal.asReadonly()); - Object.defineProperty(deepSignal, SIGNAL_STATE_META_KEY, { + Object.defineProperty(deepSignal, STATE_SIGNAL, { value: stateSignal, }); diff --git a/modules/signals/src/signal-store-feature.ts b/modules/signals/src/signal-store-feature.ts new file mode 100644 index 0000000000..8f4eaf8d37 --- /dev/null +++ b/modules/signals/src/signal-store-feature.ts @@ -0,0 +1,224 @@ +import { + EmptyFeatureResult, + MergeFeatureResults, + SignalStoreFeature, + SignalStoreFeatureResult, +} from './signal-store-models'; + +export function signalStoreFeature( + f1: SignalStoreFeature +): SignalStoreFeature; +export function signalStoreFeature< + F1 extends SignalStoreFeatureResult, + F2 extends SignalStoreFeatureResult +>( + f1: SignalStoreFeature, + f2: SignalStoreFeature<{} & F1, F2> +): SignalStoreFeature>; +export function signalStoreFeature< + F1 extends SignalStoreFeatureResult, + F2 extends SignalStoreFeatureResult, + F3 extends SignalStoreFeatureResult +>( + f1: SignalStoreFeature, + f2: SignalStoreFeature<{} & F1, F2>, + f3: SignalStoreFeature, F3> +): SignalStoreFeature>; +export function signalStoreFeature< + F1 extends SignalStoreFeatureResult, + F2 extends SignalStoreFeatureResult, + F3 extends SignalStoreFeatureResult, + F4 extends SignalStoreFeatureResult +>( + f1: SignalStoreFeature, + f2: SignalStoreFeature<{} & F1, F2>, + f3: SignalStoreFeature, F3>, + f4: SignalStoreFeature, F4> +): SignalStoreFeature< + EmptyFeatureResult, + MergeFeatureResults<[F1, F2, F3, F4]> +>; +export function signalStoreFeature< + F1 extends SignalStoreFeatureResult, + F2 extends SignalStoreFeatureResult, + F3 extends SignalStoreFeatureResult, + F4 extends SignalStoreFeatureResult, + F5 extends SignalStoreFeatureResult +>( + f1: SignalStoreFeature, + f2: SignalStoreFeature<{} & F1, F2>, + f3: SignalStoreFeature, F3>, + f4: SignalStoreFeature, F4>, + f5: SignalStoreFeature, F5> +): SignalStoreFeature< + EmptyFeatureResult, + MergeFeatureResults<[F1, F2, F3, F4, F5]> +>; +export function signalStoreFeature< + F1 extends SignalStoreFeatureResult, + F2 extends SignalStoreFeatureResult, + F3 extends SignalStoreFeatureResult, + F4 extends SignalStoreFeatureResult, + F5 extends SignalStoreFeatureResult, + F6 extends SignalStoreFeatureResult +>( + f1: SignalStoreFeature, + f2: SignalStoreFeature<{} & F1, F2>, + f3: SignalStoreFeature, F3>, + f4: SignalStoreFeature, F4>, + f5: SignalStoreFeature, F5>, + f6: SignalStoreFeature, F6> +): SignalStoreFeature< + EmptyFeatureResult, + MergeFeatureResults<[F1, F2, F3, F4, F5, F6]> +>; + +export function signalStoreFeature< + Input extends Partial, + F1 extends SignalStoreFeatureResult +>( + input: Input, + f1: SignalStoreFeature +): SignalStoreFeature; +export function signalStoreFeature< + Input extends Partial, + F1 extends SignalStoreFeatureResult, + F2 extends SignalStoreFeatureResult +>( + input: Input, + f1: SignalStoreFeature, + f2: SignalStoreFeature< + MergeFeatureResults<[EmptyFeatureResult & Input, F1]>, + F2 + > +): SignalStoreFeature< + EmptyFeatureResult & Input, + MergeFeatureResults<[F1, F2]> +>; +export function signalStoreFeature< + Input extends Partial, + F1 extends SignalStoreFeatureResult, + F2 extends SignalStoreFeatureResult, + F3 extends SignalStoreFeatureResult +>( + input: Input, + f1: SignalStoreFeature, + f2: SignalStoreFeature< + MergeFeatureResults<[EmptyFeatureResult & Input, F1]>, + F2 + >, + f3: SignalStoreFeature< + MergeFeatureResults<[EmptyFeatureResult & Input, F1, F2]>, + F3 + > +): SignalStoreFeature< + EmptyFeatureResult & Input, + MergeFeatureResults<[F1, F2, F3]> +>; +export function signalStoreFeature< + Input extends Partial, + F1 extends SignalStoreFeatureResult, + F2 extends SignalStoreFeatureResult, + F3 extends SignalStoreFeatureResult, + F4 extends SignalStoreFeatureResult +>( + Input: Input, + f1: SignalStoreFeature, + f2: SignalStoreFeature< + MergeFeatureResults<[EmptyFeatureResult & Input, F1]>, + F2 + >, + f3: SignalStoreFeature< + MergeFeatureResults<[EmptyFeatureResult & Input, F1, F2]>, + F3 + >, + f4: SignalStoreFeature< + MergeFeatureResults<[EmptyFeatureResult & Input, F1, F2, F3]>, + F4 + > +): SignalStoreFeature< + EmptyFeatureResult & Input, + MergeFeatureResults<[F1, F2, F3, F4]> +>; +export function signalStoreFeature< + Input extends Partial, + F1 extends SignalStoreFeatureResult, + F2 extends SignalStoreFeatureResult, + F3 extends SignalStoreFeatureResult, + F4 extends SignalStoreFeatureResult, + F5 extends SignalStoreFeatureResult +>( + input: Input, + f1: SignalStoreFeature, + f2: SignalStoreFeature< + MergeFeatureResults<[EmptyFeatureResult & Input, F1]>, + F2 + >, + f3: SignalStoreFeature< + MergeFeatureResults<[EmptyFeatureResult & Input, F1, F2]>, + F3 + >, + f4: SignalStoreFeature< + MergeFeatureResults<[EmptyFeatureResult & Input, F1, F2, F3]>, + F4 + >, + f5: SignalStoreFeature< + MergeFeatureResults<[EmptyFeatureResult & Input, F1, F2, F3, F4]>, + F5 + > +): SignalStoreFeature< + EmptyFeatureResult & Input, + MergeFeatureResults<[F1, F2, F3, F4, F5]> +>; +export function signalStoreFeature< + Input extends Partial, + F1 extends SignalStoreFeatureResult, + F2 extends SignalStoreFeatureResult, + F3 extends SignalStoreFeatureResult, + F4 extends SignalStoreFeatureResult, + F5 extends SignalStoreFeatureResult, + F6 extends SignalStoreFeatureResult +>( + input: Input, + f1: SignalStoreFeature, + f2: SignalStoreFeature< + MergeFeatureResults<[EmptyFeatureResult & Input, F1]>, + F2 + >, + f3: SignalStoreFeature< + MergeFeatureResults<[EmptyFeatureResult & Input, F1, F2]>, + F3 + >, + f4: SignalStoreFeature< + MergeFeatureResults<[EmptyFeatureResult & Input, F1, F2, F3]>, + F4 + >, + f5: SignalStoreFeature< + MergeFeatureResults<[EmptyFeatureResult & Input, F1, F2, F3, F4]>, + F5 + >, + f6: SignalStoreFeature< + MergeFeatureResults<[EmptyFeatureResult & Input, F1, F2, F3, F4, F5]>, + F6 + > +): SignalStoreFeature< + EmptyFeatureResult & Input, + MergeFeatureResults<[F1, F2, F3, F4, F5, F6]> +>; + +export function signalStoreFeature( + featureOrInput: SignalStoreFeature | Partial, + ...restFeatures: SignalStoreFeature[] +): SignalStoreFeature { + const features = + typeof featureOrInput === 'function' + ? [featureOrInput, ...restFeatures] + : restFeatures; + + return (inputStore) => + features.reduce((store, feature) => feature(store), inputStore); +} + +export function type(): T { + return undefined as T; +} diff --git a/modules/signals/src/signal-store-models.ts b/modules/signals/src/signal-store-models.ts new file mode 100644 index 0000000000..3b8621d6e8 --- /dev/null +++ b/modules/signals/src/signal-store-models.ts @@ -0,0 +1,87 @@ +import { Signal } from '@angular/core'; +import { DeepSignal } from './deep-signal'; +import { SignalStateMeta } from './signal-state'; + +export type Prettify = { [K in keyof T]: T[K] } & {}; + +export type SignalStoreConfig = { providedIn: 'root' }; + +export type SignalStoreSlices = { + [Key in keyof State]: DeepSignal; +}; + +export type SignalStore = + Prettify< + SignalStoreSlices & + FeatureResult['signals'] & + FeatureResult['methods'] & + SignalStateMeta> + >; + +export type SignalsDictionary = Record>; + +export type MethodsDictionary = Record unknown>; + +export type SignalStoreHooks = { + onInit?: () => void; + onDestroy?: () => void; +}; + +export type InnerSignalStore< + State extends Record = Record, + Signals extends SignalsDictionary = SignalsDictionary, + Methods extends MethodsDictionary = MethodsDictionary +> = { + slices: SignalStoreSlices; + signals: Signals; + methods: Methods; + hooks: SignalStoreHooks; +} & SignalStateMeta; + +export type SignalStoreFeatureResult = { + state: Record; + signals: SignalsDictionary; + methods: MethodsDictionary; +}; + +export type EmptyFeatureResult = { state: {}; signals: {}; methods: {} }; + +export type SignalStoreFeature< + Input extends SignalStoreFeatureResult = SignalStoreFeatureResult, + Output extends SignalStoreFeatureResult = SignalStoreFeatureResult +> = ( + store: InnerSignalStore +) => InnerSignalStore; + +export type MergeFeatureResults< + FeatureResults extends SignalStoreFeatureResult[] +> = FeatureResults extends [] + ? {} + : FeatureResults extends [infer First extends SignalStoreFeatureResult] + ? First + : FeatureResults extends [ + infer First extends SignalStoreFeatureResult, + infer Second extends SignalStoreFeatureResult + ] + ? MergeTwoFeatureResults + : FeatureResults extends [ + infer First extends SignalStoreFeatureResult, + infer Second extends SignalStoreFeatureResult, + ...infer Rest extends SignalStoreFeatureResult[] + ] + ? MergeFeatureResults<[MergeTwoFeatureResults, ...Rest]> + : never; + +type FeatureResultKeys = + | keyof FeatureResult['state'] + | keyof FeatureResult['signals'] + | keyof FeatureResult['methods']; + +type MergeTwoFeatureResults< + First extends SignalStoreFeatureResult, + Second extends SignalStoreFeatureResult +> = { + state: Omit>; + signals: Omit>; + methods: Omit>; +} & Second; diff --git a/modules/signals/src/signal-store.ts b/modules/signals/src/signal-store.ts new file mode 100644 index 0000000000..65e70bd991 --- /dev/null +++ b/modules/signals/src/signal-store.ts @@ -0,0 +1,321 @@ +import { + DestroyRef, + inject, + Injectable, + Injector, + runInInjectionContext, + signal, + Type, +} from '@angular/core'; +import { defaultEqual } from './select-signal'; +import { STATE_SIGNAL } from './signal-state'; +import { + EmptyFeatureResult, + InnerSignalStore, + MergeFeatureResults, + SignalStore, + SignalStoreConfig, + SignalStoreFeature, + SignalStoreFeatureResult, +} from './signal-store-models'; + +export function signalStore( + f1: SignalStoreFeature +): Type>; +export function signalStore< + F1 extends SignalStoreFeatureResult, + F2 extends SignalStoreFeatureResult +>( + f1: SignalStoreFeature, + f2: SignalStoreFeature<{} & F1, F2> +): Type>>; +export function signalStore< + F1 extends SignalStoreFeatureResult, + F2 extends SignalStoreFeatureResult, + F3 extends SignalStoreFeatureResult +>( + f1: SignalStoreFeature, + f2: SignalStoreFeature<{} & F1, F2>, + f3: SignalStoreFeature, F3> +): Type>>; +export function signalStore< + F1 extends SignalStoreFeatureResult, + F2 extends SignalStoreFeatureResult, + F3 extends SignalStoreFeatureResult, + F4 extends SignalStoreFeatureResult +>( + f1: SignalStoreFeature, + f2: SignalStoreFeature<{} & F1, F2>, + f3: SignalStoreFeature, F3>, + f4: SignalStoreFeature, F4> +): Type>>; +export function signalStore< + F1 extends SignalStoreFeatureResult, + F2 extends SignalStoreFeatureResult, + F3 extends SignalStoreFeatureResult, + F4 extends SignalStoreFeatureResult, + F5 extends SignalStoreFeatureResult +>( + f1: SignalStoreFeature, + f2: SignalStoreFeature<{} & F1, F2>, + f3: SignalStoreFeature, F3>, + f4: SignalStoreFeature, F4>, + f5: SignalStoreFeature, F5> +): Type>>; +export function signalStore< + F1 extends SignalStoreFeatureResult, + F2 extends SignalStoreFeatureResult, + F3 extends SignalStoreFeatureResult, + F4 extends SignalStoreFeatureResult, + F5 extends SignalStoreFeatureResult, + F6 extends SignalStoreFeatureResult +>( + f1: SignalStoreFeature, + f2: SignalStoreFeature<{} & F1, F2>, + f3: SignalStoreFeature, F3>, + f4: SignalStoreFeature, F4>, + f5: SignalStoreFeature, F5>, + f6: SignalStoreFeature, F6> +): Type>>; +export function signalStore< + F1 extends SignalStoreFeatureResult, + F2 extends SignalStoreFeatureResult, + F3 extends SignalStoreFeatureResult, + F4 extends SignalStoreFeatureResult, + F5 extends SignalStoreFeatureResult, + F6 extends SignalStoreFeatureResult, + F7 extends SignalStoreFeatureResult +>( + f1: SignalStoreFeature, + f2: SignalStoreFeature<{} & F1, F2>, + f3: SignalStoreFeature, F3>, + f4: SignalStoreFeature, F4>, + f5: SignalStoreFeature, F5>, + f6: SignalStoreFeature, F6>, + f7: SignalStoreFeature, F7> +): Type>>; +export function signalStore< + F1 extends SignalStoreFeatureResult, + F2 extends SignalStoreFeatureResult, + F3 extends SignalStoreFeatureResult, + F4 extends SignalStoreFeatureResult, + F5 extends SignalStoreFeatureResult, + F6 extends SignalStoreFeatureResult, + F7 extends SignalStoreFeatureResult, + F8 extends SignalStoreFeatureResult +>( + f1: SignalStoreFeature, + f2: SignalStoreFeature<{} & F1, F2>, + f3: SignalStoreFeature, F3>, + f4: SignalStoreFeature, F4>, + f5: SignalStoreFeature, F5>, + f6: SignalStoreFeature, F6>, + f7: SignalStoreFeature, F7>, + f8: SignalStoreFeature, F8> +): Type>>; +export function signalStore< + F1 extends SignalStoreFeatureResult, + F2 extends SignalStoreFeatureResult, + F3 extends SignalStoreFeatureResult, + F4 extends SignalStoreFeatureResult, + F5 extends SignalStoreFeatureResult, + F6 extends SignalStoreFeatureResult, + F7 extends SignalStoreFeatureResult, + F8 extends SignalStoreFeatureResult, + F9 extends SignalStoreFeatureResult +>( + f1: SignalStoreFeature, + f2: SignalStoreFeature<{} & F1, F2>, + f3: SignalStoreFeature, F3>, + f4: SignalStoreFeature, F4>, + f5: SignalStoreFeature, F5>, + f6: SignalStoreFeature, F6>, + f7: SignalStoreFeature, F7>, + f8: SignalStoreFeature, F8>, + f9: SignalStoreFeature< + MergeFeatureResults<[F1, F2, F3, F4, F5, F6, F7, F8]>, + F9 + > +): Type>>; + +export function signalStore( + config: SignalStoreConfig, + f1: SignalStoreFeature +): Type>; +export function signalStore< + F1 extends SignalStoreFeatureResult, + F2 extends SignalStoreFeatureResult +>( + config: SignalStoreConfig, + f1: SignalStoreFeature, + f2: SignalStoreFeature<{} & F1, F2> +): Type>>; +export function signalStore< + F1 extends SignalStoreFeatureResult, + F2 extends SignalStoreFeatureResult, + F3 extends SignalStoreFeatureResult +>( + config: SignalStoreConfig, + f1: SignalStoreFeature, + f2: SignalStoreFeature<{} & F1, F2>, + f3: SignalStoreFeature, F3> +): Type>>; +export function signalStore< + F1 extends SignalStoreFeatureResult, + F2 extends SignalStoreFeatureResult, + F3 extends SignalStoreFeatureResult, + F4 extends SignalStoreFeatureResult +>( + config: SignalStoreConfig, + f1: SignalStoreFeature, + f2: SignalStoreFeature<{} & F1, F2>, + f3: SignalStoreFeature, F3>, + f4: SignalStoreFeature, F4> +): Type>>; +export function signalStore< + F1 extends SignalStoreFeatureResult, + F2 extends SignalStoreFeatureResult, + F3 extends SignalStoreFeatureResult, + F4 extends SignalStoreFeatureResult, + F5 extends SignalStoreFeatureResult +>( + config: SignalStoreConfig, + f1: SignalStoreFeature, + f2: SignalStoreFeature<{} & F1, F2>, + f3: SignalStoreFeature, F3>, + f4: SignalStoreFeature, F4>, + f5: SignalStoreFeature, F5> +): Type>>; +export function signalStore< + F1 extends SignalStoreFeatureResult, + F2 extends SignalStoreFeatureResult, + F3 extends SignalStoreFeatureResult, + F4 extends SignalStoreFeatureResult, + F5 extends SignalStoreFeatureResult, + F6 extends SignalStoreFeatureResult +>( + config: SignalStoreConfig, + f1: SignalStoreFeature, + f2: SignalStoreFeature<{} & F1, F2>, + f3: SignalStoreFeature, F3>, + f4: SignalStoreFeature, F4>, + f5: SignalStoreFeature, F5>, + f6: SignalStoreFeature, F6> +): Type>>; +export function signalStore< + F1 extends SignalStoreFeatureResult, + F2 extends SignalStoreFeatureResult, + F3 extends SignalStoreFeatureResult, + F4 extends SignalStoreFeatureResult, + F5 extends SignalStoreFeatureResult, + F6 extends SignalStoreFeatureResult, + F7 extends SignalStoreFeatureResult +>( + config: SignalStoreConfig, + f1: SignalStoreFeature, + f2: SignalStoreFeature<{} & F1, F2>, + f3: SignalStoreFeature, F3>, + f4: SignalStoreFeature, F4>, + f5: SignalStoreFeature, F5>, + f6: SignalStoreFeature, F6>, + f7: SignalStoreFeature, F7> +): Type>>; +export function signalStore< + F1 extends SignalStoreFeatureResult, + F2 extends SignalStoreFeatureResult, + F3 extends SignalStoreFeatureResult, + F4 extends SignalStoreFeatureResult, + F5 extends SignalStoreFeatureResult, + F6 extends SignalStoreFeatureResult, + F7 extends SignalStoreFeatureResult, + F8 extends SignalStoreFeatureResult +>( + config: SignalStoreConfig, + f1: SignalStoreFeature, + f2: SignalStoreFeature<{} & F1, F2>, + f3: SignalStoreFeature, F3>, + f4: SignalStoreFeature, F4>, + f5: SignalStoreFeature, F5>, + f6: SignalStoreFeature, F6>, + f7: SignalStoreFeature, F7>, + f8: SignalStoreFeature, F8> +): Type>>; +export function signalStore< + F1 extends SignalStoreFeatureResult, + F2 extends SignalStoreFeatureResult, + F3 extends SignalStoreFeatureResult, + F4 extends SignalStoreFeatureResult, + F5 extends SignalStoreFeatureResult, + F6 extends SignalStoreFeatureResult, + F7 extends SignalStoreFeatureResult, + F8 extends SignalStoreFeatureResult, + F9 extends SignalStoreFeatureResult +>( + config: SignalStoreConfig, + f1: SignalStoreFeature, + f2: SignalStoreFeature<{} & F1, F2>, + f3: SignalStoreFeature, F3>, + f4: SignalStoreFeature, F4>, + f5: SignalStoreFeature, F5>, + f6: SignalStoreFeature, F6>, + f7: SignalStoreFeature, F7>, + f8: SignalStoreFeature, F8>, + f9: SignalStoreFeature< + MergeFeatureResults<[F1, F2, F3, F4, F5, F6, F7, F8]>, + F9 + > +): Type>>; + +export function signalStore( + ...args: [SignalStoreConfig, ...SignalStoreFeature[]] | SignalStoreFeature[] +): Type> { + const signalStoreArgs = [...args]; + + const config: Partial = + 'providedIn' in signalStoreArgs[0] + ? (signalStoreArgs.shift() as SignalStoreConfig) + : {}; + const features = signalStoreArgs as SignalStoreFeature[]; + + @Injectable({ providedIn: config.providedIn || null }) + class SignalStore { + constructor() { + const innerStore = features.reduce( + (store, feature) => feature(store), + getInitialInnerStore() + ); + const { slices, signals, methods, hooks } = innerStore; + const props = { ...slices, ...signals, ...methods }; + + (this as any)[STATE_SIGNAL] = innerStore[STATE_SIGNAL]; + + for (const key in props) { + (this as any)[key] = props[key]; + } + + if (hooks.onInit) { + hooks.onInit(); + } + + if (hooks.onDestroy) { + const injector = inject(Injector); + + inject(DestroyRef).onDestroy(() => { + runInInjectionContext(injector, hooks.onDestroy!); + }); + } + } + } + + return SignalStore; +} + +export function getInitialInnerStore(): InnerSignalStore { + return { + [STATE_SIGNAL]: signal({}, { equal: defaultEqual }), + slices: {}, + signals: {}, + methods: {}, + hooks: {}, + }; +} diff --git a/modules/signals/src/with-hooks.ts b/modules/signals/src/with-hooks.ts new file mode 100644 index 0000000000..bd8abc7abf --- /dev/null +++ b/modules/signals/src/with-hooks.ts @@ -0,0 +1,52 @@ +import { STATE_SIGNAL, SignalStateMeta } from './signal-state'; +import { + EmptyFeatureResult, + Prettify, + SignalStoreFeature, + SignalStoreSlices, + SignalStoreFeatureResult, +} from './signal-store-models'; + +type HooksFactory = ( + store: Prettify< + SignalStoreSlices & + Input['signals'] & + Input['methods'] & + SignalStateMeta> + > +) => void; + +export function withHooks(hooks: { + onInit?: HooksFactory; + onDestroy?: HooksFactory; +}): SignalStoreFeature { + return (store) => { + const createHook = (name: keyof typeof hooks) => { + const hook = hooks[name]; + const currentHook = store.hooks[name]; + + return hook + ? () => { + if (currentHook) { + currentHook(); + } + + hook({ + [STATE_SIGNAL]: store[STATE_SIGNAL], + ...store.slices, + ...store.signals, + ...store.methods, + }); + } + : currentHook; + }; + + return { + ...store, + hooks: { + onInit: createHook('onInit'), + onDestroy: createHook('onDestroy'), + }, + }; + }; +} diff --git a/modules/signals/src/with-methods.ts b/modules/signals/src/with-methods.ts new file mode 100644 index 0000000000..ee0ff47dbc --- /dev/null +++ b/modules/signals/src/with-methods.ts @@ -0,0 +1,45 @@ +import { excludeKeys } from './helpers'; +import { STATE_SIGNAL, SignalStateMeta } from './signal-state'; +import { + EmptyFeatureResult, + InnerSignalStore, + MethodsDictionary, + Prettify, + SignalsDictionary, + SignalStoreFeature, + SignalStoreFeatureResult, + SignalStoreSlices, +} from './signal-store-models'; + +export function withMethods< + Input extends SignalStoreFeatureResult, + Methods extends MethodsDictionary +>( + methodsFactory: ( + store: Prettify< + SignalStoreSlices & + Input['signals'] & + Input['methods'] & + SignalStateMeta> + > + ) => Methods +): SignalStoreFeature { + return (store) => { + const methods = methodsFactory({ + [STATE_SIGNAL]: store[STATE_SIGNAL], + ...store.slices, + ...store.signals, + ...store.methods, + }); + const methodsKeys = Object.keys(methods); + const slices = excludeKeys(store.slices, methodsKeys); + const signals = excludeKeys(store.signals, methodsKeys); + + return { + ...store, + slices, + signals, + methods: { ...store.methods, ...methods }, + } as InnerSignalStore, SignalsDictionary, Methods>; + }; +} diff --git a/modules/signals/src/with-signals.ts b/modules/signals/src/with-signals.ts new file mode 100644 index 0000000000..d28ebcfa91 --- /dev/null +++ b/modules/signals/src/with-signals.ts @@ -0,0 +1,33 @@ +import { excludeKeys } from './helpers'; +import { + EmptyFeatureResult, + InnerSignalStore, + Prettify, + SignalsDictionary, + SignalStoreFeature, + SignalStoreFeatureResult, + SignalStoreSlices, +} from './signal-store-models'; + +export function withSignals< + Input extends SignalStoreFeatureResult, + Signals extends SignalsDictionary +>( + signalsFactory: ( + store: Prettify & Input['signals']> + ) => Signals +): SignalStoreFeature { + return (store) => { + const signals = signalsFactory({ ...store.slices, ...store.signals }); + const signalsKeys = Object.keys(signals); + const slices = excludeKeys(store.slices, signalsKeys); + const methods = excludeKeys(store.methods, signalsKeys); + + return { + ...store, + slices, + signals: { ...store.signals, ...signals }, + methods, + } as InnerSignalStore, Signals>; + }; +} diff --git a/modules/signals/src/with-state.ts b/modules/signals/src/with-state.ts new file mode 100644 index 0000000000..b6f74f6563 --- /dev/null +++ b/modules/signals/src/with-state.ts @@ -0,0 +1,57 @@ +import { toDeepSignal } from './deep-signal'; +import { excludeKeys } from './helpers'; +import { patchState } from './patch-state'; +import { selectSignal } from './select-signal'; +import { NotAllowedStateCheck, STATE_SIGNAL } from './signal-state'; +import { + EmptyFeatureResult, + InnerSignalStore, + SignalsDictionary, + SignalStoreFeature, + SignalStoreFeatureResult, +} from './signal-store-models'; + +export function withState>( + state: State & NotAllowedStateCheck +): SignalStoreFeature< + EmptyFeatureResult, + EmptyFeatureResult & { + state: State; + } +>; +export function withState>( + stateFactory: () => State & NotAllowedStateCheck +): SignalStoreFeature< + EmptyFeatureResult, + EmptyFeatureResult & { + state: State; + } +>; +export function withState>( + stateOrFactory: State | (() => State) +): SignalStoreFeature< + SignalStoreFeatureResult, + EmptyFeatureResult & { state: State } +> { + return (store) => { + const state = + typeof stateOrFactory === 'function' ? stateOrFactory() : stateOrFactory; + const stateKeys = Object.keys(state); + + patchState(store, state); + + const slices = stateKeys.reduce((acc, key) => { + const slice = selectSignal(() => store[STATE_SIGNAL]()[key]); + return { ...acc, [key]: toDeepSignal(slice) }; + }, {} as SignalsDictionary); + const signals = excludeKeys(store.signals, stateKeys); + const methods = excludeKeys(store.methods, stateKeys); + + return { + ...store, + slices: { ...store.slices, ...slices }, + signals, + methods, + } as InnerSignalStore; + }; +}