Skip to content

Commit c8b15dd

Browse files
rainerhahnekampmarkostanimirovictimdeschryvermichael-small
authored
feat(signals): enhance withComputed to accept computation functions (#4822)
Closes #4782 Co-authored-by: Marko Stanimirović <markostanimirovic95@gmail.com> Co-authored-by: Tim Deschryver <28659384+timdeschryver@users.noreply.github.com> Co-authored-by: michael-small <33669563+michael-small@users.noreply.github.com>
1 parent 9a16813 commit c8b15dd

File tree

4 files changed

+222
-12
lines changed

4 files changed

+222
-12
lines changed
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { expecter } from 'ts-snippet';
2+
import { compilerOptions } from './helpers';
3+
import { signalStore, withComputed } from 'modules/signals/src';
4+
import { TestBed } from '@angular/core/testing';
5+
6+
describe('withComputed', () => {
7+
const expectSnippet = expecter(
8+
(code) => `
9+
import {
10+
deepComputed,
11+
signalStore,
12+
withComputed,
13+
} from '@ngrx/signals';
14+
import { TestBed } from '@angular/core/testing';
15+
import { signal } from '@angular/core';
16+
17+
${code}
18+
`,
19+
compilerOptions()
20+
);
21+
22+
it('creates a Signal automatically', () => {
23+
const snippet = `
24+
const Store = signalStore(
25+
withComputed(() => ({
26+
user: () => ({ firstName: 'John', lastName: 'Doe' })
27+
}))
28+
);
29+
30+
const store = TestBed.inject(Store);
31+
const user = store.user;
32+
`;
33+
34+
expectSnippet(snippet).toSucceed();
35+
expectSnippet(snippet).toInfer(
36+
'user',
37+
'Signal<{ firstName: string; lastName: string; }>'
38+
);
39+
});
40+
41+
it('keeps a WritableSignal intact, if passed', () => {
42+
const snippet = `
43+
const user = signal({ firstName: 'John', lastName: 'Doe' });
44+
45+
const Store = signalStore(
46+
withComputed(() => ({
47+
user,
48+
}))
49+
);
50+
51+
const store = TestBed.inject(Store);
52+
const userSignal = store.user;
53+
`;
54+
55+
expectSnippet(snippet).toSucceed();
56+
expectSnippet(snippet).toInfer(
57+
'userSignal',
58+
'WritableSignal<{ firstName: string; lastName: string; }>'
59+
);
60+
});
61+
62+
it('keeps a DeepSignal intact, if passed', () => {
63+
const snippet = `
64+
const user = deepComputed(
65+
signal({
66+
name: 'John Doe',
67+
address: {
68+
street: '123 Main St',
69+
city: 'Anytown',
70+
},
71+
})
72+
);
73+
74+
const Store = signalStore(
75+
withComputed(() => ({
76+
user,
77+
}))
78+
);
79+
80+
const store = TestBed.inject(Store);
81+
const userSignal = store.user;
82+
`;
83+
84+
expectSnippet(snippet).toSucceed();
85+
expectSnippet(snippet).toInfer(
86+
'userSignal',
87+
'DeepSignal<{ name: string; address: { street: string; city: string; }; }>'
88+
);
89+
});
90+
});

modules/signals/spec/with-computed.spec.ts

Lines changed: 93 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
1-
import { signal } from '@angular/core';
2-
import { withComputed, withMethods, withState } from '../src';
3-
import { getInitialInnerStore } from '../src/signal-store';
1+
import { computed, signal } from '@angular/core';
2+
import {
3+
deepComputed,
4+
signalStoreFeature,
5+
withComputed,
6+
withMethods,
7+
withState,
8+
} from '../src';
9+
import { getInitialInnerStore, signalStore } from '../src/signal-store';
10+
import { TestBed } from '@angular/core/testing';
411

512
describe('withComputed', () => {
613
it('adds computed signals to the store immutably', () => {
@@ -54,4 +61,87 @@ describe('withComputed', () => {
5461
'p1, s2, m1, Symbol(computed_secret)'
5562
);
5663
});
64+
65+
it('adds computed automatically if the value is a function', () => {
66+
const initialStore = getInitialInnerStore();
67+
68+
const store = signalStoreFeature(
69+
withState({ a: 2, b: 3 }),
70+
withComputed(({ a, b }) => ({
71+
sum: () => a() + b(),
72+
product: () => a() * b(),
73+
}))
74+
)(initialStore);
75+
76+
expect(store.props.sum()).toBe(5);
77+
expect(store.props.product()).toBe(6);
78+
});
79+
80+
it('allows to mix user-provided computeds and automatically computed ones', () => {
81+
const initialStore = getInitialInnerStore();
82+
83+
const store = signalStoreFeature(
84+
withState({ a: 2, b: 3 }),
85+
withComputed(({ a, b }) => ({
86+
sum: () => a() + b(),
87+
product: computed(() => a() * b()),
88+
}))
89+
)(initialStore);
90+
91+
expect(store.props.sum()).toBe(5);
92+
expect(store.props.product()).toBe(6);
93+
});
94+
95+
it('does not change a WritableSignal', () => {
96+
const user = signal({ firstName: 'John', lastName: 'Doe' });
97+
98+
const Store = signalStore(
99+
{ providedIn: 'root' },
100+
withComputed(() => ({
101+
user,
102+
}))
103+
);
104+
105+
const store = TestBed.inject(Store);
106+
107+
expect(store.user).toBe(user);
108+
});
109+
110+
it('does not change a DeepSignal', () => {
111+
const user = deepComputed(
112+
signal({
113+
name: 'John Doe',
114+
address: {
115+
street: '123 Main St',
116+
city: 'Anytown',
117+
},
118+
})
119+
);
120+
121+
const Store = signalStore(
122+
{ providedIn: 'root' },
123+
withComputed(() => ({
124+
user,
125+
}))
126+
);
127+
128+
const store = TestBed.inject(Store);
129+
130+
expect(store.user).toBe(user);
131+
});
132+
133+
it('does not change a Signal', () => {
134+
const user = computed(() => ({ firstName: 'John', lastName: 'Doe' }));
135+
136+
const Store = signalStore(
137+
{ providedIn: 'root' },
138+
withComputed(() => ({
139+
user,
140+
}))
141+
);
142+
143+
const store = TestBed.inject(Store);
144+
145+
expect(store.user).toBe(user);
146+
});
57147
});

modules/signals/src/with-computed.ts

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,51 @@
1+
import { computed, isSignal, Signal } from '@angular/core';
12
import {
2-
SignalsDictionary,
33
SignalStoreFeature,
44
SignalStoreFeatureResult,
55
StateSignals,
66
} from './signal-store-models';
77
import { Prettify } from './ts-helpers';
88
import { withProps } from './with-props';
99

10+
type ComputedResult<
11+
ComputedDictionary extends Record<
12+
string | symbol,
13+
Signal<unknown> | (() => unknown)
14+
>
15+
> = {
16+
[P in keyof ComputedDictionary]: ComputedDictionary[P] extends Signal<unknown>
17+
? ComputedDictionary[P]
18+
: ComputedDictionary[P] extends () => infer V
19+
? Signal<V>
20+
: never;
21+
};
22+
1023
export function withComputed<
1124
Input extends SignalStoreFeatureResult,
12-
ComputedSignals extends SignalsDictionary
25+
ComputedDictionary extends Record<
26+
string | symbol,
27+
Signal<unknown> | (() => unknown)
28+
>
1329
>(
14-
signalsFactory: (
30+
computedFactory: (
1531
store: Prettify<StateSignals<Input['state']> & Input['props']>
16-
) => ComputedSignals
32+
) => ComputedDictionary
1733
): SignalStoreFeature<
1834
Input,
19-
{ state: {}; props: ComputedSignals; methods: {} }
35+
{ state: {}; props: ComputedResult<ComputedDictionary>; methods: {} }
2036
> {
21-
return withProps(signalsFactory);
37+
return withProps((store) => {
38+
const computedResult = computedFactory(store);
39+
const computedResultKeys = Reflect.ownKeys(computedResult);
40+
41+
return computedResultKeys.reduce((prev, key) => {
42+
const signalOrComputation = computedResult[key];
43+
return {
44+
...prev,
45+
[key]: isSignal(signalOrComputation)
46+
? signalOrComputation
47+
: computed(signalOrComputation),
48+
};
49+
}, {} as ComputedResult<ComputedDictionary>);
50+
});
2251
}

projects/ngrx.io/content/guide/signals/signal-store/index.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,7 @@ export class BookSearch {
145145

146146
Computed signals can be added to the store using the `withComputed` feature.
147147
This feature accepts a factory function as an input argument, which is executed within the injection context.
148-
The factory should return a dictionary of computed signals, utilizing previously defined state signals and properties that are accessible through its input argument.
148+
The factory should return a dictionary containing either computed signals or functions that return values (which are automatically wrapped in computed signals), utilizing previously defined state signals and properties that are accessible through its input argument.
149149

150150
<code-example header="book-search-store.ts">
151151

@@ -162,13 +162,14 @@ export const BookSearchStore = signalStore(
162162
// 👇 Accessing previously defined state signals and properties.
163163
withComputed(({ books, filter }) => ({
164164
booksCount: computed(() => books().length),
165-
sortedBooks: computed(() => {
165+
// 👇 Adds computed automatically
166+
sortedBooks: () => {
166167
const direction = filter.order() === 'asc' ? 1 : -1;
167168

168169
return books().toSorted((a, b) =>
169170
direction * a.title.localeCompare(b.title)
170171
);
171-
}),
172+
},
172173
}))
173174
);
174175

0 commit comments

Comments
 (0)