diff --git a/modules/signals/spec/helpers.ts b/modules/signals/spec/helpers.ts index 9dfbd8c88f..7d1ac1219b 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 f2e82dfb3c..000d63b015 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 properties 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.tick(); - expect(stateCounter).toBe(2); + expect(stateCounter).toBe(1); expect(userCounter).toBe(1); patchState(state, (state) => state); TestBed.tick(); - 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 2e430f338e..51bd8b0215 100644 --- a/modules/signals/spec/state-source.spec.ts +++ b/modules/signals/spec/state-source.spec.ts @@ -1,8 +1,12 @@ import { + computed, createEnvironmentInjector, effect, EnvironmentInjector, Injectable, + isSignal, + linkedSignal, + resource, signal, } from '@angular/core'; import { TestBed } from '@angular/core/testing'; @@ -17,10 +21,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'); @@ -36,18 +39,24 @@ describe('StateSource', () => { [SECRET]: 'secret', }; + const consoleWarnSpy = vi.spyOn(console, 'warn'); + + beforeEach(() => { + consoleWarnSpy.mockClear(); + }); + 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 +90,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 +107,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 +134,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'), }); }); @@ -145,6 +159,94 @@ describe('StateSource', () => { }); }); }); + + describe('undefined root properties', () => { + it('skips and warns on optional root properties, when they are missing in the init state', () => { + type UserState = { + id: number; + middleName?: string; + }; + const initialState: UserState = { id: 1 }; + const userState = signalState(initialState); + + patchState(userState, { middleName: 'Michael' }); + + expect(consoleWarnSpy).toHaveBeenCalledWith( + '@ngrx/signals: Skipping update for unknown property in state source.', + 'Property: middleName' + ); + expect(userState()).toEqual({ id: 1 }); + }); + + it('updates optional properties with an initialized value', () => { + type UserState = { + id: number; + middleName?: string; + }; + const initialState: UserState = { id: 1, middleName: 'Michael' }; + const userState = signalState(initialState); + + patchState(userState, { middleName: undefined }); + expect(userState()).toEqual({ id: 1, middleName: undefined }); + + patchState(userState, { middleName: 'Martin' }); + expect(userState()).toEqual({ id: 1, middleName: 'Martin' }); + + expect(consoleWarnSpy).not.toHaveBeenCalled(); + }); + + it('supports root properties with union type of undefined and does not warn', () => { + type UserState = { + id: number; + middleName: string | undefined; + }; + const initialState: UserState = { id: 1, middleName: undefined }; + const userState = signalState(initialState); + + patchState(userState, { middleName: 'Michael' }); + + expect(userState()).toEqual({ id: 1, middleName: 'Michael' }); + expect(consoleWarnSpy).not.toHaveBeenCalled(); + }); + }); + + it('should only patch affected root properties', () => { + let updateCounter = 0; + const userSignal = signal( + { + firstName: 'John', + lastName: 'Smith', + }, + { + equal: (a, b) => { + updateCounter++; + return a === b; + }, + } + ); + + const UserStore = signalStore( + { providedIn: 'root', protectedState: false }, + withState({ user: userSignal, city: 'Changan' }) + ); + const store = TestBed.inject(UserStore); + + expect(updateCounter).toBe(0); + + patchState(store, { city: 'Xian' }); + expect(updateCounter).toBe(0); + + patchState(store, (state) => state); + expect(updateCounter).toBe(0); + + patchState(store, ({ user }) => ({ user })); + expect(updateCounter).toBe(0); + + patchState(store, ({ user }) => ({ + user: { ...user, firstName: 'Jane' }, + })); + expect(updateCounter).toBe(1); + }); }); describe('getState', () => { @@ -192,6 +294,27 @@ describe('StateSource', () => { expect(executionCount).toBe(2); }); }); + + it('does not support a dynamic type as state', () => { + const Store = signalStore( + { providedIn: 'root' }, + withState>({}), + withMethods((store) => ({ + addNumber(num: number): void { + patchState(store, { + [num]: num, + }); + }, + })) + ); + const store = TestBed.inject(Store); + + store.addNumber(1); + store.addNumber(2); + store.addNumber(3); + + expect(getState(store)).toEqual({}); + }); }); describe('watchState', () => { @@ -344,4 +467,125 @@ describe('StateSource', () => { }); }); }); + + describe('integration of Signals natively', () => { + [ + { + name: 'signalStore', + setup(state: State) { + const Store = signalStore( + { providedIn: 'root', protectedState: false }, + withState(state) + ); + return TestBed.inject(Store); + }, + }, + { + name: 'signalState', + setup(state: State) { + return signalState(state); + }, + }, + ].forEach(({ name, setup }) => { + describe(name, () => { + it('integrates writable Signals as-is', () => { + const user = { + id: 1, + name: 'John Doe', + }; + const userSignal = signal(user); + + const store = setup({ user: userSignal }); + const prettyName = computed( + () => `${store.user().name} ${store.user().id}` + ); + + expect(store.user()).toBe(user); + expect(prettyName()).toBe('John Doe 1'); + + userSignal.set({ id: 2, name: 'Jane Doe' }); + expect(store.user()).toEqual({ id: 2, name: 'Jane Doe' }); + + patchState(store, { user: { id: 3, name: 'Jack Doe' } }); + expect(store.user()).toEqual({ id: 3, name: 'Jack Doe' }); + }); + + it('integrates a linkedSignal and its update mechanism', () => { + const triggerSignal = signal(1); + const userLinkedSignal = linkedSignal({ + source: triggerSignal, + computation: () => ({ id: 1, name: 'John Doe' }), + }); + + const store = setup({ user: userLinkedSignal }); + const prettyName = computed( + () => `${store.user().name} ${store.user().id}` + ); + + expect(store.user()).toEqual({ id: 1, name: 'John Doe' }); + expect(prettyName()).toBe('John Doe 1'); + + patchState(store, { user: { id: 2, name: 'Jane Doe' } }); + expect(prettyName()).toBe('Jane Doe 2'); + + triggerSignal.set(2); + expect(prettyName()).toBe('John Doe 1'); + }); + + it('supports a resource', async () => { + const resourceTrigger = signal(1); + const userResource = TestBed.runInInjectionContext(() => + resource({ + request: resourceTrigger, + loader: (params) => + Promise.resolve({ id: params.request, name: 'John Doe' }), + defaultValue: { id: 0, name: 'Loading...' }, + }) + ); + + const store = setup({ user: userResource.value }); + expect(store.user()).toEqual({ id: 0, name: 'Loading...' }); + + await new Promise((resolve) => setTimeout(resolve)); + expect(store.user()).toEqual({ id: 1, name: 'John Doe' }); + + resourceTrigger.set(2); + await new Promise((resolve) => setTimeout(resolve)); + expect(store.user()).toEqual({ id: 2, name: 'John Doe' }); + }); + + it('allows mixed writable Signal Types', () => { + const user = { + id: 1, + name: 'John Doe', + }; + const userSignal = signal(user); + const product = { id: 1, name: 'Product A' }; + + const store = setup({ user: userSignal, product }); + + expect(store.user()).toBe(user); + expect(store.product()).toBe(product); + }); + + it('does not strip a readonly Signal', () => { + const store = setup({ n: signal(1).asReadonly() }); + + expect(isSignal(store.n())).toBe(true); + expect(store.n()()).toBe(1); + }); + + it('does not strip a nested writable Signal', () => { + const user = { + id: 1, + name: 'John Doe', + }; + const userSignal = signal(user); + const store = setup({ data: { user: userSignal } }); + + expect(isSignal(store.data.user())).toBe(true); + }); + }); + }); + }); }); diff --git a/modules/signals/spec/types/signal-state.types.spec.ts b/modules/signals/spec/types/signal-state.types.spec.ts index b0a4874276..4ac4643c7d 100644 --- a/modules/signals/spec/types/signal-state.types.spec.ts +++ b/modules/signals/spec/types/signal-state.types.spec.ts @@ -142,17 +142,17 @@ describe('signalState', () => { expectSnippet(snippet).toInfer( 'setStateKeys', - 'unique symbol | keyof Signal>' + 'unique symbol | keyof Signal>>' ); expectSnippet(snippet).toInfer( 'mapStateKeys', - 'unique symbol | keyof Signal>' + 'unique symbol | keyof Signal>>' ); expectSnippet(snippet).toInfer( 'uintArrayStateKeys', - 'unique symbol | keyof Signal>' + 'unique symbol | keyof Signal>>' ); }); @@ -175,22 +175,22 @@ describe('signalState', () => { expectSnippet(snippet).toInfer( 'weakSetStateKeys', - 'unique symbol | keyof Signal>' + 'unique symbol | keyof Signal>>' ); expectSnippet(snippet).toInfer( 'dateStateKeys', - 'unique symbol | keyof Signal' + 'unique symbol | keyof Signal>' ); expectSnippet(snippet).toInfer( 'errorStateKeys', - 'unique symbol | keyof Signal' + 'unique symbol | keyof Signal>' ); expectSnippet(snippet).toInfer( 'regExpStateKeys', - 'unique symbol | keyof Signal' + 'unique symbol | keyof Signal>' ); }); @@ -204,7 +204,7 @@ describe('signalState', () => { expectSnippet(snippet).toInfer( 'stateKeys', - 'unique symbol | keyof Signal<() => void>' + 'unique symbol | keyof Signal void>>' ); }); @@ -282,7 +282,7 @@ describe('signalState', () => { expectSnippet(snippet).toInfer( 'state1Keys', - 'unique symbol | keyof Signal<{ [key: string]: number; }>' + 'unique symbol | keyof Signal>' ); expectSnippet(snippet).toInfer( @@ -292,7 +292,7 @@ describe('signalState', () => { expectSnippet(snippet).toInfer( 'state2Keys', - 'unique symbol | keyof Signal<{ [key: number]: { foo: string; }; }>' + 'unique symbol | keyof Signal>' ); expectSnippet(snippet).toInfer( @@ -302,7 +302,7 @@ describe('signalState', () => { expectSnippet(snippet).toInfer( 'state3Keys', - 'unique symbol | keyof Signal>' + 'unique symbol | keyof Signal>>' ); expectSnippet(snippet).toInfer( diff --git a/modules/signals/spec/types/signal-store.types.spec.ts b/modules/signals/spec/types/signal-store.types.spec.ts index 6854f27064..2bb990d2f8 100644 --- a/modules/signals/spec/types/signal-store.types.spec.ts +++ b/modules/signals/spec/types/signal-store.types.spec.ts @@ -4,7 +4,7 @@ import { compilerOptions } from './helpers'; describe('signalStore', () => { const expectSnippet = expecter( (code) => ` - import { computed, inject, Signal } from '@angular/core'; + import { computed, inject, Signal, signal } from '@angular/core'; import { getState, patchState, @@ -863,6 +863,47 @@ describe('signalStore', () => { `).toFail(/'_count2' does not exist in type/); }); + it('exposes a writable Signal as readonly', () => { + const snippet = ` + type User = { + id: number; + name: string; + location: { + city: string; + country: string; + } + } + + const userSignal = signal({ + id: 1, + name: 'John Doe', + location: { + city: 'New York', + country: 'USA' + } + }); + + const Store = signalStore( + withState({ + user: userSignal, + foo: signal('bar') + }) + ); + + const store = new Store(); + const user = store.user; + const foo = store.foo; + `; + + expectSnippet(snippet).toSucceed(); + + expectSnippet(snippet).toInfer( + 'user', + 'DeepSignal<{ id: number; name: string; location: { city: string; country: string; }; }>' + ); + expectSnippet(snippet).toInfer('foo', 'Signal'); + }); + describe('custom features', () => { const baseSnippet = ` function withX() { diff --git a/modules/signals/spec/types/with-resource.types.spec.ts b/modules/signals/spec/types/with-resource.types.spec.ts new file mode 100644 index 0000000000..97e1a2603f --- /dev/null +++ b/modules/signals/spec/types/with-resource.types.spec.ts @@ -0,0 +1,264 @@ +import { expecter } from 'ts-snippet'; +import { compilerOptions } from './helpers'; + +describe('signalStore', () => { + const expectSnippet = expecter( + (code) => ` + import { computed, inject, resource, Resource, Signal, signal } from '@angular/core'; + import { + patchState, + mapToResource, + signalStore, + withResource, + withState + } from '@ngrx/signals'; + + ${code} + `, + compilerOptions() + ); + + describe('unnamed resource', () => { + it('satisfies the Resource interface without default value', () => { + const snippet = ` + const Store = signalStore( + withResource(() => resource({loader: () => Promise.resolve(1)})) + ); + + const store: Resource = new Store(); + `; + + expectSnippet(snippet).toSucceed(); + }); + + it('satisfies the Resource interface with default value', () => { + const snippet = ` + const Store = signalStore( + withResource(() => resource({loader: () => Promise.resolve(1), defaultValue: 0})) + ); + + const store: Resource = new Store(); + `; + + expectSnippet(snippet).toSucceed(); + }); + + it('provides hasValue as type predicate when explicitly typed', () => { + const snippet = ` + const Store = signalStore( + withResource(() => resource({ loader: () => Promise.resolve(1) })) + ); + + const store: Resource = new Store(); + if (store.hasValue()) { + const value: number = store.value(); + } + `; + + expectSnippet(snippet).toSucceed(); + }); + + it('fails on hasValue as type predicate when not explicitly typed', () => { + const snippet = ` + const Store = signalStore( + withResource(() => resource({ loader: () => Promise.resolve(1) })) + ); + + const store = new Store(); + if (store.hasValue()) { + const value: number = store.value(); + } + `; + + expectSnippet(snippet).toFail( + /Type 'number | undefined' is not assignable to type 'number'/ + ); + }); + + it('does not have access to the STATE_SOURCE', () => { + const snippet = ` + const Store = signalStore( + withState({ id: 1 }), + withResource((store) => + resource({ + params: store.id, + loader: ({ params: id }) => { + patchState(store, { id: 0 }); + return Promise.resolve(id + 1); + }, + }) + ) + ); + `; + + expectSnippet(snippet).toFail(/Property '\[STATE_SOURCE\]' is missing/); + }); + }); + + describe('named resources', () => { + it('does not have access to the STATE_SOURCE', () => { + const snippet = ` + const Store = signalStore( + withState({ id: 1 }), + withResource((store) => ({ + user: resource({ + params: store.id, + loader: ({ params: id }) => { + patchState(store, { id: 0 }); + return Promise.resolve(id + 1); + }, + }), + })) + ); + `; + + expectSnippet(snippet).toFail(/Property '\[STATE_SOURCE\]' is missing/); + }); + }); + + it('shoud allow different resource types with named resources', () => { + const snippet = ` + const Store = signalStore( + withResource((store) => ({ + id: resource({ + loader: () => Promise.resolve(1), + defaultValue: 0, + }), + word: resource({ + loader: () => Promise.resolve('hello'), + defaultValue: '', + }), + optionalId: resource({ + loader: () => Promise.resolve(1), + }) + })) + ); + const store = new Store(); + const id = store.idValue; + const word = store.wordValue; + const optionalId = store.optionalIdValue; + `; + + expectSnippet(snippet).toInfer('id', 'Signal'); + expectSnippet(snippet).toInfer('word', 'Signal'); + expectSnippet(snippet).toInfer('optionalId', 'Signal'); + }); + + describe('mapToResource', () => { + it('satisfies the Resource interface without default value', () => { + const snippet = ` + const Store = signalStore( + withResource(() => ({ id: resource({ loader: () => Promise.resolve(1) }) })) + ); + + const store = new Store(); + mapToResource(store, 'id') satisfies Resource; + `; + + expectSnippet(snippet).toSucceed(); + }); + + it('satisfies the Resource interface with default value', () => { + const snippet = ` + const Store = signalStore( + withResource(() => ({ + id: resource({ + loader: () => Promise.resolve(1), + defaultValue: 0 + }) + })) + ); + + const store = new Store(); + mapToResource(store, 'id') satisfies Resource; + `; + + expectSnippet(snippet).toSucceed(); + }); + + it('provides hasValue as type predicate', () => { + const snippet = ` + const Store = signalStore( + withResource(() => ({id: resource({ loader: () => Promise.resolve(1) })})) + ); + + const store = new Store(); + const res = mapToResource(store, 'id'); + if (res.hasValue()) { + const value: number = res.value(); + } + `; + + expectSnippet(snippet).toSucceed(); + }); + + describe('resource name checks', () => { + const setupStore = ` + const Store = signalStore( + withState({ key: 1, work: 'test' }), + withResource(() => ({ + id: resource({ loader: () => Promise.resolve(1) }), + word: resource({ loader: () => Promise.resolve('hello') }) + })) + ); + + const store = new Store(); + `; + + it('allows passing id as a valid resource name', () => { + const snippet = ` + ${setupStore} + mapToResource(store, 'id'); + `; + + expectSnippet(snippet).toSucceed(); + }); + + it('allows passing word as a valid resource name', () => { + const snippet = ` + ${setupStore} + mapToResource(store, 'word'); + `; + + expectSnippet(snippet).toSucceed(); + }); + + it('fails when passing key as a resource name', () => { + const snippet = ` + ${setupStore} + mapToResource(store, 'key'); + `; + + expectSnippet(snippet).toFail( + /Argument of type '"key"' is not assignable to parameter of type '"id" | "word"/ + ); + }); + + it('fails when passing work as a resource name', () => { + const snippet = ` + ${setupStore} + mapToResource(store, 'work'); + `; + + expectSnippet(snippet).toFail( + /Argument of type '"work"' is not assignable to parameter of type '"id" | "word"/ + ); + }); + }); + + it('fails when Resource properties are not fully defined', () => { + const snippet = ` + const Store = signalStore( + withState({ userValue: 0 }) + ); + + const store = new Store(); + mapToResource(store, 'user'); + `; + + expectSnippet(snippet).toFail( + /Argument of type '"user"' is not assignable to parameter of type 'never'/ + ); + }); + }); +}); diff --git a/modules/signals/spec/with-resource.spec.ts b/modules/signals/spec/with-resource.spec.ts new file mode 100644 index 0000000000..c39822af47 --- /dev/null +++ b/modules/signals/spec/with-resource.spec.ts @@ -0,0 +1,456 @@ +import { httpResource, provideHttpClient } from '@angular/common/http'; +import { + HttpTestingController, + provideHttpClientTesting, +} from '@angular/common/http/testing'; +import { inject, Injectable, resource } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { vitest } from 'vitest'; +import { patchState, withMethods, withProps } from '../src'; +import { signalStore } from '../src/signal-store'; +import { mapToResource, withResource } from '../src/with-resource'; +import { withState } from '../src/with-state'; + +const wait = (ms = 0) => new Promise((resolve) => setTimeout(resolve, ms)); + +type Address = { + street: string; + city: { + zip: string; + name: string; + }; + country: string; +}; + +const venice: Address = { + street: 'Sestiere Dorsoduro, 2771', + city: { + zip: '30123', + name: 'Venezia VE', + }, + country: 'Italy', +}; + +@Injectable({ providedIn: 'root' }) +class AddressResolver { + resolve(id: number) { + void id; + return Promise.resolve
(venice); + } +} + +function setupWithUnnamedResource() { + const addressResolver = { + resolve: vitest.fn<(id: number) => Promise
>(() => + Promise.resolve(venice) + ), + }; + const AddressStore = signalStore( + { providedIn: 'root', protectedState: false }, + withState({ id: undefined as number | undefined }), + withResource((store) => { + const resolver = inject(AddressResolver); + return resource({ + params: store.id, + loader: ({ params: id }) => resolver.resolve(id), + }); + }), + withMethods((store) => ({ reload: () => store._reload() })) + ); + + TestBed.configureTestingModule({ + providers: [ + { + provide: AddressResolver, + useValue: addressResolver, + }, + ], + }); + + const store = TestBed.inject(AddressStore); + + return { store, addressResolver }; +} + +function setupWithNamedResource() { + const addressResolver = { + resolve: vitest.fn<(id: number) => Promise
>(() => + Promise.resolve(venice) + ), + }; + + const UserStore = signalStore( + { providedIn: 'root', protectedState: false }, + withState({ id: undefined as number | undefined }), + withResource((store) => { + const resolver = inject(AddressResolver); + return { + address: resource({ + params: store.id, + loader: ({ params: id }) => resolver.resolve(id), + }), + }; + }), + withMethods((store) => ({ reload: () => store._addressReload() })) + ); + + TestBed.configureTestingModule({ + providers: [ + { + provide: AddressResolver, + useValue: addressResolver, + }, + ], + }); + + const store = TestBed.inject(UserStore); + + return { store, addressResolver }; +} + +describe('withResource', () => { + describe('InnerSignalStore access', () => { + it('can access the state signals', async () => { + const UserStore = signalStore( + { providedIn: 'root' }, + withState({ userId: 1 }), + withResource((store) => + resource({ + params: store.userId, + loader: ({ params: id }) => Promise.resolve(id + 1), + }) + ) + ); + + const userStore = TestBed.inject(UserStore); + + await wait(); + expect(userStore.value()).toBe(2); + }); + + it('can access the props', async () => { + const UserStore = signalStore( + { providedIn: 'root' }, + withProps(() => ({ userId: 1 })), + withResource((store) => + resource({ + params: () => store.userId, + loader: ({ params: id }) => Promise.resolve(id + 1), + }) + ) + ); + + const userStore = TestBed.inject(UserStore); + await wait(); + expect(userStore.value()).toBe(2); + }); + + it('can access the methods', async () => { + const UserStore = signalStore( + { providedIn: 'root' }, + withMethods(() => ({ getValue: () => 1 })), + withResource((store) => + resource({ + params: () => store.getValue(), + loader: ({ params: id }) => Promise.resolve(id + 1), + }) + ) + ); + + const userStore = TestBed.inject(UserStore); + await wait(); + expect(userStore.value()).toBe(2); + }); + }); + + describe('status checks', () => { + for (const { name, setup } of [ + { + name: 'unnamed resource', + setup: () => { + const { store, addressResolver } = setupWithUnnamedResource(); + const setId = (id: number) => patchState(store, { id }); + const setValue = (value: Address) => patchState(store, { value }); + return { storeAndResource: store, addressResolver, setId, setValue }; + }, + }, + { + name: 'mapped named resource', + setup: () => { + const { store, addressResolver } = setupWithNamedResource(); + const storeAndResource = mapToResource(store, 'address'); + const setId = (id: number) => patchState(store, { id }); + const setValue = (value: Address) => + patchState(store, { addressValue: value }); + return { storeAndResource, addressResolver, setId, setValue }; + }, + }, + ]) { + describe(name, () => { + it('has idle status in the beginning', () => { + const { storeAndResource } = setup(); + + expect(storeAndResource.status()).toBe('idle'); + expect(storeAndResource.value()).toBeUndefined(); + expect(storeAndResource.error()).toBeUndefined(); + expect(storeAndResource.isLoading()).toBe(false); + expect(storeAndResource.hasValue()).toBe(false); + }); + + it('has loading status when loading', () => { + const { storeAndResource, addressResolver, setId } = setup(); + + addressResolver.resolve.mockResolvedValue(venice); + setId(1); + + expect(storeAndResource.status()).toBe('loading'); + expect(storeAndResource.value()).toBeUndefined(); + expect(storeAndResource.error()).toBeUndefined(); + expect(storeAndResource.isLoading()).toBe(true); + expect(storeAndResource.hasValue()).toBe(false); + }); + + it('has resolved status when loaded', async () => { + const { storeAndResource, addressResolver, setId } = setup(); + + addressResolver.resolve.mockResolvedValue(venice); + setId(1); + + await wait(); + + expect(storeAndResource.status()).toBe('resolved'); + expect(storeAndResource.value()).toEqual(venice); + expect(storeAndResource.error()).toBeUndefined(); + expect(storeAndResource.isLoading()).toBe(false); + expect(storeAndResource.hasValue()).toBe(true); + }); + + it('has error status when error', async () => { + const { storeAndResource, addressResolver, setId } = setup(); + + addressResolver.resolve.mockRejectedValue(new Error('Error')); + setId(1); + await wait(); + + expect(storeAndResource.status()).toBe('error'); + expect(() => storeAndResource.value()).toThrow(); + expect(storeAndResource.error()).toBeInstanceOf(Error); + expect(storeAndResource.isLoading()).toBe(false); + expect(storeAndResource.hasValue()).toBe(false); + }); + + it('has local once updated', async () => { + const { storeAndResource, addressResolver, setId, setValue } = + setup(); + + addressResolver.resolve.mockResolvedValue(venice); + setId(1); + + await wait(); + setValue({ ...venice, country: 'Italia' }); + + expect(storeAndResource.status()).toBe('local'); + expect(storeAndResource.value()?.country).toBe('Italia'); + expect(storeAndResource.error()).toBeUndefined(); + expect(storeAndResource.isLoading()).toBe(false); + expect(storeAndResource.hasValue()).toBe(true); + }); + }); + } + + it('reloads an unnamed resource', async () => { + const { store, addressResolver } = setupWithUnnamedResource(); + + addressResolver.resolve.mockResolvedValue(venice); + patchState(store, { id: 1 }); + + await wait(); + expect(store.hasValue()).toBe(true); + + addressResolver.resolve.mockResolvedValue({ + ...venice, + country: 'Great Britain', + }); + store.reload(); + + await wait(); + expect(store.value()?.country).toBe('Great Britain'); + }); + + describe('named resource', () => { + it('has idle status in the beginning', () => { + const { store } = setupWithNamedResource(); + + expect(store.addressStatus()).toBe('idle'); + expect(store.addressValue()).toBeUndefined(); + expect(store.addressError()).toBeUndefined(); + expect(store.addressIsLoading()).toBe(false); + expect(store.addressHasValue()).toBe(false); + }); + + it('has loading status when loading', () => { + const { store, addressResolver } = setupWithNamedResource(); + + addressResolver.resolve.mockResolvedValue(venice); + patchState(store, { id: 1 }); + + expect(store.addressStatus()).toBe('loading'); + expect(store.addressValue()).toBeUndefined(); + expect(store.addressError()).toBeUndefined(); + expect(store.addressIsLoading()).toBe(true); + expect(store.addressHasValue()).toBe(false); + }); + + it('has resolved status when loaded', async () => { + const { store, addressResolver } = setupWithNamedResource(); + + addressResolver.resolve.mockResolvedValue(venice); + patchState(store, { id: 1 }); + + await wait(); + + expect(store.addressStatus()).toBe('resolved'); + expect(store.addressValue()).toEqual(venice); + expect(store.addressError()).toBeUndefined(); + expect(store.addressIsLoading()).toBe(false); + expect(store.addressHasValue()).toBe(true); + }); + + it('has error status when error', async () => { + const { store, addressResolver } = setupWithNamedResource(); + + addressResolver.resolve.mockRejectedValue(new Error('Error')); + patchState(store, { id: 1 }); + await wait(); + + expect(store.addressStatus()).toBe('error'); + expect(() => store.addressValue()).toThrow(); + expect(store.addressError()).toBeInstanceOf(Error); + expect(store.addressIsLoading()).toBe(false); + expect(store.addressHasValue()).toBe(false); + }); + + it('has local once updated', async () => { + const { store, addressResolver } = setupWithNamedResource(); + + addressResolver.resolve.mockResolvedValue(venice); + patchState(store, { id: 1 }); + + await wait(); + patchState(store, ({ addressValue }) => ({ + addressValue: addressValue + ? { ...addressValue, country: 'Italia' } + : undefined, + })); + + expect(store.addressStatus()).toBe('local'); + expect(store.addressValue()?.country).toBe('Italia'); + expect(store.addressError()).toBeUndefined(); + expect(store.addressIsLoading()).toBe(false); + expect(store.addressHasValue()).toBe(true); + }); + + it('can also reload by resource name', async () => { + const { store, addressResolver } = setupWithNamedResource(); + + addressResolver.resolve.mockResolvedValueOnce(venice); + patchState(store, { id: 1 }); + await wait(); + expect(store.addressStatus()).toBe('resolved'); + store.reload(); + expect(store.addressStatus()).toBe('reloading'); + }); + }); + }); + + describe('override protection', () => { + const warningSpy = vi.spyOn(console, 'warn'); + + afterEach(() => { + warningSpy.mockClear(); + }); + + for (const memberName of [ + 'value', + 'status', + 'error', + 'isLoading', + '_reload', + 'hasValue', + ]) { + it(`warns if ${memberName} is not a member of the store`, () => { + const Store = signalStore( + { providedIn: 'root' }, + withProps(() => ({ [memberName]: true })), + withResource(() => resource({ loader: () => Promise.resolve(1) })) + ); + + TestBed.inject(Store); + + expect(warningSpy).toHaveBeenCalledWith( + '@ngrx/signals: SignalStore members cannot be overridden.', + 'Trying to override:', + memberName + ); + }); + } + + it('also checks for named resources', () => { + const Store = signalStore( + { providedIn: 'root' }, + withState({ userValue: 1 }), + withResource(() => ({ + user: resource({ + loader: ({ params: id }) => Promise.resolve(1), + }), + })) + ); + + TestBed.inject(Store); + + expect(warningSpy).toHaveBeenCalledWith( + '@ngrx/signals: SignalStore members cannot be overridden.', + 'Trying to override:', + 'userValue' + ); + }); + }); + + it('works also with list/detail use case', async () => { + const Store = signalStore( + { providedIn: 'root', protectedState: false }, + withState({ id: undefined as number | undefined }), + withResource(({ id }) => ({ + list: httpResource<{ id: number; name: string }[]>(() => '/address', { + defaultValue: [], + }), + detail: httpResource
(() => + id() ? `/address/${id()}` : undefined + ), + })) + ); + + TestBed.configureTestingModule({ + providers: [provideHttpClient(), provideHttpClientTesting()], + }); + + const store = TestBed.inject(Store); + const ctrl = TestBed.inject(HttpTestingController); + + expect(store.listValue()).toEqual([]); + expect(store.detailValue()).toBeUndefined(); + await wait(); + ctrl.expectOne('/address').flush([{ id: 1, name: 'Italy' }]); + + await wait(); + expect(store.listValue()).toEqual([{ id: 1, name: 'Italy' }]); + expect(store.detailValue()).toBeUndefined(); + + patchState(store, { id: 1 }); + await wait(); + ctrl.expectOne('/address/1').flush(venice); + await wait(); + expect(store.listValue()).toEqual([{ id: 1, name: 'Italy' }]); + expect(store.detailValue()).toEqual(venice); + }); +}); diff --git a/modules/signals/spec/with-state.spec.ts b/modules/signals/spec/with-state.spec.ts index 53db1fb54b..b6f2a913d6 100644 --- a/modules/signals/spec/with-state.spec.ts +++ b/modules/signals/spec/with-state.spec.ts @@ -1,18 +1,25 @@ -import { isSignal, signal } from '@angular/core'; +import { + computed, + isSignal, + linkedSignal, + resource, + signal, +} from '@angular/core'; +import { TestBed } from '@angular/core/testing'; import { withComputed, withMethods, withState } from '../src'; -import { STATE_SOURCE } from '../src/state-source'; -import { getInitialInnerStore } from '../src/signal-store'; +import { getInitialInnerStore, signalStore } from '../src/signal-store'; +import { getState, patchState } 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 +53,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/index.ts b/modules/signals/src/index.ts index 1b758aeabc..25a5b8af8f 100644 --- a/modules/signals/src/index.ts +++ b/modules/signals/src/index.ts @@ -27,4 +27,5 @@ export { withFeature } from './with-feature'; export { withHooks } from './with-hooks'; export { withMethods } from './with-methods'; export { withProps } from './with-props'; +export { mapToResource, withResource } from './with-resource'; export { withState } from './with-state'; diff --git a/modules/signals/src/signal-state.ts b/modules/signals/src/signal-state.ts index d31521151e..210311a741 100644 --- a/modules/signals/src/signal-state.ts +++ b/modules/signals/src/signal-state.ts @@ -1,18 +1,52 @@ -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 { + isWritableSignal, + STATE_SOURCE, + StateResult, + WritableStateSource, +} from './state-source'; -export type SignalState = DeepSignal & - WritableStateSource; +export type SignalState = DeepSignal> & + WritableStateSource>; export function signalState( initialState: State ): SignalState { - const stateSource = signal(initialState as State); - const signalState = toDeepSignal(stateSource.asReadonly()); + const stateKeys = Reflect.ownKeys(initialState); + + const stateSource = stateKeys.reduce((signalsDict, key) => { + const signalOrValue = (initialState as Record)[ + key + ]; + return { + ...signalsDict, + [key]: isWritableSignal(signalOrValue) + ? signalOrValue + : signal(signalOrValue), + }; + }, {} as SignalsDictionary); + + const signalState = computed(() => + stateKeys.reduce( + (state, key) => ({ + ...state, + [key]: stateSource[key](), + }), + {} + ) + ); + Object.defineProperty(signalState, STATE_SOURCE, { value: stateSource, }); + 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..4ae7584472 100644 --- a/modules/signals/src/state-source.ts +++ b/modules/signals/src/state-source.ts @@ -10,16 +10,26 @@ import { } from '@angular/core'; import { Prettify } from './ts-helpers'; -const STATE_WATCHERS = new WeakMap, Array>>(); +declare const ngDevMode: unknown; + +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 StateResult = { + [K in keyof StateInput]: StateInput[K] extends WritableSignal + ? V + : StateInput[K]; }; export type PartialStateUpdater = ( @@ -30,40 +40,80 @@ export type StateWatcher = ( state: NoInfer ) => void; +export function isWritableSignal( + signal: unknown +): signal is WritableSignal { + return ( + isSignal(signal) && + 'set' in signal && + 'update' in signal && + typeof signal.set === 'function' && + typeof signal.update === 'function' + ); +} + 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) => { + return isWritableSignal(signals[key]); + }); } 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)) { + if (typeof ngDevMode !== 'undefined' && ngDevMode) { + console.warn( + '@ngrx/signals: Skipping update for unknown property in state source.', + `Property: ${String(key)}` + ); + } + continue; + } + const signalKey = key as keyof State; + + if (currentState[signalKey] === newState[signalKey]) { + continue; + } + + 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-resource.ts b/modules/signals/src/with-resource.ts new file mode 100644 index 0000000000..09525b60d4 --- /dev/null +++ b/modules/signals/src/with-resource.ts @@ -0,0 +1,331 @@ +import { Resource, ResourceRef, ResourceStatus, Signal } from '@angular/core'; +import { toDeepSignal } from './deep-signal'; +import { assertUniqueStoreMembers } from './signal-store-assertions'; +import { + InnerSignalStore, + SignalStoreFeature, + SignalStoreFeatureResult, + StateSignals, +} from './signal-store-models'; +import { isWritableSignal, STATE_SOURCE } from './state-source'; + +//** Types for `withResource` */ + +export type ResourceResult = { + state: { value: T }; + props: { + status: Signal; + error: Signal; + isLoading: Signal; + }; + methods: { + hasValue(): this is Resource>; + _reload(): boolean; + }; +}; + +type ResourceDictionary = Record>; + +type NamedResourceResult = { + state: { + [Prop in keyof T as `${Prop & + string}Value`]: T[Prop]['value'] extends Signal ? S : never; + }; + props: { + [Prop in keyof T as `${Prop & string}Status`]: Signal; + } & { + [Prop in keyof T as `${Prop & string}Error`]: Signal; + } & { + [Prop in keyof T as `${Prop & string}IsLoading`]: Signal; + }; + methods: { + [Prop in keyof T as `${Prop & string}HasValue`]: () => this is Resource< + Exclude + >; + } & { + [Prop in keyof T as `_${Prop & string}Reload`]: () => boolean; + }; +}; + +//** Implementation of `withResource` */ + +/** + * @experimental + * @description + * + * Integrates a `Resource` into the SignalStore and makes the store instance + * implement the `Resource` interface. + * + * The resource’s value is stored under the `value` key in the state + * and is exposed as a `DeepSignal`. + * + * It can also be updated via `patchState`. + * + * @usageNotes + * + * ```ts + * const UserStore = signalStore( + * withState({ userId: undefined as number | undefined }), + * withResource(({ userId }) => + * httpResource(() => + * userId === undefined ? undefined : `/users/${userId}` + * ) + * ) + * ); + * + * const userStore = new UserStore(); + * userStore.value(); // User | undefined + * ``` + * + * @param resourceFactory A factory function that receives the store's state signals, + * methods, and props. Needs to return a `ResourceRef`. + */ +export function withResource< + Input extends SignalStoreFeatureResult, + ResourceValue +>( + resourceFactory: ( + store: Input['props'] & Input['methods'] & StateSignals + ) => ResourceRef +): SignalStoreFeature>; + +/** + * @experimental + * @description + * + * Integrates multiple resources into the SignalStore. Each resource is + * registered by name, which is used as a prefix when spreading the members + * of `Resource` onto the store. + * + * Each resource’s value is part of the state, stored under the `value` key + * with the resource name as prefix. Values are exposed as `DeepSignal`s and + * can be updated via `patchState`. + * + * @usageNotes + * + * ```ts + * const UserStore = signalStore( + * withState({ userId: undefined as number | undefined }), + * withResource(({ userId }) => ({ + * list: httpResource(() => '/users', { defaultValue: [] }), + * detail: httpResource(() => + * userId === undefined ? undefined : `/users/${userId}` + * ), + * })) + * ); + * + * const userStore = new UserStore(); + * userStore.listValue(); // [] + * userStore.detailValue(); // User | undefined + * ``` + * + * @param resourceFactory A factory function that receives the store's props, + * methods, and state signals. It must return a `Record`. + */ +export function withResource< + Input extends SignalStoreFeatureResult, + Dictionary extends ResourceDictionary +>( + resourceFactory: ( + store: Input['props'] & Input['methods'] & StateSignals + ) => Dictionary +): SignalStoreFeature>; + +export function withResource< + Input extends SignalStoreFeatureResult, + ResourceValue +>( + resourceFactory: ( + store: Input['props'] & Input['methods'] & StateSignals + ) => ResourceRef | ResourceDictionary +): SignalStoreFeature { + return (store) => { + const resourceOrDictionary = resourceFactory({ + ...store.stateSignals, + ...store.props, + ...store.methods, + }); + + if (isResourceRef(resourceOrDictionary)) { + return createUnnamedResource(store, resourceOrDictionary); + } else { + return createNamedResource(store, resourceOrDictionary); + } + }; +} + +function createUnnamedResource( + store: InnerSignalStore, + resource: ResourceRef +) { + assertUniqueStoreMembers(store, [ + 'value', + 'status', + 'error', + 'isLoading', + '_reload', + 'hasValue', + ]); + + function hasValue(): this is Resource> { + return resource.hasValue(); + } + + return { + ...store, + [STATE_SOURCE]: { ...store[STATE_SOURCE], value: resource.value }, + stateSignals: { + ...store.stateSignals, + value: toDeepSignal(resource.value), + }, + props: { + ...store.props, + status: resource.status, + error: resource.error, + isLoading: resource.isLoading, + }, + methods: { + ...store.methods, + hasValue, + _reload: () => resource.reload(), + }, + }; +} + +function createNamedResource( + inputStore: InnerSignalStore, + dictionary: Dictionary +) { + return Object.keys(dictionary).reduce((store, resourceName) => { + const resource = dictionary[resourceName]; + assertUniqueStoreMembers( + store, + ['Value', 'Status', 'Error', 'IsLoading', 'HasValue', `_`].map( + (suffix) => `${resourceName}${suffix}` + ) + ); + + function hasValue(): boolean { + return resource.hasValue(); + } + + return { + ...store, + [STATE_SOURCE]: { + ...store[STATE_SOURCE], + [`${resourceName}Value`]: resource.value, + }, + stateSignals: { + ...store.stateSignals, + [`${resourceName}Value`]: toDeepSignal(resource.value), + }, + props: { + ...store.props, + [`${resourceName}Status`]: resource.status, + [`${resourceName}Error`]: resource.error, + [`${resourceName}IsLoading`]: resource.isLoading, + }, + methods: { + ...store.methods, + [`${resourceName}HasValue`]: hasValue, + [`_${resourceName}Reload`]: () => resource.reload(), + }, + }; + }, inputStore); +} + +function isResourceRef(value: unknown): value is ResourceRef { + return ( + typeof value === 'object' && + value !== null && + 'value' in value && + isWritableSignal(value.value) && + 'status' in value && + 'error' in value && + 'isLoading' in value && + 'hasValue' in value && + 'reload' in value + ); +} + +//** Types for `mapToResource` */ + +type NamedResource = { + [Prop in `${Name}Value`]: Signal; +} & { + [Prop in `${Name}Status`]: Signal; +} & { + [Prop in `${Name}Error`]: Signal; +} & { + [Prop in `${Name}IsLoading`]: Signal; +} & { + [Prop in `${Name}HasValue`]: () => boolean; +}; + +type IsValidResourceName< + Name extends string, + Store extends Record +> = Store[`${Name}Value`] extends Signal + ? Store extends NamedResource + ? true + : false + : false; + +type ResourceNames> = keyof { + [Prop in keyof Store as Prop extends `${infer Name}Value` + ? IsValidResourceName extends true + ? Name + : never + : never]: Store[Prop] extends Signal ? S : never; +}; + +type MappedResource< + Store extends Record, + Name extends string +> = Resource ? S : never>; + +//** Implementation of `mapToResource` */ + +/** + * @experimental + * @description + * + * Maps a named resource to type `Resource`. + * + * @usageNotes + * + * ```ts + * const store = signalStore( + * withState({ userId: undefined as number | undefined }), + * withResource(({ userId }) => ({ + * user: httpResource(() => '/users', { defaultValue: [] }), + * })) + * ); + * const userResource = mapToResource(store, 'user'); + * userResource satisfies Resource; + * ``` + * + * @param store The store instance to map the resource to. + * @param name The name of the resource to map. + * @returns `ResourceRef` + */ +export function mapToResource< + Name extends ResourceNames, + Store extends Record +>(store: Store, name: Name): MappedResource { + const resourceName = String(name); + + function hasValue(): this is Resource< + Exclude, undefined> + > { + return (store[`${resourceName}HasValue`] as () => boolean)(); + } + + return { + value: store[`${resourceName}Value`], + status: store[`${resourceName}Status`], + error: store[`${resourceName}Error`], + isLoading: store[`${resourceName}IsLoading`], + hasValue, + } as MappedResource; +} diff --git a/modules/signals/src/with-state.ts b/modules/signals/src/with-state.ts index e2ca7aede3..69a0250c86 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,24 +8,25 @@ import { SignalStoreFeature, SignalStoreFeatureResult, } from './signal-store-models'; +import { isWritableSignal, STATE_SOURCE, StateResult } from './state-source'; export function withState( stateFactory: () => State ): SignalStoreFeature< EmptyFeatureResult, - { state: State; props: {}; methods: {} } + { state: StateResult; props: {}; methods: {} } >; export function withState( state: State ): SignalStoreFeature< EmptyFeatureResult, - { state: State; props: {}; methods: {} } + { state: StateResult; props: {}; methods: {} } >; export function withState( stateOrFactory: State | (() => State) ): SignalStoreFeature< SignalStoreFeatureResult, - { state: State; props: {}; methods: {} } + { state: StateResult; props: {}; methods: {} } > { return (store) => { const state = @@ -35,21 +35,22 @@ 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 stateSource = store[STATE_SOURCE] as Record< + string | symbol, + Signal + >; + const stateSignals = {} as SignalsDictionary; + for (const key of stateKeys) { + const signalOrValue = (state as Record)[key]; + stateSource[key] = isWritableSignal(signalOrValue) + ? signalOrValue + : signal(signalOrValue); + stateSignals[key] = toDeepSignal(stateSource[key]); + } return { ...store, stateSignals: { ...store.stateSignals, ...stateSignals }, - } as InnerSignalStore; + } as InnerSignalStore>; }; } 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( diff --git a/projects/ngrx.io/content/guide/signals/signal-state.md b/projects/ngrx.io/content/guide/signals/signal-state.md index b3045e3d3a..a0eb887864 100644 --- a/projects/ngrx.io/content/guide/signals/signal-state.md +++ b/projects/ngrx.io/content/guide/signals/signal-state.md @@ -54,6 +54,20 @@ console.log(firstName()); // logs: 'Eric' console.log(lastName()); // logs: 'Clapton' ``` +When a state property holds an object as its value, the `signalState` function generates a `DeepSignal`. +It can be used as a regular read-only signal, but it also contains signals for each property of the object it refers to. + +```ts +const firstName = user.firstName; // type: Signal +const lastName = user.lastName; // type: Signal + +console.log(firstName()); // logs: 'Eric' +console.log(lastName()); // logs: 'Clapton' +``` + +If the root properties of a state are already a `WritableSignal`, then they are reused instead of creating new signals. +This allows the integration of external `WritableSignal`s, such as `linkedSignal` or `resource.value`. +
For enhanced performance, deeply nested signals are generated lazily and initialized only upon first access. diff --git a/projects/www/src/app/pages/guide/signals/signal-state.md b/projects/www/src/app/pages/guide/signals/signal-state.md index bcc597d162..a754011ebe 100644 --- a/projects/www/src/app/pages/guide/signals/signal-state.md +++ b/projects/www/src/app/pages/guide/signals/signal-state.md @@ -54,6 +54,35 @@ console.log(firstName()); // logs: 'Eric' console.log(lastName()); // logs: 'Clapton' ``` +If the root properties of a state are already of type `WritableSignal`, they will be reused, instead of creating new signals. +This allows an integration of external `WritableSignal`s — such as `linkedSignal` or `resource.value`. + +```ts +import { linkedSignal } from '@angular/core'; +import { signalState } from '@ngrx/signals'; +import { User } from './user.model'; + +const referenceId = signal(1); + +const userState = signalState({ + user: linkedSignal(() => ({ + id: referenceId(), + firstName: '', + lastName: '', + })), + isAdmin: false, +}); + +console.log(userState.user()); // logs: { id: 1, firstName: '', lastName: '' } +patchState(userState, { + user: { id: 2, firstName: 'Brian', lastName: 'May' }, +}); +console.log(userState.user()); // logs: { id: 2, firstName: 'Brian', lastName: 'May' } + +referenceId.set(3); +console.log(userState.user()); // logs: { id: 3, firstName: '', lastName: '' } +``` + For enhanced performance, deeply nested signals are generated lazily and initialized only upon first access.