Skip to content

Commit 745d91f

Browse files
feat(signals): add signalState and selectSignal APIs (#4007)
Closes #3993
1 parent b0d63fd commit 745d91f

File tree

10 files changed

+564
-7
lines changed

10 files changed

+564
-7
lines changed

modules/signals/spec/helpers.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { Component } from '@angular/core';
2+
import { TestBed } from '@angular/core/testing';
3+
4+
export function testEffects(testFn: (tick: () => void) => void): () => void {
5+
@Component({ template: '', standalone: true })
6+
class TestComponent {}
7+
8+
return () => {
9+
const fixture = TestBed.configureTestingModule({
10+
imports: [TestComponent],
11+
}).createComponent(TestComponent);
12+
13+
TestBed.runInInjectionContext(() => testFn(() => fixture.detectChanges()));
14+
};
15+
}

modules/signals/spec/index.spec.ts

Lines changed: 0 additions & 5 deletions
This file was deleted.
Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
import { effect, isSignal, signal } from '@angular/core';
2+
import { selectSignal } from '../src';
3+
import { testEffects } from './helpers';
4+
5+
describe('selectSignal', () => {
6+
it('creates a signal from provided projector function', () => {
7+
const s1 = signal(1);
8+
const s2 = selectSignal(() => s1() + 1);
9+
10+
expect(isSignal(s2)).toBe(true);
11+
expect(s2()).toBe(2);
12+
13+
s1.set(2);
14+
15+
expect(s2()).toBe(3);
16+
});
17+
18+
it('creates a signal from provided signals dictionary', () => {
19+
const s1 = signal(1);
20+
const s2 = signal(2);
21+
const s3 = selectSignal({ s1, s2 });
22+
23+
expect(isSignal(s3)).toBe(true);
24+
expect(s3()).toEqual({ s1: 1, s2: 2 });
25+
26+
s1.set(10);
27+
28+
expect(s3()).toEqual({ s1: 10, s2: 2 });
29+
30+
s1.set(100);
31+
s2.set(20);
32+
33+
expect(s3()).toEqual({ s1: 100, s2: 20 });
34+
});
35+
36+
it('creates a signal by combining provided signals', () => {
37+
const s1 = signal(1);
38+
const s2 = signal(2);
39+
const s3 = selectSignal(s1, s2, (v1, v2) => v1 + v2);
40+
41+
expect(isSignal(s3)).toBe(true);
42+
expect(s3()).toBe(3);
43+
44+
s1.set(10);
45+
46+
expect(s3()).toBe(12);
47+
48+
s1.set(100);
49+
s2.set(20);
50+
51+
expect(s3()).toBe(120);
52+
});
53+
54+
it(
55+
'uses default equality function when custom one is not provided',
56+
testEffects((tick) => {
57+
const initialState = { x: { y: { z: 1 }, k: 2 }, l: 3 };
58+
const state = signal(initialState);
59+
60+
const x = selectSignal(() => state().x);
61+
const y = selectSignal(x, (x) => x.y);
62+
const z = selectSignal(y, (y) => y.z);
63+
const k = selectSignal(x, (x) => x.k);
64+
const l = selectSignal(() => state().l);
65+
const zPlusK = selectSignal(z, k, (z, k) => z + k);
66+
const zWithL = selectSignal({ z, l });
67+
68+
let xEmitted = 0;
69+
let yEmitted = 0;
70+
let zEmitted = 0;
71+
let zPlusKEmitted = 0;
72+
let zWithLEmitted = 0;
73+
74+
effect(() => {
75+
x();
76+
xEmitted++;
77+
});
78+
79+
effect(() => {
80+
y();
81+
yEmitted++;
82+
});
83+
84+
effect(() => {
85+
z();
86+
zEmitted++;
87+
});
88+
89+
effect(() => {
90+
zPlusK();
91+
zPlusKEmitted++;
92+
});
93+
94+
effect(() => {
95+
zWithL();
96+
zWithLEmitted++;
97+
});
98+
99+
expect(xEmitted).toBe(0);
100+
expect(yEmitted).toBe(0);
101+
expect(zEmitted).toBe(0);
102+
expect(zPlusKEmitted).toBe(0);
103+
expect(zWithLEmitted).toBe(0);
104+
105+
tick();
106+
107+
expect(xEmitted).toBe(1);
108+
expect(yEmitted).toBe(1);
109+
expect(zEmitted).toBe(1);
110+
expect(zPlusKEmitted).toBe(1);
111+
expect(zWithLEmitted).toBe(1);
112+
113+
state.update((state) => ({ ...state, l: 10 }));
114+
tick();
115+
116+
expect(xEmitted).toBe(1);
117+
expect(yEmitted).toBe(1);
118+
expect(zEmitted).toBe(1);
119+
expect(zPlusKEmitted).toBe(1);
120+
expect(zWithLEmitted).toBe(2);
121+
122+
state.update((state) => ({ ...state, x: { ...state.x, k: 20 } }));
123+
tick();
124+
125+
expect(xEmitted).toBe(2);
126+
expect(yEmitted).toBe(1);
127+
expect(zEmitted).toBe(1);
128+
expect(zPlusKEmitted).toBe(2);
129+
expect(zWithLEmitted).toBe(2);
130+
131+
state.update((state) => ({ ...state, x: { ...state.x, y: { z: 1 } } }));
132+
tick();
133+
134+
expect(xEmitted).toBe(3);
135+
expect(yEmitted).toBe(2);
136+
expect(zEmitted).toBe(1);
137+
expect(zPlusKEmitted).toBe(2);
138+
expect(zWithLEmitted).toBe(2);
139+
140+
state.update((state) => ({ ...state, x: { ...state.x, y: { z: 10 } } }));
141+
tick();
142+
143+
expect(xEmitted).toBe(4);
144+
expect(yEmitted).toBe(3);
145+
expect(zEmitted).toBe(2);
146+
expect(zPlusKEmitted).toBe(3);
147+
expect(zWithLEmitted).toBe(3);
148+
})
149+
);
150+
151+
it(
152+
'uses custom equality function when provided',
153+
testEffects((tick) => {
154+
const state = signal([1, 2, 3]);
155+
const numbers = selectSignal(() => state(), {
156+
equal: (a, b) => a.length === b.length,
157+
});
158+
const first = selectSignal(state, (numbers) => numbers[0], {
159+
equal: (a: number, b: number) => Math.round(a) === Math.round(b),
160+
});
161+
162+
let numbersEmitted = 0;
163+
let firstEmitted = 0;
164+
165+
effect(() => {
166+
numbers();
167+
numbersEmitted++;
168+
});
169+
170+
effect(() => {
171+
first();
172+
firstEmitted++;
173+
});
174+
175+
expect(numbersEmitted).toBe(0);
176+
expect(firstEmitted).toBe(0);
177+
178+
tick();
179+
180+
expect(numbersEmitted).toBe(1);
181+
expect(firstEmitted).toBe(1);
182+
183+
state.set([10, 20, 30]);
184+
tick();
185+
186+
expect(numbersEmitted).toBe(1);
187+
expect(firstEmitted).toBe(2);
188+
189+
state.set([10.1, 20.1, 30.1]);
190+
tick();
191+
192+
expect(numbersEmitted).toBe(1);
193+
expect(firstEmitted).toBe(2);
194+
195+
state.set([10.9, 20.9]);
196+
tick();
197+
198+
expect(numbersEmitted).toBe(2);
199+
expect(firstEmitted).toBe(3);
200+
201+
state.set([10.7, 20.7, 30.7]);
202+
tick();
203+
204+
expect(numbersEmitted).toBe(3);
205+
expect(firstEmitted).toBe(3);
206+
})
207+
);
208+
});

0 commit comments

Comments
 (0)