Skip to content

Commit 0010281

Browse files
feat(signals): add signalStore and signalStoreFeature (#4049)
Closes #4000
1 parent 58e2c46 commit 0010281

20 files changed

+1605
-62
lines changed

modules/signals/spec/helpers.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Component } from '@angular/core';
1+
import { Component, inject, Type } from '@angular/core';
22
import { TestBed } from '@angular/core/testing';
33

44
export function testEffects(testFn: (tick: () => void) => void): () => void {
@@ -13,3 +13,28 @@ export function testEffects(testFn: (tick: () => void) => void): () => void {
1313
TestBed.runInInjectionContext(() => testFn(() => fixture.detectChanges()));
1414
};
1515
}
16+
17+
export function createLocalStore<Store extends Type<unknown>>(
18+
storeToken: Store
19+
): {
20+
store: InstanceType<Store>;
21+
destroy: () => void;
22+
} {
23+
@Component({
24+
standalone: true,
25+
template: '',
26+
providers: [storeToken],
27+
})
28+
class TestComponent {
29+
store = inject(storeToken);
30+
}
31+
32+
const fixture = TestBed.configureTestingModule({
33+
imports: [TestComponent],
34+
}).createComponent(TestComponent);
35+
36+
return {
37+
store: fixture.componentInstance.store,
38+
destroy: () => fixture.destroy(),
39+
};
40+
}
Lines changed: 69 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { patchState, signalState } from '../src';
1+
import { patchState, signalState, signalStore, withState } from '../src';
2+
import { STATE_SIGNAL } from '../src/signal-state';
23

