Skip to content

Commit 369f5a1

Browse files
fix(resource): add proxied error handling [backport v20]
We are introducing an option `ErrorHandling` type which can be either `native`, `undefined value`, or `previous value`. In case the `value` of a source is called, the `native` option will use the resources as is. In the other cases it is adding a proxy to the `value` which trap all calls. As the name says, `undefined value`, would return undefined if the resource is in an `error` state and the `value` throws. `previous value` would simply return the previous value. The default setting is `undefined value`. This is necessary due to the dead loop reported in #242. We went for that instead of `previous value` because it was the resource's behavior in Angular 19. We are aware that this can be seen as a breaking change, since `undefined value` will always make the `value` signal return `T | undefined` instead of potentially throwing, but we see this as a fix and therefore not really breaking. This feature will be backported to v20 as well. `withEntityResource` is based on `withResource` and will therefore also not throw in an error state anymore. `withEntityResource`'s API has not changed. Closes #242
1 parent c489c6e commit 369f5a1

13 files changed

Lines changed: 975 additions & 380 deletions
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
export type Assert<T extends true> = T;
2+
export type AssertNot<T extends false> = T;
3+
4+
export type IsEqual<T, U> = [T] extends [U]
5+
? [U] extends [T]
6+
? true
7+
: false
8+
: false;
9+
10+
export type Satisfies<T, U> = T extends U ? true : false;

libs/ngrx-toolkit/src/lib/with-entity-resources.spec.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,4 +253,43 @@ describe('withEntityResources', () => {
253253
]);
254254
});
255255
});
256+
257+
describe('error handling', () => {
258+
it('does not throw for unnamed resources', async () => {
259+
const Store = signalStore(
260+
{ providedIn: 'root' },
261+
withEntityResources(() =>
262+
resource({
263+
loader: (): Promise<Todo[]> => {
264+
return Promise.reject('error');
265+
},
266+
}),
267+
),
268+
);
269+
270+
const store = TestBed.inject(Store);
271+
await wait();
272+
expect(store.status()).toEqual('error');
273+
expect(store.ids()).toEqual([]);
274+
expect(store.entities()).toEqual([]);
275+
expect(store.value()).toBeUndefined();
276+
});
277+
278+
it('does not throw for named resources', async () => {
279+
const Store = signalStore(
280+
{ providedIn: 'root' },
281+
withEntityResources(() => ({
282+
todos: resource({
283+
loader: (): Promise<Todo[]> => Promise.reject('error'),
284+
}),
285+
})),
286+
);
287+
const store = TestBed.inject(Store);
288+
await wait();
289+
290+
expect(store.todosIds()).toEqual([]);
291+
expect(store.todosEntities()).toEqual([]);
292+
expect(store.todosValue()).toBeUndefined();
293+
});
294+
});
256295
});

libs/ngrx-toolkit/src/lib/with-entity-resources.ts

Lines changed: 98 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { ResourceRef, Signal, computed, linkedSignal } from '@angular/core';
1+
import { ResourceRef, Signal, isSignal, linkedSignal } from '@angular/core';
22
import {
33
SignalStoreFeature,
44
SignalStoreFeatureResult,
@@ -56,7 +56,7 @@ import {
5656
* );
5757
*
5858
* const store = TestBed.inject(Store);
59-
* store.status(); // 'idle' | 'loading' | 'resolved' | 'error'
59+
* store.status(); // 'idle' | 'loading' | 'resolved' | 'error' | 'local'
6060
* store.value(); // Todo[]
6161
* store.ids(); // EntityId[]
6262
* store.entityMap(); // Record<EntityId, Todo>
@@ -93,7 +93,7 @@ export function withEntityResources<
9393
>(
9494
resourceFactory: (
9595
store: Input['props'] & Input['methods'] & StateSignals<Input['state']>,
96-
) => ResourceRef<readonly Entity[] | Entity[] | undefined>,
96+
) => ResourceRef<TypedEntityResourceValue<Entity>>,
9797
): SignalStoreFeature<Input, EntityResourceResult<Entity>>;
9898

9999
export function withEntityResources<
@@ -107,7 +107,7 @@ export function withEntityResources<
107107

108108
export function withEntityResources<
109109
Input extends SignalStoreFeatureResult,
110-
ResourceValue extends readonly unknown[] | unknown[] | undefined,
110+
ResourceValue extends EntityResourceValue,
111111
>(
112112
entityResourceFactory: (
113113
store: Input['props'] & Input['methods'] & StateSignals<Input['state']>,
@@ -127,52 +127,93 @@ export function withEntityResources<
127127
};
128128
}
129129

