Skip to content

Commit 1ba9b7a

Browse files
alxhubthePunderWoman
authored andcommitted
feat(core): resource composition via snapshots
* Define `ResourceSnapshot<T>` as a type union of possible states for a `Resource<T>`. * Add `Resource.snapshot()` to convert a `Resource` to a signal of its snapshot. * Add `resourceFromSnapshots` to convert a reactive snapshot back into a `Resource`. By converting resources from/to `Signal<ResourceSnapshot>`s, full composition of resources is now possible on top of signal composition APIs like `computed` and `linkedSignal`. For example, a common feature request is to have a `Resource` which retains its value when its reactive source (params) changes. This can now be built as a utility, leveraging `linkedSignal`'s previous value capability: ```ts function withPreviousValue<T>(input: Resource<T>): Resource<T> { const derived = linkedSignal({ source: input.snapshot, computation: (snap, previous) => { if (snap.status === 'loading' && previous?.value) { // When the input resource enters loading state, we keep the value // from its previous state, if any. return {status: 'loading', value: previous.value.value}; } // Otherwise we simply forward the state of the input resource. return snap; }, }); return resourceFromSnapshots(derived); } // In application code: userId = input.required<number>(); user = withPreviousValue(httpResource(() => `/user/{this.userId()}`)); // if `userId()` switches, `user.value()` will keep the old value until // the new one is ready! ```
1 parent 65fa5b5 commit 1ba9b7a

6 files changed

Lines changed: 232 additions & 1 deletion

File tree

