Content-Length: 899966 | pFad | http://github.com/angular/angular/commit/3ba39bc28f93c208f7b50fcb878fe1aa1bc0413d

22 fix(core): getting resource value throws an error instead of returnin… · angular/angular@3ba39bc · GitHub
Skip to content

Commit 3ba39bc

Browse files
Humberdatscott
authored andcommitted
fix(core): getting resource value throws an error instead of returning undefined (#61441)
When there is an underlying error state it would not be possible to swallow the error with: `computed(() => res.value()?.inner);` PR Close #61441
1 parent a89f1cf commit 3ba39bc

File tree

3 files changed

+174
-14
lines changed

3 files changed

+174
-14
lines changed

packages/core/src/resource/api.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ export type ResourceStatus = 'idle' | 'error' | 'loading' | 'reloading' | 'resol
4646
*/
4747
export interface Resource<T> {
4848
/**
49-
* The current value of the `Resource`, or `undefined` if there is no current value.
49+
* The current value of the `Resource`, or throws an error if the resource is in an error state.
5050
*/
5151
readonly value: Signal<T>;
5252

@@ -167,7 +167,7 @@ export interface BaseResourceOptions<T, R> {
167167

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

packages/core/src/resource/resource.ts

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,13 @@ import {PendingTasks} from '../pending_tasks';
3232
import {linkedSignal} from '../render3/reactivity/linked_signal';
3333
import {DestroyRef} from '../linker/destroy_ref';
3434

35+
/**
36+
* Whether a `Resource.value()` should throw an error when the resource is in the error state.
37+
*
38+
* This internal flag is being used to gradually roll out this behavior.
39+
*/
40+
const RESOURCE_VALUE_THROWS_ERRORS_DEFAULT = true;
41+
3542
/**
3643
* Constructs a `Resource` that projects a reactive request to an asynchronous operation defined by
3744
* a loader function, which exposes the result of the loading operation via signals.
@@ -72,6 +79,7 @@ export function resource<T, R>(options: ResourceOptions<T, R>): ResourceRef<T |
7279
options.defaultValue,
7380
options.equal ? wrapEqualityFn(options.equal) : undefined,
7481
options.injector ?? inject(Injector),
82+
RESOURCE_VALUE_THROWS_ERRORS_DEFAULT,
7583
);
7684
}
7785

@@ -114,13 +122,22 @@ abstract class BaseWritableResource<T> implements WritableResource<T> {
114122

115123
abstract set(value: T): void;
116124

125+
private readonly isError = computed(() => this.status() === 'error');
126+
117127
update(updateFn: (value: T) => T): void {
118128
this.set(updateFn(untracked(this.value)));
119129
}
120130

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

123133
hasValue(): this is ResourceRef<Exclude<T, undefined>> {
134+
// Note: we specifically read `isError()` instead of `status()` here to avoid triggering
135+
// reactive consumers which read `hasValue()`. This way, if `hasValue()` is used inside of an
136+
// effect, it doesn't cause the effect to rerun on every status change.
137+
if (this.isError()) {
138+
return false;
139+
}
140+
124141
return this.value() !== undefined;
125142
}
126143

@@ -154,17 +171,31 @@ export class ResourceImpl<T, R> extends BaseWritableResource<T> implements Resou
154171
constructor(
155172
request: () => R,
156173
private readonly loaderFn: ResourceStreamingLoader<T, R>,
157-
private readonly defaultValue: T,
174+
defaultValue: T,
158175
private readonly equal: ValueEqualityFn<T> | undefined,
159176
injector: Injector,
177+
throwErrorsFromValue: boolean = RESOURCE_VALUE_THROWS_ERRORS_DEFAULT,
160178
) {
161179
super(
162180
// Feed a computed signal for the value to `BaseWritableResource`, which will upgrade it to a
163181
// `WritableSignal` that delegates to `ResourceImpl.set`.
164182
computed(
165183
() => {
166184
const streamValue = this.state().stream?.();
167-
return streamValue && isResolved(streamValue) ? streamValue.value : this.defaultValue;
185+
186+
if (!streamValue) {
187+
return defaultValue;
188+
}
189+
190+
if (!isResolved(streamValue)) {
191+
if (throwErrorsFromValue) {
192+
throw new ResourceValueError(this.error()!);
193+
} else {
194+
return defaultValue;
195+
}
196+
}
197+
198+
return streamValue.value;
168199
},
169200
{equal},
170201
),
@@ -401,7 +432,7 @@ function projectStatusOfState(state: ResourceState<unknown>): ResourceStatus {
401432
case 'loading':
402433
return state.extRequest.reload === 0 ? 'loading' : 'reloading';
403434
case 'resolved':
404-
return isResolved(untracked(state.stream!)) ? 'resolved' : 'error';
435+
return isResolved(state.stream!()) ? 'resolved' : 'error';
405436
default:
406437
return state.status;
407438
}
@@ -419,6 +450,17 @@ export function encapsulateResourceError(error: unknown): Error {
419450
return new ResourceWrappedError(error);
420451
}
421452

453+
class ResourceValueError extends Error {
454+
constructor(error: Error) {
455+
super(
456+
ngDevMode
457+
? `Resource is currently in an error state (see Error.cause for details): ${error.message}`
458+
: error.message,
459+
{cause: error},
460+
);
461+
}
462+
}
463+
422464
class ResourceWrappedError extends Error {
423465
constructor(error: unknown) {
424466
super(

packages/core/test/resource/resource_spec.ts

Lines changed: 127 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@
88

99
import {
1010
ApplicationRef,
11+
computed,
1112
createEnvironmentInjector,
13+
effect,
1214
EnvironmentInjector,
1315
Injector,
1416
resource,
@@ -140,16 +142,18 @@ describe('resource', () => {
140142
});
141143

142144
TestBed.tick();
143-
await backend.reject(requestParam, 'Something went wrong....');
145+
await backend.reject(requestParam, new Error('Something went wrong....'));
144146

145147
expect(echoResource.status()).toBe('error');
146148
expect(echoResource.isLoading()).toBeFalse();
147149
expect(echoResource.hasValue()).toBeFalse();
148-
expect(echoResource.value()).toEqual(undefined);
149-
expect(echoResource.error()).toEqual(
150-
jasmine.objectContaining({cause: 'Something went wrong....'}),
151-
),
152-
expect(echoResource.error()!.message).toContain('Resource');
150+
151+
const err = extractError(() => echoResource.value())!;
152+
expect(err).not.toBeUndefined();
153+
expect(err instanceof Error).toBeTrue();
154+
expect(err!.message).toContain('Resource');
155+
expect(err.cause).toEqual(new Error('Something went wrong....'));
156+
expect(echoResource.error()).toEqual(new Error('Something went wrong....'));
153157
});
154158

155159
it('should expose errors on reload', async () => {
@@ -183,8 +187,109 @@ describe('resource', () => {
183187
expect(echoResource.status()).toBe('error');
184188
expect(echoResource.isLoading()).toBeFalse();
185189
expect(echoResource.hasValue()).toBeFalse();
186-
expect(echoResource.value()).toEqual(undefined);
190+
const err = extractError(() => echoResource.value())!;
191+
expect(err).not.toBeUndefined();
192+
expect(err.message).toContain('Resource');
193+
expect(err.message).toContain('KO');
194+
expect(err.cause).toEqual(new Error('KO'));
195+
expect(echoResource.error()).toEqual(Error('KO'));
196+
197+
counter.update((value) => value + 1);
198+
TestBed.tick();
199+
await backend.flush();
200+
201+
expect(echoResource.status()).toBe('resolved');
202+
expect(echoResource.isLoading()).toBeFalse();
203+
expect(echoResource.hasValue()).toBeTrue();
204+
expect(echoResource.value()).toEqual('ok');
205+
expect(echoResource.error()).toBe(undefined);
206+
});
207+
208+
it('should not trigger consumers on every status change via hasValue()', async () => {
209+
const params = signal<number | undefined>(undefined);
210+
const testResource = resource({
211+
params,
212+
// Hanging promise, never resolves
213+
loader: () => new Promise(() => {}),
214+
injector: TestBed.inject(Injector),
215+
});
216+
217+
let effectRuns = 0;
218+
effect(
219+
() => {
220+
testResource.hasValue();
221+
effectRuns++;
222+
},
223+
{injector: TestBed.inject(Injector)},
224+
);
225+
226+
TestBed.tick();
227+
// Params are undefined, status should be idle.
228+
expect(testResource.status()).toBe('idle');
229+
// Effect should run the first time.
230+
expect(effectRuns).toBe(1);
231+
232+
// Set params, causing the stauts to become 'loading'.
233+
params.set(0);
234+
expect(testResource.status()).toBe('loading');
235+
TestBed.tick();
236+
// The effect should not rerun.
237+
expect(effectRuns).toBe(1);
238+
});
239+
240+
it('should update computed signals', async () => {
241+
const backend = new MockEchoBackend();
242+
const counter = signal(0);
243+
const echoResource = resource({
244+
params: () => ({counter: counter()}),
245+
loader: (params) => {
246+
if (params.params.counter % 2 === 0) {
247+
return Promise.resolve(params.params.counter);
248+
} else {
249+
throw new Error('KO');
250+
}
251+
},
252+
injector: TestBed.inject(Injector),
253+
});
254+
const computedValue = computed(() => {
255+
if (!echoResource.hasValue()) {
256+
return -1;
257+
}
258+
return echoResource.value();
259+
});
260+
261+
TestBed.tick();
262+
await backend.flush();
263+
264+
expect(echoResource.status()).toBe('resolved');
265+
expect(echoResource.hasValue()).toBeTrue();
266+
expect(echoResource.value()).toEqual(0);
267+
expect(computedValue()).toEqual(0);
268+
expect(echoResource.error()).toBe(undefined);
269+
270+
counter.update((value) => value + 1);
271+
TestBed.tick();
272+
await backend.flush();
273+
274+
expect(echoResource.status()).toBe('error');
275+
expect(echoResource.hasValue()).toBeFalse();
276+
const err = extractError(() => echoResource.value())!;
277+
expect(err).not.toBeUndefined();
278+
expect(err.message).toContain('Resource');
279+
expect(err.message).toContain('KO');
280+
expect(err.cause).toEqual(new Error('KO'));
281+
expect(computedValue()).toEqual(-1);
187282
expect(echoResource.error()).toEqual(Error('KO'));
283+
284+
counter.update((value) => value + 1);
285+
TestBed.tick();
286+
await backend.flush();
287+
288+
expect(echoResource.status()).toBe('resolved');
289+
expect(echoResource.hasValue()).toBeTrue();
290+
expect(echoResource.value()).toEqual(2);
291+
expect(computedValue()).toEqual(2);
292+
expect(echoResource.error()).toBe(undefined);
188293
});
189294

190295
it('should respond to a request that changes while loading', async () => {
@@ -231,7 +336,7 @@ describe('resource', () => {
231336
expect(res.value()).toBe(1);
232337
});
233338

234-
it('should return a default value if provided', async () => {
339+
it('should throw an error when getting a value even when provided with a default value', async () => {
235340
const DEFAULT: string[] = [];
236341
const request = signal(0);
237342
const res = resource({
@@ -256,7 +361,11 @@ describe('resource', () => {
256361
request.set(2);
257362
await TestBed.inject(ApplicationRef).whenStable();
258363
expect(res.error()).not.toBeUndefined();
259-
expect(res.value()).toBe(DEFAULT);
364+
const err = extractError(() => res.value())!;
365+
expect(err).not.toBeUndefined();
366+
expect(err.message).toContain('Resource');
367+
expect(err.message).toContain('err');
368+
expect(err.cause).toEqual(new Error('err'));
260369
});
261370

262371
it('should _not_ load if the request resolves to undefined', () => {
@@ -731,3 +840,12 @@ describe('resource', () => {
731840
function flushMicrotasks(): Promise<void> {
732841
return new Promise((resolve) => setTimeout(resolve, 0));
733842
}
843+
844+
function extractError(fn: () => unknown): Error | undefined {
845+
try {
846+
fn();
847+
return undefined;
848+
} catch (err: unknown) {
849+
return err as Error;
850+
}
851+
}

0 commit comments

Comments
 (0)








ApplySandwichStrip

pFad - (p)hone/(F)rame/(a)nonymizer/(d)eclutterfier!      Saves Data!


--- a PPN by Garber Painting Akron. With Image Size Reduction included!

Fetched URL: http://github.com/angular/angular/commit/3ba39bc28f93c208f7b50fcb878fe1aa1bc0413d

Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy