Skip to content
Closed
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
6 changes: 3 additions & 3 deletions goldens/public-api/core/index.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -1607,10 +1607,9 @@ export function resolveForwardRef<T>(type: T): T;

// @public
export interface Resource<T> {
readonly error: Signal<unknown>;
readonly error: Signal<Error | undefined>;
hasValue(): this is Resource<Exclude<T, undefined>>;
readonly isLoading: Signal<boolean>;
reload(): boolean;
readonly status: Signal<ResourceStatus>;
readonly value: Signal<T>;
}
Expand Down Expand Up @@ -1658,7 +1657,7 @@ export type ResourceStreamingLoader<T, R> = (param: ResourceLoaderParams<R>) =>
export type ResourceStreamItem<T> = {
value: T;
} | {
error: unknown;
error: Error;
};

// @public
Expand Down Expand Up @@ -2022,6 +2021,7 @@ export interface WritableResource<T> extends Resource<T> {
asReadonly(): Resource<T>;
// (undocumented)
hasValue(): this is WritableResource<Exclude<T, undefined>>;
reload(): boolean;
set(value: T): void;
update(updater: (value: T) => T): void;
// (undocumented)
Expand Down
3 changes: 2 additions & 1 deletion packages/common/http/src/resource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
type ValueEqualityFn,
ɵRuntimeError,
ɵRuntimeErrorCode,
ɵencapsulateResourceError as encapsulateResourceError,
} from '@angular/core';
import type {Subscription} from 'rxjs';

Expand Down Expand Up @@ -344,7 +345,7 @@ class HttpResourceImpl<T>
try {
send({value: parse ? parse(event.body) : (event.body as T)});
} catch (error) {
send({error});
send({error: encapsulateResourceError(error)});
}
break;
case HttpEventType.DownloadProgress:
Expand Down
14 changes: 14 additions & 0 deletions packages/common/http/test/resource_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -289,4 +289,18 @@ describe('httpResource', () => {
await TestBed.inject(ApplicationRef).whenStable();
expect(res.value()).toBe(buffer);
});

it('should send request on reload', async () => {
const backend = TestBed.inject(HttpTestingController);
const res = httpResource(() => '/data', {injector: TestBed.inject(Injector)});
TestBed.tick();
let req = backend.expectOne('/data');
req.flush([]);
await TestBed.inject(ApplicationRef).whenStable();

res.reload();
TestBed.tick();
req = backend.expectOne('/data');
req.flush([]);
});
});
13 changes: 7 additions & 6 deletions packages/core/rxjs-interop/src/rx_resource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
ɵRuntimeErrorCode,
} from '../../src/core';
import {Observable, Subscription} from 'rxjs';
import {encapsulateResourceError} from '../../src/resource/resource';

/**
* Like `ResourceOptions` but uses an RxJS-based `loader`.
Expand Down Expand Up @@ -61,11 +62,11 @@ export function rxResource<T, R>(opts: RxResourceOptions<T, R>): ResourceRef<T |
params.abortSignal.addEventListener('abort', onAbort);

// Start off stream as undefined.
const stream = signal<{value: T} | {error: unknown}>({value: undefined as T});
let resolve: ((value: Signal<{value: T} | {error: unknown}>) => void) | undefined;
const promise = new Promise<Signal<{value: T} | {error: unknown}>>((r) => (resolve = r));
const stream = signal<{value: T} | {error: Error}>({value: undefined as T});
let resolve: ((value: Signal<{value: T} | {error: Error}>) => void) | undefined;
const promise = new Promise<Signal<{value: T} | {error: Error}>>((r) => (resolve = r));

function send(value: {value: T} | {error: unknown}): void {
function send(value: {value: T} | {error: Error}): void {
stream.set(value);
resolve?.(stream);
resolve = undefined;
Expand All @@ -82,8 +83,8 @@ export function rxResource<T, R>(opts: RxResourceOptions<T, R>): ResourceRef<T |

sub = streamFn(params).subscribe({
next: (value) => send({value}),
error: (error) => {
send({error});
error: (error: unknown) => {
send({error: encapsulateResourceError(error)});
params.abortSignal.removeEventListener('abort', onAbort);
},
complete: () => {
Expand Down
3 changes: 2 additions & 1 deletion packages/core/rxjs-interop/test/rx_resource_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,8 @@ describe('rxResource()', () => {
expect(res.value()).toBe(3);

response.error('fail');
expect(res.error()).toBe('fail');
expect(res.error()).toEqual(jasmine.objectContaining({cause: 'fail'}));
expect(res.error()!.message).toContain('Resource');
});
});

Expand Down
5 changes: 4 additions & 1 deletion packages/core/src/core_private_export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,9 @@ export {
disableProfiling as ɵdisableProfiling,
} from './profiler';

export {ResourceImpl as ɵResourceImpl} from './resource/resource';
export {
ResourceImpl as ɵResourceImpl,
encapsulateResourceError as ɵencapsulateResourceError,
} from './resource/resource';

export {getClosestComponentName as ɵgetClosestComponentName} from './internal/get_closest_component_name';
28 changes: 14 additions & 14 deletions packages/core/src/resource/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export type ResourceStatus = 'idle' | 'error' | 'loading' | 'reloading' | 'resol
*/
export interface Resource<T> {
/**
* The current value of the `Resource`, or `undefined` if there is no current value.
* The current value of the `Resource`, or throws an error if the resource is in an error state.
*/
readonly value: Signal<T>;

Expand All @@ -59,7 +59,7 @@ export interface Resource<T> {
/**
* When in the `error` state, this returns the last known error from the `Resource`.
*/
readonly error: Signal<unknown>;
readonly error: Signal<Error | undefined>;

/**
* Whether this resource is loading a new value (or reloading the existing one).
Expand All @@ -72,16 +72,6 @@ export interface Resource<T> {
* This function is reactive.
*/
hasValue(): this is Resource<Exclude<T, undefined>>;

/**
* Instructs the resource to re-load any asynchronous dependency it may have.
*
* Note that the resource will not enter its reloading state until the actual backend request is
* made.
*
* @returns true if a reload was initiated, false if a reload was unnecessary or unsupported
*/
reload(): boolean;
}

/**
Expand All @@ -105,6 +95,16 @@ export interface WritableResource<T> extends Resource<T> {
*/
update(updater: (value: T) => T): void;
asReadonly(): Resource<T>;

/**
* Instructs the resource to re-load any asynchronous dependency it may have.
*
* Note that the resource will not enter its reloading state until the actual backend request is
* made.
*
* @returns true if a reload was initiated, false if a reload was unnecessary or unsupported
*/
reload(): boolean;
}

/**
Expand Down Expand Up @@ -167,7 +167,7 @@ export interface BaseResourceOptions<T, 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.
* when the resource is still loading.
*/
defaultValue?: NoInfer<T>;

Expand Down Expand Up @@ -225,4 +225,4 @@ export type ResourceOptions<T, R> = PromiseResourceOptions<T, R> | StreamingReso
/**
* @experimental
*/
export type ResourceStreamItem<T> = {value: T} | {error: unknown};
export type ResourceStreamItem<T> = {value: T} | {error: Error};
79 changes: 73 additions & 6 deletions packages/core/src/resource/resource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,13 @@ import {PendingTasks} from '../pending_tasks';
import {linkedSignal} from '../render3/reactivity/linked_signal';
import {DestroyRef} from '../linker/destroy_ref';

/**
* Whether a `Resource.value()` should throw an error when the resource is in the error state.
*
* This internal flag is being used to gradually roll out this behavior.
*/
const RESOURCE_VALUE_THROWS_ERRORS_DEFAULT = true;

