@mmstack/resource 22.2.0 → 22.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +95 -2
- package/fesm2022/mmstack-resource.mjs +75 -38
- package/fesm2022/mmstack-resource.mjs.map +1 -1
- package/package.json +1 -1
- package/types/mmstack-resource.d.ts +44 -1
package/README.md
CHANGED
|
@@ -26,6 +26,7 @@ It's designed to be opt-in feature by feature: starting with `queryResource()` a
|
|
|
26
26
|
- [Pausing a resource](#pausing-a-resource)
|
|
27
27
|
- [Default options (`provideResourceOptions`)](#default-options-provideresourceoptions)
|
|
28
28
|
- [Composition (retry / refresh / keepPrevious)](#composition-retry--refresh--keepprevious)
|
|
29
|
+
- [Testing](#testing)
|
|
29
30
|
- [Recipes](#recipes)
|
|
30
31
|
|
|
31
32
|
## Install
|
|
@@ -36,7 +37,9 @@ npm install @mmstack/resource
|
|
|
36
37
|
|
|
37
38
|
## Quick start
|
|
38
39
|
|
|
39
|
-
|
|
40
|
+
A plain `queryResource()` works with nothing but `provideHttpClient()` — an in-memory cache is wired up by default. To turn caching on you add the interceptors (below) and opt resources into it per request. `provideQueryCache()` is **optional**: call it only when you want persistence (IndexedDB), cross-tab sync, or to tune the global TTL/stale defaults — it overrides the in-memory default.
|
|
41
|
+
|
|
42
|
+
Recommended app config — interceptors plus `provideQueryCache()` for a tuned, persistent cache:
|
|
40
43
|
|
|
41
44
|
```typescript
|
|
42
45
|
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
|
@@ -433,7 +436,7 @@ const rows = keyArray(items, (item) => buildRowVm(item), {
|
|
|
433
436
|
|
|
434
437
|
### `provideQueryCache(options?)`
|
|
435
438
|
|
|
436
|
-
|
|
439
|
+
Overrides the shared `Cache` in the root injector. It's optional — without it `queryResource` falls back to an in-memory cache (no persistence, no cross-tab sync). Call `provideQueryCache()` to add IndexedDB persistence, cross-tab sync, or to tune the global TTL/stale defaults.
|
|
437
440
|
|
|
438
441
|
```typescript
|
|
439
442
|
provideQueryCache({
|
|
@@ -657,6 +660,96 @@ Practical consequences:
|
|
|
657
660
|
- **`keepPrevious` works alongside both.** While a retry or refresh is in flight, `value()` is the previous successful result, not `undefined`.
|
|
658
661
|
- **Circuit breaker beats retry.** If the breaker opens during a retry sequence, the resource is disabled — no more retries until the breaker probes and closes.
|
|
659
662
|
|
|
663
|
+
## Testing
|
|
664
|
+
|
|
665
|
+
Testing code that uses `queryResource`/`mutationResource` comes down to two things: a deterministic cache and a way to feed mock HTTP responses.
|
|
666
|
+
|
|
667
|
+
### `provideMockQueryCache(options?)`
|
|
668
|
+
|
|
669
|
+
A real in-memory cache built for tests. Unlike `provideQueryCache()` it never touches IndexedDB or `BroadcastChannel`, and it disables the cleanup sweep interval — so it's safe under `vi.useFakeTimers()` / `jest.useFakeTimers()` and leaves no timers pinned between specs. It's a real cache (not a stub), so cache hits behave exactly as in production and you can assert against them.
|
|
670
|
+
|
|
671
|
+
```typescript
|
|
672
|
+
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
|
673
|
+
import { TestBed } from '@angular/core/testing';
|
|
674
|
+
import {
|
|
675
|
+
createCacheInterceptor,
|
|
676
|
+
createDedupeRequestsInterceptor,
|
|
677
|
+
provideMockQueryCache,
|
|
678
|
+
} from '@mmstack/resource';
|
|
679
|
+
|
|
680
|
+
beforeEach(() => {
|
|
681
|
+
TestBed.configureTestingModule({
|
|
682
|
+
providers: [
|
|
683
|
+
provideMockQueryCache(),
|
|
684
|
+
provideHttpClient(
|
|
685
|
+
withInterceptors([
|
|
686
|
+
createCacheInterceptor(),
|
|
687
|
+
createDedupeRequestsInterceptor(),
|
|
688
|
+
// your response-mocking interceptor (see below)
|
|
689
|
+
]),
|
|
690
|
+
),
|
|
691
|
+
],
|
|
692
|
+
});
|
|
693
|
+
});
|
|
694
|
+
```
|
|
695
|
+
|
|
696
|
+
> A plain `queryResource` no longer requires any cache provider at all (the in-memory default applies), so `provideMockQueryCache()` is only needed when you want the deterministic, timer-free cache for asserting caching behavior.
|
|
697
|
+
|
|
698
|
+
### `provideMockResourceSensors(options?)`
|
|
699
|
+
|
|
700
|
+
Resources auto-pause when the network drops or the page is hidden. To drive that behavior in a test — instead of relying on the real `navigator.onLine` / `document.visibilityState` — provide controllable sensors. Pass your own writable signals to toggle state mid-test; omit them for a static online + visible environment.
|
|
701
|
+
|
|
702
|
+
```typescript
|
|
703
|
+
import { signal } from '@angular/core';
|
|
704
|
+
import { provideMockResourceSensors } from '@mmstack/resource';
|
|
705
|
+
|
|
706
|
+
const online = signal(true);
|
|
707
|
+
|
|
708
|
+
TestBed.configureTestingModule({
|
|
709
|
+
providers: [provideMockResourceSensors({ networkStatus: online })],
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
// ...later in the test
|
|
713
|
+
online.set(false); // the resource sees the network drop and disables
|
|
714
|
+
```
|
|
715
|
+
|
|
716
|
+
### Mocking HTTP responses
|
|
717
|
+
|
|
718
|
+
For most tests — a cold cache or a resource that doesn't cache — Angular's standard `provideHttpClientTesting()` + `HttpTestingController` works out of the box, because a cache miss flows through the interceptor chain to the testing backend like any other request.
|
|
719
|
+
|
|
720
|
+
```typescript
|
|
721
|
+
import {
|
|
722
|
+
provideHttpClient,
|
|
723
|
+
provideHttpClientTesting,
|
|
724
|
+
} from '@angular/common/http/testing';
|
|
725
|
+
import { HttpTestingController } from '@angular/common/http/testing';
|
|
726
|
+
|
|
727
|
+
const ctrl = TestBed.inject(HttpTestingController);
|
|
728
|
+
ctrl.expectOne('https://example.com/posts').flush([{ id: 1 }]);
|
|
729
|
+
```
|
|
730
|
+
|
|
731
|
+
When you specifically want to assert that a **cache hit short-circuits before the network**, `HttpTestingController` won't see the second request (that's the point). For those cases set the mock response on the request's `HttpContext` and read it from a tiny interceptor, which lets you count how many requests actually reached the network:
|
|
732
|
+
|
|
733
|
+
```typescript
|
|
734
|
+
import { HttpContextToken, type HttpInterceptorFn } from '@angular/common/http';
|
|
735
|
+
import { HttpResponse } from '@angular/common/http';
|
|
736
|
+
import { of } from 'rxjs';
|
|
737
|
+
|
|
738
|
+
const MOCK = new HttpContextToken<() => unknown>(() => () => null);
|
|
739
|
+
|
|
740
|
+
const mockInterceptor: HttpInterceptorFn = (req) =>
|
|
741
|
+
of(new HttpResponse({ body: req.context.get(MOCK)(), status: 200 }));
|
|
742
|
+
```
|
|
743
|
+
|
|
744
|
+
Pass `context` on the request and reuse the same cache key (same URL) to observe the hit:
|
|
745
|
+
|
|
746
|
+
```typescript
|
|
747
|
+
queryResource(
|
|
748
|
+
() => ({ url, context: new HttpContext().set(MOCK, () => ({ ok: true })) }),
|
|
749
|
+
{ cache: { staleTime: 10_000 } },
|
|
750
|
+
);
|
|
751
|
+
```
|
|
752
|
+
|
|
660
753
|
## Recipes
|
|
661
754
|
|
|
662
755
|
### Optimistic update with rollback
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { HttpHeaders, HttpParams, HttpResponse, HttpContextToken, HttpContext, httpResource, HttpClient } from '@angular/common/http';
|
|
2
2
|
import * as i0 from '@angular/core';
|
|
3
|
-
import { isDevMode, signal, computed, untracked, InjectionToken, inject,
|
|
3
|
+
import { isDevMode, signal, computed, untracked, InjectionToken, inject, DestroyRef, PLATFORM_ID, effect, Injector, Injectable, runInInjectionContext, linkedSignal } from '@angular/core';
|
|
4
4
|
import { mutable, toWritable, keepPrevious, sensor, injectTransitionScope, injectPaused, nestedEffect } from '@mmstack/primitives';
|
|
5
5
|
import { finalize, shareReplay, of, tap, map, interval, firstValueFrom, catchError, combineLatestWith, filter } from 'rxjs';
|
|
6
6
|
import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop';
|
|
@@ -501,7 +501,7 @@ class Cache {
|
|
|
501
501
|
};
|
|
502
502
|
if (this.cleanupOpt.maxSize <= 0)
|
|
503
503
|
throw new Error('maxSize must be greater than 0');
|
|
504
|
-
// a non-finite checkInterval disables the sweeper entirely (used by
|
|
504
|
+
// a non-finite checkInterval disables the sweeper entirely (used by provideMockQueryCache)
|
|
505
505
|
const cleanupInterval = Number.isFinite(this.cleanupOpt.checkInterval)
|
|
506
506
|
? setInterval(() => {
|
|
507
507
|
this.cleanup();
|
|
@@ -872,7 +872,48 @@ class Cache {
|
|
|
872
872
|
this.internal.set(new Map(keep));
|
|
873
873
|
}
|
|
874
874
|
}
|
|
875
|
-
const CLIENT_CACHE_TOKEN = new InjectionToken('INTERNAL_CLIENT_CACHE'
|
|
875
|
+
const CLIENT_CACHE_TOKEN = new InjectionToken('INTERNAL_CLIENT_CACHE', {
|
|
876
|
+
// Memory-only default so a plain queryResource works with zero config. No
|
|
877
|
+
// IndexedDB / BroadcastChannel — keeps it SSR-safe and request-isolated under
|
|
878
|
+
// SSR (root injector is per-request). provideQueryCache() overrides this to
|
|
879
|
+
// layer on persistence / cross-tab sync / global TTL tuning.
|
|
880
|
+
providedIn: 'root',
|
|
881
|
+
factory: () => {
|
|
882
|
+
const cache = new Cache();
|
|
883
|
+
inject(DestroyRef, { optional: true })?.onDestroy(() => cache.destroy());
|
|
884
|
+
return cache;
|
|
885
|
+
},
|
|
886
|
+
});
|
|
887
|
+
/**
|
|
888
|
+
* Provides a deterministic, in-memory `QueryCache` for unit tests.
|
|
889
|
+
*
|
|
890
|
+
* Unlike {@link provideQueryCache} this never touches IndexedDB or BroadcastChannel
|
|
891
|
+
* and disables the cleanup sweep interval (`checkInterval: Infinity`), so it plays
|
|
892
|
+
* nicely with `vi.useFakeTimers()`. It's a real cache (not a stub), so you can
|
|
893
|
+
* assert cache hits via {@link injectQueryCache} / its `stats` signal.
|
|
894
|
+
*
|
|
895
|
+
* @example
|
|
896
|
+
* TestBed.configureTestingModule({
|
|
897
|
+
* providers: [
|
|
898
|
+
* provideMockQueryCache(),
|
|
899
|
+
* provideHttpClient(withInterceptors([createCacheInterceptor()])),
|
|
900
|
+
* ],
|
|
901
|
+
* });
|
|
902
|
+
*/
|
|
903
|
+
function provideMockQueryCache(opt) {
|
|
904
|
+
return {
|
|
905
|
+
provide: CLIENT_CACHE_TOKEN,
|
|
906
|
+
useFactory: () => {
|
|
907
|
+
const cache = new Cache(opt?.ttl, opt?.staleTime, {
|
|
908
|
+
type: 'lru',
|
|
909
|
+
maxSize: 200,
|
|
910
|
+
checkInterval: Infinity,
|
|
911
|
+
});
|
|
912
|
+
inject(DestroyRef, { optional: true })?.onDestroy(() => cache.destroy());
|
|
913
|
+
return cache;
|
|
914
|
+
},
|
|
915
|
+
};
|
|
916
|
+
}
|
|
876
917
|
/**
|
|
877
918
|
* Provides the instance of the QueryCache for queryResource. This should probably be called
|
|
878
919
|
* in your application's root configuration, but can also be overriden with component/module providers.
|
|
@@ -935,15 +976,12 @@ function provideQueryCache(opt) {
|
|
|
935
976
|
return null;
|
|
936
977
|
}
|
|
937
978
|
};
|
|
938
|
-
// version-suffixed so two deploys with incompatible schemas in adjacent tabs don't
|
|
939
|
-
// push entries into each other's caches (the `version` option only fences IndexedDB)
|
|
940
979
|
const syncChannelId = `mmstack-query-cache-sync_v${opt?.version ?? 1}`;
|
|
941
980
|
return {
|
|
942
981
|
provide: CLIENT_CACHE_TOKEN,
|
|
943
982
|
useFactory: () => {
|
|
944
983
|
const onServer = inject(PLATFORM_ID) === 'server';
|
|
945
|
-
// no IndexedDB / BroadcastChannel on the server
|
|
946
|
-
// isolated, request-lived, memory-only cache
|
|
984
|
+
// no IndexedDB / BroadcastChannel on the server
|
|
947
985
|
const syncTabsOpt = !onServer && opt?.syncTabs
|
|
948
986
|
? {
|
|
949
987
|
id: syncChannelId,
|
|
@@ -983,24 +1021,6 @@ function provideQueryCache(opt) {
|
|
|
983
1021
|
},
|
|
984
1022
|
};
|
|
985
1023
|
}
|
|
986
|
-
class NoopCache extends Cache {
|
|
987
|
-
constructor() {
|
|
988
|
-
// Infinity checkInterval → no sweep interval is ever armed, so the shared
|
|
989
|
-
// instance below never pins a timer
|
|
990
|
-
super(undefined, undefined, {
|
|
991
|
-
type: 'lru',
|
|
992
|
-
maxSize: 200,
|
|
993
|
-
checkInterval: Infinity,
|
|
994
|
-
});
|
|
995
|
-
}
|
|
996
|
-
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
997
|
-
store(_, __, ___ = super.staleTime, ____ = super.ttl) {
|
|
998
|
-
// noop
|
|
999
|
-
}
|
|
1000
|
-
}
|
|
1001
|
-
// one shared instance — minting a NoopCache per injectQueryCache() miss would leak
|
|
1002
|
-
// an instance (and previously an interval) on every prod call without a provider
|
|
1003
|
-
let NOOP_CACHE;
|
|
1004
1024
|
/**
|
|
1005
1025
|
* Injects the `QueryCache` instance that is used within queryResource.
|
|
1006
1026
|
* Allows for direct modification of cached data, but is mostly meant for internal use.
|
|
@@ -1025,18 +1045,8 @@ let NOOP_CACHE;
|
|
|
1025
1045
|
*/
|
|
1026
1046
|
function injectQueryCache(injector) {
|
|
1027
1047
|
const cache = injector
|
|
1028
|
-
? injector.get(CLIENT_CACHE_TOKEN
|
|
1029
|
-
|
|
1030
|
-
})
|
|
1031
|
-
: inject(CLIENT_CACHE_TOKEN, {
|
|
1032
|
-
optional: true,
|
|
1033
|
-
});
|
|
1034
|
-
if (!cache) {
|
|
1035
|
-
if (isDevMode())
|
|
1036
|
-
throw new Error('Cache not provided, please add provideQueryCache() to providers array');
|
|
1037
|
-
else
|
|
1038
|
-
return (NOOP_CACHE ??= new NoopCache());
|
|
1039
|
-
}
|
|
1048
|
+
? injector.get(CLIENT_CACHE_TOKEN)
|
|
1049
|
+
: inject(CLIENT_CACHE_TOKEN);
|
|
1040
1050
|
return cache;
|
|
1041
1051
|
}
|
|
1042
1052
|
/**
|
|
@@ -1952,6 +1962,33 @@ function injectNetworkStatus() {
|
|
|
1952
1962
|
function injectPageVisibility() {
|
|
1953
1963
|
return inject(ResourceSensors).pageVisibility;
|
|
1954
1964
|
}
|
|
1965
|
+
/**
|
|
1966
|
+
* Provides controllable {@link ResourceSensors} for unit tests, letting you drive a
|
|
1967
|
+
* resource's offline / page-hidden behavior deterministically instead of relying on
|
|
1968
|
+
* the real `navigator.onLine` / `document.visibilityState`.
|
|
1969
|
+
*
|
|
1970
|
+
* Pass your own writable signals to toggle state mid-test; omit them for a static
|
|
1971
|
+
* online + visible environment.
|
|
1972
|
+
*
|
|
1973
|
+
* @example
|
|
1974
|
+
* import { signal } from '@angular/core';
|
|
1975
|
+
*
|
|
1976
|
+
* const online = signal(true);
|
|
1977
|
+
* TestBed.configureTestingModule({
|
|
1978
|
+
* providers: [provideMockResourceSensors({ networkStatus: online })],
|
|
1979
|
+
* });
|
|
1980
|
+
* // ...later in the test
|
|
1981
|
+
* online.set(false); // the resource now sees the network as down
|
|
1982
|
+
*/
|
|
1983
|
+
function provideMockResourceSensors(opt) {
|
|
1984
|
+
return {
|
|
1985
|
+
provide: ResourceSensors,
|
|
1986
|
+
useValue: {
|
|
1987
|
+
networkStatus: opt?.networkStatus ?? signal(true),
|
|
1988
|
+
pageVisibility: opt?.pageVisibility ?? signal('visible'),
|
|
1989
|
+
},
|
|
1990
|
+
};
|
|
1991
|
+
}
|
|
1955
1992
|
|
|
1956
1993
|
function toResourceObject(res) {
|
|
1957
1994
|
return {
|
|
@@ -2725,5 +2762,5 @@ function mutationResource(request, options0 = {}) {
|
|
|
2725
2762
|
* Generated bundle index. Do not edit.
|
|
2726
2763
|
*/
|
|
2727
2764
|
|
|
2728
|
-
export { Cache, MutationCancelledError, PAUSED, applyResourceRegistration, createCacheInterceptor, createCircuitBreaker, createDedupeRequestsInterceptor, hashRequest, infiniteQueryResource, injectQueryCache, injectResourceOptions, manualQueryResource, mutationResource, noDedupe, provideCircuitBreakerDefaultOptions, provideMutationResourceOptions, provideQueryCache, provideQueryResourceOptions, provideResourceOptions, provideTypedResourceOptions, queryResource };
|
|
2765
|
+
export { Cache, MutationCancelledError, PAUSED, applyResourceRegistration, createCacheInterceptor, createCircuitBreaker, createDedupeRequestsInterceptor, hashRequest, infiniteQueryResource, injectQueryCache, injectResourceOptions, manualQueryResource, mutationResource, noDedupe, provideCircuitBreakerDefaultOptions, provideMockQueryCache, provideMockResourceSensors, provideMutationResourceOptions, provideQueryCache, provideQueryResourceOptions, provideResourceOptions, provideTypedResourceOptions, queryResource };
|
|
2729
2766
|
//# sourceMappingURL=mmstack-resource.mjs.map
|