From 066daef9af44da836a0d7421fe3763ce0e047c8a Mon Sep 17 00:00:00 2001 From: Michael Small Date: Thu, 23 Apr 2026 19:30:38 -0500 Subject: [PATCH 01/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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/15] 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 658bcf798a81e778d292ad891aabba8db9e67a10 Mon Sep 17 00:00:00 2001 From: Michael Small Date: Sat, 6 Jun 2026 16:03:25 -0500 Subject: [PATCH 15/15] chore: remove commented out, unneeded util --- libs/ngrx-toolkit/src/lib/with-resource.ts | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/libs/ngrx-toolkit/src/lib/with-resource.ts b/libs/ngrx-toolkit/src/lib/with-resource.ts index c7456308..0da527bb 100644 --- a/libs/ngrx-toolkit/src/lib/with-resource.ts +++ b/libs/ngrx-toolkit/src/lib/with-resource.ts @@ -524,18 +524,6 @@ function capitalizeKey(value: string): string { return value.charAt(0).toUpperCase() + value.slice(1); } -// 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` */ type NamedResource = {