/**
* 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.
Expand Down Expand Up @@ -72,6 +79,7 @@ export function resource<T, R>(options: ResourceOptions<T, R>): ResourceRef<T |
options.defaultValue,
options.equal ? wrapEqualityFn(options.equal) : undefined,
options.injector ?? inject(Injector),
RESOURCE_VALUE_THROWS_ERRORS_DEFAULT,
);
}

Expand Down Expand Up @@ -101,7 +109,8 @@ type WrappedRequest = {request: unknown; reload: number};
abstract class BaseWritableResource<T> implements WritableResource<T> {
readonly value: WritableSignal<T>;
abstract readonly status: Signal<ResourceStatus>;
abstract readonly error: Signal<unknown>;
abstract readonly error: Signal<Error | undefined>;

abstract reload(): boolean;

constructor(value: Signal<T>) {
Expand All @@ -113,13 +122,22 @@ abstract class BaseWritableResource<T> implements WritableResource<T> {

abstract set(value: T): void;

private readonly isError = computed(() => this.status() === 'error');

update(updateFn: (value: T) => T): void {
this.set(updateFn(untracked(this.value)));
}

readonly isLoading = computed(() => this.status() === 'loading' || this.status() === 'reloading');

hasValue(): this is ResourceRef<Exclude<T, undefined>> {
// Note: we specifically read `isError()` instead of `status()` here to avoid triggering
// reactive consumers which read `hasValue()`. This way, if `hasValue()` is used inside of an
// effect, it doesn't cause the effect to rerun on every status change.
if (this.isError()) {
return false;
}

return this.value() !== undefined;
}

Expand Down Expand Up @@ -153,17 +171,36 @@ export class ResourceImpl<T, R> extends BaseWritableResource<T> implements Resou
constructor(
request: () => R,
private readonly loaderFn: ResourceStreamingLoader<T, R>,
private readonly defaultValue: T,
defaultValue: T,
private readonly equal: ValueEqualityFn<T> | undefined,
injector: Injector,
throwErrorsFromValue: boolean = RESOURCE_VALUE_THROWS_ERRORS_DEFAULT,
) {
super(
// Feed a computed signal for the value to `BaseWritableResource`, which will upgrade it to a
// `WritableSignal` that delegates to `ResourceImpl.set`.
computed(
() => {
const streamValue = this.state().stream?.();
return streamValue && isResolved(streamValue) ? streamValue.value : this.defaultValue;

if (!streamValue) {
return defaultValue;
}

// Prevents `hasValue()` from throwing an error when a reload happened in the error state
if (this.state().status === 'loading' && this.error()) {
return defaultValue;
}

if (!isResolved(streamValue)) {
if (throwErrorsFromValue) {
throw new ResourceValueError(this.error()!);
} else {
return defaultValue;
}
}

return streamValue.value;
},
{equal},
),
Expand Down Expand Up @@ -346,7 +383,7 @@ export class ResourceImpl<T, R> extends BaseWritableResource<T> implements Resou
extRequest,
status: 'resolved',
previousStatus: 'error',
stream: signal({error: err}),
stream: signal({error: encapsulateResourceError(err)}),
});
} finally {
// Resolve the pending task now that the resource has a value.
Expand Down Expand Up @@ -381,7 +418,7 @@ function getLoader<T, R>(options: ResourceOptions<T, R>): ResourceStreamingLoade
try {
return signal({value: await options.loader(params)});
} catch (err) {
return signal({error: err});
return signal({error: encapsulateResourceError(err)});
}
};
}
Expand All @@ -400,7 +437,7 @@ function projectStatusOfState(state: ResourceState<unknown>): ResourceStatus {
case 'loading':
return state.extRequest.reload === 0 ? 'loading' : 'reloading';
case 'resolved':
return isResolved(untracked(state.stream!)) ? 'resolved' : 'error';
return isResolved(state.stream!()) ? 'resolved' : 'error';
default:
return state.status;
}
Expand All @@ -409,3 +446,33 @@ function projectStatusOfState(state: ResourceState<unknown>): ResourceStatus {
function isResolved<T>(state: ResourceStreamItem<T>): state is {value: T} {
return (state as {error: unknown}).error === undefined;
}

export function encapsulateResourceError(error: unknown): Error {
if (error instanceof Error) {
return error;
}

return new ResourceWrappedError(error);
}

class ResourceValueError extends Error {
constructor(error: Error) {
super(
ngDevMode
? `Resource is currently in an error state (see Error.cause for details): ${error.message}`
: error.message,
{cause: error},
);
}
}

class ResourceWrappedError extends Error {
constructor(error: unknown) {
super(
ngDevMode
? `Resource returned an error that's not an Error instance: ${String(error)}. Check this error's .cause for the actual error.`
: String(error),
{cause: error},
);
}
}
Loading