130-
function createUnnamedEntityResource<
131-
R extends ResourceRef<readonly unknown[] | unknown[] | undefined>,
132-
>(resource: R) {
133-
type E = InferEntityFromRef<R> & { id: EntityId };
134-
const { idsLinked, entityMapLinked, entitiesSignal } =
135-
createEntityDerivations<E>(
136-
resource.value as Signal<readonly E[] | E[] | undefined>,
137-
);
138-
130+
/**
131+
* We cannot use the value of `resource` directly, but
132+
* have to use the one created through {@link withResource}
133+
* because {@link withResource} creates a Proxy around the resource value
134+
* to avoid the error throwing behavior of the Resource API.
135+
*/
136+
function createUnnamedEntityResource<E extends Entity>(
137+
resource: ResourceRef<TypedEntityResourceValue<E>>,
138+
) {
139139
return signalStoreFeature(
140140
withResource(() => resource),
141-
withLinkedState(() => ({
142-
entityMap: entityMapLinked,
143-
ids: idsLinked,
144-
})),
145-
withComputed(() => ({
146-
entities: entitiesSignal,
141+
withLinkedState(({ value }) => {
142+
const { ids, entityMap } = createEntityDerivations(value);
143+
144+
return {
145+
entityMap,
146+
ids,
147+
};
148+
}),
149+
withComputed(({ ids, entityMap }) => ({
150+
entities: createComputedEntities(ids, entityMap),
147151
})),
148152
);
149153
}
150154

155+
/**
156+
* See {@link createUnnamedEntityResource} for why we cannot use the value of `resource` directly.
157+
*/
151158
function createNamedEntityResources<Dictionary extends EntityDictionary>(
152159
dictionary: Dictionary,
153160
) {
154161
const keys = Object.keys(dictionary);
155162

156-
const linkedState: Record<string, Signal<unknown>> = {};
157-
const computedProps: Record<string, Signal<unknown>> = {};
163+
const stateFactories = keys.map((name) => {
164+
return (store: Record<string, unknown>) => {
165+
const resourceValue = store[
166+
`${name}Value`
167+
] as Signal<EntityResourceValue>;
168+
if (!isSignal(resourceValue)) {
169+
throw new Error(`Resource's value ${name}Value does not exist`);
170+
}
171+
172+
const { ids, entityMap } = createEntityDerivations(resourceValue);
173+
174+
return {
175+
[`${name}EntityMap`]: entityMap,
176+
[`${name}Ids`]: ids,
177+
};
178+
};
179+
});
180+
181+
const computedFactories = keys.map((name) => {
182+
return (store: Record<string, unknown>) => {
183+
const ids = store[`${name}Ids`] as Signal<EntityId[]>;
184+
const entityMap = store[`${name}EntityMap`] as Signal<
185+
Record<EntityId, Entity>
186+
>;
158187

159-
keys.forEach((name) => {
160-
const ref = dictionary[name];
161-
type E = InferEntityFromRef<typeof ref> & { id: EntityId };
162-
const { idsLinked, entityMapLinked, entitiesSignal } =
163-
createEntityDerivations<E>(
164-
ref.value as Signal<readonly E[] | E[] | undefined>,
165-
);
188+
if (!isSignal(ids)) {
189+
throw new Error(`Entity Resource's ids ${name}Ids does not exist`);
190+
}
191+
if (!isSignal(entityMap)) {
192+
throw new Error(
193+
`Entity Resource's entityMap ${name}EntityMap does not exist`,
194+
);
195+
}
166196

167-
linkedState[`${String(name)}EntityMap`] = entityMapLinked;
168-
linkedState[`${String(name)}Ids`] = idsLinked;
169-
computedProps[`${String(name)}Entities`] = entitiesSignal;
197+
return {
198+
[`${name}Entities`]: createComputedEntities(ids, entityMap),
199+
};
200+
};
170201
});
171202

172203
return signalStoreFeature(
173204
withResource(() => dictionary),
174-
withLinkedState(() => linkedState),
175-
withComputed(() => computedProps),
205+
withLinkedState((store) =>
206+
stateFactories.reduce(
207+
(acc, factory) => ({ ...acc, ...factory(store) }),
208+
{},
209+
),
210+
),
211+
withComputed((store) =>
212+
computedFactories.reduce(
213+
(acc, factory) => ({ ...acc, ...factory(store) }),
214+
{},
215+
),
216+
),
176217
);
177218
}
178219

@@ -209,20 +250,19 @@ type ArrayElement<T> = T extends readonly (infer E)[] | (infer E)[] ? E : never;
209250
type InferEntityFromSignal<T> =
210251
T extends Signal<infer V> ? ArrayElement<V> : never;
211252

212-
type InferEntityFromRef<
213-
R extends ResourceRef<readonly unknown[] | unknown[] | undefined>,
214-
> = R['value'] extends Signal<infer V> ? ArrayElement<V> : never;
215-
216253
type MergeUnion<U> = (U extends unknown ? (k: U) => void : never) extends (
217254
k: infer I,
218255
) => void
219256
? I
220257
: never;
221258

222-
export type EntityDictionary = Record<
223-
string,
224-
ResourceRef<readonly unknown[] | unknown[] | undefined>
225-
>;
259+
type Entity = { id: EntityId };
260+
261+
type EntityResourceValue = Entity[] | (Entity[] | undefined);
262+
263+
type TypedEntityResourceValue<E extends Entity> = E[] | (E[] | undefined);
264+
265+
export type EntityDictionary = Record<string, ResourceRef<EntityResourceValue>>;
226266

227267
type MergeNamedEntityStates<T extends EntityDictionary> = MergeUnion<
228268
{
@@ -249,21 +289,20 @@ type MergeNamedEntityProps<T extends EntityDictionary> = MergeUnion<
249289
>;
250290

251291
export type NamedEntityResourceResult<T extends EntityDictionary> = {
252-
state: NamedResourceResult<T>['state'] & MergeNamedEntityStates<T>;
253-
props: NamedResourceResult<T>['props'] & MergeNamedEntityProps<T>;
254-
methods: NamedResourceResult<T>['methods'];
292+
state: NamedResourceResult<T, false>['state'] & MergeNamedEntityStates<T>;
293+
props: NamedResourceResult<T, false>['props'] & MergeNamedEntityProps<T>;
294+
methods: NamedResourceResult<T, false>['methods'];
255295
};
256296

257297
/**
258298
* @internal
259299
* @description
260300
*
261-
* Creates the three entity-related signals (`ids`, `entityMap`, `entities`) from
301+
* Creates the two entity-related state properties (`ids`, `entityMap`) from
262302
* a single source signal of entities. This mirrors the public contract of
263303
* `withEntities()`:
264304
* - `ids`: derived list of entity ids
265305
* - `entityMap`: map of id -> entity
266-
* - `entities`: projection of `ids` through `entityMap`
267306
*
268307
* Implementation details:
269308
* - Uses `withLinkedState` + `linkedSignal` for `ids` and `entityMap` so they are
@@ -280,15 +319,15 @@ export type NamedEntityResourceResult<T extends EntityDictionary> = {
280319
* derived from signals. Using linked signals keeps the data flow declarative
281320
* and avoids imperative syncing code.
282321
*/
283-
function createEntityDerivations<E extends { id: EntityId }>(
284-
source: Signal<readonly E[] | E[] | undefined>,
322+
function createEntityDerivations<E extends Entity>(
323+
source: Signal<TypedEntityResourceValue<E>>,
285324
) {
286-
const idsLinked = linkedSignal({
325+
const ids = linkedSignal({
287326
source,
288327
computation: (list) => (list ?? []).map((e) => e.id),
289328
});
290329

291-
const entityMapLinked = linkedSignal({
330+
const entityMap = linkedSignal({
292331
source,
293332
computation: (list) => {
294333
const map = {} as Record<EntityId, E>;
@@ -299,11 +338,14 @@ function createEntityDerivations<E extends { id: EntityId }>(
299338
},
300339
});
301340

302-
const entitiesSignal = computed(() => {
303-
const ids = idsLinked();
304-
const map = entityMapLinked();
305-
return ids.map((id) => map[id]) as readonly E[];
306-
});
341+
return { ids, entityMap };
342+
}
307343

308-
return { idsLinked, entityMapLinked, entitiesSignal };
344+
function createComputedEntities<E extends Entity>(
345+
ids: Signal<EntityId[]>,
346+
entityMap: Signal<Record<EntityId, E>>,
347+
) {
348+
return () => {
349+
return ids().map((id) => entityMap()[id]);
350+
};
309351
}

0 commit comments

Comments
 (0)