@mmstack/resource 19.5.0 → 19.6.0
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 +92 -2
- package/fesm2022/mmstack-resource.mjs +207 -58
- package/fesm2022/mmstack-resource.mjs.map +1 -1
- package/index.d.ts +1 -0
- package/lib/manual-query.d.ts +51 -2
- package/lib/mutation-resource.d.ts +72 -5
- package/lib/options.d.ts +38 -0
- package/lib/query-resource.d.ts +105 -19
- package/lib/util/refresh.d.ts +1 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -21,6 +21,9 @@ It's designed to be opt-in feature by feature: starting with `queryResource()` a
|
|
|
21
21
|
- [`manualQueryResource`](#manualqueryresource)
|
|
22
22
|
- [Caching](#caching)
|
|
23
23
|
- [Circuit breakers](#circuit-breakers)
|
|
24
|
+
- [Transitions & Suspense](#transitions--suspense)
|
|
25
|
+
- [Pausing a resource](#pausing-a-resource)
|
|
26
|
+
- [Default options (`provideResourceOptions`)](#default-options-provideresourceoptions)
|
|
24
27
|
- [Composition (retry / refresh / keepPrevious)](#composition-retry--refresh--keepprevious)
|
|
25
28
|
- [Recipes](#recipes)
|
|
26
29
|
|
|
@@ -177,12 +180,12 @@ queryResource(() => ({
|
|
|
177
180
|
|
|
178
181
|
```ts
|
|
179
182
|
queryResource<TResult, TRaw = TResult>(
|
|
180
|
-
request: () => HttpResourceRequest | string | undefined,
|
|
183
|
+
request: (ctx: RequestContext) => HttpResourceRequest | string | undefined | typeof PAUSED,
|
|
181
184
|
options?: QueryResourceOptions<TResult, TRaw>,
|
|
182
185
|
): QueryResourceRef<TResult>
|
|
183
186
|
```
|
|
184
187
|
|
|
185
|
-
`request` is a reactive function. Whenever it returns a new value, a new request is made; returning `undefined` disables the resource until the function returns something again.
|
|
188
|
+
`request` is a reactive function. Whenever it returns a new value, a new request is made; returning `undefined` **disables** the resource until the function returns something again. It receives a `RequestContext` whose `paused` token it can return to **pause** instead — see [pausing a resource](#pausing-a-resource).
|
|
186
189
|
|
|
187
190
|
### Options
|
|
188
191
|
|
|
@@ -196,7 +199,9 @@ queryResource<TResult, TRaw = TResult>(
|
|
|
196
199
|
| `circuitBreaker` | `true \| CircuitBreaker \| { threshold?, timeout?, … }` | off | See [circuit breakers](#circuit-breakers). |
|
|
197
200
|
| `cache` | `ResourceCacheOptions` | off | Enables caching for this resource. See [caching](#caching). |
|
|
198
201
|
| `triggerOnSameRequest` | `boolean` | `false` | Re-run even if the request object equals the previous one. Use sparingly. |
|
|
202
|
+
| `register` | `boolean \| { suspends?: boolean }` | `false` | Auto-register into the nearest transition scope. See [transitions & Suspense](#transitions--suspense). |
|
|
199
203
|
| `equal` | `ValueEqualityFn<TResult>` | `Object.is` | Custom equality for the result value (forwarded to `httpResource`). |
|
|
204
|
+
| `equalRequest` | `(a, b) => boolean` | structural | Custom equality for the **request** object (controls dedup / refetch). Defaults to a deep structural compare. |
|
|
200
205
|
| `injector` | `Injector` | `inject(Injector)` | Use this injector for cache/circuit-breaker resolution. Required if calling outside an injection context. |
|
|
201
206
|
| `parse` | `(raw: TRaw) => TResult` | identity | Transform the raw HTTP response. Does not affect cache keys. |
|
|
202
207
|
|
|
@@ -270,6 +275,16 @@ mutationResource(request, { queue: true });
|
|
|
270
275
|
|
|
271
276
|
Queued mutations sit in a signal-backed queue and execute one at a time. The queue **persists across resource-disabled states** — if the circuit breaker opens or the network drops, queued mutations stay pending and run when the resource recovers. This is intentional for resilience (think "POST goes out when we're back online"), but it does mean a queued mutation can fire long after the user triggered it. Don't enable `queue` if that's surprising in your UX.
|
|
272
277
|
|
|
278
|
+
### Re-firing with an identical body (`triggerOnSameRequest`)
|
|
279
|
+
|
|
280
|
+
A mutation is an imperative command, so by default an identical `mutate(body)` while one is in flight is **deduplicated** (double-click protection). When a repeat with the same body _must_ fire — e.g. a "resend" button — set `triggerOnSameRequest: true`, and every `mutate()` fires regardless of whether the body changed.
|
|
281
|
+
|
|
282
|
+
```typescript
|
|
283
|
+
mutationResource(request, { triggerOnSameRequest: true });
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
A mutation also honours the `register` option — but it registers the **mutation ref itself** into the transition scope (its internal query is never registered), so a `<mm-suspense>`/transition reacts to the mutation's own `pending` state. See [transitions & Suspense](#transitions--suspense).
|
|
287
|
+
|
|
273
288
|
### Return shape (`MutationResourceRef<T, TMutation>`)
|
|
274
289
|
|
|
275
290
|
| Member | Type | Notes |
|
|
@@ -410,6 +425,81 @@ authService.refreshToken().subscribe(() => {
|
|
|
410
425
|
|
|
411
426
|
`hardReset()` is also useful for testing — it gives you a "back to factory state" handle without reconstructing the breaker.
|
|
412
427
|
|
|
428
|
+
## Transitions & Suspense
|
|
429
|
+
|
|
430
|
+
Resources plug into `@mmstack/primitives`' [transition scope](https://www.npmjs.com/package/@mmstack/primitives#concurrency--transitions) — the machinery behind Suspense boundaries and route transitions. Set `register` and the resource adds itself to the nearest scope (and removes itself on destroy), so a `<mm-suspense>` boundary or a `<mm-transition-outlet>` can coordinate its loading state:
|
|
431
|
+
|
|
432
|
+
The boundary provides the scope, so the resource has to register from **inside** it — i.e. the data-owning component sits within the `<mm-suspense>` tags (registration resolves the scope up the injector tree). A query declared on the same component that _renders_ the boundary is above it and won't be captured.
|
|
433
|
+
|
|
434
|
+
```typescript
|
|
435
|
+
import { Component, input } from '@angular/core';
|
|
436
|
+
import { SuspenseBoundary } from '@mmstack/primitives';
|
|
437
|
+
import { queryResource } from '@mmstack/resource';
|
|
438
|
+
|
|
439
|
+
@Component({ selector: 'user-profile', template: `{{ user.value()?.name }}` })
|
|
440
|
+
class UserProfile {
|
|
441
|
+
readonly id = input.required<string>();
|
|
442
|
+
// `register: { suspends: true }` registers into the nearest scope (the
|
|
443
|
+
// <mm-suspense> above) and blocks its first paint until a value lands.
|
|
444
|
+
readonly user = queryResource<User>(() => `/api/users/${this.id()}`, {
|
|
445
|
+
register: { suspends: true },
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
@Component({
|
|
450
|
+
selector: 'user-page',
|
|
451
|
+
imports: [SuspenseBoundary, UserProfile],
|
|
452
|
+
template: `
|
|
453
|
+
<mm-suspense>
|
|
454
|
+
<span placeholder>Loading…</span>
|
|
455
|
+
<user-profile [id]="id()" />
|
|
456
|
+
</mm-suspense>
|
|
457
|
+
`,
|
|
458
|
+
})
|
|
459
|
+
class UserPage {
|
|
460
|
+
readonly id = input.required<string>();
|
|
461
|
+
}
|
|
462
|
+
```
|
|
463
|
+
|
|
464
|
+
- `register: true` (or `{ suspends: false }`) — register for the **pending indicator + hold-stale**; does _not_ block first paint. The right choice for in-region data: the boundary shows the held value with `aria-busy`, not a placeholder.
|
|
465
|
+
- `register: { suspends: true }` — register as **suspending**: the boundary holds its placeholder until this resource has a value (full Suspense).
|
|
466
|
+
- `false` / omitted — don't register.
|
|
467
|
+
|
|
468
|
+
Combine with `keepPrevious: true` so reloads hold the last value instead of flashing empty — then a `<mm-suspense>` shows the placeholder only on the genuine first load, and `startTransition` (from `@mmstack/primitives`) can reveal a multi-resource update in one frame. For navigation, `@mmstack/router-core`'s `<mm-transition-outlet>` keeps the current route on screen until the incoming route's registered resources settle.
|
|
469
|
+
|
|
470
|
+
## Pausing a resource
|
|
471
|
+
|
|
472
|
+
The request fn can return `ctx.paused` (the `PAUSED` token) to **pause** the resource: it holds its current value and last request, stops polling, and does **not** refetch on resume unless the request changed. This is distinct from returning `undefined` (which _disables_ — a disabled resource may refetch when re-enabled; a paused one resumes exactly where it left off). It pairs with keep-alive (`MmActivity` / `injectPaused`) so a hidden tab's queries go quiet without losing their data:
|
|
473
|
+
|
|
474
|
+
```typescript
|
|
475
|
+
import { injectPaused } from '@mmstack/primitives';
|
|
476
|
+
|
|
477
|
+
class Panel {
|
|
478
|
+
private readonly paused = injectPaused(); // true while the tab is hidden by *mmActivity
|
|
479
|
+
|
|
480
|
+
readonly data = queryResource<Data>((ctx) =>
|
|
481
|
+
this.paused() ? ctx.paused : `/api/data/${this.id()}`,
|
|
482
|
+
);
|
|
483
|
+
}
|
|
484
|
+
```
|
|
485
|
+
|
|
486
|
+
## Default options (`provideResourceOptions`)
|
|
487
|
+
|
|
488
|
+
Common options (`register`, `retry`, `circuitBreaker`, `triggerOnSameRequest`) can be defaulted app-wide, with a three-layer precedence — **per-call > type-specific provider > common provider**:
|
|
489
|
+
|
|
490
|
+
```typescript
|
|
491
|
+
providers: [
|
|
492
|
+
// Layer 1 — applies to every resource kind.
|
|
493
|
+
provideResourceOptions({ retry: { max: 2 }, register: true }),
|
|
494
|
+
// Layer 2 — queries only (inherits + overrides layer 1).
|
|
495
|
+
provideQueryResourceOptions({ circuitBreaker: true }),
|
|
496
|
+
// Layer 2 — mutations only.
|
|
497
|
+
provideMutationResourceOptions({ register: false }),
|
|
498
|
+
];
|
|
499
|
+
```
|
|
500
|
+
|
|
501
|
+
Each accepts a value or a factory (`() => options`). A per-call option always wins — including opting out of a provider default with `register: false` — so you can make "all queries participate in transitions" the default and turn it off for the odd one.
|
|
502
|
+
|
|
413
503
|
## Composition (retry / refresh / keepPrevious)
|
|
414
504
|
|
|
415
505
|
The wrappers stack in a fixed order inside `queryResource`:
|
|
@@ -1,10 +1,47 @@
|
|
|
1
1
|
import * as i0 from '@angular/core';
|
|
2
|
-
import {
|
|
3
|
-
import { mutable, toWritable, sensor, nestedEffect } from '@mmstack/primitives';
|
|
2
|
+
import { InjectionToken, inject, runInInjectionContext, DestroyRef, isDevMode, untracked, computed, signal, effect, Injector, ResourceStatus, Injectable, linkedSignal } from '@angular/core';
|
|
3
|
+
import { injectTransitionScope, mutable, toWritable, keepPrevious, sensor, nestedEffect } from '@mmstack/primitives';
|
|
4
4
|
import { HttpHeaders, HttpResponse, HttpParams, HttpContextToken, HttpContext, httpResource, HttpClient } from '@angular/common/http';
|
|
5
5
|
import { of, tap, map, finalize, shareReplay, interval, firstValueFrom, catchError, combineLatestWith, filter } from 'rxjs';
|
|
6
6
|
import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop';
|
|
7
7
|
|
|
8
|
+
const RESOURCE_OPTIONS = new InjectionToken('@mmstack/resource:resource-options', { factory: () => ({}) });
|
|
9
|
+
function asProvider(token, valueOrFn) {
|
|
10
|
+
return typeof valueOrFn === 'function'
|
|
11
|
+
? { provide: token, useFactory: valueOrFn }
|
|
12
|
+
: { provide: token, useValue: valueOrFn };
|
|
13
|
+
}
|
|
14
|
+
/** Layer 1: defaults that apply to ALL resource kinds. Type-specific providers inherit + override these. */
|
|
15
|
+
function provideResourceOptions(valueOrFn) {
|
|
16
|
+
return asProvider(RESOURCE_OPTIONS, valueOrFn);
|
|
17
|
+
}
|
|
18
|
+
function injectResourceOptions(injector) {
|
|
19
|
+
return injector ? injector.get(RESOURCE_OPTIONS) : inject(RESOURCE_OPTIONS);
|
|
20
|
+
}
|
|
21
|
+
/** Shared helper for the type-specific providers (query/mutation), so precedence is identical. */
|
|
22
|
+
function provideTypedResourceOptions(token, valueOrFn) {
|
|
23
|
+
return asProvider(token, valueOrFn);
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Applies a resolved `register` option to a freshly-created resource — adds it to the nearest
|
|
27
|
+
* transition scope and removes it on destroy. Runs in the resource's injection context (or the
|
|
28
|
+
* provided `injector`), since registration needs `TRANSITION_SCOPE` + `DestroyRef`.
|
|
29
|
+
*/
|
|
30
|
+
function applyResourceRegistration(ref, register, injector) {
|
|
31
|
+
if (!register)
|
|
32
|
+
return;
|
|
33
|
+
const opt = register === true ? { suspends: false } : register;
|
|
34
|
+
const run = injector
|
|
35
|
+
? (fn) => runInInjectionContext(injector, fn)
|
|
36
|
+
: (fn) => fn();
|
|
37
|
+
run(() => {
|
|
38
|
+
const scope = injectTransitionScope();
|
|
39
|
+
const destroyRef = inject(DestroyRef);
|
|
40
|
+
scope.add(ref, opt);
|
|
41
|
+
destroyRef.onDestroy(() => scope.remove(ref));
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
8
45
|
function createNoopDB() {
|
|
9
46
|
return {
|
|
10
47
|
getAll: async () => [],
|
|
@@ -653,17 +690,13 @@ function hash(...args) {
|
|
|
653
690
|
}
|
|
654
691
|
|
|
655
692
|
function normalizeParams(params) {
|
|
656
|
-
const p = params instanceof HttpParams
|
|
657
|
-
? params
|
|
658
|
-
: new HttpParams({ fromObject: params });
|
|
693
|
+
const p = params instanceof HttpParams ? params : new HttpParams({ fromObject: params });
|
|
659
694
|
return p
|
|
660
695
|
.keys()
|
|
661
696
|
.toSorted()
|
|
662
697
|
.map((key) => {
|
|
663
698
|
const encodedKey = encodeURIComponent(key);
|
|
664
|
-
return (p.getAll(key) ?? [])
|
|
665
|
-
.map((v) => `${encodedKey}=${encodeURIComponent(v)}`)
|
|
666
|
-
.join('&');
|
|
699
|
+
return (p.getAll(key) ?? []).map((v) => `${encodedKey}=${encodeURIComponent(v)}`).join('&');
|
|
667
700
|
})
|
|
668
701
|
.join('&');
|
|
669
702
|
}
|
|
@@ -683,8 +716,7 @@ function hashBody(body) {
|
|
|
683
716
|
entries.sort(([ak, av], [bk, bv]) => ak.localeCompare(bk) || av.localeCompare(bv));
|
|
684
717
|
return `FormData:${entries.map(([k, v]) => `${k}=${v}`).join('&')}`;
|
|
685
718
|
}
|
|
686
|
-
if (typeof URLSearchParams !== 'undefined' &&
|
|
687
|
-
body instanceof URLSearchParams) {
|
|
719
|
+
if (typeof URLSearchParams !== 'undefined' && body instanceof URLSearchParams) {
|
|
688
720
|
const sp = new URLSearchParams(body);
|
|
689
721
|
sp.sort();
|
|
690
722
|
return `URLSearchParams:${sp.toString()}`;
|
|
@@ -1354,51 +1386,37 @@ function hasSlowConnection() {
|
|
|
1354
1386
|
return false;
|
|
1355
1387
|
}
|
|
1356
1388
|
|
|
1357
|
-
function persist(src, equal) {
|
|
1358
|
-
// linkedSignal allows us to access previous source value
|
|
1359
|
-
const persisted = linkedSignal({
|
|
1360
|
-
source: () => src(),
|
|
1361
|
-
computation: (next, prev) => {
|
|
1362
|
-
if (next === undefined && prev !== undefined)
|
|
1363
|
-
return prev.value;
|
|
1364
|
-
return next;
|
|
1365
|
-
},
|
|
1366
|
-
equal,
|
|
1367
|
-
});
|
|
1368
|
-
// if original value was WritableSignal then override linkedSignal methods to original...angular uses linkedSignal under the hood in ResourceImpl, this applies to that.
|
|
1369
|
-
if ('set' in src) {
|
|
1370
|
-
persisted.set = src.set;
|
|
1371
|
-
persisted.update = src.update;
|
|
1372
|
-
persisted.asReadonly = src.asReadonly;
|
|
1373
|
-
}
|
|
1374
|
-
return persisted;
|
|
1375
|
-
}
|
|
1376
1389
|
function persistResourceValues(resource, shouldPersist = false, equal) {
|
|
1377
1390
|
if (!shouldPersist)
|
|
1378
1391
|
return resource;
|
|
1379
1392
|
return {
|
|
1380
1393
|
...resource,
|
|
1381
|
-
statusCode:
|
|
1382
|
-
headers:
|
|
1383
|
-
value:
|
|
1394
|
+
statusCode: keepPrevious(resource.statusCode),
|
|
1395
|
+
headers: keepPrevious(resource.headers),
|
|
1396
|
+
value: keepPrevious(resource.value, { equal }),
|
|
1384
1397
|
};
|
|
1385
1398
|
}
|
|
1386
1399
|
|
|
1387
|
-
// refresh resource every n miliseconds or don't refresh if undefined provided. 0 also excluded, due to it not being a valid usecase
|
|
1388
|
-
function refresh(resource, destroyRef, refresh) {
|
|
1400
|
+
// refresh resource every n miliseconds or don't refresh if undefined provided. 0 also excluded, due to it not being a valid usecase.
|
|
1401
|
+
function refresh(resource, destroyRef, refresh, inactive) {
|
|
1389
1402
|
if (!refresh)
|
|
1390
1403
|
return resource; // no refresh requested
|
|
1404
|
+
const tick = () => {
|
|
1405
|
+
if (inactive?.())
|
|
1406
|
+
return; // disabled / paused → skip the poll
|
|
1407
|
+
resource.reload();
|
|
1408
|
+
};
|
|
1391
1409
|
// we can use RxJs here as reloading the resource will always be a side effect & as such does not impact the reactive graph in any way.
|
|
1392
1410
|
let sub = interval(refresh)
|
|
1393
1411
|
.pipe(takeUntilDestroyed(destroyRef))
|
|
1394
|
-
.subscribe(
|
|
1412
|
+
.subscribe(tick);
|
|
1395
1413
|
const reload = () => {
|
|
1396
1414
|
sub.unsubscribe(); // do not conflict with manual reload
|
|
1397
1415
|
const hasReloaded = resource.reload();
|
|
1398
1416
|
// resubscribe after manual reload
|
|
1399
1417
|
sub = interval(refresh)
|
|
1400
1418
|
.pipe(takeUntilDestroyed(destroyRef))
|
|
1401
|
-
.subscribe(
|
|
1419
|
+
.subscribe(tick);
|
|
1402
1420
|
return hasReloaded;
|
|
1403
1421
|
};
|
|
1404
1422
|
return {
|
|
@@ -1483,7 +1501,35 @@ function toResourceObject(res) {
|
|
|
1483
1501
|
};
|
|
1484
1502
|
}
|
|
1485
1503
|
|
|
1486
|
-
|
|
1504
|
+
const QUERY_RESOURCE_OPTIONS = new InjectionToken('@mmstack/resource:query-resource-options', { factory: () => ({}) });
|
|
1505
|
+
/**
|
|
1506
|
+
* Layer 2 (query): default options for every `queryResource`, inheriting + overriding the
|
|
1507
|
+
* common defaults from `provideResourceOptions`. Per-call options override these in turn.
|
|
1508
|
+
*/
|
|
1509
|
+
function provideQueryResourceOptions(valueOrFn) {
|
|
1510
|
+
return provideTypedResourceOptions(QUERY_RESOURCE_OPTIONS, valueOrFn);
|
|
1511
|
+
}
|
|
1512
|
+
function injectQueryResourceOptions(injector) {
|
|
1513
|
+
return injector
|
|
1514
|
+
? injector.get(QUERY_RESOURCE_OPTIONS)
|
|
1515
|
+
: inject(QUERY_RESOURCE_OPTIONS);
|
|
1516
|
+
}
|
|
1517
|
+
/**
|
|
1518
|
+
* Returned from a resource's request fn to PAUSE it: the resource holds its current value and last
|
|
1519
|
+
* request (so it does not refetch on resume), and stops background work (no polling, no refetch
|
|
1520
|
+
* while paused). Distinct from returning `undefined` (DISABLE), which drops the request — a
|
|
1521
|
+
* disabled resource may refetch when re-enabled, a paused one resumes exactly where it left off.
|
|
1522
|
+
*
|
|
1523
|
+
* The request fn receives a {@link RequestContext} and can just return `ctx.paused`.
|
|
1524
|
+
*/
|
|
1525
|
+
const PAUSED = Symbol('@mmstack/resource:paused');
|
|
1526
|
+
function queryResource(request, options0) {
|
|
1527
|
+
// Two-layer option injection: per-call > provideQueryResourceOptions > provideResourceOptions.
|
|
1528
|
+
const options = {
|
|
1529
|
+
...injectResourceOptions(options0?.injector),
|
|
1530
|
+
...injectQueryResourceOptions(options0?.injector),
|
|
1531
|
+
...options0,
|
|
1532
|
+
};
|
|
1487
1533
|
const cache = injectQueryCache(options?.injector);
|
|
1488
1534
|
const destroyRef = options?.injector
|
|
1489
1535
|
? options.injector.get(DestroyRef)
|
|
@@ -1494,28 +1540,50 @@ function queryResource(request, options) {
|
|
|
1494
1540
|
const networkAvailable = injectNetworkStatus();
|
|
1495
1541
|
const eq = options?.triggerOnSameRequest
|
|
1496
1542
|
? undefined
|
|
1497
|
-
:
|
|
1498
|
-
const
|
|
1543
|
+
: (options?.equalRequest ?? createEqualRequest());
|
|
1544
|
+
const requestCtx = { paused: PAUSED };
|
|
1545
|
+
const rawResult = computed(() => request(requestCtx));
|
|
1546
|
+
const paused = computed(() => rawResult() === PAUSED);
|
|
1547
|
+
const rawRequest = computed(() => {
|
|
1548
|
+
const r = rawResult();
|
|
1549
|
+
return r === PAUSED ? undefined : (r ?? undefined);
|
|
1550
|
+
});
|
|
1499
1551
|
const disabledReason = computed(() => {
|
|
1500
1552
|
if (!networkAvailable())
|
|
1501
1553
|
return 'offline';
|
|
1502
1554
|
if (cb.isOpen())
|
|
1503
1555
|
return 'circuit-open';
|
|
1556
|
+
// PAUSED makes rawRequest undefined, so it reports 'no-request' here (and skips polling),
|
|
1557
|
+
// while stableRequest below HOLDS the last request so the value is kept (no refetch on resume).
|
|
1504
1558
|
if (!rawRequest())
|
|
1505
1559
|
return 'no-request';
|
|
1506
1560
|
return null;
|
|
1507
1561
|
});
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1562
|
+
// While PAUSED, hold the previous request so httpResource sees no change — it keeps its value and
|
|
1563
|
+
// does NOT refetch. On resume the request is re-evaluated, so it refetches only if it changed.
|
|
1564
|
+
const heldRequest = linkedSignal({
|
|
1565
|
+
source: () => {
|
|
1566
|
+
if (paused())
|
|
1567
|
+
return { req: undefined, held: true };
|
|
1568
|
+
if (disabledReason() !== null)
|
|
1569
|
+
return { req: undefined, held: false };
|
|
1570
|
+
const req = rawRequest();
|
|
1571
|
+
if (!req)
|
|
1572
|
+
return { req: undefined, held: false };
|
|
1573
|
+
if (typeof req === 'string')
|
|
1574
|
+
return { req: { method: 'GET', url: req }, held: false };
|
|
1575
|
+
return { req, held: false };
|
|
1576
|
+
},
|
|
1577
|
+
computation: (curr, prev) => curr.held && prev !== undefined ? prev.value : curr.req,
|
|
1578
|
+
});
|
|
1579
|
+
// Dedup via the request-equality (the linkedSignal re-runs on every source tick; this computed
|
|
1580
|
+
// is what actually gates httpResource — so an equal/held request never triggers a refetch).
|
|
1581
|
+
const stableRequest = computed(() => heldRequest(), {
|
|
1518
1582
|
equal: (a, b) => {
|
|
1583
|
+
if (a === b)
|
|
1584
|
+
return true;
|
|
1585
|
+
if (a === undefined || b === undefined)
|
|
1586
|
+
return false;
|
|
1519
1587
|
if (eq)
|
|
1520
1588
|
return eq(a, b);
|
|
1521
1589
|
return a === b;
|
|
@@ -1583,7 +1651,8 @@ function queryResource(request, options) {
|
|
|
1583
1651
|
};
|
|
1584
1652
|
},
|
|
1585
1653
|
});
|
|
1586
|
-
|
|
1654
|
+
// A disabled (offline / circuit-open / no-request) or PAUSED resource must not poll.
|
|
1655
|
+
resource = refresh(resource, destroyRef, options?.refresh, () => disabledReason() !== null);
|
|
1587
1656
|
resource = retryOnError(resource, options?.retry, options?.onError);
|
|
1588
1657
|
resource = persistResourceValues(resource, options?.keepPrevious, options?.equal);
|
|
1589
1658
|
const set = (value) => {
|
|
@@ -1613,7 +1682,7 @@ function queryResource(request, options) {
|
|
|
1613
1682
|
const client = options?.injector
|
|
1614
1683
|
? options.injector.get(HttpClient)
|
|
1615
1684
|
: inject(HttpClient);
|
|
1616
|
-
|
|
1685
|
+
const ref = {
|
|
1617
1686
|
...resource,
|
|
1618
1687
|
value,
|
|
1619
1688
|
set,
|
|
@@ -1675,6 +1744,9 @@ function queryResource(request, options) {
|
|
|
1675
1744
|
}
|
|
1676
1745
|
},
|
|
1677
1746
|
};
|
|
1747
|
+
// Auto-register into the nearest transition scope if the (merged) options ask for it.
|
|
1748
|
+
applyResourceRegistration(ref, options.register, options?.injector);
|
|
1749
|
+
return ref;
|
|
1678
1750
|
}
|
|
1679
1751
|
|
|
1680
1752
|
function manualQueryResource(request, options) {
|
|
@@ -1718,6 +1790,19 @@ function manualQueryResource(request, options) {
|
|
|
1718
1790
|
}
|
|
1719
1791
|
|
|
1720
1792
|
const NULL_VALUE = Symbol('@mmstack/resource:null');
|
|
1793
|
+
const MUTATION_RESOURCE_OPTIONS = new InjectionToken('@mmstack/resource:mutation-resource-options', { factory: () => ({}) });
|
|
1794
|
+
/**
|
|
1795
|
+
* Layer 2 (mutation): default options for every `mutationResource`, inheriting + overriding the
|
|
1796
|
+
* common defaults from `provideResourceOptions`. Per-call options override these in turn.
|
|
1797
|
+
*/
|
|
1798
|
+
function provideMutationResourceOptions(valueOrFn) {
|
|
1799
|
+
return provideTypedResourceOptions(MUTATION_RESOURCE_OPTIONS, valueOrFn);
|
|
1800
|
+
}
|
|
1801
|
+
function injectMutationResourceOptions(injector) {
|
|
1802
|
+
return injector
|
|
1803
|
+
? injector.get(MUTATION_RESOURCE_OPTIONS)
|
|
1804
|
+
: inject(MUTATION_RESOURCE_OPTIONS);
|
|
1805
|
+
}
|
|
1721
1806
|
/**
|
|
1722
1807
|
* Creates a resource for performing mutations (e.g., POST, PUT, PATCH, DELETE requests).
|
|
1723
1808
|
* Unlike `queryResource`, `mutationResource` is designed for one-off operations that change data.
|
|
@@ -1736,10 +1821,54 @@ const NULL_VALUE = Symbol('@mmstack/resource:null');
|
|
|
1736
1821
|
* @typeParam TMethod - The HTTP method to be used for the mutation (defaults to `HttpResourceRequest['method']`).
|
|
1737
1822
|
* @returns A `MutationResourceRef` instance, which provides methods for triggering the mutation
|
|
1738
1823
|
* and observing its status.
|
|
1824
|
+
*
|
|
1825
|
+
* @example
|
|
1826
|
+
* ```ts
|
|
1827
|
+
* // Basic PATCH mutation
|
|
1828
|
+
* const updateUser = mutationResource<User, User, Partial<User>>(
|
|
1829
|
+
* (body) => ({ url: `/api/users/${userId()}`, method: 'PATCH', body }),
|
|
1830
|
+
* {
|
|
1831
|
+
* onSuccess: (saved) => toast.success(`Updated ${saved.name}`),
|
|
1832
|
+
* onError: (err) => toast.error(err),
|
|
1833
|
+
* },
|
|
1834
|
+
* );
|
|
1835
|
+
*
|
|
1836
|
+
* updateUser.mutate({ name: 'Alice' });
|
|
1837
|
+
* ```
|
|
1838
|
+
*
|
|
1839
|
+
* @example
|
|
1840
|
+
* ```ts
|
|
1841
|
+
* // Optimistic update with rollback via the `ctx` returned from `onMutate`
|
|
1842
|
+
* const updateUser = mutationResource<User, User, Partial<User>, { prev: User | null }>(
|
|
1843
|
+
* (body) => ({ url: `/api/users/${userId()}`, method: 'PATCH', body }),
|
|
1844
|
+
* {
|
|
1845
|
+
* onMutate: (patch) => {
|
|
1846
|
+
* const prev = current();
|
|
1847
|
+
* current.update((u) => (u ? { ...u, ...patch } : u));
|
|
1848
|
+
* return { prev };
|
|
1849
|
+
* },
|
|
1850
|
+
* onError: (_err, { prev }) => current.set(prev),
|
|
1851
|
+
* },
|
|
1852
|
+
* );
|
|
1853
|
+
* ```
|
|
1739
1854
|
*/
|
|
1740
|
-
function mutationResource(request,
|
|
1741
|
-
|
|
1742
|
-
const
|
|
1855
|
+
function mutationResource(request, options0 = {}) {
|
|
1856
|
+
// Two-layer option injection: per-call > provideMutationResourceOptions > provideResourceOptions.
|
|
1857
|
+
const options = {
|
|
1858
|
+
...injectResourceOptions(options0.injector),
|
|
1859
|
+
...injectMutationResourceOptions(options0.injector),
|
|
1860
|
+
...options0,
|
|
1861
|
+
};
|
|
1862
|
+
// `register` is pulled out (and forced off on the inner query below) so the mutation ref is
|
|
1863
|
+
// the only thing registered into the transition scope, not its internal query resource.
|
|
1864
|
+
const { onMutate, onError, onSuccess, onSettled, equal, register, equalRequest, ...rest } = options;
|
|
1865
|
+
const requestEqual = equalRequest ?? createEqualRequest(equal);
|
|
1866
|
+
// A mutation is an imperative command, so `triggerOnSameRequest` means "fire on EVERY mutate(),
|
|
1867
|
+
// even with an identical body". By default we dedup an identical value/request while one is in
|
|
1868
|
+
// flight (double-click protection); when this is set, both the `next` and `req` dedup are bypassed
|
|
1869
|
+
// so a repeat click isn't silently swallowed mid-flight. (Otherwise it'd be dropped until `next`
|
|
1870
|
+
// resets to NULL on settle — the "every other click" symptom.)
|
|
1871
|
+
const triggerOnSame = options.triggerOnSameRequest ?? false;
|
|
1743
1872
|
const eq = equal ?? Object.is;
|
|
1744
1873
|
const next = signal(NULL_VALUE, {
|
|
1745
1874
|
equal: (a, b) => {
|
|
@@ -1747,6 +1876,8 @@ function mutationResource(request, options = {}) {
|
|
|
1747
1876
|
return true;
|
|
1748
1877
|
if (a === NULL_VALUE || b === NULL_VALUE)
|
|
1749
1878
|
return false;
|
|
1879
|
+
if (triggerOnSame)
|
|
1880
|
+
return false;
|
|
1750
1881
|
return eq(a, b);
|
|
1751
1882
|
},
|
|
1752
1883
|
});
|
|
@@ -1775,7 +1906,15 @@ function mutationResource(request, options = {}) {
|
|
|
1775
1906
|
return;
|
|
1776
1907
|
return request(nr) ?? undefined;
|
|
1777
1908
|
}, {
|
|
1778
|
-
equal:
|
|
1909
|
+
equal: (a, b) => {
|
|
1910
|
+
if (a === undefined && b === undefined)
|
|
1911
|
+
return true;
|
|
1912
|
+
if (a === undefined || b === undefined)
|
|
1913
|
+
return false;
|
|
1914
|
+
if (triggerOnSame)
|
|
1915
|
+
return false;
|
|
1916
|
+
return requestEqual(a, b);
|
|
1917
|
+
},
|
|
1779
1918
|
});
|
|
1780
1919
|
const lastValue = linkedSignal({
|
|
1781
1920
|
source: next,
|
|
@@ -1791,14 +1930,22 @@ function mutationResource(request, options = {}) {
|
|
|
1791
1930
|
return;
|
|
1792
1931
|
return request(nr) ?? undefined;
|
|
1793
1932
|
}, {
|
|
1794
|
-
equal:
|
|
1933
|
+
equal: (a, b) => {
|
|
1934
|
+
if (a === b)
|
|
1935
|
+
return true;
|
|
1936
|
+
if (a === undefined || b === undefined)
|
|
1937
|
+
return false;
|
|
1938
|
+
return requestEqual(a, b);
|
|
1939
|
+
},
|
|
1795
1940
|
});
|
|
1796
1941
|
const cb = createCircuitBreaker(options?.circuitBreaker === true
|
|
1797
1942
|
? undefined
|
|
1798
1943
|
: (options?.circuitBreaker ?? false), options?.injector);
|
|
1799
1944
|
const resource = queryResource(req, {
|
|
1800
1945
|
...rest,
|
|
1946
|
+
register: false, // the mutation ref handles registration; never register the inner query
|
|
1801
1947
|
circuitBreaker: cb,
|
|
1948
|
+
equalRequest: requestEqual,
|
|
1802
1949
|
defaultValue: NULL_VALUE, // doesnt matter since .value is not accessible
|
|
1803
1950
|
});
|
|
1804
1951
|
const destroyRef = options.injector
|
|
@@ -1832,7 +1979,7 @@ function mutationResource(request, options = {}) {
|
|
|
1832
1979
|
next.set(NULL_VALUE);
|
|
1833
1980
|
});
|
|
1834
1981
|
const shouldQueue = options.queue ?? false;
|
|
1835
|
-
|
|
1982
|
+
const ref = {
|
|
1836
1983
|
...resource,
|
|
1837
1984
|
destroy: () => {
|
|
1838
1985
|
statusSub.unsubscribe();
|
|
@@ -1863,11 +2010,13 @@ function mutationResource(request, options = {}) {
|
|
|
1863
2010
|
// redeclare disabled with last value so that it is not affected by the resource's internal disablement logic
|
|
1864
2011
|
disabled: computed(() => cb.isOpen() || lastValueRequest() === undefined),
|
|
1865
2012
|
};
|
|
2013
|
+
applyResourceRegistration(ref, register, options0.injector);
|
|
2014
|
+
return ref;
|
|
1866
2015
|
}
|
|
1867
2016
|
|
|
1868
2017
|
/**
|
|
1869
2018
|
* Generated bundle index. Do not edit.
|
|
1870
2019
|
*/
|
|
1871
2020
|
|
|
1872
|
-
export { Cache, createCacheInterceptor, createCircuitBreaker, createDedupeRequestsInterceptor, injectQueryCache, manualQueryResource, mutationResource, noDedupe, provideCircuitBreakerDefaultOptions, provideQueryCache, queryResource };
|
|
2021
|
+
export { Cache, PAUSED, applyResourceRegistration, createCacheInterceptor, createCircuitBreaker, createDedupeRequestsInterceptor, injectQueryCache, injectResourceOptions, manualQueryResource, mutationResource, noDedupe, provideCircuitBreakerDefaultOptions, provideMutationResourceOptions, provideQueryCache, provideQueryResourceOptions, provideResourceOptions, provideTypedResourceOptions, queryResource };
|
|
1873
2022
|
//# sourceMappingURL=mmstack-resource.mjs.map
|