diff --git a/modules/signals/spec/helpers.ts b/modules/signals/spec/helpers.ts new file mode 100644 index 0000000000..847c5e689b --- /dev/null +++ b/modules/signals/spec/helpers.ts @@ -0,0 +1,15 @@ +import { Component } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; + +export function testEffects(testFn: (tick: () => void) => void): () => void { + @Component({ template: '', standalone: true }) + class TestComponent {} + + return () => { + const fixture = TestBed.configureTestingModule({ + imports: [TestComponent], + }).createComponent(TestComponent); + + TestBed.runInInjectionContext(() => testFn(() => fixture.detectChanges())); + }; +} diff --git a/modules/signals/spec/index.spec.ts b/modules/signals/spec/index.spec.ts deleted file mode 100644 index 0deeacca10..0000000000 --- a/modules/signals/spec/index.spec.ts +++ /dev/null @@ -1,5 +0,0 @@ -describe('NgRx Signals', () => { - it('should work', () => { - expect(true).toBe(true); - }); -}); diff --git a/modules/signals/spec/select-signal.spec.ts b/modules/signals/spec/select-signal.spec.ts new file mode 100644 index 0000000000..f4243e660a --- /dev/null +++ b/modules/signals/spec/select-signal.spec.ts @@ -0,0 +1,208 @@ +import { effect, isSignal, signal } from '@angular/core'; +import { selectSignal } from '../src'; +import { testEffects } from './helpers'; + +describe('selectSignal', () => { + it('creates a signal from provided projector function', () => { + const s1 = signal(1); + const s2 = selectSignal(() => s1() + 1); + + expect(isSignal(s2)).toBe(true); + expect(s2()).toBe(2); + + s1.set(2); + + expect(s2()).toBe(3); + }); + + it('creates a signal from provided signals dictionary', () => { + const s1 = signal(1); + const s2 = signal(2); + const s3 = selectSignal({ s1, s2 }); + + expect(isSignal(s3)).toBe(true); + expect(s3()).toEqual({ s1: 1, s2: 2 }); + + s1.set(10); + + expect(s3()).toEqual({ s1: 10, s2: 2 }); + + s1.set(100); + s2.set(20); + + expect(s3()).toEqual({ s1: 100, s2: 20 }); + }); + + it('creates a signal by combining provided signals', () => { + const s1 = signal(1); + const s2 = signal(2); + const s3 = selectSignal(s1, s2, (v1, v2) => v1 + v2); + + expect(isSignal(s3)).toBe(true); + expect(s3()).toBe(3); + + s1.set(10); + + expect(s3()).toBe(12); + + s1.set(100); + s2.set(20); + + expect(s3()).toBe(120); + }); + + it( + 'uses default equality function when custom one is not provided', + testEffects((tick) => { + const initialState = { x: { y: { z: 1 }, k: 2 }, l: 3 }; + const state = signal(initialState); + + const x = selectSignal(() => state().x); + const y = selectSignal(x, (x) => x.y); + const z = selectSignal(y, (y) => y.z); + const k = selectSignal(x, (x) => x.k); + const l = selectSignal(() => state().l); + const zPlusK = selectSignal(z, k, (z, k) => z + k); + const zWithL = selectSignal({ z, l }); + + let xEmitted = 0; + let yEmitted = 0; + let zEmitted = 0; + let zPlusKEmitted = 0; + let zWithLEmitted = 0; + + effect(() => { + x(); + xEmitted++; + }); + + effect(() => { + y(); + yEmitted++; + }); + + effect(() => { + z(); + zEmitted++; + }); + + effect(() => { + zPlusK(); + zPlusKEmitted++; + }); + + effect(() => { + zWithL(); + zWithLEmitted++; + }); + + expect(xEmitted).toBe(0); + expect(yEmitted).toBe(0); + expect(zEmitted).toBe(0); + expect(zPlusKEmitted).toBe(0); + expect(zWithLEmitted).toBe(0); + + tick(); + + expect(xEmitted).toBe(1); + expect(yEmitted).toBe(1); + expect(zEmitted).toBe(1); + expect(zPlusKEmitted).toBe(1); + expect(zWithLEmitted).toBe(1); + + state.update((state) => ({ ...state, l: 10 })); + tick(); + + expect(xEmitted).toBe(1); + expect(yEmitted).toBe(1); + expect(zEmitted).toBe(1); + expect(zPlusKEmitted).toBe(1); + expect(zWithLEmitted).toBe(2); + + state.update((state) => ({ ...state, x: { ...state.x, k: 20 } })); + tick(); + + expect(xEmitted).toBe(2); + expect(yEmitted).toBe(1); + expect(zEmitted).toBe(1); + expect(zPlusKEmitted).toBe(2); + expect(zWithLEmitted).toBe(2); + + state.update((state) => ({ ...state, x: { ...state.x, y: { z: 1 } } })); + tick(); + + expect(xEmitted).toBe(3); + expect(yEmitted).toBe(2); + expect(zEmitted).toBe(1); + expect(zPlusKEmitted).toBe(2); + expect(zWithLEmitted).toBe(2); + + state.update((state) => ({ ...state, x: { ...state.x, y: { z: 10 } } })); + tick(); + + expect(xEmitted).toBe(4); + expect(yEmitted).toBe(3); + expect(zEmitted).toBe(2); + expect(zPlusKEmitted).toBe(3); + expect(zWithLEmitted).toBe(3); + }) + ); + + it( + 'uses custom equality function when provided', + testEffects((tick) => { + const state = signal([1, 2, 3]); + const numbers = selectSignal(() => state(), { + equal: (a, b) => a.length === b.length, + }); + const first = selectSignal(state, (numbers) => numbers[0], { + equal: (a: number, b: number) => Math.round(a) === Math.round(b), + }); + + let numbersEmitted = 0; + let firstEmitted = 0; + + effect(() => { + numbers(); + numbersEmitted++; + }); + + effect(() => { + first(); + firstEmitted++; + }); + + expect(numbersEmitted).toBe(0); + expect(firstEmitted).toBe(0); + + tick(); + + expect(numbersEmitted).toBe(1); + expect(firstEmitted).toBe(1); + + state.set([10, 20, 30]); + tick(); + + expect(numbersEmitted).toBe(1); + expect(firstEmitted).toBe(2); + + state.set([10.1, 20.1, 30.1]); + tick(); + + expect(numbersEmitted).toBe(1); + expect(firstEmitted).toBe(2); + + state.set([10.9, 20.9]); + tick(); + + expect(numbersEmitted).toBe(2); + expect(firstEmitted).toBe(3); + + state.set([10.7, 20.7, 30.7]); + tick(); + + expect(numbersEmitted).toBe(3); + expect(firstEmitted).toBe(3); + }) + ); +}); diff --git a/modules/signals/spec/signal-state.spec.ts b/modules/signals/spec/signal-state.spec.ts new file mode 100644 index 0000000000..f20524ed14 --- /dev/null +++ b/modules/signals/spec/signal-state.spec.ts @@ -0,0 +1,179 @@ +import { effect, isSignal } from '@angular/core'; +import { signalState } from '../src'; +import { testEffects } from './helpers'; + +describe('signalState', () => { + const initialState = { + user: { + firstName: 'John', + lastName: 'Smith', + }, + foo: 'bar', + numbers: [1, 2, 3], + ngrx: 'signals', + }; + + describe('$update', () => { + it('updates state via partial state object', () => { + const state = signalState(initialState); + + state.$update({ + user: { firstName: 'Johannes', lastName: 'Schmidt' }, + foo: 'baz', + }); + + expect(state()).toEqual({ + ...initialState, + user: { firstName: 'Johannes', lastName: 'Schmidt' }, + foo: 'baz', + }); + }); + + it('updates state via updater function', () => { + const state = signalState(initialState); + + state.$update((state) => ({ + numbers: [...state.numbers, 4], + ngrx: 'rocks', + })); + + expect(state()).toEqual({ + ...initialState, + numbers: [1, 2, 3, 4], + ngrx: 'rocks', + }); + }); + + it('updates state via sequence of partial state objects and updater functions', () => { + const state = signalState(initialState); + + state.$update( + { 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('updates state immutably', () => { + const state = signalState(initialState); + + state.$update({ + 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); + }); + }); + + describe('nested signals', () => { + it('creates signals for nested state slices', () => { + const state = signalState(initialState); + + expect(state()).toBe(initialState); + expect(isSignal(state)).toBe(true); + + expect(state.user()).toBe(initialState.user); + expect(isSignal(state.user)).toBe(true); + + expect(state.user.firstName()).toBe(initialState.user.firstName); + expect(isSignal(state.user.firstName)).toBe(true); + + expect(state.foo()).toBe(initialState.foo); + expect(isSignal(state.foo)).toBe(true); + + 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('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); + + state.$update({ numbers: [1, 2, 3] }); + tick(); + + expect(numbersEmitted).toBe(2); + expect(userEmitted).toBe(1); + expect(firstNameEmitted).toBe(1); + + state.$update((state) => ({ + user: { ...state.user, lastName: 'Schmidt' }, + })); + tick(); + + expect(numbersEmitted).toBe(2); + expect(userEmitted).toBe(2); + expect(firstNameEmitted).toBe(1); + + state.$update((state) => ({ + user: { ...state.user, firstName: 'Johannes' }, + })); + tick(); + + expect(numbersEmitted).toBe(2); + expect(userEmitted).toBe(3); + expect(firstNameEmitted).toBe(2); + }) + ); + }); +}); diff --git a/modules/signals/src/deep-signal.ts b/modules/signals/src/deep-signal.ts new file mode 100644 index 0000000000..a441180773 --- /dev/null +++ b/modules/signals/src/deep-signal.ts @@ -0,0 +1,28 @@ +import { isSignal, Signal, untracked } from '@angular/core'; +import { selectSignal } from './select-signal'; + +export type DeepSignal = Signal & + (T extends Record + ? Readonly<{ [K in keyof T]: DeepSignal }> + : unknown); + +export function toDeepSignal(signal: Signal): DeepSignal { + const value = untracked(() => signal()); + if (!isRecord(value)) { + return signal as DeepSignal; + } + + return new Proxy(signal, { + get(target: any, prop) { + if (prop in value && !target[prop]) { + target[prop] = selectSignal(() => target()[prop]); + } + + return isSignal(target[prop]) ? toDeepSignal(target[prop]) : target[prop]; + }, + }); +} + +function isRecord(value: unknown): value is Record { + return value?.constructor === Object; +} diff --git a/modules/signals/src/index.ts b/modules/signals/src/index.ts index 7f810d3f32..735ffeaf91 100644 --- a/modules/signals/src/index.ts +++ b/modules/signals/src/index.ts @@ -1 +1,3 @@ -export default 0; +export { selectSignal } from './select-signal'; +export { signalState } from './signal-state'; +export { SignalStateUpdater } from './signal-state-models'; diff --git a/modules/signals/src/select-signal.ts b/modules/signals/src/select-signal.ts new file mode 100644 index 0000000000..305fe3ab49 --- /dev/null +++ b/modules/signals/src/select-signal.ts @@ -0,0 +1,70 @@ +import { computed, Signal, ValueEqualityFn } from '@angular/core'; + +type SelectSignalConfig = { equal: ValueEqualityFn }; + +type SignalValue = T extends Signal ? V : never; + +export function selectSignal( + projector: () => Result, + config?: SelectSignalConfig +): Signal; +export function selectSignal>>( + signals: Signals, + config?: SelectSignalConfig<{ [K in keyof Signals]: SignalValue }> +): Signal<{ [K in keyof Signals]: SignalValue }>; +export function selectSignal[], Result>( + ...args: [ + ...signals: Signals, + projector: ( + ...values: { [I in keyof Signals]: SignalValue } + ) => Result + ] +): Signal; +export function selectSignal[], Result>( + ...args: [ + ...signals: Signals, + projector: ( + ...values: { [I in keyof Signals]: SignalValue } + ) => Result, + config: SelectSignalConfig + ] +): Signal; +export function selectSignal( + ...selectSignalArgs: unknown[] +): Signal { + const args = [...selectSignalArgs]; + + const config: SelectSignalConfig = + typeof args[args.length - 1] === 'object' && args.length > 1 + ? (args.pop() as SelectSignalConfig) + : { equal: defaultEqual }; + + if (typeof args[0] === 'object') { + const signalsDictionary = args[0] as Record>; + const computation = () => { + return Object.keys(signalsDictionary).reduce( + (acc, key) => ({ ...acc, [key]: signalsDictionary[key]() }), + {} as Result + ); + }; + + return computed(computation, config); + } + + const projector = args.pop() as (...values: unknown[]) => Result; + const signals = args as Signal[]; + + const computation = + signals.length === 0 + ? projector + : () => { + const values = signals.map((signal) => signal()); + return projector(...values); + }; + + return computed(computation, config); +} + +export function defaultEqual(previous: T, current: T): boolean { + return previous === current; +} diff --git a/modules/signals/src/signal-state-models.ts b/modules/signals/src/signal-state-models.ts new file mode 100644 index 0000000000..ad8a2c3277 --- /dev/null +++ b/modules/signals/src/signal-state-models.ts @@ -0,0 +1,21 @@ +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 new file mode 100644 index 0000000000..a29c3ed49c --- /dev/null +++ b/modules/signals/src/signal-state.ts @@ -0,0 +1,34 @@ +import { signal, WritableSignal } from '@angular/core'; +import { toDeepSignal } from './deep-signal'; +import { defaultEqual } from './select-signal'; +import { + NotAllowedStateCheck, + SignalState, + SignalStateUpdate, +} from './signal-state-models'; + +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); + + 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 + ) + ); +} diff --git a/modules/signals/tsconfig.build.json b/modules/signals/tsconfig.build.json index 68760a9c13..1cc454eaf9 100644 --- a/modules/signals/tsconfig.build.json +++ b/modules/signals/tsconfig.build.json @@ -15,7 +15,12 @@ "lib": ["ES2022", "dom"], "target": "ES2022", "skipLibCheck": true, - "strict": true + "strict": true, + "forceConsistentCasingInFileNames": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true }, "files": ["public_api.ts"], "include": ["**/*.ts"],