diff --git a/libs/ngrx-toolkit/src/lib/test-utils/types.ts b/libs/ngrx-toolkit/src/lib/test-utils/types.ts new file mode 100644 index 00000000..69751f6a --- /dev/null +++ b/libs/ngrx-toolkit/src/lib/test-utils/types.ts @@ -0,0 +1,10 @@ +export type Assert = T; +export type AssertNot = T; + +export type IsEqual = [T] extends [U] + ? [U] extends [T] + ? true + : false + : false; + +export type Satisfies = T extends U ? true : false; diff --git a/libs/ngrx-toolkit/src/lib/with-entity-resources.spec.ts b/libs/ngrx-toolkit/src/lib/with-entity-resources.spec.ts index d45ba6e2..131b2e0b 100644 --- a/libs/ngrx-toolkit/src/lib/with-entity-resources.spec.ts +++ b/libs/ngrx-toolkit/src/lib/with-entity-resources.spec.ts @@ -253,4 +253,43 @@ describe('withEntityResources', () => { ]); }); }); + + describe('error handling', () => { + it('does not throw for unnamed resources', async () => { + const Store = signalStore( + { providedIn: 'root' }, + withEntityResources(() => + resource({ + loader: (): Promise => { + return Promise.reject('error'); + }, + }), + ), + ); + + const store = TestBed.inject(Store); + await wait(); + expect(store.status()).toEqual('error'); + expect(store.ids()).toEqual([]); + expect(store.entities()).toEqual([]); + expect(store.value()).toBeUndefined(); + }); + + it('does not throw for named resources', async () => { + const Store = signalStore( + { providedIn: 'root' }, + withEntityResources(() => ({ + todos: resource({ + loader: (): Promise => Promise.reject('error'), + }), + })), + ); + const store = TestBed.inject(Store); + await wait(); + + expect(store.todosIds()).toEqual([]); + expect(store.todosEntities()).toEqual([]); + expect(store.todosValue()).toBeUndefined(); + }); + }); }); diff --git a/libs/ngrx-toolkit/src/lib/with-entity-resources.ts b/libs/ngrx-toolkit/src/lib/with-entity-resources.ts index 5253de35..f3afac0f 100644 --- a/libs/ngrx-toolkit/src/lib/with-entity-resources.ts +++ b/libs/ngrx-toolkit/src/lib/with-entity-resources.ts @@ -1,4 +1,4 @@ -import { ResourceRef, Signal, computed, linkedSignal } from '@angular/core'; +import { ResourceRef, Signal, isSignal, linkedSignal } from '@angular/core'; import { SignalStoreFeature, SignalStoreFeatureResult, @@ -56,7 +56,7 @@ import { * ); * * const store = TestBed.inject(Store); - * store.status(); // 'idle' | 'loading' | 'resolved' | 'error' + * store.status(); // 'idle' | 'loading' | 'resolved' | 'error' | 'local' * store.value(); // Todo[] * store.ids(); // EntityId[] * store.entityMap(); // Record @@ -93,7 +93,7 @@ export function withEntityResources< >( resourceFactory: ( store: Input['props'] & Input['methods'] & StateSignals, - ) => ResourceRef, + ) => ResourceRef>, ): SignalStoreFeature>; export function withEntityResources< @@ -107,7 +107,7 @@ export function withEntityResources< export function withEntityResources< Input extends SignalStoreFeatureResult, - ResourceValue extends readonly unknown[] | unknown[] | undefined, + ResourceValue extends EntityResourceValue, >( entityResourceFactory: ( store: Input['props'] & Input['methods'] & StateSignals, @@ -127,52 +127,93 @@ export function withEntityResources< }; } -function createUnnamedEntityResource< - R extends ResourceRef, ->(resource: R) { - type E = InferEntityFromRef & { id: EntityId }; - const { idsLinked, entityMapLinked, entitiesSignal } = - createEntityDerivations( - resource.value as Signal, - ); - +/** + * We cannot use the value of `resource` directly, but + * have to use the one created through {@link withResource} + * because {@link withResource} creates a Proxy around the resource value + * to avoid the error throwing behavior of the Resource API. + */ +function createUnnamedEntityResource( + resource: ResourceRef>, +) { return signalStoreFeature( withResource(() => resource), - withLinkedState(() => ({ - entityMap: entityMapLinked, - ids: idsLinked, - })), - withComputed(() => ({ - entities: entitiesSignal, + withLinkedState(({ value }) => { + const { ids, entityMap } = createEntityDerivations(value); + + return { + entityMap, + ids, + }; + }), + withComputed(({ ids, entityMap }) => ({ + entities: createComputedEntities(ids, entityMap), })), ); } +/** + * See {@link createUnnamedEntityResource} for why we cannot use the value of `resource` directly. + */ function createNamedEntityResources( dictionary: Dictionary, ) { const keys = Object.keys(dictionary); - const linkedState: Record> = {}; - const computedProps: Record> = {}; + const stateFactories = keys.map((name) => { + return (store: Record) => { + const resourceValue = store[ + `${name}Value` + ] as Signal; + if (!isSignal(resourceValue)) { + throw new Error(`Resource's value ${name}Value does not exist`); + } + + const { ids, entityMap } = createEntityDerivations(resourceValue); + + return { + [`${name}EntityMap`]: entityMap, + [`${name}Ids`]: ids, + }; + }; + }); + + const computedFactories = keys.map((name) => { + return (store: Record) => { + const ids = store[`${name}Ids`] as Signal; + const entityMap = store[`${name}EntityMap`] as Signal< + Record + >; - keys.forEach((name) => { - const ref = dictionary[name]; - type E = InferEntityFromRef & { id: EntityId }; - const { idsLinked, entityMapLinked, entitiesSignal } = - createEntityDerivations( - ref.value as Signal, - ); + if (!isSignal(ids)) { + throw new Error(`Entity Resource's ids ${name}Ids does not exist`); + } + if (!isSignal(entityMap)) { + throw new Error( + `Entity Resource's entityMap ${name}EntityMap does not exist`, + ); + } - linkedState[`${String(name)}EntityMap`] = entityMapLinked; - linkedState[`${String(name)}Ids`] = idsLinked; - computedProps[`${String(name)}Entities`] = entitiesSignal; + return { + [`${name}Entities`]: createComputedEntities(ids, entityMap), + }; + }; }); return signalStoreFeature( withResource(() => dictionary), - withLinkedState(() => linkedState), - withComputed(() => computedProps), + withLinkedState((store) => + stateFactories.reduce( + (acc, factory) => ({ ...acc, ...factory(store) }), + {}, + ), + ), + withComputed((store) => + computedFactories.reduce( + (acc, factory) => ({ ...acc, ...factory(store) }), + {}, + ), + ), ); } @@ -209,20 +250,19 @@ type ArrayElement = T extends readonly (infer E)[] | (infer E)[] ? E : never; type InferEntityFromSignal = T extends Signal ? ArrayElement : never; -type InferEntityFromRef< - R extends ResourceRef, -> = R['value'] extends Signal ? ArrayElement : never; - type MergeUnion = (U extends unknown ? (k: U) => void : never) extends ( k: infer I, ) => void ? I : never; -export type EntityDictionary = Record< - string, - ResourceRef ->; +type Entity = { id: EntityId }; + +type EntityResourceValue = Entity[] | (Entity[] | undefined); + +type TypedEntityResourceValue = E[] | (E[] | undefined); + +export type EntityDictionary = Record>; type MergeNamedEntityStates = MergeUnion< { @@ -249,21 +289,20 @@ type MergeNamedEntityProps = MergeUnion< >; export type NamedEntityResourceResult = { - state: NamedResourceResult['state'] & MergeNamedEntityStates; - props: NamedResourceResult['props'] & MergeNamedEntityProps; - methods: NamedResourceResult['methods']; + state: NamedResourceResult['state'] & MergeNamedEntityStates; + props: NamedResourceResult['props'] & MergeNamedEntityProps; + methods: NamedResourceResult['methods']; }; /** * @internal * @description * - * Creates the three entity-related signals (`ids`, `entityMap`, `entities`) from + * Creates the two entity-related state properties (`ids`, `entityMap`) from * a single source signal of entities. This mirrors the public contract of * `withEntities()`: * - `ids`: derived list of entity ids * - `entityMap`: map of id -> entity - * - `entities`: projection of `ids` through `entityMap` * * Implementation details: * - Uses `withLinkedState` + `linkedSignal` for `ids` and `entityMap` so they are @@ -280,15 +319,15 @@ export type NamedEntityResourceResult = { * derived from signals. Using linked signals keeps the data flow declarative * and avoids imperative syncing code. */ -function createEntityDerivations( - source: Signal, +function createEntityDerivations( + source: Signal>, ) { - const idsLinked = linkedSignal({ + const ids = linkedSignal({ source, computation: (list) => (list ?? []).map((e) => e.id), }); - const entityMapLinked = linkedSignal({ + const entityMap = linkedSignal({ source, computation: (list) => { const map = {} as Record; @@ -299,11 +338,14 @@ function createEntityDerivations( }, }); - const entitiesSignal = computed(() => { - const ids = idsLinked(); - const map = entityMapLinked(); - return ids.map((id) => map[id]) as readonly E[]; - }); + return { ids, entityMap }; +} - return { idsLinked, entityMapLinked, entitiesSignal }; +function createComputedEntities( + ids: Signal, + entityMap: Signal>, +) { + return () => { + return ids().map((id) => entityMap()[id]); + }; } diff --git a/libs/ngrx-toolkit/src/lib/with-resource.spec.ts b/libs/ngrx-toolkit/src/lib/with-resource.spec.ts index 82e6b2d5..5958eae0 100644 --- a/libs/ngrx-toolkit/src/lib/with-resource.spec.ts +++ b/libs/ngrx-toolkit/src/lib/with-resource.spec.ts @@ -3,7 +3,7 @@ import { HttpTestingController, provideHttpClientTesting, } from '@angular/common/http/testing'; -import { inject, Injectable, Resource, resource, Signal } from '@angular/core'; +import { Resource, resource, Signal } from '@angular/core'; import { rxResource } from '@angular/core/rxjs-interop'; import { TestBed } from '@angular/core/testing'; import { @@ -14,103 +14,16 @@ import { withState, } from '@ngrx/signals'; import { of } from 'rxjs'; -import { mapToResource, withResource } from './with-resource'; +import { Assert, AssertNot, IsEqual, Satisfies } from './test-utils/types'; +import { ErrorHandling, mapToResource, withResource } from './with-resource'; +import { Address, venice, vienna } from './with-resource/tests/util/fixtures'; +import { paramsForResourceTypes } from './with-resource/tests/util/params-for-resource-types'; +import { setupUnnamedResource } from './with-resource/tests/util/setup-unnamed-resource'; describe('withResource', () => { describe('standard tests', () => { const wait = (ms = 0) => new Promise((resolve) => setTimeout(resolve, ms)); - type Address = { - street: string; - city: { - zip: string; - name: string; - }; - country: string; - }; - - const venice: Address = { - street: 'Sestiere Dorsoduro, 2771', - city: { - zip: '30123', - name: 'Venezia VE', - }, - country: 'Italy', - }; - - @Injectable({ providedIn: 'root' }) - class AddressResolver { - resolve(id: number) { - void id; - return Promise.resolve
(venice); - } - } - - function setupWithUnnamedResource() { - const addressResolver = { - resolve: jest.fn(() => Promise.resolve(venice)), - }; - const AddressStore = signalStore( - { providedIn: 'root', protectedState: false }, - withState({ id: undefined as number | undefined }), - withResource((store) => { - const resolver = inject(AddressResolver); - return resource({ - params: store.id, - loader: ({ params: id }) => resolver.resolve(id), - }); - }), - withMethods((store) => ({ reload: () => store._reload() })), - ); - - TestBed.configureTestingModule({ - providers: [ - { - provide: AddressResolver, - useValue: addressResolver, - }, - ], - }); - - const store = TestBed.inject(AddressStore); - - return { store, addressResolver }; - } - - function setupWithNamedResource() { - const addressResolver = { - resolve: jest.fn(() => Promise.resolve(venice)), - }; - - const UserStore = signalStore( - { providedIn: 'root', protectedState: false }, - withState({ id: undefined as number | undefined }), - withResource((store) => { - const resolver = inject(AddressResolver); - return { - address: resource({ - params: store.id, - loader: ({ params: id }) => resolver.resolve(id), - }), - }; - }), - withMethods((store) => ({ reload: () => store._addressReload() })), - ); - - TestBed.configureTestingModule({ - providers: [ - { - provide: AddressResolver, - useValue: addressResolver, - }, - ], - }); - - const store = TestBed.inject(UserStore); - - return { store, addressResolver }; - } - describe('withResource', () => { describe('InnerSignalStore access', () => { it('can access the state signals', async () => { @@ -185,206 +98,167 @@ describe('withResource', () => { }); describe('status checks', () => { - for (const { name, setup } of [ - { - name: 'unnamed resource', - setup: () => { - const { store, addressResolver } = setupWithUnnamedResource(); - const setId = (id: number) => patchState(store, { id }); - const setValue = (value: Address) => patchState(store, { value }); - return { - storeAndResource: store, - addressResolver, - setId, - setValue, - }; - }, - }, - { - name: 'mapped named resource', - setup: () => { - const { store, addressResolver } = setupWithNamedResource(); - const storeAndResource = mapToResource(store, 'address'); - const setId = (id: number) => patchState(store, { id }); - const setValue = (value: Address) => - patchState(store, { addressValue: value }); - return { storeAndResource, addressResolver, setId, setValue }; - }, - }, - ]) { - describe(name, () => { - it('has idle status in the beginning', () => { - const { storeAndResource } = setup(); - - expect(storeAndResource.status()).toBe('idle'); - expect(storeAndResource.value()).toBeUndefined(); - expect(storeAndResource.error()).toBeUndefined(); - expect(storeAndResource.isLoading()).toBe(false); - expect(storeAndResource.hasValue()).toBe(false); - }); - - it('has loading status when loading', () => { - const { storeAndResource, addressResolver, setId } = setup(); - - addressResolver.resolve.mockResolvedValue(venice); - setId(1); - - expect(storeAndResource.status()).toBe('loading'); - expect(storeAndResource.value()).toBeUndefined(); - expect(storeAndResource.error()).toBeUndefined(); - expect(storeAndResource.isLoading()).toBe(true); - expect(storeAndResource.hasValue()).toBe(false); - }); + describe('all except error', () => { + describe.each([ + 'native', + 'undefined value', + 'previous value', + ] as ErrorHandling[])(`Error Handling: %s`, (errorHandling) => { + describe.each(paramsForResourceTypes(errorHandling))( + `$name`, + ({ setup }) => { + it('has idle status in the beginning', () => { + const { getValue, getMetadata } = setup(); + + expect(getValue()).toBeUndefined(); + expect(getMetadata()).toEqual({ + status: 'idle', + error: undefined, + isLoading: false, + hasValue: false, + }); + }); + + it('has loading status when loading', () => { + const { getValue, getMetadata, addressResolver, setId } = + setup(); + + addressResolver.resolve.mockResolvedValue(venice); + setId(1); + + expect(getValue()).toBeUndefined(); + expect(getMetadata()).toEqual({ + status: 'loading', + error: undefined, + isLoading: true, + hasValue: false, + }); + }); + + it('has resolved status when loaded', async () => { + const { getValue, getMetadata, addressResolver, setId } = + setup(); + + addressResolver.resolve.mockResolvedValue(venice); + setId(1); + + await wait(); + + expect(getValue()).toEqual(venice); + expect(getMetadata()).toEqual({ + status: 'resolved', + error: undefined, + isLoading: false, + hasValue: true, + }); + }); + + it('has local once updated', async () => { + const { + getValue, + getMetadata, + addressResolver, + setId, + setValue, + } = setup(); + + addressResolver.resolve.mockResolvedValue(venice); + setId(1); + + await wait(); + setValue(vienna); + + expect(getValue()).toEqual(vienna); + expect(getMetadata()).toEqual({ + status: 'local', + error: undefined, + isLoading: false, + hasValue: true, + }); + }); + }, + ); - it('has resolved status when loaded', async () => { - const { storeAndResource, addressResolver, setId } = setup(); + it('reloads an unnamed resource', async () => { + const { addressResolver, setId, getMetadata, reload, getValue } = + setupUnnamedResource(errorHandling); addressResolver.resolve.mockResolvedValue(venice); setId(1); await wait(); + expect(getMetadata().hasValue).toBe(true); - expect(storeAndResource.status()).toBe('resolved'); - expect(storeAndResource.value()).toEqual(venice); - expect(storeAndResource.error()).toBeUndefined(); - expect(storeAndResource.isLoading()).toBe(false); - expect(storeAndResource.hasValue()).toBe(true); - }); - - it('has error status when error', async () => { - const { storeAndResource, addressResolver, setId } = setup(); - - addressResolver.resolve.mockRejectedValue(new Error('Error')); - setId(1); - await wait(); - - expect(storeAndResource.status()).toBe('error'); - expect(() => storeAndResource.value()).toThrow(); - expect(storeAndResource.error()).toBeInstanceOf(Error); - expect(storeAndResource.isLoading()).toBe(false); - expect(storeAndResource.hasValue()).toBe(false); - }); - - it('has local once updated', async () => { - const { storeAndResource, addressResolver, setId, setValue } = - setup(); - - addressResolver.resolve.mockResolvedValue(venice); - setId(1); + addressResolver.resolve.mockResolvedValue(vienna); + reload(); await wait(); - setValue({ ...venice, country: 'Italia' }); - - expect(storeAndResource.status()).toBe('local'); - expect(storeAndResource.value()?.country).toBe('Italia'); - expect(storeAndResource.error()).toBeUndefined(); - expect(storeAndResource.isLoading()).toBe(false); - expect(storeAndResource.hasValue()).toBe(true); + expect(getValue()).toEqual(vienna); }); }); - } - - it('reloads an unnamed resource', async () => { - const { store, addressResolver } = setupWithUnnamedResource(); - - addressResolver.resolve.mockResolvedValue(venice); - patchState(store, { id: 1 }); - - await wait(); - expect(store.hasValue()).toBe(true); - - addressResolver.resolve.mockResolvedValue({ - ...venice, - country: 'Great Britain', - }); - store.reload(); - - await wait(); - expect(store.value()?.country).toBe('Great Britain'); }); - describe('named resource', () => { - it('has idle status in the beginning', () => { - const { store } = setupWithNamedResource(); - - expect(store.addressStatus()).toBe('idle'); - expect(store.addressValue()).toBeUndefined(); - expect(store.addressError()).toBeUndefined(); - expect(store.addressIsLoading()).toBe(false); - expect(store.addressHasValue()).toBe(false); - }); - - it('has loading status when loading', () => { - const { store, addressResolver } = setupWithNamedResource(); - - addressResolver.resolve.mockResolvedValue(venice); - patchState(store, { id: 1 }); - - expect(store.addressStatus()).toBe('loading'); - expect(store.addressValue()).toBeUndefined(); - expect(store.addressError()).toBeUndefined(); - expect(store.addressIsLoading()).toBe(true); - expect(store.addressHasValue()).toBe(false); - }); - - it('has resolved status when loaded', async () => { - const { store, addressResolver } = setupWithNamedResource(); - - addressResolver.resolve.mockResolvedValue(venice); - patchState(store, { id: 1 }); + describe('error status', () => { + describe('Error Handling: native', () => { + it.each(paramsForResourceTypes('native'))( + `$name`, + async ({ setup }) => { + const { addressResolver, setId, getValue } = setup(); - await wait(); + addressResolver.resolve.mockRejectedValue(new Error('Error')); + setId(1); + await wait(); - expect(store.addressStatus()).toBe('resolved'); - expect(store.addressValue()).toEqual(venice); - expect(store.addressError()).toBeUndefined(); - expect(store.addressIsLoading()).toBe(false); - expect(store.addressHasValue()).toBe(true); + expect(() => getValue()).toThrow(); + }, + ); }); - it('has error status when error', async () => { - const { store, addressResolver } = setupWithNamedResource(); - - addressResolver.resolve.mockRejectedValue(new Error('Error')); - patchState(store, { id: 1 }); - await wait(); + describe('Error Handling: undefined value', () => { + it.each(paramsForResourceTypes('undefined value'))( + '$name', + async ({ setup }) => { + const { addressResolver, setId, getValue } = setup(); - expect(store.addressStatus()).toBe('error'); - expect(() => store.addressValue()).toThrow(); - expect(store.addressError()).toBeInstanceOf(Error); - expect(store.addressIsLoading()).toBe(false); - expect(store.addressHasValue()).toBe(false); + addressResolver.resolve.mockRejectedValue(new Error('Error')); + setId(1); + await wait(); + expect(getValue()).toBeUndefined(); + }, + ); }); - it('has local once updated', async () => { - const { store, addressResolver } = setupWithNamedResource(); + describe('Error Handling: previous value', () => { + it('returns the previous value', async () => { + const { addressResolver, setId, getValue } = + setupUnnamedResource('previous value'); - addressResolver.resolve.mockResolvedValue(venice); - patchState(store, { id: 1 }); + setId(1); + addressResolver.resolve.mockReturnValue(Promise.resolve(venice)); + await wait(); + expect(getValue()).toBe(venice); - await wait(); - patchState(store, ({ addressValue }) => ({ - addressValue: addressValue - ? { ...addressValue, country: 'Italia' } - : undefined, - })); + setId(2); + addressResolver.resolve.mockRejectedValue(new Error('Error')); + await wait(); + expect(getValue()).toBe(venice); + }); - expect(store.addressStatus()).toBe('local'); - expect(store.addressValue()?.country).toBe('Italia'); - expect(store.addressError()).toBeUndefined(); - expect(store.addressIsLoading()).toBe(false); - expect(store.addressHasValue()).toBe(true); - }); + it('returns the local previous value', async () => { + const { addressResolver, setId, setValue, getValue } = + setupUnnamedResource('previous value'); - it('can also reload by resource name', async () => { - const { store, addressResolver } = setupWithNamedResource(); + setId(1); + addressResolver.resolve.mockReturnValue(Promise.resolve(venice)); + await wait(); + expect(getValue()).toBe(venice); + setValue(vienna); - addressResolver.resolve.mockResolvedValueOnce(venice); - patchState(store, { id: 1 }); - await wait(); - expect(store.addressStatus()).toBe('resolved'); - store.reload(); - expect(store.addressStatus()).toBe('reloading'); + setId(2); + addressResolver.resolve.mockRejectedValue(new Error('Error')); + await wait(); + expect(getValue()).toBe(vienna); + }); }); }); }); @@ -396,16 +270,10 @@ describe('withResource', () => { warningSpy.mockClear(); }); - for (const memberName of [ - //TODO wait for https://github.com/ngrx/platform/pull/4932 - // 'value', - 'status', - 'error', - 'isLoading', - '_reload', - 'hasValue', - ]) { - it(`warns if ${memberName} is not a member of the store`, () => { + //TODO wait for https://github.com/ngrx/platform/pull/4932 and then add 'value' to the list + it.each(['status', 'error', 'isLoading', '_reload', 'hasValue'])( + `warns if %s is not a member of the store`, + (memberName) => { const Store = signalStore( { providedIn: 'root' }, withProps(() => ({ [memberName]: true })), @@ -421,8 +289,8 @@ describe('withResource', () => { 'Trying to override:', memberName, ); - }); - } + }, + ); //TODO wait for https://github.com/ngrx/platform/pull/4932 it.skip('also checks for named resources', () => { @@ -490,13 +358,16 @@ describe('withResource', () => { }); describe('Type Tests', () => { - describe('unnamed resource', () => { + describe('Error Handling: default', () => { it('satisfies the Resource interface without default value', () => { const Store = signalStore( { providedIn: 'root' }, withResource(() => resource({ loader: () => Promise.resolve(1) })), ); - TestBed.inject(Store) satisfies Resource; + const _store = TestBed.inject(Store); + type _T1 = Assert< + Satisfies> + >; }); it('satisfies the Resource interface with default value', () => { @@ -506,7 +377,10 @@ describe('withResource', () => { resource({ loader: () => Promise.resolve(1), defaultValue: 0 }), ), ); - TestBed.inject(Store) satisfies Resource; + const _store = TestBed.inject(Store); + type _T1 = Assert< + Satisfies> + >; }); it('provides hasValue as type predicate when explicitly typed', () => { @@ -516,7 +390,7 @@ describe('withResource', () => { ); const store: Resource = TestBed.inject(Store); if (store.hasValue()) { - store.value() satisfies number; + type _T1 = Assert>>; } }); @@ -527,11 +401,128 @@ describe('withResource', () => { ); const store = TestBed.inject(Store); if (store.hasValue()) { - // @ts-expect-error - we want to test the type error - store.value() satisfies number; + const _value = store.value(); + type _T1 = AssertNot>; } }); + }); + describe('Error Handling: undefined value', () => { + it('satisfies the Resource interface without default value', () => { + const Store = signalStore( + { providedIn: 'root' }, + withResource(() => resource({ loader: () => Promise.resolve(1) }), { + errorHandling: 'undefined value', + }), + ); + const _store = TestBed.inject(Store); + type _T1 = Assert< + Satisfies> + >; + }); + + it('satisfies the Resource interface with default value', () => { + const Store = signalStore( + { providedIn: 'root' }, + withResource( + () => + resource({ loader: () => Promise.resolve(1), defaultValue: 0 }), + { errorHandling: 'undefined value' }, + ), + ); + const _store = TestBed.inject(Store); + type _T1 = Assert< + Satisfies> + >; + }); + + it('provides hasValue as type predicate when explicitly typed', () => { + const Store = signalStore( + { providedIn: 'root' }, + withResource(() => resource({ loader: () => Promise.resolve(1) }), { + errorHandling: 'undefined value', + }), + ); + const store: Resource = TestBed.inject(Store); + if (store.hasValue()) { + type _T1 = Assert>>; + } + }); + + it('fails on hasValue as type predicate when not explicitly typed', () => { + const Store = signalStore( + { providedIn: 'root' }, + withResource(() => resource({ loader: () => Promise.resolve(1) }), { + errorHandling: 'undefined value', + }), + ); + const store = TestBed.inject(Store); + if (store.hasValue()) { + const _value = store.value(); + type _T1 = AssertNot>; + } + }); + }); + + describe.each(['previous value', 'native'] as const)( + `Error Handling: %s`, + (errorHandling) => { + it('satisfies the Resource interface without default value', () => { + const Store = signalStore( + { providedIn: 'root' }, + withResource(() => resource({ loader: () => Promise.resolve(1) }), { + errorHandling, + }), + ); + const _store = TestBed.inject(Store); + type _T1 = Assert< + Satisfies> + >; + }); + + it('satisfies the Resource interface with default value', () => { + const Store = signalStore( + { providedIn: 'root' }, + withResource( + () => + resource({ loader: () => Promise.resolve(1), defaultValue: 0 }), + { errorHandling }, + ), + ); + const _store = TestBed.inject(Store); + type _T1 = Assert>>; + }); + + it('provides hasValue as type predicate when explicitly typed', () => { + const Store = signalStore( + { providedIn: 'root' }, + withResource(() => resource({ loader: () => Promise.resolve(1) }), { + errorHandling, + }), + ); + const store: Resource = TestBed.inject(Store); + if (store.hasValue()) { + type _T1 = Assert>>; + } + }); + + it('fails on hasValue as type predicate when not explicitly typed', () => { + const Store = signalStore( + { providedIn: 'root' }, + withResource(() => resource({ loader: () => Promise.resolve(1) }), { + errorHandling, + }), + ); + const store = TestBed.inject(Store); + if (store.hasValue()) { + const _value = store.value(); + type _T1 = AssertNot>; + } + }); + }, + ); + + describe('unnamed resource', () => { it('does not have access to the STATE_SOURCE', () => { signalStore( withState({ id: 1 }), @@ -572,55 +563,86 @@ describe('withResource', () => { it('shoud allow different resource types with named resources', () => { const Store = signalStore( { providedIn: 'root' }, - withResource(() => ({ - id: resource({ - loader: () => Promise.resolve(1), - defaultValue: 0, + withResource( + () => ({ + id: resource({ + loader: () => Promise.resolve(1), + defaultValue: 0, + }), }), - })), - withResource(() => ({ - word: resource({ - loader: () => Promise.resolve('hello'), - defaultValue: '', + { errorHandling: 'native' }, + ), + withResource( + () => ({ + word: resource({ + loader: () => Promise.resolve('hello'), + defaultValue: '', + }), }), - })), + { errorHandling: 'undefined value' }, + ), + withResource( + () => ({ + optionalId: resource({ + loader: () => Promise.resolve(1), + defaultValue: 0, + }), + }), + { errorHandling: 'previous value' }, + ), withResource(() => ({ - optionalId: resource({ - loader: () => Promise.resolve(1), + digit: resource({ + loader: () => Promise.resolve(-1), + defaultValue: 0, }), })), ); - const store = TestBed.inject(Store); + const _store = TestBed.inject(Store); - store.idValue satisfies Signal; - store.wordValue satisfies Signal; - store.optionalIdValue satisfies Signal; + type _T1 = Assert>>; + type _T2 = Assert< + IsEqual> + >; + type _T3 = Assert>>; + type _T4 = Assert< + IsEqual> + >; }); describe('mapToResource', () => { it('satisfies the Resource interface without default value', () => { const Store = signalStore( { providedIn: 'root' }, - withResource(() => ({ - id: resource({ loader: () => Promise.resolve(1) }), - })), + withResource( + () => ({ + id: resource({ loader: () => Promise.resolve(1) }), + }), + { errorHandling: 'native' }, + ), ); - const store = TestBed.inject(Store); - mapToResource(store, 'id') satisfies Resource; + const _store = mapToResource(TestBed.inject(Store), 'id'); + type _T1 = Assert>>; }); - it('satisfies the Resource interface with default value', () => { + it('satisfies the Resource interface with default value and native error handling', () => { const Store = signalStore( { providedIn: 'root' }, - withResource(() => ({ - id: resource({ loader: () => Promise.resolve(1), defaultValue: 0 }), - })), + withResource( + () => ({ + id: resource({ + loader: () => Promise.resolve(1), + defaultValue: 0, + }), + }), + { errorHandling: 'native' }, + ), ); const store = TestBed.inject(Store); - mapToResource(store, 'id') satisfies Resource; + const _resource = mapToResource(store, 'id'); + type _T1 = Assert>>; }); it('provides hasValue as type predicate', () => { @@ -635,7 +657,8 @@ describe('withResource', () => { const res = mapToResource(store, 'id'); if (res.hasValue()) { - res.value() satisfies number; + const _value = res.value(); + type _T1 = Assert>; } }); @@ -679,4 +702,44 @@ describe('withResource', () => { }); }); }); + + describe('Signature Tests', () => { + it('can call unnamed with error handler', () => { + signalStore( + withResource( + () => ({ + id: resource({ loader: () => Promise.resolve(1) }), + }), + { errorHandling: 'undefined value' }, + ), + ); + }); + + it('can call named with error handler', () => { + signalStore( + withResource( + () => ({ + id: resource({ loader: () => Promise.resolve(1) }), + }), + { errorHandling: 'undefined value' }, + ), + ); + }); + + it('can call unnamed without error handler', () => { + signalStore( + withResource(() => ({ + id: resource({ loader: () => Promise.resolve(1) }), + })), + ); + }); + + it('can call named without error handler', () => { + signalStore( + withResource(() => ({ + id: resource({ loader: () => Promise.resolve(1) }), + })), + ); + }); + }); }); diff --git a/libs/ngrx-toolkit/src/lib/with-resource.ts b/libs/ngrx-toolkit/src/lib/with-resource.ts index c2011b29..5251935f 100644 --- a/libs/ngrx-toolkit/src/lib/with-resource.ts +++ b/libs/ngrx-toolkit/src/lib/with-resource.ts @@ -6,6 +6,7 @@ import { ResourceRef, ResourceStatus, Signal, + untracked, WritableSignal, } from '@angular/core'; import { @@ -33,10 +34,17 @@ export type ResourceResult = { export type ResourceDictionary = Record>; -export type NamedResourceResult = { +export type NamedResourceResult< + T extends ResourceDictionary, + HasUndefinedErrorHandling extends boolean, +> = { state: { [Prop in keyof T as `${Prop & - string}Value`]: T[Prop]['value'] extends Signal ? S : never; + string}Value`]: T[Prop]['value'] extends Signal + ? HasUndefinedErrorHandling extends true + ? S | undefined + : S + : never; }; props: { [Prop in keyof T as `${Prop & string}Status`]: Signal; @@ -54,6 +62,16 @@ export type NamedResourceResult = { }; }; +export type ErrorHandling = 'native' | 'undefined value' | 'previous value'; + +export type ResourceOptions = { + errorHandling?: ErrorHandling; +}; + +const defaultOptions: Required = { + errorHandling: 'undefined value', +}; + //** Implementation of `withResource` */ /** @@ -63,7 +81,7 @@ export type NamedResourceResult = { * Integrates a `Resource` into the SignalStore and makes the store instance * implement the `Resource` interface. * - * The resource’s value is stored under the `value` key in the state + * The resource's value is stored under the `value` key in the state * and is exposed as a `DeepSignal`. * * It can also be updated via `patchState`. @@ -85,7 +103,8 @@ export type NamedResourceResult = { * ``` * * @param resourceFactory A factory function that receives the store's state signals, - * methods, and props. Needs to return a `ResourceRef`. + * methods, and props. + * @param resourceOptions Allows configuration of the error handling behavior. */ export function withResource< Input extends SignalStoreFeatureResult, @@ -94,6 +113,26 @@ export function withResource< resourceFactory: ( store: Input['props'] & Input['methods'] & StateSignals, ) => ResourceRef, +): SignalStoreFeature>; + +export function withResource< + Input extends SignalStoreFeatureResult, + ResourceValue, +>( + resourceFactory: ( + store: Input['props'] & Input['methods'] & StateSignals, + ) => ResourceRef, + resourceOptions: { errorHandling: 'undefined value' }, +): SignalStoreFeature>; + +export function withResource< + Input extends SignalStoreFeatureResult, + ResourceValue, +>( + resourceFactory: ( + store: Input['props'] & Input['methods'] & StateSignals, + ) => ResourceRef, + resourceOptions?: ResourceOptions, ): SignalStoreFeature>; /** @@ -128,6 +167,7 @@ export function withResource< * * @param resourceFactory A factory function that receives the store's props, * methods, and state signals. It must return a `Record`. + * @param resourceOptions Allows to configure the error handling behavior. */ export function withResource< Input extends SignalStoreFeatureResult, @@ -136,7 +176,27 @@ export function withResource< resourceFactory: ( store: Input['props'] & Input['methods'] & StateSignals, ) => Dictionary, -): SignalStoreFeature>; +): SignalStoreFeature>; + +export function withResource< + Input extends SignalStoreFeatureResult, + Dictionary extends ResourceDictionary, +>( + resourceFactory: ( + store: Input['props'] & Input['methods'] & StateSignals, + ) => Dictionary, + resourceOptions: { errorHandling: 'undefined value' }, +): SignalStoreFeature>; + +export function withResource< + Input extends SignalStoreFeatureResult, + Dictionary extends ResourceDictionary, +>( + resourceFactory: ( + store: Input['props'] & Input['methods'] & StateSignals, + ) => Dictionary, + resourceOptions?: ResourceOptions, +): SignalStoreFeature>; export function withResource< Input extends SignalStoreFeatureResult, @@ -145,7 +205,12 @@ export function withResource< resourceFactory: ( store: Input['props'] & Input['methods'] & StateSignals, ) => ResourceRef | ResourceDictionary, + resourceOptions?: ResourceOptions, ): SignalStoreFeature { + const options: Required = { + ...defaultOptions, + ...(resourceOptions || {}), + }; return (store) => { const resourceOrDictionary = resourceFactory({ ...store.stateSignals, @@ -154,22 +219,31 @@ export function withResource< }); if (isResourceRef(resourceOrDictionary)) { - return createUnnamedResource(resourceOrDictionary)(store); + return createUnnamedResource( + resourceOrDictionary, + options.errorHandling, + )(store); } else { - return createNamedResource(resourceOrDictionary)(store); + return createNamedResource( + resourceOrDictionary, + options.errorHandling, + )(store); } }; } function createUnnamedResource( resource: ResourceRef, + errorHandling: ErrorHandling, ) { function hasValue(): this is Resource> { return resource.hasValue(); } return signalStoreFeature( - withLinkedState(() => ({ value: resource.value })), + withLinkedState(() => ({ + value: valueSignalForErrorHandling(resource, errorHandling), + })), withProps(() => ({ status: resource.status, error: resource.error, @@ -184,13 +258,17 @@ function createUnnamedResource( function createNamedResource( dictionary: Dictionary, + errorHandling: ErrorHandling, ) { const keys = Object.keys(dictionary); const state: Record> = keys.reduce( (state, resourceName) => ({ ...state, - [`${resourceName}Value`]: dictionary[resourceName].value, + [`${resourceName}Value`]: valueSignalForErrorHandling( + dictionary[resourceName], + errorHandling, + ), }), {}, ); @@ -261,7 +339,7 @@ type IsValidResourceName< : false : false; -type ResourceNames> = keyof { +export type ResourceNames> = keyof { [Prop in keyof Store as Prop extends `${infer Name}Value` ? IsValidResourceName extends true ? Name @@ -319,3 +397,145 @@ export function mapToResource< hasValue, } as MappedResource; } + +/** + * Strategies to work around the error throwing behavior of the Resource API. + * + * The original idea was to use a `linkedSignal` as the state's value Signal. It would mean + * that we can leverage the `computation` callback to handle the error. The downside is that + * changes to that signal will not be reflected in the underlying resource, i.e. the resource + * will not switch to status `local`. + * + * 1. An option to fix that would be to put the `linkedSignal` as property in the SignalStore, + * where it would have the name `value`. Given, we apply a `DeepSignal` to it, it would not + * break from the outside. The original value would be put into the state behind a hidden Symbol + * as property name. In order to update the state, users will get an updater function, called + * `setResource`. + * + * That works perfectly for unnamed resources, but could cause potential problems + * for named resources, when they are defined multiple times, i.e. calling `withResource` + * multiple times. The reason is that, we would have to hide their values in the state again + * behind a symbol, but that would be a property which gets defined once, and would get new + * subproperties (the values of the resources) added per additional `withResource` call. + * + * Using a separate updated method is a common SignalStore pattern, which is also used + * in `withEntities`. + * + * For named resources, `setResource` would come with a name as first parameter. + * + * We saw in earlier experiments that there are TypeScript-specific challenges. + * + * Pros: + * - Uses Angular's native `linkedSignal` and isn't a hackish approach + * - Status transitions to 'local' work correctly (via direct `res.value.set()` in `setResource`) + * - Works with `patchState`/`getState` (`linkedSignal` handles errors on read) + * - Clear, explicit API with dedicated `setResource()` method + * + * Cons: + * - Requires API change: users must use `setResource()` instead of `patchState(store, { value })` + * - Named resources with multiple `withResource` calls: hidden state management becomes complex + * + * 2. A possible alternative would be to use a Proxy on value. Instead of using a `linkedSignal`, + * we can leave the value signal as is and create a proxy around it that intercepts the get/call + * operation and handles the error. The downside is that we need to implement the proxy ourselves, + * which is not as clean as using a `linkedSignal`. On the other hand, there are indicators that + * in future version of Angular, there are better ways of handling errors, which means that this + * approach is only temporary. + * + * It could also happen, that we are getting some sort of "Mapped Signal", where not just the + * reading (as in `linkedSignal`) but also the writing is handled. + * + * Pros: + * - No API changes: `patchState(store, { value: x })` works naturally + * - Status transitions to 'local' work correctly (writes go directly to original signal) + * - Works with `patchState`/`getState` (proxy intercepts reads and handles errors) + * - Uniform solution: same approach for both named and unnamed resources + * - Transparent: looks and behaves like a normal signal from the outside + * + * Cons: + * - Manual implementation: must properly handle all signal methods (`set`, `update`, `asReadonly`, etc.) + * - Dependency tracking: need to verify proxy doesn't break Angular's reactivity system + * - More complex proxy logic required for 'previous value' strategy (caching previous values) + * - Less "Angular-native": doesn't leverage `linkedSignal`'s built-in reactivity guarantees + * + * ===== + * + * The decision was made to use the proxy approach, because it is temporary and will not be + * a breaking change. + */ +function valueSignalForErrorHandling( + res: ResourceRef, + errorHandling: 'undefined value', +): WritableSignal; + +function valueSignalForErrorHandling( + res: ResourceRef, + errorHandling: ErrorHandling, +): WritableSignal; + +function valueSignalForErrorHandling( + res: ResourceRef, + errorHandling: ErrorHandling, +): WritableSignal { + const originalSignal = res.value; + + switch (errorHandling) { + case 'native': + return originalSignal; + case 'undefined value': { + return new Proxy(originalSignal, { + apply(target) { + const status = untracked(() => res.status()); + try { + // Always call the underlying signal to ensure reactivity. + const value = target(); + if (status === 'error') { + return undefined; + } + return value; + } catch (error) { + if (status === 'error') { + return undefined; + } + throw error; + } + }, + }); + } + case 'previous value': { + let previousValue: T | undefined = undefined; + let hasPreviousValue = false; + + return new Proxy(originalSignal, { + apply(target) { + const status = untracked(() => res.status()); + try { + // Always call the underlying signal to ensure reactivity. + const value = target(); + if (status === 'error') { + if (!hasPreviousValue) { + throw new Error( + 'Impossible state: previous value is not available -> resource was initialized with error', + ); + } + return previousValue; + } + previousValue = value; + hasPreviousValue = true; + return value; + } catch (error) { + if (status === 'error') { + if (!hasPreviousValue) { + throw new Error( + 'Impossible state: previous value is not available -> resource was initialized with error', + ); + } + return previousValue; + } + throw error; + } + }, + }); + } + } +} diff --git a/libs/ngrx-toolkit/src/lib/with-resource/tests/util/fixtures.ts b/libs/ngrx-toolkit/src/lib/with-resource/tests/util/fixtures.ts new file mode 100644 index 00000000..2f5da7e8 --- /dev/null +++ b/libs/ngrx-toolkit/src/lib/with-resource/tests/util/fixtures.ts @@ -0,0 +1,36 @@ +import { Injectable } from '@angular/core'; + +export type Address = { + street: string; + city: { + zip: string; + name: string; + }; + country: string; +}; + +export const venice: Address = { + street: 'Sestiere Dorsoduro, 2771', + city: { + zip: '30123', + name: 'Venezia VE', + }, + country: 'Italy', +}; + +export const vienna: Address = { + street: 'Schottenring, 1', + city: { + zip: '1010', + name: 'Vienna', + }, + country: 'Austria', +}; + +@Injectable({ providedIn: 'root' }) +export class AddressResolver { + resolve(id: number) { + void id; + return Promise.resolve
(this.address); + } +} diff --git a/libs/ngrx-toolkit/src/lib/with-resource/tests/util/params-for-resource-types.ts b/libs/ngrx-toolkit/src/lib/with-resource/tests/util/params-for-resource-types.ts new file mode 100644 index 00000000..1ef458f0 --- /dev/null +++ b/libs/ngrx-toolkit/src/lib/with-resource/tests/util/params-for-resource-types.ts @@ -0,0 +1,21 @@ +import { ErrorHandling } from '../../../with-resource'; +import { setupMappedResource } from './setup-mapped-resource'; +import { setupNamedResource } from './setup-named-resource'; +import { setupUnnamedResource } from './setup-unnamed-resource'; + +export function paramsForResourceTypes(errorHandling: ErrorHandling) { + return [ + { + name: 'unnamed resource', + setup: () => setupUnnamedResource(errorHandling), + }, + { + name: 'mapped named resource', + setup: () => setupMappedResource(errorHandling), + }, + { + name: 'named resource', + setup: () => setupNamedResource(errorHandling), + }, + ]; +} diff --git a/libs/ngrx-toolkit/src/lib/with-resource/tests/util/resource-test-adapter.ts b/libs/ngrx-toolkit/src/lib/with-resource/tests/util/resource-test-adapter.ts new file mode 100644 index 00000000..d0cc9457 --- /dev/null +++ b/libs/ngrx-toolkit/src/lib/with-resource/tests/util/resource-test-adapter.ts @@ -0,0 +1,16 @@ +import { ResourceStatus } from '@angular/core'; +import { Address } from './fixtures'; + +export type ResourceTestAdapter = { + addressResolver: { resolve: jest.Mock }; + setId: (id: number) => void; + setValue: (value: Address) => void; + getValue: () => Address | undefined; + getMetadata: () => { + status: ResourceStatus; + error: Error | undefined; + isLoading: boolean; + hasValue: boolean; + }; + reload: () => void; +}; diff --git a/libs/ngrx-toolkit/src/lib/with-resource/tests/util/setup-mapped-resource.ts b/libs/ngrx-toolkit/src/lib/with-resource/tests/util/setup-mapped-resource.ts new file mode 100644 index 00000000..6f0af070 --- /dev/null +++ b/libs/ngrx-toolkit/src/lib/with-resource/tests/util/setup-mapped-resource.ts @@ -0,0 +1,33 @@ +import { + ErrorHandling, + mapToResource, + ResourceNames, +} from '../../../with-resource'; +import { ResourceTestAdapter } from './resource-test-adapter'; +import { setupNamedResource } from './setup-named-resource'; + +export function setupMappedResource( + errorHandling: ErrorHandling, +): ResourceTestAdapter { + const { addressResolver, setId, setValue, reload, store } = + setupNamedResource(errorHandling); + + const resource = mapToResource( + store, + 'address' as ResourceNames>, + ); + + return { + addressResolver, + setId, + setValue, + getValue: () => resource.value(), + getMetadata: () => ({ + status: resource.status(), + error: resource.error(), + isLoading: resource.isLoading(), + hasValue: resource.hasValue(), + }), + reload, + }; +} diff --git a/libs/ngrx-toolkit/src/lib/with-resource/tests/util/setup-named-resource.ts b/libs/ngrx-toolkit/src/lib/with-resource/tests/util/setup-named-resource.ts new file mode 100644 index 00000000..2dc4a969 --- /dev/null +++ b/libs/ngrx-toolkit/src/lib/with-resource/tests/util/setup-named-resource.ts @@ -0,0 +1,59 @@ +import { inject, resource } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { patchState, signalStore, withMethods, withState } from '@ngrx/signals'; +import { ErrorHandling, withResource } from '../../../with-resource'; +import { Address, AddressResolver, venice } from './fixtures'; +import { ResourceTestAdapter } from './resource-test-adapter'; + +export function setupNamedResource( + errorHandling: ErrorHandling, +): ResourceTestAdapter & { store: Record } { + const addressResolver = { + resolve: jest.fn(() => Promise.resolve(venice)), + }; + + const UserStore = signalStore( + { providedIn: 'root', protectedState: false }, + withState({ id: undefined as number | undefined }), + withResource( + (store) => { + const resolver = inject(AddressResolver); + return { + address: resource({ + params: store.id, + loader: ({ params: id }) => resolver.resolve(id), + }), + }; + }, + { errorHandling }, + ), + withMethods((store) => ({ reload: () => store._addressReload() })), + ); + + TestBed.configureTestingModule({ + providers: [ + { + provide: AddressResolver, + useValue: addressResolver, + }, + ], + }); + + const store = TestBed.inject(UserStore); + + // avoid TypeScript's excessive property checks + return { + store, + addressResolver, + setId: (id: number) => patchState(store, { id }), + setValue: (value: Address) => patchState(store, { addressValue: value }), + getValue: () => store.addressValue(), + getMetadata: () => ({ + status: store.addressStatus(), + error: store.addressError(), + isLoading: store.addressIsLoading(), + hasValue: store.addressHasValue(), + }), + reload: () => store.reload(), + }; +} diff --git a/libs/ngrx-toolkit/src/lib/with-resource/tests/util/setup-unnamed-resource.ts b/libs/ngrx-toolkit/src/lib/with-resource/tests/util/setup-unnamed-resource.ts new file mode 100644 index 00000000..0939d10d --- /dev/null +++ b/libs/ngrx-toolkit/src/lib/with-resource/tests/util/setup-unnamed-resource.ts @@ -0,0 +1,54 @@ +import { inject, resource } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { patchState, signalStore, withMethods, withState } from '@ngrx/signals'; +import { ErrorHandling, withResource } from '../../../with-resource'; +import { Address, AddressResolver, venice } from './fixtures'; +import { ResourceTestAdapter } from './resource-test-adapter'; + +export function setupUnnamedResource( + errorHandling?: ErrorHandling, +): ResourceTestAdapter { + const addressResolver = { + resolve: jest.fn(() => Promise.resolve(venice)), + }; + const AddressStore = signalStore( + { providedIn: 'root', protectedState: false }, + withState({ id: undefined as number | undefined }), + withResource( + (store) => { + const resolver = inject(AddressResolver); + return resource({ + params: store.id, + loader: ({ params: id }) => resolver.resolve(id), + }); + }, + { errorHandling }, + ), + withMethods((store) => ({ reload: () => store._reload() })), + ); + + TestBed.configureTestingModule({ + providers: [ + { + provide: AddressResolver, + useValue: addressResolver, + }, + ], + }); + + const store = TestBed.inject(AddressStore); + + return { + addressResolver, + setId: (id: number) => patchState(store, { id }), + setValue: (value: Address) => patchState(store, { value }), + getValue: () => store.value(), + getMetadata: () => ({ + status: store.status(), + error: store.error(), + isLoading: store.isLoading(), + hasValue: store.hasValue(), + }), + reload: () => store.reload(), + }; +} diff --git a/libs/ngrx-toolkit/tsconfig.lib.json b/libs/ngrx-toolkit/tsconfig.lib.json index 063e5257..d3ffdc91 100644 --- a/libs/ngrx-toolkit/tsconfig.lib.json +++ b/libs/ngrx-toolkit/tsconfig.lib.json @@ -8,10 +8,11 @@ "types": [] }, "exclude": [ + "src/**/tests/**/*.ts", "src/**/*.spec.ts", "src/test-setup.ts", "jest.config.ts", "src/**/*.test.ts" ], - "include": ["src/**/*.ts"] + "include": ["src/**/*.ts", "src/lib/test-utils"] } diff --git a/libs/ngrx-toolkit/tsconfig.spec.json b/libs/ngrx-toolkit/tsconfig.spec.json index 4f77cfc6..db372e05 100644 --- a/libs/ngrx-toolkit/tsconfig.spec.json +++ b/libs/ngrx-toolkit/tsconfig.spec.json @@ -14,6 +14,7 @@ "src/**/*.test.ts", "src/**/*.spec.ts", "src/**/*.d.ts", + "src/**/tests/**/*.ts", "src/lib/storage-sync/tests/with-storage-sync-indexedb.spec.ts" ] }