Skip to content

Commit 4ba9954

Browse files
feat(signals): add withResource for integrating resources
Adds `withResource`, a SignalStore feature for integrating any `ResourceRef` into the store instance. This includes `ResourceRef`s returned from framework-provided helpers like `resource`, `rxResource`, or `httpResource`, as well as custom user-defined implementations. Supports two integration modes: --- **Unnamed Resource (Inheritance)** Spreads all members of the `Resource` onto the SignalStore, making the store implement the `Resource` API directly. The `reload` method is added as a private `_reload()` method. ```ts const UserStore = signalStore( withState({ userId: undefined as number | undefined }), withResource(({ userId }) => httpResource<User>(() => userId === undefined ? undefined : `/users/${userId}` ) ) ); const userStore = new UserStore(); userStore.value(); // User | undefined ``` --- **Named Resources (Composition)** Supports multiple resources by passing a `Record<string, ResourceRef>`. Each resource is prefixed, and its members are merged into the SignalStore. The `reload` method becomes `_{resourceName}Reload()`. ```ts const UserStore = signalStore( withState({ userId: undefined as number | undefined }), withResource(({ userId }) => ({ list: httpResource<User[]>(() => '/users', { defaultValue: [] }), detail: httpResource<User>(() => userId === undefined ? undefined : `/users/${userId}` ), })) ); const userStore = new UserStore(); userStore.listValue(); // [] userStore.detailValue(); // User | undefined ``` --- **Named Resources and `Resource`** The `mapToResource` helper maps a named resource to the standard `Resource` interface. This is useful when working with APIs or utilities that require a `Resource<T>` type. ```ts function processUserResource(userResource: Resource<User | undefined>) { // ... } const userStore = new UserStore(); const userResource = mapToResource(userStore, 'detail'); // Resource<User | undefined> processUserResource(userResource); ```
1 parent ee3d751 commit 4ba9954

File tree

4 files changed

+1052
-0
lines changed

4 files changed

