/
githubmirror
/
angular
Обзор
Документация
Войти
/
githubmirror
/
angular
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
main
packages/core/test/resource/resource_spec.ts
1 188 строк
36 KB
Matthieu Riegler
feat(core): add ability to cache resources for SSR
06 май 2026, 19:57
Не верифицирован
06 май 2026, 19:57
5a7c1e6
Код
Авторство
О чём код?
/** * @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 { ApplicationRef, ɵCACHE_ACTIVE as CACHE_ACTIVE, Component, computed, createEnvironmentInjector, effect, EnvironmentInjector, Injector, Input, inputBinding, makeStateKey, resource, ResourceRef, ResourceStatus, signal, TransferState, } from '../../src/core'; import {promiseWithResolvers} from '../../src/util/promise_with_resolvers'; import {TestBed} from '../../testing'; abstract class MockBackend<T, R> { protected pending = new Map< T, {resolve: (r: R) => void; reject: (reason: any) => void; promise: Promise<R>} >(); fetch(request: T): Promise<R> { const p = new Promise<R>((resolve, reject) => { this.pending.set(request, {resolve, reject, promise: undefined!}); }); this.pending.get(request)!.promise = p; return p; } abort(req: T) { return this.reject(req, 'aborted'); } reject(req: T, reason: any) { const entry = this.pending.get(req); if (entry) { this.pending.delete(req); entry.reject(reason); } return flushMicrotasks(); } async flush(): Promise<void> { const allPending = Array.from(this.pending.values()).map((pending) => pending.promise); for (const [req, {resolve}] of this.pending) { resolve(this.prepareResponse(req)); } this.pending.clear(); await Promise.all(allPending); await flushMicrotasks(); } protected abstract prepareResponse(request: T): R; } class MockEchoBackend<T> extends MockBackend<T, T> { override prepareResponse(request: T) { return request; } } class MockResponseCountingBackend extends MockBackend<number, string> { counter = 0; override prepareResponse(request: number) { return request + ':' + this.counter++; } } describe('resource', () => { it('should expose data and status based on reactive request', async () => { const counter = signal(0); const backend = new MockEchoBackend(); const echoResource = resource({ params: () => ({counter: counter()}), loader: (params) => backend.fetch(params.params), injector: TestBed.inject(Injector), }); // a freshly created resource is in the loading state expect(echoResource.status()).toBe('loading'); expect(echoResource.isLoading()).toBeTrue(); expect(echoResource.hasValue()).toBeFalse(); expect(echoResource.value()).toBeUndefined(); expect(echoResource.error()).toBe(undefined); TestBed.tick(); await backend.flush(); expect(echoResource.status()).toBe('resolved'); expect(echoResource.isLoading()).toBeFalse(); expect(echoResource.hasValue()).toBeTrue(); expect(echoResource.value()).toEqual({counter: 0}); expect(echoResource.error()).toBe(undefined); counter.update((c) => c + 1); TestBed.tick(); await backend.flush(); expect(echoResource.status()).toBe('resolved'); expect(echoResource.isLoading()).toBeFalse(); expect(echoResource.hasValue()).toBeTrue(); expect(echoResource.value()).toEqual({counter: 1}); expect(echoResource.error()).toBe(undefined); }); it('should report idle status as the previous status on first run', async () => { let prevStatus: ResourceStatus | undefined; resource({ loader: async ({previous}) => { // Ensure the loader only runs once. expect(prevStatus).toBeUndefined(); prevStatus = previous.status; return true; }, injector: TestBed.inject(Injector), }); TestBed.tick(); await flushMicrotasks(); expect(prevStatus).toBe('idle'); }); it('should expose errors thrown during resource loading', async () => { const backend = new MockEchoBackend(); const requestParam = {}; const echoResource = resource({ params: () => requestParam, loader: (params) => backend.fetch(params.params), injector: TestBed.inject(Injector), }); TestBed.tick(); await backend.reject(requestParam, new Error('Something went wrong....')); expect(echoResource.status()).toBe('error'); expect(echoResource.isLoading()).toBeFalse(); expect(echoResource.hasValue()).toBeFalse(); const err = extractError(() => echoResource.value())!; expect(err).not.toBeUndefined(); expect(err instanceof Error).toBeTrue(); expect(err!.message).toContain('Resource'); expect(err.cause).toEqual(new Error('Something went wrong....')); expect(echoResource.error()).toEqual(new Error('Something went wrong....')); }); it('should expose errors on reload', async () => { const backend = new MockEchoBackend(); const counter = signal(0); const echoResource = resource({ params: () => ({counter: counter()}), loader: (params) => { if (params.params.counter % 2 === 0) { return Promise.resolve('ok'); } else { throw new Error('KO'); } }, injector: TestBed.inject(Injector), }); TestBed.tick(); await backend.flush(); expect(echoResource.status()).toBe('resolved'); expect(echoResource.isLoading()).toBeFalse(); expect(echoResource.hasValue()).toBeTrue(); expect(echoResource.value()).toEqual('ok'); expect(echoResource.error()).toBe(undefined); counter.update((value) => value + 1); TestBed.tick(); await backend.flush(); expect(echoResource.status()).toBe('error'); expect(echoResource.isLoading()).toBeFalse(); expect(echoResource.hasValue()).toBeFalse(); const err = extractError(() => echoResource.value())!; expect(err).not.toBeUndefined(); expect(err.message).toContain('Resource'); expect(err.message).toContain('KO'); expect(err.cause).toEqual(new Error('KO')); expect(echoResource.error()).toEqual(Error('KO')); counter.update((value) => value + 1); TestBed.tick(); await backend.flush(); expect(echoResource.status()).toBe('resolved'); expect(echoResource.isLoading()).toBeFalse(); expect(echoResource.hasValue()).toBeTrue(); expect(echoResource.value()).toEqual('ok'); expect(echoResource.error()).toBe(undefined); }); it('should not trigger consumers on every status change via hasValue()', async () => { const params = signal<number | undefined>(undefined); const testResource = resource({ params, // Hanging promise, never resolves loader: () => new Promise(() => {}), injector: TestBed.inject(Injector), }); let effectRuns = 0; effect( () => { testResource.hasValue(); effectRuns++; }, {injector: TestBed.inject(Injector)}, ); TestBed.tick(); // Params are undefined, status should be idle. expect(testResource.status()).toBe('idle'); // Effect should run the first time. expect(effectRuns).toBe(1); // Set params, causing the stauts to become 'loading'. params.set(0); expect(testResource.status()).toBe('loading'); TestBed.tick(); // The effect should not rerun. expect(effectRuns).toBe(1); }); it('should not trigger consumers on every value change via hasValue()', async () => { const testResource = resource<number | undefined, unknown>({ loader: () => Promise.resolve(undefined), injector: TestBed.inject(Injector), }); let effectRuns = 0; effect( () => { testResource.hasValue(); effectRuns++; }, {injector: TestBed.inject(Injector)}, ); TestBed.tick(); // Starts off without a value expect(testResource.hasValue()).toBeFalse(); // Effect should run the first time. expect(effectRuns).toBe(1); // Set value to something defined testResource.set(0); TestBed.tick(); // Value is now defined expect(testResource.hasValue()).toBeTrue(); // The effect should have been re-run. expect(effectRuns).toBe(2); // Set value to something else defined testResource.set(1); TestBed.tick(); // Value is still defined expect(testResource.hasValue()).toBeTrue(); // The effect should not rerun. expect(effectRuns).toBe(2); }); it('should update computed signals', async () => { const backend = new MockEchoBackend(); const counter = signal(0); const echoResource = resource({ params: () => ({counter: counter()}), loader: (params) => { if (params.params.counter % 2 === 0) { return Promise.resolve(params.params.counter); } else { throw new Error('KO'); } }, injector: TestBed.inject(Injector), }); const computedValue = computed(() => { if (!echoResource.hasValue()) { return -1; } return echoResource.value(); }); TestBed.tick(); await backend.flush(); expect(echoResource.status()).toBe('resolved'); expect(echoResource.hasValue()).toBeTrue(); expect(echoResource.value()).toEqual(0); expect(computedValue()).toEqual(0); expect(echoResource.error()).toBe(undefined); counter.update((value) => value + 1); TestBed.tick(); await backend.flush(); expect(echoResource.status()).toBe('error'); expect(echoResource.hasValue()).toBeFalse(); const err = extractError(() => echoResource.value())!; expect(err).not.toBeUndefined(); expect(err.message).toContain('Resource'); expect(err.message).toContain('KO'); expect(err.cause).toEqual(new Error('KO')); expect(computedValue()).toEqual(-1); expect(echoResource.error()).toEqual(Error('KO')); counter.update((value) => value + 1); TestBed.tick(); await backend.flush(); expect(echoResource.status()).toBe('resolved'); expect(echoResource.hasValue()).toBeTrue(); expect(echoResource.value()).toEqual(2); expect(computedValue()).toEqual(2); expect(echoResource.error()).toBe(undefined); }); it('should respond to a request that changes while loading', async () => { const appRef = TestBed.inject(ApplicationRef); const request = signal(0); let resolve: Array<() => void> = []; const res = resource({ params: request, loader: async ({params}) => { const p = promiseWithResolvers<number>(); resolve.push(() => p.resolve(params)); return p.promise; }, injector: TestBed.inject(Injector), }); // Start the load by running the effect inside the resource. appRef.tick(); // We should have a pending load. expect(resolve.length).toBe(1); // Change the request. request.set(1); // Resolve the first load. resolve[0](); await flushMicrotasks(); // The resource should still be loading. Ticking (triggering the 2nd effect) // should not change the loading status. expect(res.status()).toBe('loading'); appRef.tick(); expect(res.status()).toBe('loading'); expect(resolve.length).toBe(2); // Resolve the second load. resolve[1]?.(); await flushMicrotasks(); // We should see the resolved value. expect(res.status()).toBe('resolved'); expect(res.value()).toBe(1); }); it('should throw an error when getting a value even when provided with a default value', async () => { const DEFAULT: string[] = []; const request = signal(0); const res = resource({ params: request, loader: async ({params}) => { if (params === 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.error()).not.toBeUndefined(); const err = extractError(() => res.value())!; expect(err).not.toBeUndefined(); expect(err.message).toContain('Resource'); expect(err.message).toContain('err'); expect(err.cause).toEqual(new Error('err')); }); it('should _not_ load if the request resolves to undefined', () => { const counter = signal(0); const backend = new MockEchoBackend(); const echoResource = resource({ params: () => (counter() > 5 ? {counter: counter()} : undefined), loader: (params) => backend.fetch(params.params), injector: TestBed.inject(Injector), }); TestBed.tick(); expect(echoResource.status()).toBe('idle'); expect(echoResource.isLoading()).toBeFalse(); counter.set(10); TestBed.tick(); expect(echoResource.isLoading()).toBeTrue(); }); it('should cancel pending requests before starting a new one', async () => { const counter = signal(0); const backend = new MockEchoBackend<{counter: number}>(); const aborted: {counter: number}[] = []; const echoResource = resource<{counter: number}, {counter: number}>({ params: () => ({counter: counter()}), loader: ({params, abortSignal}) => { abortSignal.addEventListener('abort', () => backend.abort(params)); return backend.fetch(params).catch((reason) => { if (reason === 'aborted') { aborted.push(params); } throw new Error(reason); }); }, injector: TestBed.inject(Injector), }); // start a request without resolving the previous one TestBed.tick(); await Promise.resolve(); // start a new request and resolve all counter.update((c) => c + 1); TestBed.tick(); await backend.flush(); expect(echoResource.status()).toBe('resolved'); expect(echoResource.value()).toEqual({counter: 1}); expect(echoResource.error()).toBe(undefined); expect(aborted).toEqual([{counter: 0}]); }); it('should cancel pending requests when the resource is destroyed via injector', async () => { const counter = signal(0); const backend = new MockEchoBackend<{counter: number}>(); const aborted: {counter: number}[] = []; const injector = createEnvironmentInjector([], TestBed.inject(EnvironmentInjector)); const echoResource = resource<{counter: number}, {counter: number}>({ params: () => ({counter: counter()}), loader: ({params, abortSignal}) => { abortSignal.addEventListener('abort', () => backend.abort(params)); return backend.fetch(params).catch((reason) => { if (reason === 'aborted') { aborted.push(params); } throw new Error(reason); }); }, injector, }); // start a request without resolving the previous one TestBed.tick(); await Promise.resolve(); injector.destroy(); await backend.flush(); expect(echoResource.status()).toBe('idle'); expect(echoResource.value()).toBe(undefined); expect(echoResource.error()).toBe(undefined); expect(aborted).toEqual([{counter: 0}]); }); it('should cancel pending requests when the resource is manually destroyed', async () => { const counter = signal(0); const backend = new MockEchoBackend<{counter: number}>(); const aborted: {counter: number}[] = []; const injector = createEnvironmentInjector([], TestBed.inject(EnvironmentInjector)); const echoResource = resource<{counter: number}, {counter: number}>({ params: () => ({counter: counter()}), loader: ({params, abortSignal}) => { abortSignal.addEventListener('abort', () => backend.abort(params)); return backend.fetch(params).catch((reason) => { if (reason === 'aborted') { aborted.push(params); } throw new Error(reason); }); }, injector, }); // start a request without resolving the previous one TestBed.tick(); await Promise.resolve(); echoResource.destroy(); await backend.flush(); expect(echoResource.status()).toBe('idle'); expect(echoResource.value()).toBe(undefined); expect(echoResource.error()).toBe(undefined); expect(aborted).toEqual([{counter: 0}]); }); it('should not respond to reactive state changes in a loader', async () => { const unrelated = signal('a'); const backend = new MockResponseCountingBackend(); const res = resource<string, number>({ params: () => 0, loader: (params) => { // read reactive state and assure it is _not_ tracked unrelated(); return backend.fetch(params.params); }, injector: TestBed.inject(Injector), }); TestBed.tick(); await backend.flush(); expect(res.value()).toBe('0:0'); unrelated.set('b'); TestBed.tick(); // there is no chang in the status expect(res.status()).toBe('resolved'); await backend.flush(); // there is no chang in the value expect(res.value()).toBe('0:0'); }); it('should allow setting local state', async () => { const counter = signal(0); const backend = new MockEchoBackend(); const echoResource = resource({ params: () => ({counter: counter()}), loader: (params) => backend.fetch(params.params), injector: TestBed.inject(Injector), }); TestBed.tick(); await backend.flush(); expect(echoResource.status()).toBe('resolved'); expect(echoResource.isLoading()).toBeFalse(); expect(echoResource.value()).toEqual({counter: 0}); expect(echoResource.error()).toBe(undefined); echoResource.value.set({counter: 100}); expect(echoResource.status()).toBe('local'); expect(echoResource.isLoading()).toBeFalse(); expect(echoResource.hasValue()).toBeTrue(); expect(echoResource.value()).toEqual({counter: 100}); expect(echoResource.error()).toBe(undefined); counter.set(1); TestBed.tick(); await backend.flush(); expect(echoResource.status()).toBe('resolved'); expect(echoResource.value()).toEqual({counter: 1}); expect(echoResource.error()).toBe(undefined); // state setter is also exposed on the resource directly echoResource.set({counter: 200}); expect(echoResource.status()).toBe('local'); expect(echoResource.hasValue()).toBeTrue(); expect(echoResource.value()).toEqual({counter: 200}); }); it('should allow re-fetching data', async () => { const backend = new MockResponseCountingBackend(); const res = resource<string, number>({ params: () => 0, loader: (params) => backend.fetch(params.params), injector: TestBed.inject(Injector), }); TestBed.tick(); await backend.flush(); expect(res.status()).toBe('resolved'); expect(res.value()).toBe('0:0'); expect(res.error()).toBe(undefined); res.reload(); expect(res.status()).toBe('reloading'); expect(res.value()).toBe('0:0'); TestBed.tick(); await backend.flush(); expect(res.status()).toBe('resolved'); expect(res.isLoading()).toBeFalse(); expect(res.value()).toBe('0:1'); expect(res.error()).toBe(undefined); // calling refresh multiple times should _not_ result in multiple requests res.reload(); TestBed.tick(); res.reload(); TestBed.tick(); await backend.flush(); expect(res.value()).toBe('0:2'); }); it('should respect provided equality function for the results signal', async () => { const res = resource({ loader: async () => 0, equal: (a, b) => true, injector: TestBed.inject(Injector), }); res.value.set(5); expect(res.status()).toBe('local'); expect(res.value()).toBe(5); expect(res.error()).toBe(undefined); res.value.set(10); expect(res.status()).toBe('local'); expect(res.value()).toBe(5); // equality blocked writes expect(res.error()).toBe(undefined); }); it('should convert writable resource to its read-only version', () => { const res = resource({ loader: async () => 0, equal: (a, b) => true, injector: TestBed.inject(Injector), }); const readonlyRes = res.asReadonly(); // @ts-expect-error readonlyRes.asReadonly; // @ts-expect-error readonlyRes.value.set; }); it('should synchronously change states', async () => { const request = signal<number | undefined>(undefined); const backend = new MockEchoBackend(); const echoResource = resource({ params: request, loader: (params) => backend.fetch(params.params), injector: TestBed.inject(Injector), }); // Idle to start. expect(echoResource.status()).toBe('idle'); // Switch to loading state should be synchronous. request.set(1); expect(echoResource.status()).toBe('loading'); // And back to idle. request.set(undefined); expect(echoResource.status()).toBe('idle'); // Allow the load to proceed. request.set(2); TestBed.tick(); await backend.flush(); expect(echoResource.status()).toBe('resolved'); // Reload state should be synchronous. echoResource.reload(); expect(echoResource.status()).toBe('reloading'); // Back to idle. request.set(undefined); expect(echoResource.status()).toBe('idle'); }); it('set() should abort a pending load', async () => { const request = signal<number | undefined>(1); const backend = new MockEchoBackend(); const echoResource = resource({ params: request, loader: (params) => backend.fetch(params.params), injector: TestBed.inject(Injector), }); const appRef = TestBed.inject(ApplicationRef); // Fully resolve the resource to start. TestBed.tick(); await backend.flush(); expect(echoResource.status()).toBe('resolved'); // Trigger loading state. request.set(2); expect(echoResource.status()).toBe('loading'); // Set the resource to a new value. echoResource.set(3); // Now run the effect, which should be a no-op as the resource was set to a local value. TestBed.tick(); // We should still be in local state. expect(echoResource.status()).toBe('local'); expect(echoResource.value()).toBe(3); // Flush the resource await backend.flush(); await appRef.whenStable(); // We should still be in local state. expect(echoResource.status()).toBe('local'); expect(echoResource.value()).toBe(3); }); it('set() should abort a pending reload', async () => { const request = signal<number | undefined>(1); const backend = new MockEchoBackend(); const echoResource = resource({ params: request, loader: (params) => backend.fetch(params.params), injector: TestBed.inject(Injector), }); const appRef = TestBed.inject(ApplicationRef); // Fully resolve the resource to start. TestBed.tick(); await backend.flush(); expect(echoResource.status()).toBe('resolved'); // Trigger reloading state. echoResource.reload(); expect(echoResource.status()).toBe('reloading'); // Set the resource to a new value. echoResource.set(3); // Now run the effect, which should be a no-op as the resource was set to a local value. TestBed.tick(); // We should still be in local state. expect(echoResource.status()).toBe('local'); expect(echoResource.value()).toBe(3); // Flush the resource await backend.flush(); await appRef.whenStable(); // We should still be in local state. expect(echoResource.status()).toBe('local'); expect(echoResource.value()).toBe(3); }); it('should allow streaming', async () => { const res = resource({ stream: () => signal({value: 'done'}), injector: TestBed.inject(Injector), }); // We rely an a synchronous tick to ensure that the stream is properly initialized, as it runs inside an effect. TestBed.tick(); expect(res.status()).toBe('resolved'); expect(res.value()).toBe('done'); }); it('should allow streaming with param & sync stream', async () => { const param = signal('a'); const res = resource({ params: param, stream: ({params}) => signal({value: params + ' done'}), injector: TestBed.inject(Injector), }); // We rely an a synchronous tick to ensure that the stream is properly initialized, as it runs inside an effect. TestBed.tick(); expect(res.status()).toBe('resolved'); expect(res.value()).toBe('a done'); }); it('should allow async streaming', async () => { const appRef = TestBed.inject(ApplicationRef); const res = resource({ stream: async () => signal({value: 'done'}), injector: TestBed.inject(Injector), }); expect(res.status()).toBe('loading'); TestBed.tick(); // We're still loading, the promise hasn't resolved yet. expect(res.status()).toBe('loading'); await appRef.whenStable(); expect(res.status()).toBe('resolved'); expect(res.value()).toBe('done'); }); it('should allow streaming & cancelling with param & sync stream', async () => { const appRef = TestBed.inject(ApplicationRef); const param = signal('a'); let streamCount = 0; const res = resource({ params: param, stream: ({params}) => { streamCount++; return signal({value: params + ' done'}); }, injector: TestBed.inject(Injector), }); // The stream is not evaluated eagerly, because we have to wait for init (in case we read inputs) expect(streamCount).toBe(0); TestBed.tick(); expect(streamCount).toBe(1); expect(res.status()).toBe('resolved'); expect(res.value()).toBe('a done'); param.set('b'); await appRef.whenStable(); expect(streamCount).toBe(2); expect(res.status()).toBe('resolved'); expect(res.value()).toBe('b done'); }); it('should allow stream from input()', async () => { @Component({ selector: 'test', template: `{{ res.value() }}`, }) class TestComponent { @Input() value = ''; res = resource({ params: () => this.value, stream: ({params}) => signal({value: params + ' done'}), }); } const fixture = TestBed.createComponent(TestComponent, { bindings: [inputBinding('value', signal('a'))], }); TestBed.tick(); expect(fixture.componentInstance.res.value()).toEqual('a done'); }); it('should error via error()', async () => { const appRef = TestBed.inject(ApplicationRef); const res = resource({ stream: async () => signal({error: new Error('fail')}), injector: TestBed.inject(Injector), }); await appRef.whenStable(); expect(res.status()).toBe('error'); expect(res.error()).toEqual(new Error('fail')); }); it('should allow to set a value on when in error state', async () => { const appRef = TestBed.inject(ApplicationRef); let res = resource({ stream: async () => signal({error: new Error('fail')}), injector: TestBed.inject(Injector), }); await appRef.whenStable(); expect(res.status()).toBe('error'); res.set('new value'); expect(res.status()).toBe('local'); expect(res.value()).toBe('new value'); // also via setting the value on the signal directly res = resource({ stream: async () => signal({error: new Error('fail')}), injector: TestBed.inject(Injector), }); await appRef.whenStable(); expect(res.status()).toBe('error'); res.value.set('new value'); expect(res.status()).toBe('local'); expect(res.value()).toBe('new value'); }); it('should transition across streamed states', async () => { const appRef = TestBed.inject(ApplicationRef); const stream = signal<{value: number} | {error: Error}>({value: 1}); const res = resource({ stream: async () => stream, injector: TestBed.inject(Injector), }); await appRef.whenStable(); stream.set({value: 2}); expect(res.value()).toBe(2); stream.set({value: 3}); expect(res.value()).toBe(3); stream.set({error: new Error('fail')}); expect(res.error()).toEqual(new Error('fail')); stream.set({value: 4}); expect(res.value()).toBe(4); }); it('should not accept new values/errors after a request is cancelled', async () => { const appRef = TestBed.inject(ApplicationRef); const stream = signal<{value: number} | {error: Error}>({value: 0}); const request = signal(1); const res = resource({ params: request, stream: async ({params}) => { if (params === 1) { return stream; } else { return signal({value: 0}); } }, injector: TestBed.inject(Injector), }); await appRef.whenStable(); stream.set({value: 1}); expect(res.value()).toBe(1); // Changing the request aborts the previous one. request.set(2); // The previous set/error functions should no longer result in changes to the resource. stream.set({value: 2}); expect(res.value()).toBe(undefined); stream.set({error: new Error('fail')}); expect(res.value()).toBe(undefined); }); it('should interrupt pending request if the same value is set', async () => { const counter = signal(0); const backend = new MockEchoBackend<{counter: number} | null>(); const aborted: ({counter: number} | null)[] = []; const echoResource = resource<{counter: number} | null, {counter: number} | null>({ params: () => ({counter: counter()}), loader: ({params, abortSignal}) => { abortSignal.addEventListener('abort', () => backend.abort(params)); return backend.fetch(params).catch((reason) => { if (reason === 'aborted') { aborted.push(params); } throw new Error(reason); }); }, injector: TestBed.inject(Injector), }); // Start the initial load. TestBed.tick(); await Promise.resolve(); expect(echoResource.status()).toBe('loading'); expect(echoResource.value()).toBe(undefined); expect(echoResource.error()).toBe(undefined); expect(aborted).toEqual([]); // Interrupt by setting a value before the request has resolved. echoResource.set(null); TestBed.tick(); await backend.flush(); expect(echoResource.status()).toBe('local'); expect(echoResource.value()).toBe(null); expect(echoResource.error()).toBe(undefined); expect(aborted).toEqual([{counter: 0}]); // Reload the resource to trigger another request. echoResource.reload(); TestBed.tick(); await Promise.resolve(); expect(echoResource.status()).toBe('reloading'); expect(echoResource.value()).toBe(null); expect(echoResource.error()).toBe(undefined); expect(aborted).toEqual([{counter: 0}]); // Interrupt the reload with the same value as before. echoResource.set(null); await backend.flush(); expect(echoResource.status()).toBe('local'); expect(echoResource.value()).toBe(null); expect(echoResource.error()).toBe(undefined); expect(aborted).toEqual([{counter: 0}, {counter: 0}]); }); it('should return default value when getting a value after reloading in the error state', async () => { const backend = new MockEchoBackend(); const requestParam = {}; const echoResource = resource({ params: () => requestParam, loader: (params) => backend.fetch(params.params), injector: TestBed.inject(Injector), defaultValue: 'my-default-value', }); TestBed.tick(); await backend.reject(requestParam, new Error('Something went wrong....')); expect(echoResource.status()).toBe('error'); expect(echoResource.isLoading()).toBeFalse(); expect(echoResource.hasValue()).toBeFalse(); const errFromValue = extractError(() => echoResource.value())!; expect(errFromValue.message).toContain('Resource'); expect(errFromValue.message).toContain('Something went wrong....'); expect(errFromValue.cause).toEqual(new Error('Something went wrong....')); expect(echoResource.error()!).toEqual(new Error('Something went wrong....')); echoResource.reload(); TestBed.tick(); expect(echoResource.status()).toBe('reloading'); expect(echoResource.isLoading()).toBeTrue(); expect(echoResource.hasValue()).toBeTrue(); expect(echoResource.value()).toEqual('my-default-value'); expect(echoResource.error()).toEqual(new Error('Something went wrong....')); await backend.flush(); TestBed.tick(); expect(echoResource.status()).toBe('resolved'); expect(echoResource.isLoading()).toBeFalse(); expect(echoResource.hasValue()).toBeTrue(); expect(echoResource.value()).toEqual({}); expect(echoResource.error()).toEqual(undefined); }); describe('types', () => { it('should narrow hasValue() when the value can be undefined', () => { const result: ResourceRef<number | undefined> = resource({ params: () => 1, loader: async ({params}) => params, injector: TestBed.inject(Injector), }); if (result.hasValue()) { const _value: number = result.value(); } else if (result.isLoading()) { // @ts-expect-error const _value: number = result.value(); } else if (result.error()) { } const readonly = result.asReadonly(); if (readonly.hasValue()) { const _value: number = readonly.value(); } else if (readonly.isLoading()) { // @ts-expect-error const _value: number = readonly.value(); } else if (readonly.error()) { } }); it('should not narrow hasValue() when a default value is provided', () => { const result: ResourceRef<number> = resource({ params: () => 1, loader: async ({params}) => params, injector: TestBed.inject(Injector), defaultValue: 0, }); if (result.hasValue()) { const _value: number = result.value(); } else if (result.isLoading()) { const _value: number = result.value(); } else if (result.error()) { } const readonly = result.asReadonly(); if (readonly.hasValue()) { const _value: number = readonly.value(); } else if (readonly.isLoading()) { const _value: number = readonly.value(); } else if (readonly.error()) { } }); it('should not narrow hasValue() when the resource type is unknown', () => { const result: ResourceRef<unknown> = resource({ params: () => 1 as unknown, loader: async ({params}) => params, injector: TestBed.inject(Injector), defaultValue: 0, }); if (result.hasValue()) { const _value: unknown = result.value(); } else if (result.isLoading()) { const _value: unknown = result.value(); } else if (result.error()) { } const readonly = result.asReadonly(); if (readonly.hasValue()) { const _value: unknown = readonly.value(); } else if (readonly.isLoading()) { const _value: unknown = readonly.value(); } else if (readonly.error()) { } }); }); }); function flushMicrotasks(): Promise<void> { return new Promise((resolve) => setTimeout(resolve, 0)); } function extractError(fn: () => unknown): Error | undefined { try { fn(); return undefined; } catch (err: unknown) { return err as Error; } } describe('with TransferState', () => { let transferState: TransferState; beforeEach(() => { TestBed.configureTestingModule({ providers: [TransferState, {provide: CACHE_ACTIVE, useValue: {isActive: true}}], }); transferState = TestBed.inject(TransferState); }); it('should read from TransferState if a key is present', async () => { const key = makeStateKey<number>('test-key'); transferState.set(key, 123); const testResource = resource({ loader: async () => 456, id: key, injector: TestBed.inject(Injector), }); // Should be synchronously resolved from cache expect(testResource.status()).toBe('resolved'); expect(testResource.value()).toBe(123); // Should prevent loader from running await flushMicrotasks(); expect(testResource.value()).toBe(123); }); it('should write to TransferState on server when resolved', async () => { (globalThis as any).ngServerMode = true; const key = 'server-key'; const testResource = resource({ loader: async () => 789, id: key, injector: TestBed.inject(Injector), }); expect(testResource.status()).toBe('loading'); await flushMicrotasks(); expect(testResource.status()).toBe('resolved'); expect(testResource.value()).toBe(789); expect(transferState.get(makeStateKey<number>(key), null!)).toBe(789); (globalThis as any).ngServerMode = undefined; }); it('should not write to TransferState on client when resolved', async () => { const key = 'client-key'; const testResource = resource({ loader: async () => 101112, id: key, injector: TestBed.inject(Injector), }); await flushMicrotasks(); expect(testResource.status()).toBe('resolved'); expect(testResource.value()).toBe(101112); expect(transferState.hasKey(makeStateKey(key))).toBeFalse(); }); });