Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions apps/demo/src/app/with-resource/with-resource.component.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<h1>withResource</h1>
<a
href="https://ngrx-toolkit.angulararchitects.io/docs/with-resource"
target="_blank"
>withResource doc page</a
>

<h2>Single Resource</h2>
<pre>value: {{ store.value() | json }}</pre>
<pre>status: {{ store.status() }}</pre>
<pre>error: {{ store.error() | json }}</pre>
<pre>hasValue: {{ store.hasValue() }}</pre>
@if (store.hasValue()) {
<pre>value: {{ store.value() | json }}</pre>
}

<h2>Named Resource</h2>
<pre>{{ store.listValue() | json }}</pre>
<pre>status: {{ store.listStatus() }}</pre>
<pre>error: {{ store.listError() | json }}</pre>
<pre>hasValue: {{ store.listHasValue() }}</pre>
@if (store.listHasValue()) {
<pre>value: {{ store.listValue() | json }}</pre>
}
24 changes: 4 additions & 20 deletions apps/demo/src/app/with-resource/with-resource.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ const url = 'https://demo.angulararchitects.io/api/flight?from=Paris&to=';

export const FlightStore = signalStore(
withState({ flightTo: 'New York' }),
withResource(({ flightTo }) => httpResource(() => `${url}${flightTo()}`)),
withResource(({ flightTo }) =>
httpResource<Flight[]>(() => `${url}${flightTo()}`),
),
withResource(({ flightTo }) => ({
list: httpResource<Flight[]>(() => `${url}${flightTo()}`, {
defaultValue: [],
Expand All @@ -20,25 +22,7 @@ export const FlightStore = signalStore(
@Component({
selector: 'demo-with-resource',
imports: [JsonPipe],
template: `,
<h1>withResource</h1>
<a
href="https://ngrx-toolkit.angulararchitects.io/docs/with-resource"
target="_blank"
>withResource doc page</a
>

<h2>Single Resource</h2>
<pre>value: {{ store.value() | json }}</pre>
<pre>status: {{ store.status() }}</pre>
<pre>error: {{ store.error() | json }}</pre>
<pre>hasValue: {{ store.hasValue() }}</pre>

<h2>Named Resource</h2>
<pre>{{ store.listValue() | json }}</pre>
<pre>status: {{ store.listStatus() }}</pre>
<pre>error: {{ store.listError() | json }}</pre>
<pre>hasValue: {{ store.listHasValue() }}</pre> `,
templateUrl: './with-resource.component.html',
providers: [FlightStore],
})
export class WithResourceComponent {
Expand Down
2 changes: 0 additions & 2 deletions docs/docs/with-entity-resources.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,6 @@ title: withEntityResources()
import { withEntityResources } from '@angular-architects/ngrx-toolkit';
```

> **⚠️ Important Note**: We have found some issues with `hasValue()` not narrowing correctly. If you have any insights or want to follow developments, please refer to our issue: ["bug(withResource and Mutations): hasValue() does not narrow the respective value signal #235"](https://github.com/angular-architects/ngrx-toolkit/issues/235)

`withEntityResources()` integrates Angular Resources that return arrays into NgRx SignalStore using the Entity helpers from `@ngrx/signals/entities`.

> Note: This feature builds on [withResource()](./with-resource.md) and adds an entity view over array resources.
Expand Down
2 changes: 0 additions & 2 deletions docs/docs/with-resource.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,6 @@ import { withResource } from '@angular-architects/ngrx-toolkit';

> **⚠️ Important Note**: This extension is very likely to land in NgRx once Angular's `Resource` enters developer preview. The `withResource` extension provides early access to this functionality and will be maintained for compatibility until the official NgRx implementation is available.

> **⚠️ Important Note**: We have found some issues with `hasValue()` not narrowing correctly. If you have any insights or want to follow developments, please refer to our issue: ["bug(withResource and Mutations): hasValue() does not narrow the respective value signal #235"](https://github.com/angular-architects/ngrx-toolkit/issues/235)

`withResource()` is a feature in NgRx SignalStore that connects Angular's Resource API with the store.
The idea: you can use a store to directly manage async data (like loading from an API), and `withResource()` helps you wire that in.

Expand Down
179 changes: 172 additions & 7 deletions libs/ngrx-toolkit/src/lib/with-resource.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {
withState,
} from '@ngrx/signals';
import { of } from 'rxjs';
import { Assert, AssertNot, IsEqual, Satisfies } from './test-utils/types';
import { Assert, IsEqual, Satisfies } from './test-utils/types';
import { ErrorHandling, mapToResource, withResource } from './with-resource';
import { Address, venice, vienna } from './with-resource/tests/util/fixtures';
import { paramsForResourceTypes } from './with-resource/tests/util/params-for-resource-types';
Expand Down Expand Up @@ -394,15 +394,15 @@ describe('withResource', () => {
}
});

it('fails on hasValue as type predicate when not explicitly typed', () => {
it('removes undefined from the inferred value type after hasValue', () => {
const Store = signalStore(
{ providedIn: 'root' },
withResource(() => resource({ loader: () => Promise.resolve(1) })),
);
const store = TestBed.inject(Store);
if (store.hasValue()) {
const _value = store.value();
type _T1 = AssertNot<IsEqual<typeof _value, number>>;
type _T1 = Assert<IsEqual<typeof _value, number>>;
}
});
});
Expand Down Expand Up @@ -449,7 +449,7 @@ describe('withResource', () => {
}
});

it('fails on hasValue as type predicate when not explicitly typed', () => {
it('removes undefined from the inferred value type after hasValue', () => {
const Store = signalStore(
{ providedIn: 'root' },
withResource(() => resource({ loader: () => Promise.resolve(1) }), {
Expand All @@ -459,11 +459,176 @@ describe('withResource', () => {
const store = TestBed.inject(Store);
if (store.hasValue()) {
const _value = store.value();
type _T1 = AssertNot<IsEqual<typeof _value, number>>;
type _T1 = Assert<IsEqual<typeof _value, number>>;
}
});

it('removes undefined from the inferred value type after hasValue for named resources (no defaults)', () => {
const StoreNativeError = signalStore(
{ providedIn: 'root' },
withResource(
() => {
return {
thing: resource({
loader: () => Promise.resolve(1),
}),
};
},
{
errorHandling: 'native',
},
),
);
const store = TestBed.inject(StoreNativeError);

const _valueNative = store.thingValue();
type _TNative = Assert<
IsEqual<typeof _valueNative, number | undefined>
>;

if (store.thingHasValue()) {
const _value = store.thingValue();
type _T1 = Assert<IsEqual<typeof _value, number>>;
}

const StorePreviousValue = signalStore(
{ providedIn: 'root' },
withResource(
() => {
return {
thing: resource({
loader: () => Promise.resolve(1),
}),
};
},
{
errorHandling: 'previous value',
},
),
);
const storePreviousValue = TestBed.inject(StorePreviousValue);

const _valuePrevious = storePreviousValue.thingValue();
type _TPrev = Assert<
IsEqual<typeof _valuePrevious, number | undefined>
>;

if (storePreviousValue.thingHasValue()) {
const _value = storePreviousValue.thingValue();
type _T1 = Assert<IsEqual<typeof _value, number>>;
}

const StoreUndefinedValue = signalStore(
{ providedIn: 'root' },
withResource(
() => {
return {
thing: resource({
loader: () => Promise.resolve(1),
}),
};
},
{
errorHandling: 'undefined value',
},
),
);
const storeUndefinedValue = TestBed.inject(StoreUndefinedValue);

const _valueUndefined = storeUndefinedValue.thingValue();
type _TUndefined = Assert<
IsEqual<typeof _valueUndefined, number | undefined>
>;

if (storeUndefinedValue.thingHasValue()) {
const _value = storeUndefinedValue.thingValue();
type _T1 = Assert<IsEqual<typeof _value, number>>;
}
});
});

it('removes undefined from the inferred value type after hasValue for named resources (defaults)', () => {
const StoreNativeError = signalStore(
{ providedIn: 'root' },
withResource(
() => {
return {
thing: resource({
loader: () => Promise.resolve(1),
defaultValue: 0,
}),
};
},
{
errorHandling: 'native',
},
),
);
const store = TestBed.inject(StoreNativeError);

const _valueNative = store.thingValue();
type _TNative = Assert<IsEqual<typeof _valueNative, number>>;

if (store.thingHasValue()) {
const _value = store.thingValue();
type _T1 = Assert<IsEqual<typeof _value, number>>;
}

const StorePreviousValue = signalStore(
{ providedIn: 'root' },
withResource(
() => {
return {
thing: resource({
loader: () => Promise.resolve(1),
defaultValue: 0,
}),
};
},
{
errorHandling: 'previous value',
},
),
);
const storePreviousValue = TestBed.inject(StorePreviousValue);

const _valuePrevious = storePreviousValue.thingValue();
type _TPrev = Assert<IsEqual<typeof _valuePrevious, number>>;

if (storePreviousValue.thingHasValue()) {
const _value = storePreviousValue.thingValue();
type _T1 = Assert<IsEqual<typeof _value, number>>;
}

const StoreUndefinedValue = signalStore(
{ providedIn: 'root' },
withResource(
() => {
return {
thing: resource({
loader: () => Promise.resolve(1),
defaultValue: 0,
}),
};
},
{
errorHandling: 'undefined value',
},
),
);
const storeUndefinedValue = TestBed.inject(StoreUndefinedValue);

const _valueUndefined = storeUndefinedValue.thingValue();
type _TUndefined = Assert<
IsEqual<typeof _valueUndefined, number | undefined>
>;

if (storeUndefinedValue.thingHasValue()) {
const _value = storeUndefinedValue.thingValue();
type _T1 = Assert<IsEqual<typeof _value, number>>;
}
});

describe.each(['previous value', 'native'] as const)(
`Error Handling: %s`,
(errorHandling) => {
Expand Down Expand Up @@ -506,7 +671,7 @@ describe('withResource', () => {
}
});

it('fails on hasValue as type predicate when not explicitly typed', () => {
it('removes undefined from the inferred value type after hasValue', () => {
const Store = signalStore(
{ providedIn: 'root' },
withResource(() => resource({ loader: () => Promise.resolve(1) }), {
Expand All @@ -516,7 +681,7 @@ describe('withResource', () => {
const store = TestBed.inject(Store);
if (store.hasValue()) {
const _value = store.value();
type _T1 = AssertNot<IsEqual<typeof _value, number>>;
type _T1 = Assert<IsEqual<typeof _value, number>>;
}
});
},
Expand Down
53 changes: 49 additions & 4 deletions libs/ngrx-toolkit/src/lib/with-resource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,32 @@ import {
withProps,
} from '@ngrx/signals';

/**
* `hasValue` captures the original type of `this` as `Original` and narrows
* only the resource portion of that type.
*
* The intersection order is significant because Signals are callable.
* TypeScript treats intersections of callable types as overloads and uses the
* first matching call signature. The narrowed resource must therefore come
* before `Original`; otherwise, `value()` resolves to the original
* `T | undefined`.
*
* ```ts
* type A = {
* id: () => number | undefined;
* hasId: <Original extends A>(
* this: Original,
* ) => this is { id: () => number } & Original;
* };
*
* function read(a: A) {
* if (a.hasId()) {
* const id = a.id(); // number
* }
* }
* ```
*/

export type ResourceResult<T> = {
state: { value: T };
props: {
Expand All @@ -29,7 +55,9 @@ export type ResourceResult<T> = {
snapshot: Signal<ResourceSnapshot<T>>;
};
methods: {
hasValue(): this is Resource<Exclude<T, undefined>>;
hasValue: <Original extends Resource<T>>(
this: Original,
) => this is Resource<Exclude<T, undefined>> & Original;
_reload(): boolean;
};
};
Expand Down Expand Up @@ -58,9 +86,26 @@ export type NamedResourceResult<
[Prop in keyof T as `${Prop & string}Snapshot`]: T[Prop]['snapshot'];
};
methods: {
[Prop in keyof T as `${Prop & string}HasValue`]: () => this is Resource<
Exclude<T[Prop]['value'], undefined>
>;
[Prop in keyof T as `${Prop & string}HasValue`]: <
Original extends Record<
`${Prop & string}Value`,
Signal<
T[Prop]['value'] extends Signal<infer S>
? HasUndefinedErrorHandling extends true
? S | undefined
: S
: never
>
>,
>(
this: Original,
) => this is Record<
`${Prop & string}Value`,
Signal<
Exclude<T[Prop]['value'] extends Signal<infer S> ? S : never, undefined>
>
> &
Original;
} & {
[Prop in keyof T as `_${Prop & string}Reload`]: () => boolean;
};
Expand Down
Loading