Thanks to visit codestin.com
Credit goes to github.com

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
19 changes: 19 additions & 0 deletions goldens/public-api/core/index.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -1627,6 +1627,7 @@ export interface Resource<T> {
// (undocumented)
hasValue(): boolean;
readonly isLoading: Signal<boolean>;
readonly snapshot: Signal<ResourceSnapshot<T>>;
readonly status: Signal<ResourceStatus>;
readonly value: Signal<T>;
}
Expand All @@ -1639,6 +1640,9 @@ export function resource<T, R>(options: ResourceOptions<T, R> & {
// @public
export function resource<T, R>(options: ResourceOptions<T, R>): ResourceRef<T | undefined>;

// @public
export function resourceFromSnapshots<T>(source: () => ResourceSnapshot<T>): Resource<T>;

// @public
export type ResourceLoader<T, R> = (param: ResourceLoaderParams<R>) => PromiseLike<T>;

Expand Down Expand Up @@ -1668,6 +1672,21 @@ export interface ResourceRef<T> extends WritableResource<T> {
hasValue(): boolean;
}

// @public
export type ResourceSnapshot<T> = {
readonly status: 'idle';
readonly value: T;
} | {
readonly status: 'loading' | 'reloading';
readonly value: T;
} | {
readonly status: 'resolved' | 'local';
readonly value: T;
} | {
readonly status: 'error';
readonly error: Error;
};

// @public
export type ResourceStatus = 'idle' | 'error' | 'loading' | 'reloading' | 'resolved' | 'local';

Expand Down
16 changes: 16 additions & 0 deletions packages/core/src/resource/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,11 @@ export interface Resource<T> {
*/
readonly isLoading: Signal<boolean>;

/**
* The current state of this resource, represented as a `ResourceSnapshot`.
*/
readonly snapshot: Signal<ResourceSnapshot<T>>;
Comment thread
JeanMeche marked this conversation as resolved.

/**
* Whether this resource has a valid current value.
*
Expand Down Expand Up @@ -241,3 +246,14 @@ export type ResourceOptions<T, R> = (
* @experimental
*/
export type ResourceStreamItem<T> = {value: T} | {error: Error};

/**
* An explicit representation of a resource's state.
*
* @experimental
*/
export type ResourceSnapshot<T> =
| {readonly status: 'idle'; readonly value: T}
| {readonly status: 'loading' | 'reloading'; readonly value: T}
| {readonly status: 'resolved' | 'local'; readonly value: T}
| {readonly status: 'error'; readonly error: Error};
51 changes: 51 additions & 0 deletions packages/core/src/resource/from_snapshots.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/

import {Resource, ResourceSnapshot} from './api';
import {isSignal, Signal} from '../render3/reactivity/api';
import {computed} from '../render3/reactivity/computed';
import {ResourceValueError} from './resource';

/**
* Creates a `Resource` driven by a source of `ResourceSnapshot`s.
*
* @experimental
*/
export function resourceFromSnapshots<T>(source: () => ResourceSnapshot<T>): Resource<T> {
return new SnapshotResource(isSignal(source) ? source : computed(source));
}

class SnapshotResource<T> implements Resource<T> {
constructor(readonly snapshot: Signal<ResourceSnapshot<T>>) {}

private get state(): ResourceSnapshot<T> {
return this.snapshot();
}

readonly value = computed(() => {
if (this.state.status === 'error') {
throw new ResourceValueError(this.state.error);
}
return this.state.value;
});
readonly status = computed(() => this.state.status);
readonly error = computed(() => (this.state.status === 'error' ? this.state.error : undefined));
readonly isLoading = computed(
() => this.state.status === 'loading' || this.state.status === 'reloading',
Comment thread
JeanMeche marked this conversation as resolved.
);

private isValueDefined = computed(
() => this.state.status !== 'error' && this.state.value !== undefined,
);

hasValue(this: T extends undefined ? this : never): this is Resource<Exclude<T, undefined>>;
hasValue(): boolean;
hasValue(): boolean {
return this.isValueDefined();
}
}
1 change: 1 addition & 0 deletions packages/core/src/resource/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,5 @@
*/

export * from './api';
export {resourceFromSnapshots} from './from_snapshots';
export {resource} from './resource';
15 changes: 14 additions & 1 deletion packages/core/src/resource/resource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
StreamingResourceOptions,
ResourceStreamItem,
ResourceLoaderParams,
ResourceSnapshot,
} from './api';

import {Injector} from '../di/injector';
Expand Down Expand Up @@ -141,6 +142,18 @@ abstract class BaseWritableResource<T> implements WritableResource<T> {
return this.value() !== undefined;
});

private _snapshot: Signal<ResourceSnapshot<T>> | undefined;
get snapshot(): Signal<ResourceSnapshot<T>> {
return (this._snapshot ??= computed(() => {
const status = this.status();
if (status === 'error') {
return {status: 'error', error: this.error()!};
} else {
return {status, value: this.value()};
}
}));
}

hasValue(): this is ResourceRef<Exclude<T, undefined>> {
return this.isValueDefined();
}
Expand Down Expand Up @@ -509,7 +522,7 @@ export function isErrorLike(error: unknown): error is Error {
);
}