+1052
-0
lines changed
Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
1+
import { expecter } from 'ts-snippet';
2+
import { compilerOptions } from './helpers';
3+
4+
describe('signalStore', () => {
5+
const expectSnippet = expecter(
6+
(code) => `
7+
import { computed, inject, resource, Resource, Signal, signal } from '@angular/core';
8+
import {
9+
patchState,
10+
mapToResource,
11+
signalStore,
12+
withResource,
13+
withState
14+
} from '@ngrx/signals';
15+
16+
${code}
17+
`,
18+
compilerOptions()
19+
);
20+
21+
describe('unnamed resource', () => {
22+
it('satisfies the Resource interface without default value', () => {
23+
const snippet = `
24+
const Store = signalStore(
25+
withResource(() => resource({loader: () => Promise.resolve(1)}))
26+
);
27+
28+
const store: Resource<number | undefined> = new Store();
29+
`;
30+
31+
expectSnippet(snippet).toSucceed();
32+
});
33+
34+
it('satisfies the Resource interface with default value', () => {
35+
const snippet = `
36+
const Store = signalStore(
37+
withResource(() => resource({loader: () => Promise.resolve(1), defaultValue: 0}))
38+
);
39+
40+
const store: Resource<number> = new Store();
41+
`;
42+
43+
expectSnippet(snippet).toSucceed();
44+
});
45+
46+
it('provides hasValue as type predicate when explicitly typed', () => {
47+
const snippet = `
48+
const Store = signalStore(
49+
withResource(() => resource({ loader: () => Promise.resolve(1) }))
50+
);
51+
52+
const store: Resource<number | undefined> = new Store();
53+
if (store.hasValue()) {
54+
const value: number = store.value();
55+
}
56+
`;
57+
58+
expectSnippet(snippet).toSucceed();
59+
});
60+
61+
it('fails on hasValue as type predicate when not explicitly typed', () => {
62+
const snippet = `
63+
const Store = signalStore(
64+
withResource(() => resource({ loader: () => Promise.resolve(1) }))
65+
);
66+
67+
const store = new Store();
68+
if (store.hasValue()) {
69+
const value: number = store.value();
70+
}
71+
`;
72+
73+
expectSnippet(snippet).toFail(
74+
/Type 'number | undefined' is not assignable to type 'number'/
75+
);
76+
});
77+
78+
it('does not have access to the STATE_SOURCE', () => {
79+
const snippet = `
80+
const Store = signalStore(
81+
withState({ id: 1 }),
82+
withResource((store) =>
83+
resource({
84+
params: store.id,
85+
loader: ({ params: id }) => {
86+
patchState(store, { id: 0 });
87+
return Promise.resolve(id + 1);
88+
},
89+
})
90+
)
91+
);
92+
`;
93+
94+
expectSnippet(snippet).toFail(/Property '\[STATE_SOURCE\]' is missing/);
95+
});
96+
});
97+
98+
describe('named resources', () => {
99+
it('does not have access to the STATE_SOURCE', () => {
100+
const snippet = `
101+
const Store = signalStore(
102+
withState({ id: 1 }),
103+
withResource((store) => ({
104+
user: resource({
105+
params: store.id,
106+
loader: ({ params: id }) => {
107+
patchState(store, { id: 0 });
108+
return Promise.resolve(id + 1);
109+
},
110+
}),
111+
}))
112+
);
113+
`;
114+
115+
expectSnippet(snippet).toFail(/Property '\[STATE_SOURCE\]' is missing/);
116+
});
117+
});
118+
119+
it('shoud allow different resource types with named resources', () => {
120+
const snippet = `
121+
const Store = signalStore(
122+
withResource((store) => ({
123+
id: resource({
124+
loader: () => Promise.resolve(1),
125+
defaultValue: 0,
126+
}),
127+
word: resource({
128+
loader: () => Promise.resolve('hello'),
129+
defaultValue: '',
130+
}),
131+
optionalId: resource({
132+
loader: () => Promise.resolve(1),
133+
})
134+
}))
135+
);
136+
const store = new Store();
137+
const id = store.idValue;
138+
const word = store.wordValue;
139+
const optionalId = store.optionalIdValue;
140+
`;
141+
142+
expectSnippet(snippet).toInfer('id', 'Signal<number>');
143+
expectSnippet(snippet).toInfer('word', 'Signal<string>');
144+
expectSnippet(snippet).toInfer('optionalId', 'Signal<number | undefined>');
145+
});
146+
147+
describe('mapToResource', () => {
148+
it('satisfies the Resource interface without default value', () => {
149+
const snippet = `
150+
const Store = signalStore(
151+
withResource(() => ({ id: resource({ loader: () => Promise.resolve(1) }) }))
152+
);
153+
154+
const store = new Store();
155+
mapToResource(store, 'id') satisfies Resource<number | undefined>;
156+
`;
157+
158+
expectSnippet(snippet).toSucceed();
159+
});
160+
161+
it('satisfies the Resource interface with default value', () => {
162+
const snippet = `
163+
const Store = signalStore(
164+
withResource(() => ({
165+
id: resource({
166+
loader: () => Promise.resolve(1),
167+
defaultValue: 0
168+
})
169+
}))
170+
);
171+
172+
const store = new Store();
173+
mapToResource(store, 'id') satisfies Resource<number | undefined>;
174+
`;
175+
176+
expectSnippet(snippet).toSucceed();
177+
});
178+
179+
it('provides hasValue as type predicate', () => {
180+
const snippet = `
181+
const Store = signalStore(
182+
withResource(() => ({id: resource({ loader: () => Promise.resolve(1) })}))
183+
);
184+
185+
const store = new Store();
186+
const res = mapToResource(store, 'id');
187+
if (res.hasValue()) {
188+
const value: number = res.value();
189+
}
190+
`;
191+
192+
expectSnippet(snippet).toSucceed();
193+
});
194+
195+
describe('resource name checks', () => {
196+
const setupStore = `
197+
const Store = signalStore(
198+
withState({ key: 1, work: 'test' }),
199+
withResource(() => ({
200+
id: resource({ loader: () => Promise.resolve(1) }),
201+
word: resource({ loader: () => Promise.resolve('hello') })
202+
}))
203+
);
204+
205+
const store = new Store();
206+
`;
207+
208+
it('allows passing id as a valid resource name', () => {
209+
const snippet = `
210+
${setupStore}
211+
mapToResource(store, 'id');
212+
`;
213+
214+
expectSnippet(snippet).toSucceed();
215+
});
216+
217+
it('allows passing word as a valid resource name', () => {
218+
const snippet = `
219+
${setupStore}
220+
mapToResource(store, 'word');
221+
`;
222+
223+
expectSnippet(snippet).toSucceed();
224+
});
225+
226+
it('fails when passing key as a resource name', () => {
227+
const snippet = `
228+
${setupStore}
229+
mapToResource(store, 'key');
230+
`;
231+
232+
expectSnippet(snippet).toFail(
233+
/Argument of type '"key"' is not assignable to parameter of type '"id" | "word"/
234+
);
235+
});
236+
237+
it('fails when passing work as a resource name', () => {
238+
const snippet = `
239+
${setupStore}
240+
mapToResource(store, 'work');
241+
`;
242+
243+
expectSnippet(snippet).toFail(
244+
/Argument of type '"work"' is not assignable to parameter of type '"id" | "word"/
245+
);
246+
});
247+
});
248+
249+
it('fails when Resource properties are not fully defined', () => {
250+
const snippet = `
251+
const Store = signalStore(
252+
withState({ userValue: 0 })
253+
);
254+
255+
const store = new Store();
256+
mapToResource(store, 'user');
257+
`;
258+
259+
expectSnippet(snippet).toFail(
260+
/Argument of type '"user"' is not assignable to parameter of type 'never'/
261+
);
262+
});
263+
});
264+
});

0 commit comments

Comments
 (0)