Skip to content

Commit

Permalink
feat(core): support default value in resource()
Browse files Browse the repository at this point in the history
Before `resource()` resolves, its value is in an unknown state. By default
it returns `undefined` in these scenarios, so the type of `.value()`
includes `undefined`.

This commit adds a `defaultValue` option to `resource()` and `rxResource()`
which overrides this default. When provided, an unresolved resource will
return this value instead of `undefined`, which simplifies the typing of
`.value()`.
  • Loading branch information
alxhub committed Jan 21, 2025
1 parent 4f46b02 commit 9c705d5
Show file tree
Hide file tree
Showing 6 changed files with 72 additions and 2 deletions.
6 changes: 6 additions & 0 deletions goldens/public-api/core/index.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@ export interface AttributeDecorator {

// @public
export interface BaseResourceOptions<T, R> {
defaultValue?: NoInfer<T>;
equal?: ValueEqualityFn<T>;
injector?: Injector;
request?: () => R;
Expand Down Expand Up @@ -1604,6 +1605,11 @@ export interface Resource<T> {
readonly value: Signal<T>;
}

// @public
export function resource<T, R>(options: ResourceOptions<T, R> & {
defaultValue: NoInfer<T>;
}): ResourceRef<T>;

// @public
export function resource<T, R>(options: ResourceOptions<T, R>): ResourceRef<T | undefined>;

Expand Down
5 changes: 5 additions & 0 deletions goldens/public-api/core/rxjs-interop/index.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ export function outputToObservable<T>(ref: OutputRef<T>): Observable<T>;
// @public
export function pendingUntilEvent<T>(injector?: Injector): MonoTypeOperatorFunction<T>;

// @public
export function rxResource<T, R>(opts: RxResourceOptions<T, R> & {
defaultValue: NoInfer<T>;
}): ResourceRef<T>;

// @public
export function rxResource<T, R>(opts: RxResourceOptions<T, R>): ResourceRef<T | undefined>;

Expand Down
13 changes: 12 additions & 1 deletion packages/core/rxjs-interop/src/rx_resource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,21 @@ export interface RxResourceOptions<T, R> extends BaseResourceOptions<T, R> {

/**
* Like `resource` but uses an RxJS based `loader` which maps the request to an `Observable` of the
* resource's value. Like `firstValueFrom`, only the first emission of the Observable is considered.
* resource's value.
*
* @experimental
*/
export function rxResource<T, R>(
opts: RxResourceOptions<T, R> & {defaultValue: NoInfer<T>},
): ResourceRef<T>;

/**
* Like `resource` but uses an RxJS based `loader` which maps the request to an `Observable` of the
* resource's value.
*
* @experimental
*/
export function rxResource<T, R>(opts: RxResourceOptions<T, R>): ResourceRef<T | undefined>;
export function rxResource<T, R>(opts: RxResourceOptions<T, R>): ResourceRef<T | undefined> {
opts?.injector || assertInInjectionContext(rxResource);
return resource<T, R>({
Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/resource/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,12 @@ export interface BaseResourceOptions<T, R> {
*/
request?: () => R;

/**
* The value which will be returned from the resource when a server value is unavailable, such as
* when the resource is still loading, or in an error state.
*/
defaultValue?: NoInfer<T>;

/**
* Equality function used to compare the return value of the loader.
*/
Expand Down
17 changes: 16 additions & 1 deletion packages/core/src/resource/resource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,13 +40,28 @@ import {DestroyRef} from '../linker/destroy_ref';
*
* @experimental
*/
export function resource<T, R>(
options: ResourceOptions<T, R> & {defaultValue: NoInfer<T>},
): ResourceRef<T>;

/**
* Constructs a `Resource` that projects a reactive request to an asynchronous operation defined by
* a loader function, which exposes the result of the loading operation via signals.
*
* Note that `resource` is intended for _read_ operations, not operations which perform mutations.
* `resource` will cancel in-progress loads via the `AbortSignal` when destroyed or when a new
* request object becomes available, which could prematurely abort mutations.
*
* @experimental
*/
export function resource<T, R>(options: ResourceOptions<T, R>): ResourceRef<T | undefined>;
export function resource<T, R>(options: ResourceOptions<T, R>): ResourceRef<T | undefined> {
options?.injector || assertInInjectionContext(resource);
const request = (options.request ?? (() => null)) as () => R;
return new ResourceImpl<T | undefined, R>(
request,
getLoader(options),
undefined,
options.defaultValue,
options.equal ? wrapEqualityFn(options.equal) : undefined,
options.injector ?? inject(Injector),
);
Expand Down
27 changes: 27 additions & 0 deletions packages/core/test/resource/resource_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,33 @@ describe('resource', () => {
expect(echoResource.error()).toEqual(Error('KO'));
});

it('should return a default value if provided', async () => {
const DEFAULT: string[] = [];
const request = signal(0);
const res = resource({
request,
loader: async ({request}) => {
if (request === 2) {
throw new Error('err');
}
return ['data'];
},
defaultValue: DEFAULT,
injector: TestBed.inject(Injector),
});
expect(res.value()).toBe(DEFAULT);

await TestBed.inject(ApplicationRef).whenStable();
expect(res.value()).not.toBe(DEFAULT);

request.set(1);
expect(res.value()).toBe(DEFAULT);

request.set(2);
await TestBed.inject(ApplicationRef).whenStable();
expect(res.value()).toBe(DEFAULT);
});

it('should _not_ load if the request resolves to undefined', () => {
const counter = signal(0);
const backend = new MockEchoBackend();
Expand Down

0 comments on commit 9c705d5

Please sign in to comment.