class ResourceValueError extends Error {
export class ResourceValueError extends Error {
constructor(error: Error) {
super(
ngDevMode
Expand Down
131 changes: 131 additions & 0 deletions packages/core/test/resource/resource_snapshot_spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/

import {ResourceSnapshot} from '../../src/resource/api';
import {resourceFromSnapshots} from '../../src/resource/from_snapshots';
import {resource} from '../../src/resource/resource';
import {signal} from '../../src/render3/reactivity/signal';
import {Injector} from '../../src/di/injector';
import {ApplicationRef} from '../../src/application/application_ref';
import {TestBed} from '../../testing/src/test_bed';

describe('resource snapshots', () => {
describe('resourceFromSnapshots', () => {
it('should represent all stages of a resource', () => {
const source = signal<ResourceSnapshot<string>>({status: 'idle', value: ''});
const res = resourceFromSnapshots(source);

expect(res.status()).toEqual('idle');
expect(res.value()).toEqual('');
expect(res.isLoading()).toBeFalse();
expect(res.hasValue()).toBeTrue();

source.set({status: 'loading', value: 'alpha'});
expect(res.status()).toEqual('loading');
expect(res.value()).toEqual('alpha');
expect(res.isLoading()).toBeTrue();
expect(res.hasValue()).toBeTrue();

source.set({status: 'resolved', value: 'beta'});
expect(res.status()).toEqual('resolved');
expect(res.value()).toEqual('beta');
expect(res.isLoading()).toBeFalse();
expect(res.hasValue()).toBeTrue();

source.set({status: 'reloading', value: 'gamma'});
expect(res.status()).toEqual('reloading');
expect(res.value()).toEqual('gamma');
expect(res.isLoading()).toBeTrue();
expect(res.hasValue()).toBeTrue();

source.set({status: 'local', value: 'delta'});
expect(res.status()).toEqual('local');
expect(res.value()).toEqual('delta');
expect(res.isLoading()).toBeFalse();
expect(res.hasValue()).toBeTrue();

const error = new Error();
source.set({status: 'error', error});
expect(res.status()).toEqual('error');
expect(res.error()).toBe(error);
expect(res.isLoading()).toBeFalse();
expect(res.hasValue()).toBeFalse();
expect(res.value).toThrowMatching((err: Error) => err.cause === error);
});

it('should return `false` for hasValue() when the value is undefined', () => {
const source = signal<ResourceSnapshot<string | undefined>>({
status: 'loading',
value: undefined,
});
const res = resourceFromSnapshots(source);

expect(res.hasValue()).toBeFalse();
});

it('should memoize the snapshot function', () => {
let readCount = 0;
function source(): ResourceSnapshot<string> {
readCount++;
return {
status: 'resolved',
value: 'test',
};
}

const res = resourceFromSnapshots(source);

// Access multiple computeds that depend on the snapshot.
res.status();
res.value();
res.error();

// The `source` function should only have been called once.
expect(readCount).toBe(1);
});
});

describe('Resource.snapshot', () => {
it('should represent idle, loading and resolved states', async () => {
const injector = TestBed.inject(Injector);
const params = signal<number | undefined>(undefined);
const res = resource({
params,
loader: () => Promise.resolve('test'),
injector,
});

expect(res.snapshot()).toEqual({status: 'idle', value: undefined});

params.set(3);
expect(res.snapshot()).toEqual({status: 'loading', value: undefined});

await injector.get(ApplicationRef).whenStable();
expect(res.snapshot()).toEqual({status: 'resolved', value: 'test'});
});

it('should represent the error state', async () => {
const injector = TestBed.inject(Injector);
const res = resource({
loader: () => {
throw new Error('test');
},
injector,
});

expect(res.snapshot()).toEqual({status: 'loading', value: undefined});

await injector.get(ApplicationRef).whenStable();
const snap = res.snapshot();
if (snap.status !== 'error') {
return fail(`Expected resource to be in error state`);
}
expect(res.error).toBeDefined();
});
});
});