goldens/public-api/core/index.api.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1627,6 +1627,7 @@ export interface Resource<T> {
16271627
// (undocumented)
16281628
hasValue(): boolean;
16291629
readonly isLoading: Signal<boolean>;
1630+
readonly snapshot: Signal<ResourceSnapshot<T>>;
16301631
readonly status: Signal<ResourceStatus>;
16311632
readonly value: Signal<T>;
16321633
}
@@ -1639,6 +1640,9 @@ export function resource<T, R>(options: ResourceOptions<T, R> & {
16391640
// @public
16401641
export function resource<T, R>(options: ResourceOptions<T, R>): ResourceRef<T | undefined>;
16411642

1643+
// @public
1644+
export function resourceFromSnapshots<T>(source: () => ResourceSnapshot<T>): Resource<T>;
1645+
16421646
// @public
16431647
export type ResourceLoader<T, R> = (param: ResourceLoaderParams<R>) => PromiseLike<T>;
16441648

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

1675+
// @public
1676+
export type ResourceSnapshot<T> = {
1677+
readonly status: 'idle';
1678+
readonly value: T;
1679+
} | {
1680+
readonly status: 'loading' | 'reloading';
1681+
readonly value: T;
1682+
} | {
1683+
readonly status: 'resolved' | 'local';
1684+
readonly value: T;
1685+
} | {
1686+
readonly status: 'error';
1687+
readonly error: Error;
1688+
};
1689+
16711690
// @public
16721691
export type ResourceStatus = 'idle' | 'error' | 'loading' | 'reloading' | 'resolved' | 'local';
16731692

packages/core/src/resource/api.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,11 @@ export interface Resource<T> {
6666
*/
6767
readonly isLoading: Signal<boolean>;
6868

69+
/**
70+
* The current state of this resource, represented as a `ResourceSnapshot`.
71+
*/
72+
readonly snapshot: Signal<ResourceSnapshot<T>>;
73+
6974
/**
7075
* Whether this resource has a valid current value.
7176
*
@@ -241,3 +246,14 @@ export type ResourceOptions<T, R> = (
241246
* @experimental
242247
*/
243248
export type ResourceStreamItem<T> = {value: T} | {error: Error};
249+
250+
/**
251+
* An explicit representation of a resource's state.
252+
*
253+
* @experimental
254+
*/
255+
export type ResourceSnapshot<T> =
256+
| {readonly status: 'idle'; readonly value: T}
257+
| {readonly status: 'loading' | 'reloading'; readonly value: T}
258+
| {readonly status: 'resolved' | 'local'; readonly value: T}
259+
| {readonly status: 'error'; readonly error: Error};
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import {Resource, ResourceSnapshot} from './api';
10+
import {isSignal, Signal} from '../render3/reactivity/api';
11+
import {computed} from '../render3/reactivity/computed';
12+
import {ResourceValueError} from './resource';
13+
14+
/**
15+
* Creates a `Resource` driven by a source of `ResourceSnapshot`s.
16+
*
17+
* @experimental
18+
*/
19+
export function resourceFromSnapshots<T>(source: () => ResourceSnapshot<T>): Resource<T> {
20+
return new SnapshotResource(isSignal(source) ? source : computed(source));
21+
}
22+
23+
class SnapshotResource<T> implements Resource<T> {
24+
constructor(readonly snapshot: Signal<ResourceSnapshot<T>>) {}
25+
26+
private get state(): ResourceSnapshot<T> {
27+
return this.snapshot();
28+
}
29+
30+
readonly value = computed(() => {
31+
if (this.state.status === 'error') {
32+
throw new ResourceValueError(this.state.error);
33+
}
34+
return this.state.value;
35+
});
36+
readonly status = computed(() => this.state.status);
37+
readonly error = computed(() => (this.state.status === 'error' ? this.state.error : undefined));
38+
readonly isLoading = computed(
39+
() => this.state.status === 'loading' || this.state.status === 'reloading',
40+
);
41+
42+
private isValueDefined = computed(
43+
() => this.state.status !== 'error' && this.state.value !== undefined,
44+
);
45+
46+
hasValue(this: T extends undefined ? this : never): this is Resource<Exclude<T, undefined>>;
47+
hasValue(): boolean;
48+
hasValue(): boolean {
49+
return this.isValueDefined();
50+
}
51+
}

packages/core/src/resource/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,5 @@
77
*/
88

99
export * from './api';
10+
export {resourceFromSnapshots} from './from_snapshots';
1011
export {resource} from './resource';

packages/core/src/resource/resource.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
StreamingResourceOptions,
2222
ResourceStreamItem,
2323
ResourceLoaderParams,
24+
ResourceSnapshot,
2425
} from './api';
2526

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

145+
private _snapshot: Signal<ResourceSnapshot<T>> | undefined;
146+
get snapshot(): Signal<ResourceSnapshot<T>> {
147+
return (this._snapshot ??= computed(() => {
148+
const status = this.status();
149+
if (status === 'error') {
150+
return {status: 'error', error: this.error()!};
151+
} else {
152+
return {status, value: this.value()};
153+
}
154+
}));
155+
}
156+
144157
hasValue(): this is ResourceRef<Exclude<T, undefined>> {
145158
return this.isValueDefined();
146159
}
@@ -509,7 +522,7 @@ export function isErrorLike(error: unknown): error is Error {
509522
);
510523
}
511524

512-
class ResourceValueError extends Error {
525+
export class ResourceValueError extends Error {
513526
constructor(error: Error) {
514527
super(
515528
ngDevMode
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import {ResourceSnapshot} from '../../src/resource/api';
10+
import {resourceFromSnapshots} from '../../src/resource/from_snapshots';
11+
import {resource} from '../../src/resource/resource';
12+
import {signal} from '../../src/render3/reactivity/signal';
13+
import {Injector} from '../../src/di/injector';
14+
import {ApplicationRef} from '../../src/application/application_ref';
15+
import {TestBed} from '../../testing/src/test_bed';
16+
17+
describe('resource snapshots', () => {
18+
describe('resourceFromSnapshots', () => {
19+
it('should represent all stages of a resource', () => {
20+
const source = signal<ResourceSnapshot<string>>({status: 'idle', value: ''});
21+
const res = resourceFromSnapshots(source);
22+
23+
expect(res.status()).toEqual('idle');
24+
expect(res.value()).toEqual('');
25+
expect(res.isLoading()).toBeFalse();
26+
expect(res.hasValue()).toBeTrue();
27+
28+
source.set({status: 'loading', value: 'alpha'});
29+
expect(res.status()).toEqual('loading');
30+
expect(res.value()).toEqual('alpha');
31+
expect(res.isLoading()).toBeTrue();
32+
expect(res.hasValue()).toBeTrue();
33+
34+
source.set({status: 'resolved', value: 'beta'});
35+
expect(res.status()).toEqual('resolved');
36+
expect(res.value()).toEqual('beta');
37+
expect(res.isLoading()).toBeFalse();
38+
expect(res.hasValue()).toBeTrue();
39+
40+
source.set({status: 'reloading', value: 'gamma'});
41+
expect(res.status()).toEqual('reloading');
42+
expect(res.value()).toEqual('gamma');
43+
expect(res.isLoading()).toBeTrue();
44+
expect(res.hasValue()).toBeTrue();
45+
46+
source.set({status: 'local', value: 'delta'});
47+
expect(res.status()).toEqual('local');
48+
expect(res.value()).toEqual('delta');
49+
expect(res.isLoading()).toBeFalse();
50+
expect(res.hasValue()).toBeTrue();
51+
52+
const error = new Error();
53+
source.set({status: 'error', error});
54+
expect(res.status()).toEqual('error');
55+
expect(res.error()).toBe(error);
56+
expect(res.isLoading()).toBeFalse();
57+
expect(res.hasValue()).toBeFalse();
58+
expect(res.value).toThrowMatching((err: Error) => err.cause === error);
59+
});
60+
61+
it('should return `false` for hasValue() when the value is undefined', () => {
62+
const source = signal<ResourceSnapshot<string | undefined>>({
63+
status: 'loading',
64+
value: undefined,
65+
});
66+
const res = resourceFromSnapshots(source);
67+
68+
expect(res.hasValue()).toBeFalse();
69+
});
70+
71+
it('should memoize the snapshot function', () => {
72+
let readCount = 0;
73+
function source(): ResourceSnapshot<string> {
74+
readCount++;
75+
return {
76+
status: 'resolved',
77+
value: 'test',
78+
};
79+
}
80+
81+
const res = resourceFromSnapshots(source);
82+
83+
// Access multiple computeds that depend on the snapshot.
84+
res.status();
85+
res.value();
86+
res.error();
87+
88+
// The `source` function should only have been called once.
89+
expect(readCount).toBe(1);
90+
});
91+
});
92+
93+
describe('Resource.snapshot', () => {
94+
it('should represent idle, loading and resolved states', async () => {
95+
const injector = TestBed.inject(Injector);
96+
const params = signal<number | undefined>(undefined);
97+
const res = resource({
98+
params,
99+
loader: () => Promise.resolve('test'),
100+
injector,
101+
});
102+
103+
expect(res.snapshot()).toEqual({status: 'idle', value: undefined});
104+
105+
params.set(3);
106+
expect(res.snapshot()).toEqual({status: 'loading', value: undefined});
107+
108+
await injector.get(ApplicationRef).whenStable();
109+
expect(res.snapshot()).toEqual({status: 'resolved', value: 'test'});
110+
});
111+
112+
it('should represent the error state', async () => {
113+
const injector = TestBed.inject(Injector);
114+
const res = resource({
115+
loader: () => {
116+
throw new Error('test');
117+
},
118+
injector,
119+
});
120+
121+
expect(res.snapshot()).toEqual({status: 'loading', value: undefined});
122+
123+
await injector.get(ApplicationRef).whenStable();
124+
const snap = res.snapshot();
125+
if (snap.status !== 'error') {
126+
return fail(`Expected resource to be in error state`);
127+
}
128+
expect(res.error).toBeDefined();
129+
});
130+
});
131+
});

0 commit comments

Comments
 (0)