From 066daef9af44da836a0d7421fe3763ce0e047c8a Mon Sep 17 00:00:00 2001 From: Michael Small Date: Thu, 23 Apr 2026 19:30:38 -0500 Subject: [PATCH 01/18] feat: widen `withResource` to `Resource` beyond `ResourceRef` --- .../src/lib/with-resource.spec.ts | 16 +- libs/ngrx-toolkit/src/lib/with-resource.ts | 231 ++++++++++++++---- .../tests/util/resource-test-adapter.ts | 3 + .../tests/util/setup-mapped-resource.ts | 3 +- .../tests/util/setup-named-resource.ts | 4 +- .../tests/util/setup-unnamed-resource.ts | 4 +- 6 files changed, 206 insertions(+), 55 deletions(-) diff --git a/libs/ngrx-toolkit/src/lib/with-resource.spec.ts b/libs/ngrx-toolkit/src/lib/with-resource.spec.ts index 5958eae0..eb882426 100644 --- a/libs/ngrx-toolkit/src/lib/with-resource.spec.ts +++ b/libs/ngrx-toolkit/src/lib/with-resource.spec.ts @@ -319,12 +319,14 @@ describe('withResource', () => { { providedIn: 'root', protectedState: false }, withState({ id: undefined as number | undefined }), withResource(({ id }) => ({ - list: httpResource<{ id: number; name: string }[]>( - () => '/address', + list: httpResource< { - defaultValue: [], - }, - ), + id: number; + name: string; + }[] + >(() => '/address', { + defaultValue: [], + }), detail: httpResource
(() => id() ? `/address/${id()}` : undefined, ), @@ -609,7 +611,9 @@ describe('withResource', () => { IsEqual> >; }); - + it('only exposes reload methods for reloadable resources', () => { + // TODO - can type tests for _reload be done? + }); describe('mapToResource', () => { it('satisfies the Resource interface without default value', () => { const Store = signalStore( diff --git a/libs/ngrx-toolkit/src/lib/with-resource.ts b/libs/ngrx-toolkit/src/lib/with-resource.ts index 5251935f..05238aee 100644 --- a/libs/ngrx-toolkit/src/lib/with-resource.ts +++ b/libs/ngrx-toolkit/src/lib/with-resource.ts @@ -1,9 +1,11 @@ //** Types for `withResource` */ +import { HttpResourceRef } from '@angular/common/http'; import { isSignal, Resource, ResourceRef, + ResourceSnapshot, ResourceStatus, Signal, untracked, @@ -19,12 +21,39 @@ import { withProps, } from '@ngrx/signals'; +// TODO - add other specifics of HttpResourceRef (headers, statusCode, progress) +export type HttpResourceRefResult = { + state: { value: T }; + props: { + status: Signal; + error: Signal; + isLoading: Signal; + snapshot: Signal>; + }; + methods: { + hasValue(): this is Resource>; + _reload(): boolean; + }; +}; export type ResourceResult = { state: { value: T }; props: { status: Signal; error: Signal; isLoading: Signal; + snapshot: Signal>; + }; + methods: { + hasValue(): this is Resource>; + }; +}; +export type ResourceRefResult = { + state: { value: T }; + props: { + status: Signal; + error: Signal; + isLoading: Signal; + snapshot: Signal>; }; methods: { hasValue(): this is Resource>; @@ -32,7 +61,43 @@ export type ResourceResult = { }; }; -export type ResourceDictionary = Record>; +type ReloadableResource = ResourceRef | HttpResourceRef; + +type InferResourceValue> = + T['value'] extends Signal ? S : never; + +type ConditionalReloadMethod> = + T extends ReloadableResource + ? { _reload(): boolean } + : Record; + +type UnnamedResourceResult< + T extends WidenedResource, + HasUndefinedErrorHandling extends boolean, +> = { + state: { + value: HasUndefinedErrorHandling extends true + ? InferResourceValue | undefined + : InferResourceValue; + }; + props: { + status: Signal; + error: Signal; + isLoading: Signal; + snapshot: Signal>>; + }; + methods: { + hasValue(): this is Resource, undefined>>; + } & ConditionalReloadMethod; +}; + +// TODO - do not export once tests are chill? +export type WidenedResource = + | ResourceRef + | Resource + | HttpResourceRef; + +export type ResourceDictionary = Record>; export type NamedResourceResult< T extends ResourceDictionary, @@ -52,13 +117,19 @@ export type NamedResourceResult< [Prop in keyof T as `${Prop & string}Error`]: Signal; } & { [Prop in keyof T as `${Prop & string}IsLoading`]: Signal; + } & { + [Prop in keyof T as `${Prop & string}Snapshot`]: Signal< + ResourceSnapshot ? S : never> + >; }; methods: { [Prop in keyof T as `${Prop & string}HasValue`]: () => this is Resource< - Exclude + Exclude, undefined> >; } & { - [Prop in keyof T as `_${Prop & string}Reload`]: () => boolean; + [Prop in keyof T as T[Prop] extends ReloadableResource + ? `_${Prop & string}Reload` + : never]: () => boolean; }; }; @@ -108,32 +179,32 @@ const defaultOptions: Required = { */ export function withResource< Input extends SignalStoreFeatureResult, - ResourceValue, + ResourceType extends WidenedResource, >( resourceFactory: ( store: Input['props'] & Input['methods'] & StateSignals, - ) => ResourceRef, -): SignalStoreFeature>; + ) => ResourceType, +): SignalStoreFeature>; export function withResource< Input extends SignalStoreFeatureResult, - ResourceValue, + ResourceType extends WidenedResource, >( resourceFactory: ( store: Input['props'] & Input['methods'] & StateSignals, - ) => ResourceRef, + ) => ResourceType, resourceOptions: { errorHandling: 'undefined value' }, -): SignalStoreFeature>; +): SignalStoreFeature>; export function withResource< Input extends SignalStoreFeatureResult, - ResourceValue, + ResourceType extends WidenedResource, >( resourceFactory: ( store: Input['props'] & Input['methods'] & StateSignals, - ) => ResourceRef, + ) => ResourceType, resourceOptions?: ResourceOptions, -): SignalStoreFeature>; +): SignalStoreFeature>; /** * @experimental @@ -166,7 +237,7 @@ export function withResource< * ``` * * @param resourceFactory A factory function that receives the store's props, - * methods, and state signals. It must return a `Record`. + * methods, and state signals. It must return a `Record`. * @param resourceOptions Allows to configure the error handling behavior. */ export function withResource< @@ -204,7 +275,7 @@ export function withResource< >( resourceFactory: ( store: Input['props'] & Input['methods'] & StateSignals, - ) => ResourceRef | ResourceDictionary, + ) => WidenedResource | ResourceDictionary, resourceOptions?: ResourceOptions, ): SignalStoreFeature { const options: Required = { @@ -218,7 +289,10 @@ export function withResource< ...store.methods, }); - if (isResourceRef(resourceOrDictionary)) { + if ( + isResourceRef(resourceOrDictionary) || + isResource(resourceOrDictionary) + ) { return createUnnamedResource( resourceOrDictionary, options.errorHandling, @@ -233,26 +307,46 @@ export function withResource< } function createUnnamedResource( - resource: ResourceRef, + resource: WidenedResource, errorHandling: ErrorHandling, ) { - function hasValue(): this is Resource> { - return resource.hasValue(); + function hasValue(): this is WidenedResource< + Exclude + > { + if (isHttpResourceRef(resource)) { + return resource.hasValue(); + } else if (isResourceRef(resource)) { + return resource.hasValue(); + } else { + return resource.hasValue(); + } + } + + const stateFeature = withLinkedState(() => ({ + value: valueSignalForErrorHandling(resource, errorHandling), + })); + const propsFeature = withProps(() => ({ + status: resource.status, + error: resource.error, + isLoading: resource.isLoading, + snapshot: resource.snapshot, + })); + + if (isHttpResourceRef(resource) || isResourceRef(resource)) { + return signalStoreFeature( + stateFeature, + propsFeature, + withMethods(() => ({ + hasValue, + _reload: () => resource.reload(), + })), + ); } return signalStoreFeature( - withLinkedState(() => ({ - value: valueSignalForErrorHandling(resource, errorHandling), - })), - withProps(() => ({ - status: resource.status, - error: resource.error, - isLoading: resource.isLoading, - })), - withMethods(() => ({ - hasValue, - _reload: () => resource.reload(), - })), + stateFeature, + propsFeature, + withMethods(() => ({ hasValue })), ); } @@ -279,17 +373,33 @@ function createNamedResource( [`${resourceName}Status`]: dictionary[resourceName].status, [`${resourceName}Error`]: dictionary[resourceName].error, [`${resourceName}IsLoading`]: dictionary[resourceName].isLoading, + [`${resourceName}Snapshot`]: dictionary[resourceName].snapshot, }), {}, ); const methods: Record boolean> = keys.reduce( (methods, resourceName) => { - return { - ...methods, - [`${resourceName}HasValue`]: () => dictionary[resourceName].hasValue(), - [`_${resourceName}Reload`]: () => dictionary[resourceName].reload(), - }; + const res = dictionary[resourceName]; + + if (isHttpResourceRef(res)) { + return { + ...methods, + [`${resourceName}HasValue`]: () => res.hasValue(), + [`_${resourceName}Reload`]: () => res.reload(), + }; + } else if (isResourceRef(res)) { + return { + ...methods, + [`${resourceName}HasValue`]: () => res.hasValue(), + [`_${resourceName}Reload`]: () => res.reload(), + }; + } else { + return { + ...methods, + [`${resourceName}HasValue`]: () => res.hasValue(), + }; + } }, {}, ); @@ -310,10 +420,44 @@ export function isResourceRef(value: unknown): value is ResourceRef { 'status' in value && 'error' in value && 'isLoading' in value && + 'snapshot' in value && 'hasValue' in value && 'reload' in value ); } +export function isHttpResourceRef( + value: unknown, +): value is HttpResourceRef { + return ( + value !== null && + typeof value === 'object' && + 'value' in value && + isSignal(value.value) && + 'status' in value && + 'error' in value && + 'isLoading' in value && + 'snapshot' in value && + 'hasValue' in value && + 'reload' in value && + 'headers' in value && + 'statusCode' in value && + 'progress' in value + ); +} + +export function isResource(value: unknown): value is Resource { + return ( + value !== null && + typeof value === 'object' && + 'value' in value && + isSignal(value.value) && + 'status' in value && + 'error' in value && + 'isLoading' in value && + 'snapshot' in value && + 'hasValue' in value + ); +} //** Types for `mapToResource` */ @@ -327,6 +471,8 @@ type NamedResource = { [Prop in `${Name}IsLoading`]: Signal; } & { [Prop in `${Name}HasValue`]: () => boolean; +} & { + [Prop in `${Name}Snapshot`]: Signal>; }; type IsValidResourceName< @@ -375,7 +521,7 @@ type MappedResource< * * @param store The store instance to map the resource to. * @param name The name of the resource to map. - * @returns `ResourceRef` + * @returns `WidenedResource` */ export function mapToResource< Name extends ResourceNames, @@ -394,6 +540,7 @@ export function mapToResource< status: store[`${resourceName}Status`], error: store[`${resourceName}Error`], isLoading: store[`${resourceName}IsLoading`], + snapshot: store[`${resourceName}Snapshot`], hasValue, } as MappedResource; } @@ -464,19 +611,19 @@ export function mapToResource< * a breaking change. */ function valueSignalForErrorHandling( - res: ResourceRef, + res: WidenedResource, errorHandling: 'undefined value', -): WritableSignal; +): Signal; function valueSignalForErrorHandling( - res: ResourceRef, + res: WidenedResource, errorHandling: ErrorHandling, -): WritableSignal; +): Signal; function valueSignalForErrorHandling( - res: ResourceRef, + res: WidenedResource, errorHandling: ErrorHandling, -): WritableSignal { +): Signal { const originalSignal = res.value; switch (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 index d0cc9457..f874c9bb 100644 --- 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 @@ -12,5 +12,8 @@ export type ResourceTestAdapter = { isLoading: boolean; hasValue: boolean; }; +}; + +export type ReloadableResourceTestAdapter = ResourceTestAdapter & { 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 index 6f0af070..bc015ba5 100644 --- 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 @@ -9,7 +9,7 @@ import { setupNamedResource } from './setup-named-resource'; export function setupMappedResource( errorHandling: ErrorHandling, ): ResourceTestAdapter { - const { addressResolver, setId, setValue, reload, store } = + const { addressResolver, setId, setValue, store } = setupNamedResource(errorHandling); const resource = mapToResource( @@ -28,6 +28,5 @@ export function setupMappedResource( 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 index 2dc4a969..ca5f1858 100644 --- 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 @@ -1,6 +1,6 @@ import { inject, resource } from '@angular/core'; import { TestBed } from '@angular/core/testing'; -import { patchState, signalStore, withMethods, withState } from '@ngrx/signals'; +import { patchState, signalStore, withState } from '@ngrx/signals'; import { ErrorHandling, withResource } from '../../../with-resource'; import { Address, AddressResolver, venice } from './fixtures'; import { ResourceTestAdapter } from './resource-test-adapter'; @@ -27,7 +27,6 @@ export function setupNamedResource( }, { errorHandling }, ), - withMethods((store) => ({ reload: () => store._addressReload() })), ); TestBed.configureTestingModule({ @@ -54,6 +53,5 @@ export function setupNamedResource( 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 index 0939d10d..c3b075cb 100644 --- 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 @@ -3,11 +3,11 @@ 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'; +import { ReloadableResourceTestAdapter } from './resource-test-adapter'; export function setupUnnamedResource( errorHandling?: ErrorHandling, -): ResourceTestAdapter { +): ReloadableResourceTestAdapter { const addressResolver = { resolve: jest.fn(() => Promise.resolve(venice)), }; From 1046fcf4f5e9870df5d1f5cf59da612b3fe68dc1 Mon Sep 17 00:00:00 2001 From: Michael Small Date: Thu, 23 Apr 2026 19:43:13 -0500 Subject: [PATCH 02/18] chore: test resource reload exposure --- .../src/lib/with-resource.spec.ts | 34 ++++++++++++++++++- .../lib/with-resource/tests/util/snapshot.ts | 26 ++++++++++++++ 2 files changed, 59 insertions(+), 1 deletion(-) create mode 100644 libs/ngrx-toolkit/src/lib/with-resource/tests/util/snapshot.ts diff --git a/libs/ngrx-toolkit/src/lib/with-resource.spec.ts b/libs/ngrx-toolkit/src/lib/with-resource.spec.ts index eb882426..5c03838f 100644 --- a/libs/ngrx-toolkit/src/lib/with-resource.spec.ts +++ b/libs/ngrx-toolkit/src/lib/with-resource.spec.ts @@ -19,6 +19,7 @@ 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'; +import { withPreviousValue } from './with-resource/tests/util/snapshot'; describe('withResource', () => { describe('standard tests', () => { @@ -612,7 +613,38 @@ describe('withResource', () => { >; }); it('only exposes reload methods for reloadable resources', () => { - // TODO - can type tests for _reload be done? + const Store = signalStore( + { providedIn: 'root' }, + withResource(() => ({ + digit: resource({ + loader: () => Promise.resolve(-1), + defaultValue: 0, + }), + digitSnapshotted: withPreviousValue( + resource({ + loader: () => Promise.resolve(-1), + defaultValue: 0, + }), + ), + })), + withResource(() => + resource({ + loader: () => Promise.resolve(-1), + defaultValue: 0, + }), + ), + withMethods((store) => ({ + namedReload: () => store._digitReload(), + unnamedReload: () => store._reload(), + // @ts-expect-error - a snapshot should not have a reload + invalidReload: () => store._digitSnapshottedReload(), + })), + ); + + const _store = TestBed.inject(Store); + + type _T1 = Assert boolean>>; + type _T2 = Assert boolean>>; }); describe('mapToResource', () => { it('satisfies the Resource interface without default value', () => { diff --git a/libs/ngrx-toolkit/src/lib/with-resource/tests/util/snapshot.ts b/libs/ngrx-toolkit/src/lib/with-resource/tests/util/snapshot.ts new file mode 100644 index 00000000..57829184 --- /dev/null +++ b/libs/ngrx-toolkit/src/lib/with-resource/tests/util/snapshot.ts @@ -0,0 +1,26 @@ +import { + linkedSignal, + Resource, + resourceFromSnapshots, + ResourceSnapshot, +} from '@angular/core'; + +export function withPreviousValue(input: Resource): Resource { + const derived = linkedSignal, ResourceSnapshot>({ + source: input.snapshot, + computation: (snap, previous) => { + if ( + snap.status === 'loading' && + previous && + previous.value.status !== 'error' + ) { + // When the input resource enters loading state, we keep the value + // from its previous state, if any. + return { status: 'loading' as const, value: previous.value.value }; + } + // Otherwise we simply forward the state of the input resource. + return snap; + }, + }); + return resourceFromSnapshots(derived); +} From 598893c624cbf6fd7faee89b338ef3ed18856d2f Mon Sep 17 00:00:00 2001 From: Michael Small Date: Thu, 23 Apr 2026 19:45:22 -0500 Subject: [PATCH 03/18] chore: un-export internal widened resource type --- libs/ngrx-toolkit/src/lib/with-resource.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/libs/ngrx-toolkit/src/lib/with-resource.ts b/libs/ngrx-toolkit/src/lib/with-resource.ts index 05238aee..933630a9 100644 --- a/libs/ngrx-toolkit/src/lib/with-resource.ts +++ b/libs/ngrx-toolkit/src/lib/with-resource.ts @@ -91,11 +91,7 @@ type UnnamedResourceResult< } & ConditionalReloadMethod; }; -// TODO - do not export once tests are chill? -export type WidenedResource = - | ResourceRef - | Resource - | HttpResourceRef; +type WidenedResource = ResourceRef | Resource | HttpResourceRef; export type ResourceDictionary = Record>; From b94e49004e3712e2d859ee92ab64ce4377d5278c Mon Sep 17 00:00:00 2001 From: Michael Small Date: Mon, 4 May 2026 12:14:47 -0500 Subject: [PATCH 04/18] chore: refactor to conditionally enable resource patching --- .../src/lib/with-resource.spec.ts | 36 ++++ libs/ngrx-toolkit/src/lib/with-resource.ts | 162 ++++++++++-------- 2 files changed, 126 insertions(+), 72 deletions(-) diff --git a/libs/ngrx-toolkit/src/lib/with-resource.spec.ts b/libs/ngrx-toolkit/src/lib/with-resource.spec.ts index 5c03838f..69ffc702 100644 --- a/libs/ngrx-toolkit/src/lib/with-resource.spec.ts +++ b/libs/ngrx-toolkit/src/lib/with-resource.spec.ts @@ -646,6 +646,42 @@ describe('withResource', () => { type _T1 = Assert boolean>>; type _T2 = Assert boolean>>; }); + + it('does not allow patching an unnamed non-reloadable resource value', () => { + signalStore( + withResource(() => + withPreviousValue( + resource({ + loader: () => Promise.resolve(1), + defaultValue: 0, + }), + ), + ), + withMethods((store) => ({ + // @ts-expect-error - non-reloadable Resource values are not state + setValue: (value: number) => patchState(store, { value }), + })), + ); + }); + + it('does not allow patching a named non-reloadable resource value', () => { + signalStore( + withResource(() => ({ + digitSnapshotted: withPreviousValue( + resource({ + loader: () => Promise.resolve(-1), + defaultValue: 0, + }), + ), + })), + withMethods((store) => ({ + setNamedValue: (value: number) => + // @ts-expect-error digitSnapshotted is a `Resource` value, but not state, so it should not be patchable + patchState(store, { digitSnapshottedValue: value }), + })), + ); + }); + describe('mapToResource', () => { it('satisfies the Resource interface without default value', () => { const Store = signalStore( diff --git a/libs/ngrx-toolkit/src/lib/with-resource.ts b/libs/ngrx-toolkit/src/lib/with-resource.ts index 933630a9..75f4650c 100644 --- a/libs/ngrx-toolkit/src/lib/with-resource.ts +++ b/libs/ngrx-toolkit/src/lib/with-resource.ts @@ -71,16 +71,33 @@ type ConditionalReloadMethod> = ? { _reload(): boolean } : Record; +declare const NON_PATCHABLE_RESOURCE_STATE: unique symbol; + +type NonPatchableResourceStateMarker = { + [NON_PATCHABLE_RESOURCE_STATE]?: never; +}; + +type ResourceValueType< + T extends WidenedResource, + HasUndefinedErrorHandling extends boolean, +> = HasUndefinedErrorHandling extends true + ? InferResourceValue | undefined + : InferResourceValue; + type UnnamedResourceResult< T extends WidenedResource, HasUndefinedErrorHandling extends boolean, > = { - state: { - value: HasUndefinedErrorHandling extends true - ? InferResourceValue | undefined - : InferResourceValue; - }; - props: { + state: T extends ReloadableResource + ? { + value: ResourceValueType; + } + : NonPatchableResourceStateMarker; + props: (T extends ReloadableResource + ? Record + : { + value: Signal>; + }) & { status: Signal; error: Signal; isLoading: Signal; @@ -100,14 +117,21 @@ export type NamedResourceResult< HasUndefinedErrorHandling extends boolean, > = { state: { - [Prop in keyof T as `${Prop & - string}Value`]: T[Prop]['value'] extends Signal - ? HasUndefinedErrorHandling extends true - ? S | undefined - : S + [Prop in keyof T as T[Prop] extends ReloadableResource + ? `${Prop & string}Value` + : never]: T[Prop] extends WidenedResource + ? ResourceValueType : never; - }; + } & NonPatchableResourceStateMarker; props: { + [Prop in keyof T as T[Prop] extends ReloadableResource + ? never + : `${Prop & string}Value`]: Signal< + T[Prop] extends WidenedResource + ? ResourceValueType + : never + >; + } & { [Prop in keyof T as `${Prop & string}Status`]: Signal; } & { [Prop in keyof T as `${Prop & string}Error`]: Signal; @@ -148,10 +172,11 @@ const defaultOptions: Required = { * 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 - * and is exposed as a `DeepSignal`. + * Reloadable resources (`ResourceRef`/`HttpResourceRef`) expose their + * value under `value` in the store state and can be updated via `patchState`. * - * It can also be updated via `patchState`. + * Plain `Resource` values are exposed as signals on the store instance, + * but are not part of state and therefore cannot be changed via `patchState`. * * @usageNotes * @@ -210,9 +235,11 @@ export function withResource< * registered by name, which is used as a prefix when spreading the members * of `Resource` onto the store. * - * Each resource’s value is part of the state, stored under the `value` key - * with the resource name as prefix. Values are exposed as `DeepSignal`s and - * can be updated via `patchState`. + * Reloadable resources (`ResourceRef`/`HttpResourceRef`) place their values + * in state using `Value` keys and support updates via `patchState`. + * + * Plain `Resource` values are exposed as read-only signals with `Value` + * but are not stored in state. * * @usageNotes * @@ -318,20 +345,19 @@ function createUnnamedResource( } } - const stateFeature = withLinkedState(() => ({ - value: valueSignalForErrorHandling(resource, errorHandling), - })); - const propsFeature = withProps(() => ({ + const metadataProps = { status: resource.status, error: resource.error, isLoading: resource.isLoading, snapshot: resource.snapshot, - })); + }; if (isHttpResourceRef(resource) || isResourceRef(resource)) { return signalStoreFeature( - stateFeature, - propsFeature, + withLinkedState(() => ({ + value: valueSignalForErrorHandling(resource, errorHandling), + })), + withProps(() => metadataProps), withMethods(() => ({ hasValue, _reload: () => resource.reload(), @@ -340,8 +366,10 @@ function createUnnamedResource( } return signalStoreFeature( - stateFeature, - propsFeature, + withProps(() => ({ + value: valueSignalForErrorHandling(resource, errorHandling), + ...metadataProps, + })), withMethods(() => ({ hasValue })), ); } @@ -352,53 +380,43 @@ function createNamedResource( ) { const keys = Object.keys(dictionary); - const state: Record> = keys.reduce( - (state, resourceName) => ({ - ...state, - [`${resourceName}Value`]: valueSignalForErrorHandling( - dictionary[resourceName], + const state: Record> = {}; + const props: Record> = {}; + const methods: Record boolean> = {}; + + for (const resourceName of keys) { + const res = dictionary[resourceName]; + + props[`${resourceName}Status`] = res.status; + props[`${resourceName}Error`] = res.error; + props[`${resourceName}IsLoading`] = res.isLoading; + props[`${resourceName}Snapshot`] = res.snapshot; + methods[`${resourceName}HasValue`] = isHttpResourceRef(res) + ? () => res.hasValue() + : isResourceRef(res) + ? () => res.hasValue() + : () => res.hasValue(); + + if (isHttpResourceRef(res) || isResourceRef(res)) { + state[`${resourceName}Value`] = valueSignalForErrorHandling( + res, errorHandling, - ), - }), - {}, - ); - - const props: Record> = keys.reduce( - (props, resourceName) => ({ - ...props, - [`${resourceName}Status`]: dictionary[resourceName].status, - [`${resourceName}Error`]: dictionary[resourceName].error, - [`${resourceName}IsLoading`]: dictionary[resourceName].isLoading, - [`${resourceName}Snapshot`]: dictionary[resourceName].snapshot, - }), - {}, - ); + ) as WritableSignal; + methods[`_${resourceName}Reload`] = () => res.reload(); + } else { + props[`${resourceName}Value`] = valueSignalForErrorHandling( + res, + errorHandling, + ); + } + } - const methods: Record boolean> = keys.reduce( - (methods, resourceName) => { - const res = dictionary[resourceName]; - - if (isHttpResourceRef(res)) { - return { - ...methods, - [`${resourceName}HasValue`]: () => res.hasValue(), - [`_${resourceName}Reload`]: () => res.reload(), - }; - } else if (isResourceRef(res)) { - return { - ...methods, - [`${resourceName}HasValue`]: () => res.hasValue(), - [`_${resourceName}Reload`]: () => res.reload(), - }; - } else { - return { - ...methods, - [`${resourceName}HasValue`]: () => res.hasValue(), - }; - } - }, - {}, - ); + if (Object.keys(state).length === 0) { + return signalStoreFeature( + withProps(() => props), + withMethods(() => methods), + ); + } return signalStoreFeature( withLinkedState(() => state), From f21f8efe34ce8b3569da0ccc5e9f470c491fa149 Mon Sep 17 00:00:00 2001 From: Michael Small Date: Sat, 30 May 2026 16:14:02 -0500 Subject: [PATCH 05/18] chore: condense `isResourceXYZ` functionality --- libs/ngrx-toolkit/src/lib/with-resource.ts | 41 ++++++++-------------- 1 file changed, 14 insertions(+), 27 deletions(-) diff --git a/libs/ngrx-toolkit/src/lib/with-resource.ts b/libs/ngrx-toolkit/src/lib/with-resource.ts index 75f4650c..fad25c86 100644 --- a/libs/ngrx-toolkit/src/lib/with-resource.ts +++ b/libs/ngrx-toolkit/src/lib/with-resource.ts @@ -425,7 +425,7 @@ function createNamedResource( ); } -export function isResourceRef(value: unknown): value is ResourceRef { +export function isResource(value: unknown): value is Resource { return ( value !== null && typeof value === 'object' && @@ -435,44 +435,31 @@ export function isResourceRef(value: unknown): value is ResourceRef { 'error' in value && 'isLoading' in value && 'snapshot' in value && - 'hasValue' in value && - 'reload' in value + 'hasValue' in value + ); +} + +export function isResourceRef(value: unknown): value is ResourceRef { + return ( + isResource(value) && + 'reload' in value && + 'set' in value && + 'update' in value && + 'asReadonly' in value ); } + export function isHttpResourceRef( value: unknown, ): value is HttpResourceRef { return ( - value !== null && - typeof value === 'object' && - 'value' in value && - isSignal(value.value) && - 'status' in value && - 'error' in value && - 'isLoading' in value && - 'snapshot' in value && - 'hasValue' in value && - 'reload' in value && + isResourceRef(value) && 'headers' in value && 'statusCode' in value && 'progress' in value ); } -export function isResource(value: unknown): value is Resource { - return ( - value !== null && - typeof value === 'object' && - 'value' in value && - isSignal(value.value) && - 'status' in value && - 'error' in value && - 'isLoading' in value && - 'snapshot' in value && - 'hasValue' in value - ); -} - //** Types for `mapToResource` */ type NamedResource = { From 1aaedd8bbdeb7277715f58e5fed2e70ff63a0768 Mon Sep 17 00:00:00 2001 From: Michael Small Date: Sat, 30 May 2026 16:16:29 -0500 Subject: [PATCH 06/18] chore: remove `HttpResourceRefResult` --- libs/ngrx-toolkit/src/lib/with-resource.ts | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/libs/ngrx-toolkit/src/lib/with-resource.ts b/libs/ngrx-toolkit/src/lib/with-resource.ts index fad25c86..0100c335 100644 --- a/libs/ngrx-toolkit/src/lib/with-resource.ts +++ b/libs/ngrx-toolkit/src/lib/with-resource.ts @@ -21,20 +21,6 @@ import { withProps, } from '@ngrx/signals'; -// TODO - add other specifics of HttpResourceRef (headers, statusCode, progress) -export type HttpResourceRefResult = { - state: { value: T }; - props: { - status: Signal; - error: Signal; - isLoading: Signal; - snapshot: Signal>; - }; - methods: { - hasValue(): this is Resource>; - _reload(): boolean; - }; -}; export type ResourceResult = { state: { value: T }; props: { From 43ec3b8055fea7ea1406a14263ac6ea104220779 Mon Sep 17 00:00:00 2001 From: Michael Small Date: Sat, 30 May 2026 16:26:31 -0500 Subject: [PATCH 07/18] chore: remove httpResource specifics --- libs/ngrx-toolkit/src/lib/with-resource.ts | 37 ++++++++++------------ 1 file changed, 17 insertions(+), 20 deletions(-) diff --git a/libs/ngrx-toolkit/src/lib/with-resource.ts b/libs/ngrx-toolkit/src/lib/with-resource.ts index 0100c335..f1111043 100644 --- a/libs/ngrx-toolkit/src/lib/with-resource.ts +++ b/libs/ngrx-toolkit/src/lib/with-resource.ts @@ -94,7 +94,7 @@ type UnnamedResourceResult< } & ConditionalReloadMethod; }; -type WidenedResource = ResourceRef | Resource | HttpResourceRef; +type WidenedResource = ResourceRef | Resource; export type ResourceDictionary = Record>; @@ -322,9 +322,7 @@ function createUnnamedResource( function hasValue(): this is WidenedResource< Exclude > { - if (isHttpResourceRef(resource)) { - return resource.hasValue(); - } else if (isResourceRef(resource)) { + if (isResourceRef(resource)) { return resource.hasValue(); } else { return resource.hasValue(); @@ -338,7 +336,7 @@ function createUnnamedResource( snapshot: resource.snapshot, }; - if (isHttpResourceRef(resource) || isResourceRef(resource)) { + if (isResourceRef(resource)) { return signalStoreFeature( withLinkedState(() => ({ value: valueSignalForErrorHandling(resource, errorHandling), @@ -377,13 +375,11 @@ function createNamedResource( props[`${resourceName}Error`] = res.error; props[`${resourceName}IsLoading`] = res.isLoading; props[`${resourceName}Snapshot`] = res.snapshot; - methods[`${resourceName}HasValue`] = isHttpResourceRef(res) + methods[`${resourceName}HasValue`] = isResourceRef(res) ? () => res.hasValue() - : isResourceRef(res) - ? () => res.hasValue() - : () => res.hasValue(); + : () => res.hasValue(); - if (isHttpResourceRef(res) || isResourceRef(res)) { + if (isResourceRef(res)) { state[`${resourceName}Value`] = valueSignalForErrorHandling( res, errorHandling, @@ -435,16 +431,17 @@ export function isResourceRef(value: unknown): value is ResourceRef { ); } -export function isHttpResourceRef( - value: unknown, -): value is HttpResourceRef { - return ( - isResourceRef(value) && - 'headers' in value && - 'statusCode' in value && - 'progress' in value - ); -} +// This may be handy in the future +// export function isHttpResourceRef( +// value: unknown, +// ): value is HttpResourceRef { +// return ( +// isResourceRef(value) && +// 'headers' in value && +// 'statusCode' in value && +// 'progress' in value +// ); +// } //** Types for `mapToResource` */ From db2f3f627521727bbbdb619fad055f580144de2a Mon Sep 17 00:00:00 2001 From: Michael Small Date: Thu, 4 Jun 2026 21:03:44 -0500 Subject: [PATCH 08/18] chore: wip arbitrary signal props in withResource --- libs/ngrx-toolkit/src/lib/with-resource.ts | 83 +++++++++++++++++++++- 1 file changed, 81 insertions(+), 2 deletions(-) diff --git a/libs/ngrx-toolkit/src/lib/with-resource.ts b/libs/ngrx-toolkit/src/lib/with-resource.ts index f1111043..0e935123 100644 --- a/libs/ngrx-toolkit/src/lib/with-resource.ts +++ b/libs/ngrx-toolkit/src/lib/with-resource.ts @@ -49,6 +49,22 @@ export type ResourceRefResult = { type ReloadableResource = ResourceRef | HttpResourceRef; +type ResourceCoreKeys = + | 'value' + | 'status' + | 'error' + | 'isLoading' + | 'snapshot' + | 'hasValue'; + +type AdditionalSignalProps> = { + [Prop in keyof T as Prop extends ResourceCoreKeys + ? never + : T[Prop] extends Signal + ? Prop + : never]: T[Prop]; +}; + type InferResourceValue> = T['value'] extends Signal ? S : never; @@ -88,7 +104,7 @@ type UnnamedResourceResult< error: Signal; isLoading: Signal; snapshot: Signal>>; - }; + } & AdditionalSignalProps; methods: { hasValue(): this is Resource, undefined>>; } & ConditionalReloadMethod; @@ -98,6 +114,15 @@ type WidenedResource = ResourceRef | Resource; export type ResourceDictionary = Record>; +type NamedAdditionalSignalProps = { + [ResourceName in keyof T & string]: { + [Prop in keyof AdditionalSignalProps & + string as `${ResourceName}${Capitalize}`]: AdditionalSignalProps< + T[ResourceName] + >[Prop]; + }; +}[keyof T & string]; + export type NamedResourceResult< T extends ResourceDictionary, HasUndefinedErrorHandling extends boolean, @@ -127,7 +152,7 @@ export type NamedResourceResult< [Prop in keyof T as `${Prop & string}Snapshot`]: Signal< ResourceSnapshot ? S : never> >; - }; + } & NamedAdditionalSignalProps; methods: { [Prop in keyof T as `${Prop & string}HasValue`]: () => this is Resource< Exclude, undefined> @@ -163,6 +188,8 @@ const defaultOptions: Required = { * * Plain `Resource` values are exposed as signals on the store instance, * but are not part of state and therefore cannot be changed via `patchState`. + * Additional signal members on the resource (for example `foo`) are also + * exposed on the store as props. * * @usageNotes * @@ -226,6 +253,8 @@ export function withResource< * * Plain `Resource` values are exposed as read-only signals with `Value` * but are not stored in state. + * Additional signal members are exposed as read-only props using + * ``. * * @usageNotes * @@ -334,6 +363,7 @@ function createUnnamedResource( error: resource.error, isLoading: resource.isLoading, snapshot: resource.snapshot, + ...extractAdditionalSignalProps(resource), }; if (isResourceRef(resource)) { @@ -375,6 +405,7 @@ function createNamedResource( props[`${resourceName}Error`] = res.error; props[`${resourceName}IsLoading`] = res.isLoading; props[`${resourceName}Snapshot`] = res.snapshot; + assignPrefixedProps(props, resourceName, extractAdditionalSignalProps(res)); methods[`${resourceName}HasValue`] = isResourceRef(res) ? () => res.hasValue() : () => res.hasValue(); @@ -431,6 +462,54 @@ export function isResourceRef(value: unknown): value is ResourceRef { ); } +function extractAdditionalSignalProps( + resource: WidenedResource, +): Record> { + const additionalSignals: Record> = {}; + + for (const key of Object.keys(resource)) { + if (isResourceCoreKey(key)) { + continue; + } + + const candidate = (resource as unknown as Record)[key]; + if (isSignal(candidate)) { + additionalSignals[key] = candidate; + } + } + + return additionalSignals; +} + +function isResourceCoreKey(key: string): key is ResourceCoreKeys { + return ( + key === 'value' || + key === 'status' || + key === 'error' || + key === 'isLoading' || + key === 'snapshot' || + key === 'hasValue' + ); +} + +function assignPrefixedProps( + target: Record>, + resourceName: string, + source: Record>, +): void { + for (const key of Object.keys(source)) { + target[`${resourceName}${capitalizeKey(key)}`] = source[key]; + } +} + +function capitalizeKey(value: string): string { + if (value.length === 0) { + return value; + } + + return value.charAt(0).toUpperCase() + value.slice(1); +} + // This may be handy in the future // export function isHttpResourceRef( // value: unknown, From 9e5f1afb1be831514897d299607b3d6da6927562 Mon Sep 17 00:00:00 2001 From: Michael Small Date: Sat, 6 Jun 2026 14:39:43 -0500 Subject: [PATCH 09/18] chore: simplify `withRes` , make union an intersection No intersection of `Resource` and `ResourceRef` as a widened resource is neccisary, since `Ref` as the superset can still be passed in and still narrowed down to read vs write. So no need for `WidenedResource`. As for the `UnionToIntersection`, it's a bit cerebral, but it unblocks having multiple named resources having extra properties. --- .../src/lib/with-resource.spec.ts | 141 ++++++++++++++++++ libs/ngrx-toolkit/src/lib/with-resource.ts | 80 ++++++---- .../tests/util/custom-resource.ts | 54 +++++++ 3 files changed, 242 insertions(+), 33 deletions(-) create mode 100644 libs/ngrx-toolkit/src/lib/with-resource/tests/util/custom-resource.ts diff --git a/libs/ngrx-toolkit/src/lib/with-resource.spec.ts b/libs/ngrx-toolkit/src/lib/with-resource.spec.ts index 69ffc702..9bcd8fc7 100644 --- a/libs/ngrx-toolkit/src/lib/with-resource.spec.ts +++ b/libs/ngrx-toolkit/src/lib/with-resource.spec.ts @@ -16,6 +16,10 @@ import { import { of } from 'rxjs'; import { Assert, AssertNot, IsEqual, Satisfies } from './test-utils/types'; import { ErrorHandling, mapToResource, withResource } from './with-resource'; +import { + restResourceReadable, + restResourceWritable, +} from './with-resource/tests/util/custom-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'; @@ -813,5 +817,142 @@ describe('withResource', () => { })), ); }); + + it('can call custom named and extract extra properties', async () => { + // TODO - move this whole test so this can `wait` can be re-used + const wait = (ms = 0) => + new Promise((resolve) => setTimeout(resolve, ms)); + + const Store = signalStore( + { providedIn: 'root', protectedState: false }, + withResource(() => ({ + idWritable: restResourceWritable(() => 'a'), + idReadable: restResourceReadable(() => 'a'), + })), + ); + const store = TestBed.inject(Store); + + await wait(); + + expect(store.idWritableValue()).toBe('a'); + expect(store.idReadableValue()).toBe('a'); + expect(store.idWritableStuff()).toBe('a stuff'); + expect(store.idReadableStuff()).toBe('a stuff'); + + patchState(store, { idWritableValue: 'b' }); + // @ts-expect-error - readabke resoruces should not have their value patchable + patchState(store, { idReadableValue: 'b' }); + + expect(store.idWritableValue()).toBe('b'); + expect(store.idReadableValue()).toBe('a'); + expect(store.idWritableStuff()).toBe('b stuff'); + expect(store.idReadableStuff()).toBe('a stuff'); + }); + it('can call custom unnamed writable and extract extra properties', async () => { + // TODO - move this whole test so this can `wait` can be re-used + const wait = (ms = 0) => + new Promise((resolve) => setTimeout(resolve, ms)); + + const Store = signalStore( + { providedIn: 'root', protectedState: false }, + withResource(() => restResourceWritable(() => 'a')), + ); + const store = TestBed.inject(Store); + + await wait(); + + expect(store.value()).toBe('a'); + expect(store.stuff()).toBe('a stuff'); + + patchState(store, { value: 'b' }); + + expect(store.value()).toBe('b'); + expect(store.stuff()).toBe('b stuff'); + }); + it('can call custom unnamed readable and extract extra properties', async () => { + // TODO - move this whole test so this can `wait` can be re-used + const wait = (ms = 0) => + new Promise((resolve) => setTimeout(resolve, ms)); + + const Store = signalStore( + { providedIn: 'root', protectedState: false }, + withResource(() => restResourceReadable(() => 'a')), + ); + const store = TestBed.inject(Store); + + await wait(); + + expect(store.value()).toBe('a'); + expect(store.stuff()).toBe('a stuff'); + + // @ts-expect-error - readabke resoruces should not have their value patchable + patchState(store, { value: 'b' }); + + expect(store.value()).toBe('a'); + expect(store.stuff()).toBe('a stuff'); + }); + it('can call custom unnamed readable and a named readable and extract extra properties', async () => { + // TODO - move this whole test so this can `wait` can be re-used + const wait = (ms = 0) => + new Promise((resolve) => setTimeout(resolve, ms)); + + const Store = signalStore( + { providedIn: 'root', protectedState: false }, + withResource(() => restResourceReadable(() => 'a')), + withResource(() => ({ + id: restResourceReadable(() => 'a'), + })), + ); + const store = TestBed.inject(Store); + + await wait(); + + expect(store.value()).toBe('a'); + expect(store.stuff()).toBe('a stuff'); + + expect(store.idValue()).toBe('a'); + expect(store.idStuff()).toBe('a stuff'); + + // @ts-expect-error - readabke resoruces should not have their value patchable + patchState(store, { value: 'b' }); + // @ts-expect-error - readable resources should not have their value patchable + patchState(store, { idValue: 'b' }); + + expect(store.value()).toBe('a'); + expect(store.stuff()).toBe('a stuff'); + expect(store.idValue()).toBe('a'); + expect(store.idStuff()).toBe('a stuff'); + }); + it('can call custom unnamed writable and a named readable and extract extra properties', async () => { + // TODO - move this whole test so this can `wait` can be re-used + const wait = (ms = 0) => + new Promise((resolve) => setTimeout(resolve, ms)); + + const Store = signalStore( + { providedIn: 'root', protectedState: false }, + withResource(() => restResourceWritable(() => 'a')), + withResource(() => ({ + id: restResourceReadable(() => 'a'), + })), + ); + const store = TestBed.inject(Store); + + await wait(); + + expect(store.value()).toBe('a'); + expect(store.stuff()).toBe('a stuff'); + + expect(store.idValue()).toBe('a'); + expect(store.idStuff()).toBe('a stuff'); + + patchState(store, { value: 'b' }); + // @ts-expect-error - readable resources should not have their value patchable + patchState(store, { idValue: 'b' }); + + expect(store.value()).toBe('b'); + expect(store.stuff()).toBe('b stuff'); + expect(store.idValue()).toBe('a'); + expect(store.idStuff()).toBe('a stuff'); + }); }); }); diff --git a/libs/ngrx-toolkit/src/lib/with-resource.ts b/libs/ngrx-toolkit/src/lib/with-resource.ts index 0e935123..c7456308 100644 --- a/libs/ngrx-toolkit/src/lib/with-resource.ts +++ b/libs/ngrx-toolkit/src/lib/with-resource.ts @@ -57,7 +57,7 @@ type ResourceCoreKeys = | 'snapshot' | 'hasValue'; -type AdditionalSignalProps> = { +type AdditionalSignalProps> = { [Prop in keyof T as Prop extends ResourceCoreKeys ? never : T[Prop] extends Signal @@ -65,10 +65,10 @@ type AdditionalSignalProps> = { : never]: T[Prop]; }; -type InferResourceValue> = +type InferResourceValue> = T['value'] extends Signal ? S : never; -type ConditionalReloadMethod> = +type ConditionalReloadMethod> = T extends ReloadableResource ? { _reload(): boolean } : Record; @@ -80,14 +80,14 @@ type NonPatchableResourceStateMarker = { }; type ResourceValueType< - T extends WidenedResource, + T extends Resource, HasUndefinedErrorHandling extends boolean, > = HasUndefinedErrorHandling extends true ? InferResourceValue | undefined : InferResourceValue; type UnnamedResourceResult< - T extends WidenedResource, + T extends Resource, HasUndefinedErrorHandling extends boolean, > = { state: T extends ReloadableResource @@ -110,18 +110,34 @@ type UnnamedResourceResult< } & ConditionalReloadMethod; }; -type WidenedResource = ResourceRef | Resource; - -export type ResourceDictionary = Record>; - -type NamedAdditionalSignalProps = { - [ResourceName in keyof T & string]: { - [Prop in keyof AdditionalSignalProps & - string as `${ResourceName}${Capitalize}`]: AdditionalSignalProps< - T[ResourceName] - >[Prop]; - }; -}[keyof T & string]; +export type ResourceDictionary = Record>; + +// Without this, only one resource would be able to have its additional signal props extracted +// When there are two or more named resources, +// neither additional property can be indexed for certain, +// because it would be a union of the two's additional properties, +// and TS cannot pick the correct one when indexing into it. +// So there would be no resolution of a particular resource's additional property. +// This type util makes this type mapping an intersection, so all additional properties +// of all resources are preserved, but also can be indexed for a particular resource. +// https://stackoverflow.com/a/50375286 explanation, with relevant TS doc links +type UnionToIntersection = ( + T extends unknown ? (arg: T) => void : never +) extends (arg: infer R) => void + ? R + : Record; + +type NamedAdditionalSignalProps = + UnionToIntersection< + { + [ResourceName in keyof T & string]: { + [Prop in keyof AdditionalSignalProps & + string as `${ResourceName}${Capitalize}`]: AdditionalSignalProps< + T[ResourceName] + >[Prop]; + }; + }[keyof T & string] + >; export type NamedResourceResult< T extends ResourceDictionary, @@ -130,7 +146,7 @@ export type NamedResourceResult< state: { [Prop in keyof T as T[Prop] extends ReloadableResource ? `${Prop & string}Value` - : never]: T[Prop] extends WidenedResource + : never]: T[Prop] extends Resource ? ResourceValueType : never; } & NonPatchableResourceStateMarker; @@ -138,7 +154,7 @@ export type NamedResourceResult< [Prop in keyof T as T[Prop] extends ReloadableResource ? never : `${Prop & string}Value`]: Signal< - T[Prop] extends WidenedResource + T[Prop] extends Resource ? ResourceValueType : never >; @@ -213,7 +229,7 @@ const defaultOptions: Required = { */ export function withResource< Input extends SignalStoreFeatureResult, - ResourceType extends WidenedResource, + ResourceType extends Resource, >( resourceFactory: ( store: Input['props'] & Input['methods'] & StateSignals, @@ -222,7 +238,7 @@ export function withResource< export function withResource< Input extends SignalStoreFeatureResult, - ResourceType extends WidenedResource, + ResourceType extends Resource, >( resourceFactory: ( store: Input['props'] & Input['methods'] & StateSignals, @@ -232,7 +248,7 @@ export function withResource< export function withResource< Input extends SignalStoreFeatureResult, - ResourceType extends WidenedResource, + ResourceType extends Resource, >( resourceFactory: ( store: Input['props'] & Input['methods'] & StateSignals, @@ -275,7 +291,7 @@ export function withResource< * ``` * * @param resourceFactory A factory function that receives the store's props, - * methods, and state signals. It must return a `Record`. + * methods, and state signals. It must return a `Record`. * @param resourceOptions Allows to configure the error handling behavior. */ export function withResource< @@ -313,7 +329,7 @@ export function withResource< >( resourceFactory: ( store: Input['props'] & Input['methods'] & StateSignals, - ) => WidenedResource | ResourceDictionary, + ) => Resource | ResourceDictionary, resourceOptions?: ResourceOptions, ): SignalStoreFeature { const options: Required = { @@ -345,12 +361,10 @@ export function withResource< } function createUnnamedResource( - resource: WidenedResource, + resource: Resource, errorHandling: ErrorHandling, ) { - function hasValue(): this is WidenedResource< - Exclude - > { + function hasValue(): this is Resource> { if (isResourceRef(resource)) { return resource.hasValue(); } else { @@ -463,7 +477,7 @@ export function isResourceRef(value: unknown): value is ResourceRef { } function extractAdditionalSignalProps( - resource: WidenedResource, + resource: Resource, ): Record> { const additionalSignals: Record> = {}; @@ -584,7 +598,7 @@ type MappedResource< * * @param store The store instance to map the resource to. * @param name The name of the resource to map. - * @returns `WidenedResource` + * @returns `Resource` */ export function mapToResource< Name extends ResourceNames, @@ -674,17 +688,17 @@ export function mapToResource< * a breaking change. */ function valueSignalForErrorHandling( - res: WidenedResource, + res: Resource, errorHandling: 'undefined value', ): Signal; function valueSignalForErrorHandling( - res: WidenedResource, + res: Resource, errorHandling: ErrorHandling, ): Signal; function valueSignalForErrorHandling( - res: WidenedResource, + res: Resource, errorHandling: ErrorHandling, ): Signal { const originalSignal = res.value; diff --git a/libs/ngrx-toolkit/src/lib/with-resource/tests/util/custom-resource.ts b/libs/ngrx-toolkit/src/lib/with-resource/tests/util/custom-resource.ts new file mode 100644 index 00000000..40d0bab2 --- /dev/null +++ b/libs/ngrx-toolkit/src/lib/with-resource/tests/util/custom-resource.ts @@ -0,0 +1,54 @@ +import { computed, Resource, ResourceRef, Signal } from '@angular/core'; +import { rxResource } from '@angular/core/rxjs-interop'; +import { of } from 'rxjs'; + +export function restResourceWritable( + params?: () => string | undefined, +): ResourceRef & { stuff: Signal } { + const resource = rxResource({ + params: () => params?.() ?? '', + stream: ({ params }) => { + return of(params); + }, + }); + + const stuff = computed(() => resource.value() + ' stuff'); + + return { + stuff, + value: resource.value, + status: resource.status, + error: resource.error, + isLoading: resource.isLoading, + snapshot: resource.snapshot, + hasValue: resource.hasValue, + set: resource.set.bind(resource), + update: resource.update.bind(resource), + asReadonly: resource.asReadonly.bind(resource), + reload: resource.reload.bind(resource), + destroy: resource.destroy.bind(resource), + }; +} + +export function restResourceReadable( + params?: () => string | undefined, +): Resource & { stuff: Signal } { + const resource = rxResource({ + params: () => params?.() ?? '', + stream: ({ params }) => { + return of(params); + }, + }).asReadonly(); + + const stuff = computed(() => resource.value() + ' stuff'); + + return { + stuff, + value: resource.value, + status: resource.status, + error: resource.error, + isLoading: resource.isLoading, + snapshot: resource.snapshot, + hasValue: resource.hasValue.bind(resource), + }; +} From bc88bcb0cf335440bfbab8e81e59efa2572e0977 Mon Sep 17 00:00:00 2001 From: Michael Small Date: Sat, 6 Jun 2026 15:04:30 -0500 Subject: [PATCH 10/18] chore: re-order new `withResource` tests by category --- .../src/lib/with-resource.spec.ts | 265 +++++++----------- 1 file changed, 97 insertions(+), 168 deletions(-) diff --git a/libs/ngrx-toolkit/src/lib/with-resource.spec.ts b/libs/ngrx-toolkit/src/lib/with-resource.spec.ts index 9bcd8fc7..8aa2ace0 100644 --- a/libs/ngrx-toolkit/src/lib/with-resource.spec.ts +++ b/libs/ngrx-toolkit/src/lib/with-resource.spec.ts @@ -268,6 +268,70 @@ describe('withResource', () => { }); }); + describe('extra properties checks', () => { + it('can call custom unnamed writable resource and extract custom extra signal properties', async () => { + const Store = signalStore( + { providedIn: 'root', protectedState: false }, + withResource(() => restResourceWritable(() => 'a')), + ); + const store = TestBed.inject(Store); + + await wait(); + + expect(store.value()).toBe('a'); + expect(store.stuff()).toBe('a stuff'); + + patchState(store, { value: 'b' }); + + expect(store.value()).toBe('b'); + expect(store.stuff()).toBe('b stuff'); + }); + it('can call custom unnamed readable resource and extract custom extra signal properties', async () => { + const Store = signalStore( + { providedIn: 'root', protectedState: false }, + withResource(() => restResourceReadable(() => 'a')), + ); + const store = TestBed.inject(Store); + + await wait(); + + expect(store.value()).toBe('a'); + expect(store.stuff()).toBe('a stuff'); + + // @ts-expect-error - readable resources should not have their value patchable + patchState(store, { value: 'b' }); + + expect(store.value()).toBe('a'); + expect(store.stuff()).toBe('a stuff'); + }); + it('can supply custom named resources (writable and unwritable) and extract custom extra signal properties', async () => { + const Store = signalStore( + { providedIn: 'root', protectedState: false }, + withResource(() => ({ + idWritable: restResourceWritable(() => 'a'), + idReadable: restResourceReadable(() => 'a'), + })), + ); + const store = TestBed.inject(Store); + + await wait(); + + expect(store.idWritableValue()).toBe('a'); + expect(store.idReadableValue()).toBe('a'); + expect(store.idWritableStuff()).toBe('a stuff'); + expect(store.idReadableStuff()).toBe('a stuff'); + + patchState(store, { idWritableValue: 'b' }); + // @ts-expect-error - readable resources should not have their value patchable + patchState(store, { idReadableValue: 'b' }); + + expect(store.idWritableValue()).toBe('b'); + expect(store.idReadableValue()).toBe('a'); + expect(store.idWritableStuff()).toBe('b stuff'); + expect(store.idReadableStuff()).toBe('a stuff'); + }); + }); + describe('override protection', () => { const warningSpy = jest.spyOn(console, 'warn'); @@ -651,39 +715,41 @@ describe('withResource', () => { type _T2 = Assert boolean>>; }); - it('does not allow patching an unnamed non-reloadable resource value', () => { - signalStore( - withResource(() => - withPreviousValue( - resource({ - loader: () => Promise.resolve(1), - defaultValue: 0, - }), + describe('patchState restrictions do not allow patching', () => { + it('an unnamed non-reloadable resource value', () => { + signalStore( + withResource(() => + withPreviousValue( + resource({ + loader: () => Promise.resolve(1), + defaultValue: 0, + }), + ), ), - ), - withMethods((store) => ({ - // @ts-expect-error - non-reloadable Resource values are not state - setValue: (value: number) => patchState(store, { value }), - })), - ); - }); + withMethods((store) => ({ + // @ts-expect-error - non-reloadable Resource values are not state + setValue: (value: number) => patchState(store, { value }), + })), + ); + }); - it('does not allow patching a named non-reloadable resource value', () => { - signalStore( - withResource(() => ({ - digitSnapshotted: withPreviousValue( - resource({ - loader: () => Promise.resolve(-1), - defaultValue: 0, - }), - ), - })), - withMethods((store) => ({ - setNamedValue: (value: number) => - // @ts-expect-error digitSnapshotted is a `Resource` value, but not state, so it should not be patchable - patchState(store, { digitSnapshottedValue: value }), - })), - ); + it('a named non-reloadable resource value', () => { + signalStore( + withResource(() => ({ + digitSnapshotted: withPreviousValue( + resource({ + loader: () => Promise.resolve(-1), + defaultValue: 0, + }), + ), + })), + withMethods((store) => ({ + setNamedValue: (value: number) => + // @ts-expect-error digitSnapshotted is a `Resource` value, but not state, so it should not be patchable + patchState(store, { digitSnapshottedValue: value }), + })), + ); + }); }); describe('mapToResource', () => { @@ -817,142 +883,5 @@ describe('withResource', () => { })), ); }); - - it('can call custom named and extract extra properties', async () => { - // TODO - move this whole test so this can `wait` can be re-used - const wait = (ms = 0) => - new Promise((resolve) => setTimeout(resolve, ms)); - - const Store = signalStore( - { providedIn: 'root', protectedState: false }, - withResource(() => ({ - idWritable: restResourceWritable(() => 'a'), - idReadable: restResourceReadable(() => 'a'), - })), - ); - const store = TestBed.inject(Store); - - await wait(); - - expect(store.idWritableValue()).toBe('a'); - expect(store.idReadableValue()).toBe('a'); - expect(store.idWritableStuff()).toBe('a stuff'); - expect(store.idReadableStuff()).toBe('a stuff'); - - patchState(store, { idWritableValue: 'b' }); - // @ts-expect-error - readabke resoruces should not have their value patchable - patchState(store, { idReadableValue: 'b' }); - - expect(store.idWritableValue()).toBe('b'); - expect(store.idReadableValue()).toBe('a'); - expect(store.idWritableStuff()).toBe('b stuff'); - expect(store.idReadableStuff()).toBe('a stuff'); - }); - it('can call custom unnamed writable and extract extra properties', async () => { - // TODO - move this whole test so this can `wait` can be re-used - const wait = (ms = 0) => - new Promise((resolve) => setTimeout(resolve, ms)); - - const Store = signalStore( - { providedIn: 'root', protectedState: false }, - withResource(() => restResourceWritable(() => 'a')), - ); - const store = TestBed.inject(Store); - - await wait(); - - expect(store.value()).toBe('a'); - expect(store.stuff()).toBe('a stuff'); - - patchState(store, { value: 'b' }); - - expect(store.value()).toBe('b'); - expect(store.stuff()).toBe('b stuff'); - }); - it('can call custom unnamed readable and extract extra properties', async () => { - // TODO - move this whole test so this can `wait` can be re-used - const wait = (ms = 0) => - new Promise((resolve) => setTimeout(resolve, ms)); - - const Store = signalStore( - { providedIn: 'root', protectedState: false }, - withResource(() => restResourceReadable(() => 'a')), - ); - const store = TestBed.inject(Store); - - await wait(); - - expect(store.value()).toBe('a'); - expect(store.stuff()).toBe('a stuff'); - - // @ts-expect-error - readabke resoruces should not have their value patchable - patchState(store, { value: 'b' }); - - expect(store.value()).toBe('a'); - expect(store.stuff()).toBe('a stuff'); - }); - it('can call custom unnamed readable and a named readable and extract extra properties', async () => { - // TODO - move this whole test so this can `wait` can be re-used - const wait = (ms = 0) => - new Promise((resolve) => setTimeout(resolve, ms)); - - const Store = signalStore( - { providedIn: 'root', protectedState: false }, - withResource(() => restResourceReadable(() => 'a')), - withResource(() => ({ - id: restResourceReadable(() => 'a'), - })), - ); - const store = TestBed.inject(Store); - - await wait(); - - expect(store.value()).toBe('a'); - expect(store.stuff()).toBe('a stuff'); - - expect(store.idValue()).toBe('a'); - expect(store.idStuff()).toBe('a stuff'); - - // @ts-expect-error - readabke resoruces should not have their value patchable - patchState(store, { value: 'b' }); - // @ts-expect-error - readable resources should not have their value patchable - patchState(store, { idValue: 'b' }); - - expect(store.value()).toBe('a'); - expect(store.stuff()).toBe('a stuff'); - expect(store.idValue()).toBe('a'); - expect(store.idStuff()).toBe('a stuff'); - }); - it('can call custom unnamed writable and a named readable and extract extra properties', async () => { - // TODO - move this whole test so this can `wait` can be re-used - const wait = (ms = 0) => - new Promise((resolve) => setTimeout(resolve, ms)); - - const Store = signalStore( - { providedIn: 'root', protectedState: false }, - withResource(() => restResourceWritable(() => 'a')), - withResource(() => ({ - id: restResourceReadable(() => 'a'), - })), - ); - const store = TestBed.inject(Store); - - await wait(); - - expect(store.value()).toBe('a'); - expect(store.stuff()).toBe('a stuff'); - - expect(store.idValue()).toBe('a'); - expect(store.idStuff()).toBe('a stuff'); - - patchState(store, { value: 'b' }); - // @ts-expect-error - readable resources should not have their value patchable - patchState(store, { idValue: 'b' }); - - expect(store.value()).toBe('b'); - expect(store.stuff()).toBe('b stuff'); - expect(store.idValue()).toBe('a'); - expect(store.idStuff()).toBe('a stuff'); - }); }); }); From 44e7b813139ba591f869fcd98e4764540f49b94a Mon Sep 17 00:00:00 2001 From: Michael Small Date: Sat, 6 Jun 2026 15:09:22 -0500 Subject: [PATCH 11/18] chore: test custom resource properties cannot override props --- .../src/lib/with-resource.spec.ts | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/libs/ngrx-toolkit/src/lib/with-resource.spec.ts b/libs/ngrx-toolkit/src/lib/with-resource.spec.ts index 8aa2ace0..c90f325e 100644 --- a/libs/ngrx-toolkit/src/lib/with-resource.spec.ts +++ b/libs/ngrx-toolkit/src/lib/with-resource.spec.ts @@ -381,6 +381,33 @@ describe('withResource', () => { 'userValue', ); }); + + //TODO wait for https://github.com/ngrx/platform/pull/4932 and then add 'value' to the list + it.each([ + 'status', + 'error', + 'isLoading', + '_reload', + 'hasValue', + 'stuff', + ])( + `warns if %s is not a member of the store with a custom extended resource`, + (memberName) => { + const Store = signalStore( + { providedIn: 'root' }, + withProps(() => ({ [memberName]: true })), + withResource(() => restResourceWritable(() => 'a')), + ); + + TestBed.inject(Store); + + expect(warningSpy).toHaveBeenCalledWith( + '@ngrx/signals: SignalStore members cannot be overridden.', + 'Trying to override:', + memberName, + ); + }, + ); }); it('works also with list/detail use case', async () => { From 60a1f57e8d30c5ba8fa86445eefec494f1ea66b5 Mon Sep 17 00:00:00 2001 From: Michael Small Date: Sat, 6 Jun 2026 15:17:54 -0500 Subject: [PATCH 12/18] chore: test combo of unnamed + named custom resources + writability --- .../src/lib/with-resource.spec.ts | 101 ++++++++++++++++++ 1 file changed, 101 insertions(+) diff --git a/libs/ngrx-toolkit/src/lib/with-resource.spec.ts b/libs/ngrx-toolkit/src/lib/with-resource.spec.ts index c90f325e..d2497bb2 100644 --- a/libs/ngrx-toolkit/src/lib/with-resource.spec.ts +++ b/libs/ngrx-toolkit/src/lib/with-resource.spec.ts @@ -330,6 +330,107 @@ describe('withResource', () => { expect(store.idWritableStuff()).toBe('b stuff'); expect(store.idReadableStuff()).toBe('a stuff'); }); + + it('can supply custom unnamed (writable and unwritable) and custom named (writable and unwritable) and extract custom extra signal properties', async () => { + const UnnamedWritableAndNamedComboStore = signalStore( + { providedIn: 'root', protectedState: false }, + withResource(() => restResourceWritable(() => 'a')), + withResource(() => ({ + idWritable: restResourceWritable(() => 'a'), + idReadable: restResourceReadable(() => 'a'), + })), + ); + const unnamedWritableAndNamedComboStore = TestBed.inject( + UnnamedWritableAndNamedComboStore, + ); + + await wait(); + + expect(unnamedWritableAndNamedComboStore.value()).toBe('a'); + expect(unnamedWritableAndNamedComboStore.stuff()).toBe('a stuff'); + expect(unnamedWritableAndNamedComboStore.idWritableValue()).toBe('a'); + expect(unnamedWritableAndNamedComboStore.idReadableValue()).toBe('a'); + expect(unnamedWritableAndNamedComboStore.idWritableStuff()).toBe( + 'a stuff', + ); + expect(unnamedWritableAndNamedComboStore.idReadableStuff()).toBe( + 'a stuff', + ); + + patchState(unnamedWritableAndNamedComboStore, { + idWritableValue: 'b', + }); + patchState(unnamedWritableAndNamedComboStore, { value: 'b' }); + patchState(unnamedWritableAndNamedComboStore, { + // @ts-expect-error - readable resources should not have their value patchable + idReadableValue: 'b', + }); + + expect(unnamedWritableAndNamedComboStore.value()).toBe('b'); + expect(unnamedWritableAndNamedComboStore.stuff()).toBe('b stuff'); + expect(unnamedWritableAndNamedComboStore.idWritableValue()).toBe('b'); + expect(unnamedWritableAndNamedComboStore.idReadableValue()).toBe('a'); + expect(unnamedWritableAndNamedComboStore.idWritableStuff()).toBe( + 'b stuff', + ); + expect(unnamedWritableAndNamedComboStore.idReadableStuff()).toBe( + 'a stuff', + ); + + const UnnamedUnwritableAndNamedComboStore = signalStore( + { providedIn: 'root', protectedState: false }, + withResource(() => restResourceReadable(() => 'a')), + withResource(() => ({ + idWritable: restResourceWritable(() => 'a'), + idReadable: restResourceReadable(() => 'a'), + })), + ); + const unnamedUnwritableAndNamedComboStore = TestBed.inject( + UnnamedUnwritableAndNamedComboStore, + ); + + await wait(); + + expect(unnamedUnwritableAndNamedComboStore.value()).toBe('a'); + expect(unnamedUnwritableAndNamedComboStore.stuff()).toBe('a stuff'); + expect(unnamedUnwritableAndNamedComboStore.idWritableValue()).toBe( + 'a', + ); + expect(unnamedUnwritableAndNamedComboStore.idReadableValue()).toBe( + 'a', + ); + expect(unnamedUnwritableAndNamedComboStore.idWritableStuff()).toBe( + 'a stuff', + ); + expect(unnamedUnwritableAndNamedComboStore.idReadableStuff()).toBe( + 'a stuff', + ); + + patchState(unnamedUnwritableAndNamedComboStore, { + idWritableValue: 'b', + }); + // @ts-expect-error - readable resources should not have their value patchable + patchState(unnamedUnwritableAndNamedComboStore, { value: 'b' }); + patchState(unnamedUnwritableAndNamedComboStore, { + // @ts-expect-error - readable resources should not have their value patchable + idReadableValue: 'b', + }); + + expect(unnamedUnwritableAndNamedComboStore.value()).toBe('a'); + expect(unnamedUnwritableAndNamedComboStore.stuff()).toBe('a stuff'); + expect(unnamedUnwritableAndNamedComboStore.idWritableValue()).toBe( + 'b', + ); + expect(unnamedUnwritableAndNamedComboStore.idReadableValue()).toBe( + 'a', + ); + expect(unnamedUnwritableAndNamedComboStore.idWritableStuff()).toBe( + 'b stuff', + ); + expect(unnamedUnwritableAndNamedComboStore.idReadableStuff()).toBe( + 'a stuff', + ); + }); }); describe('override protection', () => { From 17915d89601b2602221b131db8ebf520a401522d Mon Sep 17 00:00:00 2001 From: Michael Small Date: Sat, 6 Jun 2026 15:40:34 -0500 Subject: [PATCH 13/18] chore: add extra props write check tests --- .../src/lib/with-resource.spec.ts | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/libs/ngrx-toolkit/src/lib/with-resource.spec.ts b/libs/ngrx-toolkit/src/lib/with-resource.spec.ts index d2497bb2..632fe175 100644 --- a/libs/ngrx-toolkit/src/lib/with-resource.spec.ts +++ b/libs/ngrx-toolkit/src/lib/with-resource.spec.ts @@ -269,6 +269,72 @@ describe('withResource', () => { }); describe('extra properties checks', () => { + describe('extra properties go to `props` so they cannot be written to', () => { + it('for unnamed writables', async () => { + const Store = signalStore( + { providedIn: 'root', protectedState: false }, + withResource(() => restResourceWritable(() => 'a')), + ); + + const store = TestBed.inject(Store); + + await wait(); + + expect(store.stuff()).toBe('a stuff'); + + // @ts-expect-error - extra properties should not be patchable + patchState(store, { stuff: 'b stuff' }); + }); + it('for unnamed readables', async () => { + const Store = signalStore( + { providedIn: 'root', protectedState: false }, + withResource(() => restResourceReadable(() => 'a')), + ); + + const store = TestBed.inject(Store); + + await wait(); + + expect(store.stuff()).toBe('a stuff'); + + // @ts-expect-error - extra properties should not be patchable + patchState(store, { stuff: 'b stuff' }); + }); + it('for named readables', async () => { + const Store = signalStore( + { providedIn: 'root', protectedState: false }, + withResource(() => ({ + name: restResourceReadable(() => 'a'), + })), + ); + + const store = TestBed.inject(Store); + + await wait(); + + expect(store.nameStuff()).toBe('a stuff'); + + // @ts-expect-error - extra properties should not be patchable + patchState(store, { nameStuff: 'b stuff' }); + }); + it('for named writables', async () => { + const Store = signalStore( + { providedIn: 'root', protectedState: false }, + withResource(() => ({ + name: restResourceWritable(() => 'a'), + })), + ); + + const store = TestBed.inject(Store); + + await wait(); + + expect(store.nameStuff()).toBe('a stuff'); + + // @ts-expect-error - extra properties should not be patchable + patchState(store, { nameStuff: 'b stuff' }); + }); + }); it('can call custom unnamed writable resource and extract custom extra signal properties', async () => { const Store = signalStore( { providedIn: 'root', protectedState: false }, From 3188dd2a1db2e0585ecf08680aec399b7fe164d1 Mon Sep 17 00:00:00 2001 From: Michael Small Date: Sat, 6 Jun 2026 15:44:05 -0500 Subject: [PATCH 14/18] chore: rename helpers for extended resources --- .../src/lib/with-resource.spec.ts | 36 +++++++++---------- ...source.ts => custom-extended-resources.ts} | 4 +-- 2 files changed, 20 insertions(+), 20 deletions(-) rename libs/ngrx-toolkit/src/lib/with-resource/tests/util/{custom-resource.ts => custom-extended-resources.ts} (93%) diff --git a/libs/ngrx-toolkit/src/lib/with-resource.spec.ts b/libs/ngrx-toolkit/src/lib/with-resource.spec.ts index 632fe175..0a2f41f3 100644 --- a/libs/ngrx-toolkit/src/lib/with-resource.spec.ts +++ b/libs/ngrx-toolkit/src/lib/with-resource.spec.ts @@ -17,9 +17,9 @@ import { of } from 'rxjs'; import { Assert, AssertNot, IsEqual, Satisfies } from './test-utils/types'; import { ErrorHandling, mapToResource, withResource } from './with-resource'; import { - restResourceReadable, - restResourceWritable, -} from './with-resource/tests/util/custom-resource'; + stuffExtendedResourceReadable, + stuffExtendedResourceWritable, +} from './with-resource/tests/util/custom-extended-resources'; 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'; @@ -273,7 +273,7 @@ describe('withResource', () => { it('for unnamed writables', async () => { const Store = signalStore( { providedIn: 'root', protectedState: false }, - withResource(() => restResourceWritable(() => 'a')), + withResource(() => stuffExtendedResourceWritable(() => 'a')), ); const store = TestBed.inject(Store); @@ -288,7 +288,7 @@ describe('withResource', () => { it('for unnamed readables', async () => { const Store = signalStore( { providedIn: 'root', protectedState: false }, - withResource(() => restResourceReadable(() => 'a')), + withResource(() => stuffExtendedResourceReadable(() => 'a')), ); const store = TestBed.inject(Store); @@ -304,7 +304,7 @@ describe('withResource', () => { const Store = signalStore( { providedIn: 'root', protectedState: false }, withResource(() => ({ - name: restResourceReadable(() => 'a'), + name: stuffExtendedResourceReadable(() => 'a'), })), ); @@ -321,7 +321,7 @@ describe('withResource', () => { const Store = signalStore( { providedIn: 'root', protectedState: false }, withResource(() => ({ - name: restResourceWritable(() => 'a'), + name: stuffExtendedResourceWritable(() => 'a'), })), ); @@ -338,7 +338,7 @@ describe('withResource', () => { it('can call custom unnamed writable resource and extract custom extra signal properties', async () => { const Store = signalStore( { providedIn: 'root', protectedState: false }, - withResource(() => restResourceWritable(() => 'a')), + withResource(() => stuffExtendedResourceWritable(() => 'a')), ); const store = TestBed.inject(Store); @@ -355,7 +355,7 @@ describe('withResource', () => { it('can call custom unnamed readable resource and extract custom extra signal properties', async () => { const Store = signalStore( { providedIn: 'root', protectedState: false }, - withResource(() => restResourceReadable(() => 'a')), + withResource(() => stuffExtendedResourceReadable(() => 'a')), ); const store = TestBed.inject(Store); @@ -374,8 +374,8 @@ describe('withResource', () => { const Store = signalStore( { providedIn: 'root', protectedState: false }, withResource(() => ({ - idWritable: restResourceWritable(() => 'a'), - idReadable: restResourceReadable(() => 'a'), + idWritable: stuffExtendedResourceWritable(() => 'a'), + idReadable: stuffExtendedResourceReadable(() => 'a'), })), ); const store = TestBed.inject(Store); @@ -400,10 +400,10 @@ describe('withResource', () => { it('can supply custom unnamed (writable and unwritable) and custom named (writable and unwritable) and extract custom extra signal properties', async () => { const UnnamedWritableAndNamedComboStore = signalStore( { providedIn: 'root', protectedState: false }, - withResource(() => restResourceWritable(() => 'a')), + withResource(() => stuffExtendedResourceWritable(() => 'a')), withResource(() => ({ - idWritable: restResourceWritable(() => 'a'), - idReadable: restResourceReadable(() => 'a'), + idWritable: stuffExtendedResourceWritable(() => 'a'), + idReadable: stuffExtendedResourceReadable(() => 'a'), })), ); const unnamedWritableAndNamedComboStore = TestBed.inject( @@ -445,10 +445,10 @@ describe('withResource', () => { const UnnamedUnwritableAndNamedComboStore = signalStore( { providedIn: 'root', protectedState: false }, - withResource(() => restResourceReadable(() => 'a')), + withResource(() => stuffExtendedResourceReadable(() => 'a')), withResource(() => ({ - idWritable: restResourceWritable(() => 'a'), - idReadable: restResourceReadable(() => 'a'), + idWritable: stuffExtendedResourceWritable(() => 'a'), + idReadable: stuffExtendedResourceReadable(() => 'a'), })), ); const unnamedUnwritableAndNamedComboStore = TestBed.inject( @@ -563,7 +563,7 @@ describe('withResource', () => { const Store = signalStore( { providedIn: 'root' }, withProps(() => ({ [memberName]: true })), - withResource(() => restResourceWritable(() => 'a')), + withResource(() => stuffExtendedResourceWritable(() => 'a')), ); TestBed.inject(Store); diff --git a/libs/ngrx-toolkit/src/lib/with-resource/tests/util/custom-resource.ts b/libs/ngrx-toolkit/src/lib/with-resource/tests/util/custom-extended-resources.ts similarity index 93% rename from libs/ngrx-toolkit/src/lib/with-resource/tests/util/custom-resource.ts rename to libs/ngrx-toolkit/src/lib/with-resource/tests/util/custom-extended-resources.ts index 40d0bab2..469991b3 100644 --- a/libs/ngrx-toolkit/src/lib/with-resource/tests/util/custom-resource.ts +++ b/libs/ngrx-toolkit/src/lib/with-resource/tests/util/custom-extended-resources.ts @@ -2,7 +2,7 @@ import { computed, Resource, ResourceRef, Signal } from '@angular/core'; import { rxResource } from '@angular/core/rxjs-interop'; import { of } from 'rxjs'; -export function restResourceWritable( +export function stuffExtendedResourceWritable( params?: () => string | undefined, ): ResourceRef & { stuff: Signal } { const resource = rxResource({ @@ -30,7 +30,7 @@ export function restResourceWritable( }; } -export function restResourceReadable( +export function stuffExtendedResourceReadable( params?: () => string | undefined, ): Resource & { stuff: Signal } { const resource = rxResource({ From 073865efeba0c2e392cb68fb52093b4a69fd3f63 Mon Sep 17 00:00:00 2001 From: Michael Small Date: Sun, 31 May 2026 21:27:22 -0500 Subject: [PATCH 15/18] chore: add entityResource snapshot support, lacking conditional addEntity access --- .../src/lib/with-entity-resources.ts | 47 ++++++++++++++++--- 1 file changed, 40 insertions(+), 7 deletions(-) diff --git a/libs/ngrx-toolkit/src/lib/with-entity-resources.ts b/libs/ngrx-toolkit/src/lib/with-entity-resources.ts index f3afac0f..578a63f1 100644 --- a/libs/ngrx-toolkit/src/lib/with-entity-resources.ts +++ b/libs/ngrx-toolkit/src/lib/with-entity-resources.ts @@ -1,4 +1,12 @@ -import { ResourceRef, Signal, isSignal, linkedSignal } from '@angular/core'; +import { + Resource, + ResourceRef, + ResourceSnapshot, + ResourceStatus, + Signal, + isSignal, + linkedSignal, +} from '@angular/core'; import { SignalStoreFeature, SignalStoreFeatureResult, @@ -17,6 +25,8 @@ import { import { NamedResourceResult, ResourceResult, + WidenedResource, + isResource, isResourceRef, withResource, } from './with-resource'; @@ -93,7 +103,7 @@ export function withEntityResources< >( resourceFactory: ( store: Input['props'] & Input['methods'] & StateSignals, - ) => ResourceRef>, + ) => WidenedResource>, ): SignalStoreFeature>; export function withEntityResources< @@ -111,7 +121,7 @@ export function withEntityResources< >( entityResourceFactory: ( store: Input['props'] & Input['methods'] & StateSignals, - ) => ResourceRef | EntityDictionary, + ) => WidenedResource | EntityDictionary, ): SignalStoreFeature { return (store) => { const resourceOrDict = entityResourceFactory({ @@ -122,6 +132,8 @@ export function withEntityResources< if (isResourceRef(resourceOrDict)) { return createUnnamedEntityResource(resourceOrDict)(store); + } else if (isResource(resourceOrDict)) { + return createUnnamedEntityResource(resourceOrDict)(store); } return createNamedEntityResources(resourceOrDict)(store); }; @@ -134,12 +146,30 @@ export function withEntityResources< * to avoid the error throwing behavior of the Resource API. */ function createUnnamedEntityResource( - resource: ResourceRef>, + resource: + | ResourceRef> + | Resource>, ) { return signalStoreFeature( withResource(() => resource), - withLinkedState(({ value }) => { - const { ids, entityMap } = createEntityDerivations(value); + withLinkedState((store) => { + // `value` exists in either the `props` or `state`, but `store` here only + // infers the `state` case, so we have to do this bleh casting + const propsOrStateStore = store as { + value: Signal>; + status: Signal; + error: Signal; + isLoading: Signal; + snapshot: Signal>>; + }; + const resourceValue = propsOrStateStore[ + `value` + ] as Signal; + if (!isSignal(resourceValue)) { + throw new Error(`Resource's value ${name}Value does not exist`); + } + + const { ids, entityMap } = createEntityDerivations(resourceValue); return { entityMap, @@ -262,7 +292,10 @@ type EntityResourceValue = Entity[] | (Entity[] | undefined); type TypedEntityResourceValue = E[] | (E[] | undefined); -export type EntityDictionary = Record>; +export type EntityDictionary = Record< + string, + ResourceRef | Resource +>; type MergeNamedEntityStates = MergeUnion< { From 0d4424584afdba9af7146060dc8bd62612685b54 Mon Sep 17 00:00:00 2001 From: Michael Small Date: Tue, 2 Jun 2026 20:14:08 -0500 Subject: [PATCH 16/18] chore: wip resource snapshots for `withEntityResource` --- .../src/lib/with-entity-resources.spec.ts | 44 ++++ .../src/lib/with-entity-resources.ts | 200 +++++++++++++++--- 2 files changed, 215 insertions(+), 29 deletions(-) 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 131b2e0b..a6e9bce3 100644 --- a/libs/ngrx-toolkit/src/lib/with-entity-resources.spec.ts +++ b/libs/ngrx-toolkit/src/lib/with-entity-resources.spec.ts @@ -7,6 +7,7 @@ import { setAllEntities, } from '@ngrx/signals/entities'; import { withEntityResources } from './with-entity-resources'; +import { withPreviousValue } from './with-resource/tests/util/snapshot'; type Todo = { id: number; title: string; completed: boolean }; const wait = (ms = 0) => new Promise((r) => setTimeout(r, ms)); @@ -199,6 +200,49 @@ describe('withEntityResources', () => { ]); }); + it('does not support setAllEntities/addEntity/removeEntity for unnamed which are of type Resource', async () => { + const Store = signalStore( + { providedIn: 'root', protectedState: false }, + withEntityResources(() => + withPreviousValue( + resource({ + loader: () => Promise.resolve([] as Todo[]), + defaultValue: [], + }), + ), + ), + ); + const store = TestBed.inject(Store); + + await wait(); + + const invalidSetAll = () => + patchState( + store, + // @ts-expect-error Resources which are of type Resource should not support entity updaters + setAllEntities([ + { id: 1, title: 'A', completed: false }, + { id: 2, title: 'B', completed: true }, + ] as Todo[]), + ); + + const invalidAdd = () => + patchState( + store, + // @ts-expect-error Resources which are of type Resource should not support entity updaters + addEntity({ id: 3, title: 'C', completed: false } as Todo), + ); + + // @ts-expect-error Resources which are of type Resource should not support entity updaters + const invalidRemove = () => patchState(store, removeEntity(2)); + + expect(invalidSetAll).toBeDefined(); + expect(invalidAdd).toBeDefined(); + expect(invalidRemove).toBeDefined(); + expect(store.ids()).toEqual([]); + expect(store.entities()).toEqual([]); + }); + it('supports setAllEntities/addEntity/removeEntity for named', async () => { const Store = signalStore( { providedIn: 'root', protectedState: false }, diff --git a/libs/ngrx-toolkit/src/lib/with-entity-resources.ts b/libs/ngrx-toolkit/src/lib/with-entity-resources.ts index 578a63f1..d328b57b 100644 --- a/libs/ngrx-toolkit/src/lib/with-entity-resources.ts +++ b/libs/ngrx-toolkit/src/lib/with-entity-resources.ts @@ -4,6 +4,7 @@ import { ResourceSnapshot, ResourceStatus, Signal, + computed, isSignal, linkedSignal, } from '@angular/core'; @@ -14,6 +15,7 @@ import { signalStoreFeature, withComputed, withLinkedState, + withProps, } from '@ngrx/signals'; import { EntityId, @@ -103,7 +105,16 @@ export function withEntityResources< >( resourceFactory: ( store: Input['props'] & Input['methods'] & StateSignals, - ) => WidenedResource>, + ) => ResourceRef>, +): SignalStoreFeature>; + +export function withEntityResources< + Input extends SignalStoreFeatureResult, + Entity extends { id: EntityId }, +>( + resourceFactory: ( + store: Input['props'] & Input['methods'] & StateSignals, + ) => Resource>, ): SignalStoreFeature>; export function withEntityResources< @@ -131,7 +142,7 @@ export function withEntityResources< }); if (isResourceRef(resourceOrDict)) { - return createUnnamedEntityResource(resourceOrDict)(store); + return createUnnamedEntityResourceRef(resourceOrDict)(store); } else if (isResource(resourceOrDict)) { return createUnnamedEntityResource(resourceOrDict)(store); } @@ -145,10 +156,8 @@ export function withEntityResources< * because {@link withResource} creates a Proxy around the resource value * to avoid the error throwing behavior of the Resource API. */ -function createUnnamedEntityResource( - resource: - | ResourceRef> - | Resource>, +function createUnnamedEntityResourceRef( + resource: ResourceRef>, ) { return signalStoreFeature( withResource(() => resource), @@ -182,6 +191,38 @@ function createUnnamedEntityResource( ); } +function createUnnamedEntityResource( + resource: Resource>, +) { + return signalStoreFeature( + withResource(() => resource), + withProps((store) => { + const propsStore = store as { + value: Signal>; + status: Signal; + error: Signal; + isLoading: Signal; + snapshot: Signal>>; + }; + + const resourceValue = propsStore.value as Signal; + if (!isSignal(resourceValue)) { + throw new Error(`Resource's value signal does not exist`); + } + + const { ids, entityMap } = createReadonlyEntityDerivations(resourceValue); + + return { + ids, + entityMap, + }; + }), + withComputed(({ ids, entityMap }) => ({ + entities: createComputedEntities(ids, entityMap), + })), + ); +} + /** * See {@link createUnnamedEntityResource} for why we cannot use the value of `resource` directly. */ @@ -190,23 +231,46 @@ function createNamedEntityResources( ) { const keys = Object.keys(dictionary); - 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 stateFactories = keys + .filter((name) => isResourceRef(dictionary[name])) + .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); + const { ids, entityMap } = createEntityDerivations(resourceValue); - return { - [`${name}EntityMap`]: entityMap, - [`${name}Ids`]: ids, + return { + [`${name}EntityMap`]: entityMap, + [`${name}Ids`]: ids, + }; }; - }; - }); + }); + + const propsFactories = keys + .filter((name) => !isResourceRef(dictionary[name])) + .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 } = + createReadonlyEntityDerivations(resourceValue); + + return { + [`${name}EntityMap`]: entityMap, + [`${name}Ids`]: ids, + }; + }; + }); const computedFactories = keys.map((name) => { return (store: Record) => { @@ -234,13 +298,28 @@ function createNamedEntityResources( withResource(() => dictionary), withLinkedState((store) => stateFactories.reduce( - (acc, factory) => ({ ...acc, ...factory(store) }), + (acc, factory) => ({ + ...acc, + ...factory(store), + }), + {}, + ), + ), + withProps((store) => + propsFactories.reduce( + (acc, factory) => ({ + ...acc, + ...factory(store), + }), {}, ), ), withComputed((store) => computedFactories.reduce( - (acc, factory) => ({ ...acc, ...factory(store) }), + (acc, factory) => ({ + ...acc, + ...factory(store), + }), {}, ), ), @@ -268,12 +347,37 @@ function createNamedEntityResources( * - For named resources we return `NamedResourceResult` intersected with * `NamedEntityState` and `NamedEntityProps` for each entry. */ -export type EntityResourceResult = { +declare const NON_PATCHABLE_ENTITY_RESOURCE_STATE: unique symbol; + +type NonPatchableEntityResourceStateMarker = { + [NON_PATCHABLE_ENTITY_RESOURCE_STATE]?: never; +}; + +type ReadonlyEntityProps = { + ids: Signal; + entityMap: Signal>; +}; + +type NamedReadonlyEntityProps = { + [Prop in `${Name}Ids`]: Signal; +} & { + [Prop in `${Name}EntityMap`]: Signal>; +}; + +export type EntityResourceRefResult = { state: ResourceResult['state'] & EntityState; props: ResourceResult['props'] & EntityProps; methods: ResourceResult['methods']; }; +export type EntityResourceResult = { + state: NonPatchableEntityResourceStateMarker; + props: ResourceResult['props'] & + EntityProps & + ReadonlyEntityProps; + methods: ResourceResult['methods']; +}; + // Generic helpers for inferring entity types and merging unions type ArrayElement = T extends readonly (infer E)[] | (infer E)[] ? E : never; @@ -300,15 +404,33 @@ export type EntityDictionary = Record< type MergeNamedEntityStates = MergeUnion< { [Prop in keyof T]: Prop extends string - ? InferEntityFromSignal extends infer E - ? E extends never - ? never - : NamedEntityState + ? T[Prop] extends ResourceRef + ? InferEntityFromSignal extends infer E + ? E extends never + ? never + : NamedEntityState + : never : never : never; }[keyof T] >; +type MergeNamedReadonlyEntityProps = MergeUnion< + { + [Prop in keyof T]: Prop extends string + ? T[Prop] extends ResourceRef + ? never + : InferEntityFromSignal extends infer E + ? E extends Entity + ? NamedReadonlyEntityProps + : E extends never + ? never + : never + : never + : never; + }[keyof T] +>; + type MergeNamedEntityProps = MergeUnion< { [Prop in keyof T]: Prop extends string @@ -322,8 +444,12 @@ type MergeNamedEntityProps = MergeUnion< >; export type NamedEntityResourceResult = { - state: NamedResourceResult['state'] & MergeNamedEntityStates; - props: NamedResourceResult['props'] & MergeNamedEntityProps; + state: NamedResourceResult['state'] & + MergeNamedEntityStates & + NonPatchableEntityResourceStateMarker; + props: NamedResourceResult['props'] & + MergeNamedEntityProps & + MergeNamedReadonlyEntityProps; methods: NamedResourceResult['methods']; }; @@ -374,6 +500,22 @@ function createEntityDerivations( return { ids, entityMap }; } +function createReadonlyEntityDerivations( + source: Signal>, +) { + const ids = computed(() => (source() ?? []).map((e) => e.id)); + + const entityMap = computed(() => { + const map = {} as Record; + for (const item of source() ?? []) { + map[item.id] = item as E; + } + return map; + }); + + return { ids, entityMap }; +} + function createComputedEntities( ids: Signal, entityMap: Signal>, From 31b5a9d7473c72fe80f905c90be180b67d1a5fe7 Mon Sep 17 00:00:00 2001 From: Michael Small Date: Tue, 2 Jun 2026 21:03:52 -0500 Subject: [PATCH 17/18] chore: add test for named snapshot resources in `withEntityResource` --- .../src/lib/with-entity-resources.spec.ts | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) 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 a6e9bce3..1886fa83 100644 --- a/libs/ngrx-toolkit/src/lib/with-entity-resources.spec.ts +++ b/libs/ngrx-toolkit/src/lib/with-entity-resources.spec.ts @@ -243,6 +243,56 @@ describe('withEntityResources', () => { expect(store.entities()).toEqual([]); }); + it('does not support setAllEntities/addEntity/removeEntity for named which are of type Resource', async () => { + const Store = signalStore( + { providedIn: 'root', protectedState: false }, + withEntityResources(() => ({ + todos: withPreviousValue( + resource({ + loader: () => Promise.resolve([] as Todo[]), + defaultValue: [], + }), + ), + })), + ); + + const store = TestBed.inject(Store); + + await wait(); + + const invalidSetAll = () => + patchState( + store, + // @ts-expect-error Resources which are of type Resource should not support entity updaters + setAllEntities( + [ + { id: 1, title: 'A', completed: false }, + { id: 2, title: 'B', completed: true }, + ] as Todo[], + { collection: 'todos' }, + ), + ); + + const invalidAdd = () => + patchState( + store, + // @ts-expect-error Resources which are of type Resource should not support entity updaters + addEntity({ id: 3, title: 'C', completed: false } as Todo, { + collection: 'todos', + }), + ); + + const invalidRemove = () => + // @ts-expect-error Resources which are of type Resource should not support entity updaters + patchState(store, removeEntity(2, { collection: 'todos' })); + + expect(invalidSetAll).toBeDefined(); + expect(invalidAdd).toBeDefined(); + expect(invalidRemove).toBeDefined(); + expect(store.todosIds()).toEqual([]); + expect(store.todosEntities()).toEqual([]); + }); + it('supports setAllEntities/addEntity/removeEntity for named', async () => { const Store = signalStore( { providedIn: 'root', protectedState: false }, From 7e279d46ad78404d324704b22256563957e41461 Mon Sep 17 00:00:00 2001 From: Michael Small Date: Sat, 6 Jun 2026 16:25:12 -0500 Subject: [PATCH 18/18] chore: fix `withEntityResource` after `withResource` rebase --- .../src/lib/with-entity-resources.ts | 41 +++++++++++++++---- 1 file changed, 33 insertions(+), 8 deletions(-) diff --git a/libs/ngrx-toolkit/src/lib/with-entity-resources.ts b/libs/ngrx-toolkit/src/lib/with-entity-resources.ts index d328b57b..91fac185 100644 --- a/libs/ngrx-toolkit/src/lib/with-entity-resources.ts +++ b/libs/ngrx-toolkit/src/lib/with-entity-resources.ts @@ -27,9 +27,6 @@ import { import { NamedResourceResult, ResourceResult, - WidenedResource, - isResource, - isResourceRef, withResource, } from './with-resource'; @@ -132,7 +129,7 @@ export function withEntityResources< >( entityResourceFactory: ( store: Input['props'] & Input['methods'] & StateSignals, - ) => WidenedResource | EntityDictionary, + ) => Resource | EntityDictionary, ): SignalStoreFeature { return (store) => { const resourceOrDict = entityResourceFactory({ @@ -141,9 +138,9 @@ export function withEntityResources< ...store.methods, }); - if (isResourceRef(resourceOrDict)) { + if (isEntityResourceRef(resourceOrDict)) { return createUnnamedEntityResourceRef(resourceOrDict)(store); - } else if (isResource(resourceOrDict)) { + } else if (isEntityResource(resourceOrDict)) { return createUnnamedEntityResource(resourceOrDict)(store); } return createNamedEntityResources(resourceOrDict)(store); @@ -232,7 +229,7 @@ function createNamedEntityResources( const keys = Object.keys(dictionary); const stateFactories = keys - .filter((name) => isResourceRef(dictionary[name])) + .filter((name) => isEntityResourceRef(dictionary[name])) .map((name) => { return (store: Record) => { const resourceValue = store[ @@ -252,7 +249,7 @@ function createNamedEntityResources( }); const propsFactories = keys - .filter((name) => !isResourceRef(dictionary[name])) + .filter((name) => !isEntityResourceRef(dictionary[name])) .map((name) => { return (store: Record) => { const resourceValue = store[ @@ -524,3 +521,31 @@ function createComputedEntities( return ids().map((id) => entityMap()[id]); }; } + +export function isEntityResource( + value: unknown, +): value is Resource { + return ( + value !== null && + typeof value === 'object' && + 'value' in value && + isSignal(value.value) && + 'status' in value && + 'error' in value && + 'isLoading' in value && + 'snapshot' in value && + 'hasValue' in value + ); +} + +export function isEntityResourceRef( + value: unknown, +): value is ResourceRef { + return ( + isEntityResource(value) && + 'reload' in value && + 'set' in value && + 'update' in value && + 'asReadonly' in value + ); +}