34
describe('patchState', () => {
45
const initialState = {
@@ -11,67 +12,83 @@ describe('patchState', () => {
1112
ngrx: 'signals',
1213
};
1314

14-
it('patches state via partial state object', () => {
15-
const state = signalState(initialState);
15+
[
16+
{
17+
name: 'with signalState',
18+
stateFactory: () => signalState(initialState),
19+
},
20+
{
21+
name: 'with signalStore',
22+
stateFactory: () => {
23+
const SignalStore = signalStore(withState(initialState));
24+
return new SignalStore();
25+
},
26+
},
27+
].forEach(({ name, stateFactory }) => {
28+
describe(name, () => {
29+
it('patches state via partial state object', () => {
30+
const state = stateFactory();
1631

17-
patchState(state, {
18-
user: { firstName: 'Johannes', lastName: 'Schmidt' },
19-
foo: 'baz',
20-
});
32+
patchState(state, {
33+
user: { firstName: 'Johannes', lastName: 'Schmidt' },
34+
foo: 'baz',
35+
});
2136

22-
expect(state()).toEqual({
23-
...initialState,
24-
user: { firstName: 'Johannes', lastName: 'Schmidt' },
25-
foo: 'baz',
26-
});
27-
});
37+
expect(state[STATE_SIGNAL]()).toEqual({
38+
...initialState,
39+
user: { firstName: 'Johannes', lastName: 'Schmidt' },
40+
foo: 'baz',
41+
});
42+
});
2843

29-
it('patches state via updater function', () => {
30-
const state = signalState(initialState);
44+
it('patches state via updater function', () => {
45+
const state = stateFactory();
3146

32-
patchState(state, (state) => ({
33-
numbers: [...state.numbers, 4],
34-
ngrx: 'rocks',
35-
}));
47+
patchState(state, (state) => ({
48+
numbers: [...state.numbers, 4],
49+
ngrx: 'rocks',
50+
}));
3651

37-
expect(state()).toEqual({
38-
...initialState,
39-
numbers: [1, 2, 3, 4],
40-
ngrx: 'rocks',
41-
});
42-
});
52+
expect(state[STATE_SIGNAL]()).toEqual({
53+
...initialState,
54+
numbers: [1, 2, 3, 4],
55+
ngrx: 'rocks',
56+
});
57+
});
4358

44-
it('patches state via sequence of partial state objects and updater functions', () => {
45-
const state = signalState(initialState);
59+
it('patches state via sequence of partial state objects and updater functions', () => {
60+
const state = stateFactory();
4661

47-
patchState(
48-
state,
49-
{ user: { firstName: 'Johannes', lastName: 'Schmidt' } },
50-
(state) => ({ numbers: [...state.numbers, 4], foo: 'baz' }),
51-
(state) => ({ user: { ...state.user, firstName: 'Jovan' } }),
52-
{ foo: 'foo' }
53-
);
62+
patchState(
63+
state,
64+
{ user: { firstName: 'Johannes', lastName: 'Schmidt' } },
65+
(state) => ({ numbers: [...state.numbers, 4], foo: 'baz' }),
66+
(state) => ({ user: { ...state.user, firstName: 'Jovan' } }),
67+
{ foo: 'foo' }
68+
);
5469

55-
expect(state()).toEqual({
56-
...initialState,
57-
user: { firstName: 'Jovan', lastName: 'Schmidt' },
58-
foo: 'foo',
59-
numbers: [1, 2, 3, 4],
60-
});
61-
});
70+
expect(state[STATE_SIGNAL]()).toEqual({
71+
...initialState,
72+
user: { firstName: 'Jovan', lastName: 'Schmidt' },
73+
foo: 'foo',
74+
numbers: [1, 2, 3, 4],
75+
});
76+
});
6277

63-
it('patches state immutably', () => {
64-
const state = signalState(initialState);
78+
it('patches state immutably', () => {
79+
const state = stateFactory();
6580

66-
patchState(state, {
67-
foo: 'bar',
68-
numbers: [3, 2, 1],
69-
ngrx: 'rocks',
70-
});
81+
patchState(state, {
82+
foo: 'bar',
83+
numbers: [3, 2, 1],
84+
ngrx: 'rocks',
85+
});
7186

72-
expect(state.user()).toBe(initialState.user);
73-
expect(state.foo()).toBe(initialState.foo);
74-
expect(state.numbers()).not.toBe(initialState.numbers);
75-
expect(state.ngrx()).not.toBe(initialState.ngrx);
87+
expect(state.user()).toBe(initialState.user);
88+
expect(state.foo()).toBe(initialState.foo);
89+
expect(state.numbers()).not.toBe(initialState.numbers);
90+
expect(state.ngrx()).not.toBe(initialState.ngrx);
91+
});
92+
});
7693
});
7794
});

modules/signals/spec/signal-state.spec.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { effect, isSignal } from '@angular/core';
22
import { patchState, signalState } from '../src';
3+
import { STATE_SIGNAL } from '../src/signal-state';
34
import { testEffects } from './helpers';
45

56
describe('signalState', () => {
@@ -13,6 +14,14 @@ describe('signalState', () => {
1314
ngrx: 'signals',
1415
};
1516

17+
it('has state signal', () => {
18+
const state = signalState({});
19+
const stateSignal = state[STATE_SIGNAL];
20+
21+
expect(isSignal(stateSignal)).toBe(true);
22+
expect(typeof stateSignal.update === 'function').toBe(true);
23+
});
24+
1625
it('creates signals for nested state slices', () => {
1726
const state = signalState(initialState);
1827

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import { Signal, signal } from '@angular/core';
2+
import {
3+
selectSignal,
4+
signalStore,
5+
signalStoreFeature,
6+
type,
7+
withMethods,
8+
withSignals,
9+
withState,
10+
} from '../src';
11+
import { STATE_SIGNAL } from '../src/signal-state';
12+
13+
describe('signalStoreFeature', () => {
14+
function withCustomFeature1() {
15+
return signalStoreFeature(
16+
withState({ foo: 'foo' }),
17+
withSignals(({ foo }) => ({ bar: selectSignal(() => foo() + 1) })),
18+
withMethods(({ foo, bar }) => ({
19+
baz: () => foo() + bar() + 2,
20+
}))
21+
);
22+
}
23+
24+
function withCustomFeature2() {
25+
return signalStoreFeature(
26+
withCustomFeature1(),
27+
withMethods(({ foo, baz }) => ({
28+
bar: (value: number) => value,
29+
m: () => foo() + baz() + 3,
30+
}))
31+
);
32+
}
33+
34+
function withCustomFeatureWithInput<_>() {
35+
return signalStoreFeature(
36+
{
37+
state: type<{ foo: string }>(),
38+
signals: type<{ s: Signal<number> }>(),
39+
},
40+
withState({ foo1: 1 }),
41+
withState({ foo2: 2 })
42+
);
43+
}
44+
45+
it('creates a custom feature by combining base features', () => {
46+
const Store = signalStore(
47+
withCustomFeature1(),
48+
withSignals(({ bar }) => ({
49+
s: selectSignal(() => bar() + 's'),
50+
}))
51+
);
52+
53+
const store = new Store();
54+
55+
expect(store[STATE_SIGNAL]()).toEqual({ foo: 'foo' });
56+
expect(store.foo()).toBe('foo');
57+
expect(store.bar()).toBe('foo1');
58+
expect(store.baz()).toBe('foofoo12');
59+
expect(store.s()).toBe('foo1s');
60+
});
61+
62+
it('creates a custom feature by combining base and custom features', () => {
63+
const Store = signalStore(
64+
withCustomFeature2(),
65+
withMethods(({ foo }) => ({ m1: () => foo() + 10 }))
66+
);
67+
68+
const store = new Store();
69+
70+
expect(store[STATE_SIGNAL]()).toEqual({ foo: 'foo' });
71+
expect(store.foo()).toBe('foo');
72+
expect(store.bar(10)).toBe(10);
73+
expect(store.m()).toBe('foofoofoo123');
74+
expect(store.m1()).toBe('foo10');
75+
});
76+
77+
it('creates a custom feature with input', () => {
78+
const Store = signalStore(
79+
withCustomFeature1(),
80+
withSignals(() => ({ s: signal(1).asReadonly() })),
81+
withCustomFeatureWithInput()
82+
);
83+
84+
const store = new Store();
85+
86+
expect(store[STATE_SIGNAL]()).toEqual({ foo: 'foo', foo1: 1, foo2: 2 });
87+
expect(store.foo()).toBe('foo');
88+
expect(store.bar()).toBe('foo1');
89+
expect(store.baz()).toBe('foofoo12');
90+
expect(store.s()).toBe(1);
91+
expect(store.foo1()).toBe(1);
92+
expect(store.foo2()).toBe(2);
93+
});
94+
});

0 commit comments

Comments
 (0)