@mmstack/resource 20.7.0 → 20.8.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 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 { isDevMode, untracked, computed, InjectionToken, inject, signal, effect, Injector, linkedSignal, Injectable, DestroyRef } from '@angular/core';
3
- import { mutable, toWritable, sensor, nestedEffect } from '@mmstack/primitives';
2
+ import { InjectionToken, inject, runInInjectionContext, DestroyRef, isDevMode, untracked, computed, signal, effect, Injector, 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,57 +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(...(ngDevMode ? [{ debugName: "persisted", source: () => src(),
1360
- computation: (next, prev) => {
1361
- if (next === undefined && prev !== undefined)
1362
- return prev.value;
1363
- return next;
1364
- },
1365
- equal }] : [{
1366
- source: () => src(),
1367
- computation: (next, prev) => {
1368
- if (next === undefined && prev !== undefined)
1369
- return prev.value;
1370
- return next;
1371
- },
1372
- equal,
1373
- }]));
1374
- // if original value was WritableSignal then override linkedSignal methods to original...angular uses linkedSignal under the hood in ResourceImpl, this applies to that.
1375
- if ('set' in src) {
1376
- persisted.set = src.set;
1377
- persisted.update = src.update;
1378
- persisted.asReadonly = src.asReadonly;
1379
- }
1380
- return persisted;
1381
- }
1382
1389
  function persistResourceValues(resource, shouldPersist = false, equal) {
1383
1390
  if (!shouldPersist)
1384
1391
  return resource;
1385
1392
  return {
1386
1393
  ...resource,
1387
- statusCode: persist(resource.statusCode),
1388
- headers: persist(resource.headers),
1389
- value: persist(resource.value, equal),
1394
+ statusCode: keepPrevious(resource.statusCode),
1395
+ headers: keepPrevious(resource.headers),
1396
+ value: keepPrevious(resource.value, { equal }),
1390
1397
  };
1391
1398
  }
1392
1399
 
1393
- // refresh resource every n miliseconds or don't refresh if undefined provided. 0 also excluded, due to it not being a valid usecase
1394
- 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) {
1395
1402
  if (!refresh)
1396
1403
  return resource; // no refresh requested
1404
+ const tick = () => {
1405
+ if (inactive?.())
1406
+ return; // disabled / paused → skip the poll
1407
+ resource.reload();
1408
+ };
1397
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.
1398
1410
  let sub = interval(refresh)
1399
1411
  .pipe(takeUntilDestroyed(destroyRef))
1400
- .subscribe(() => resource.reload());
1412
+ .subscribe(tick);
1401
1413
  const reload = () => {
1402
1414
  sub.unsubscribe(); // do not conflict with manual reload
1403
1415
  const hasReloaded = resource.reload();
1404
1416
  // resubscribe after manual reload
1405
1417
  sub = interval(refresh)
1406
1418
  .pipe(takeUntilDestroyed(destroyRef))
1407
- .subscribe(() => resource.reload());
1419
+ .subscribe(tick);
1408
1420
  return hasReloaded;
1409
1421
  };
1410
1422
  return {
@@ -1489,7 +1501,35 @@ function toResourceObject(res) {
1489
1501
  };
1490
1502
  }
1491
1503
 
1492
- function queryResource(request, options) {
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
+ };
1493
1533
  const cache = injectQueryCache(options?.injector);
1494
1534
  const destroyRef = options?.injector
1495
1535
  ? options.injector.get(DestroyRef)
@@ -1500,32 +1540,70 @@ function queryResource(request, options) {
1500
1540
  const networkAvailable = injectNetworkStatus();
1501
1541
  const eq = options?.triggerOnSameRequest
1502
1542
  ? undefined
1503
- : createEqualRequest(options?.equal);
1504
- const rawRequest = computed(() => request() ?? undefined, ...(ngDevMode ? [{ debugName: "rawRequest" }] : []));
1543
+ : (options?.equalRequest ?? createEqualRequest());
1544
+ const requestCtx = { paused: PAUSED };
1545
+ const rawResult = computed(() => request(requestCtx), ...(ngDevMode ? [{ debugName: "rawResult" }] : []));
1546
+ const paused = computed(() => rawResult() === PAUSED, ...(ngDevMode ? [{ debugName: "paused" }] : []));
1547
+ const rawRequest = computed(() => {
1548
+ const r = rawResult();
1549
+ return r === PAUSED ? undefined : (r ?? undefined);
1550
+ }, ...(ngDevMode ? [{ debugName: "rawRequest" }] : []));
1505
1551
  const disabledReason = computed(() => {
1506
1552
  if (!networkAvailable())
1507
1553
  return 'offline';
1508
1554
  if (cb.isOpen())
1509
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).
1510
1558
  if (!rawRequest())
1511
1559
  return 'no-request';
1512
1560
  return null;
1513
1561
  }, ...(ngDevMode ? [{ debugName: "disabledReason" }] : []));
1514
- const stableRequest = computed(() => {
1515
- if (disabledReason() !== null)
1516
- return undefined;
1517
- const req = rawRequest();
1518
- if (!req)
1519
- return undefined;
1520
- if (typeof req === 'string')
1521
- return { method: 'GET', url: req };
1522
- return req;
1523
- }, ...(ngDevMode ? [{ debugName: "stableRequest", equal: (a, b) => {
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(...(ngDevMode ? [{ debugName: "heldRequest", source: () => {
1565
+ if (paused())
1566
+ return { req: undefined, held: true };
1567
+ if (disabledReason() !== null)
1568
+ return { req: undefined, held: false };
1569
+ const req = rawRequest();
1570
+ if (!req)
1571
+ return { req: undefined, held: false };
1572
+ if (typeof req === 'string')
1573
+ return { req: { method: 'GET', url: req }, held: false };
1574
+ return { req, held: false };
1575
+ },
1576
+ computation: (curr, prev) => curr.held && prev !== undefined ? prev.value : curr.req }] : [{
1577
+ source: () => {
1578
+ if (paused())
1579
+ return { req: undefined, held: true };
1580
+ if (disabledReason() !== null)
1581
+ return { req: undefined, held: false };
1582
+ const req = rawRequest();
1583
+ if (!req)
1584
+ return { req: undefined, held: false };
1585
+ if (typeof req === 'string')
1586
+ return { req: { method: 'GET', url: req }, held: false };
1587
+ return { req, held: false };
1588
+ },
1589
+ computation: (curr, prev) => curr.held && prev !== undefined ? prev.value : curr.req,
1590
+ }]));
1591
+ // Dedup via the request-equality (the linkedSignal re-runs on every source tick; this computed
1592
+ // is what actually gates httpResource — so an equal/held request never triggers a refetch).
1593
+ const stableRequest = computed(() => heldRequest(), ...(ngDevMode ? [{ debugName: "stableRequest", equal: (a, b) => {
1594
+ if (a === b)
1595
+ return true;
1596
+ if (a === undefined || b === undefined)
1597
+ return false;
1524
1598
  if (eq)
1525
1599
  return eq(a, b);
1526
1600
  return a === b;
1527
1601
  } }] : [{
1528
1602
  equal: (a, b) => {
1603
+ if (a === b)
1604
+ return true;
1605
+ if (a === undefined || b === undefined)
1606
+ return false;
1529
1607
  if (eq)
1530
1608
  return eq(a, b);
1531
1609
  return a === b;
@@ -1611,7 +1689,8 @@ function queryResource(request, options) {
1611
1689
  };
1612
1690
  },
1613
1691
  }]));
1614
- resource = refresh(resource, destroyRef, options?.refresh);
1692
+ // A disabled (offline / circuit-open / no-request) or PAUSED resource must not poll.
1693
+ resource = refresh(resource, destroyRef, options?.refresh, () => disabledReason() !== null);
1615
1694
  resource = retryOnError(resource, options?.retry, options?.onError);
1616
1695
  resource = persistResourceValues(resource, options?.keepPrevious, options?.equal);
1617
1696
  const set = (value) => {
@@ -1641,7 +1720,7 @@ function queryResource(request, options) {
1641
1720
  const client = options?.injector
1642
1721
  ? options.injector.get(HttpClient)
1643
1722
  : inject(HttpClient);
1644
- return {
1723
+ const ref = {
1645
1724
  ...resource,
1646
1725
  value,
1647
1726
  set,
@@ -1708,6 +1787,9 @@ function queryResource(request, options) {
1708
1787
  }
1709
1788
  },
1710
1789
  };
1790
+ // Auto-register into the nearest transition scope if the (merged) options ask for it.
1791
+ applyResourceRegistration(ref, options.register, options?.injector);
1792
+ return ref;
1711
1793
  }
1712
1794
 
1713
1795
  function manualQueryResource(request, options) {
@@ -1751,6 +1833,19 @@ function manualQueryResource(request, options) {
1751
1833
  }
1752
1834
 
1753
1835
  const NULL_VALUE = Symbol('@mmstack/resource:null');
1836
+ const MUTATION_RESOURCE_OPTIONS = new InjectionToken('@mmstack/resource:mutation-resource-options', { factory: () => ({}) });
1837
+ /**
1838
+ * Layer 2 (mutation): default options for every `mutationResource`, inheriting + overriding the
1839
+ * common defaults from `provideResourceOptions`. Per-call options override these in turn.
1840
+ */
1841
+ function provideMutationResourceOptions(valueOrFn) {
1842
+ return provideTypedResourceOptions(MUTATION_RESOURCE_OPTIONS, valueOrFn);
1843
+ }
1844
+ function injectMutationResourceOptions(injector) {
1845
+ return injector
1846
+ ? injector.get(MUTATION_RESOURCE_OPTIONS)
1847
+ : inject(MUTATION_RESOURCE_OPTIONS);
1848
+ }
1754
1849
  /**
1755
1850
  * Creates a resource for performing mutations (e.g., POST, PUT, PATCH, DELETE requests).
1756
1851
  * Unlike `queryResource`, `mutationResource` is designed for one-off operations that change data.
@@ -1769,16 +1864,62 @@ const NULL_VALUE = Symbol('@mmstack/resource:null');
1769
1864
  * @typeParam TMethod - The HTTP method to be used for the mutation (defaults to `HttpResourceRequest['method']`).
1770
1865
  * @returns A `MutationResourceRef` instance, which provides methods for triggering the mutation
1771
1866
  * and observing its status.
1867
+ *
1868
+ * @example
1869
+ * ```ts
1870
+ * // Basic PATCH mutation
1871
+ * const updateUser = mutationResource<User, User, Partial<User>>(
1872
+ * (body) => ({ url: `/api/users/${userId()}`, method: 'PATCH', body }),
1873
+ * {
1874
+ * onSuccess: (saved) => toast.success(`Updated ${saved.name}`),
1875
+ * onError: (err) => toast.error(err),
1876
+ * },
1877
+ * );
1878
+ *
1879
+ * updateUser.mutate({ name: 'Alice' });
1880
+ * ```
1881
+ *
1882
+ * @example
1883
+ * ```ts
1884
+ * // Optimistic update with rollback via the `ctx` returned from `onMutate`
1885
+ * const updateUser = mutationResource<User, User, Partial<User>, { prev: User | null }>(
1886
+ * (body) => ({ url: `/api/users/${userId()}`, method: 'PATCH', body }),
1887
+ * {
1888
+ * onMutate: (patch) => {
1889
+ * const prev = current();
1890
+ * current.update((u) => (u ? { ...u, ...patch } : u));
1891
+ * return { prev };
1892
+ * },
1893
+ * onError: (_err, { prev }) => current.set(prev),
1894
+ * },
1895
+ * );
1896
+ * ```
1772
1897
  */
1773
- function mutationResource(request, options = {}) {
1774
- const { onMutate, onError, onSuccess, onSettled, equal, ...rest } = options;
1775
- const requestEqual = createEqualRequest(equal);
1898
+ function mutationResource(request, options0 = {}) {
1899
+ // Two-layer option injection: per-call > provideMutationResourceOptions > provideResourceOptions.
1900
+ const options = {
1901
+ ...injectResourceOptions(options0.injector),
1902
+ ...injectMutationResourceOptions(options0.injector),
1903
+ ...options0,
1904
+ };
1905
+ // `register` is pulled out (and forced off on the inner query below) so the mutation ref is
1906
+ // the only thing registered into the transition scope, not its internal query resource.
1907
+ const { onMutate, onError, onSuccess, onSettled, equal, register, equalRequest, ...rest } = options;
1908
+ const requestEqual = equalRequest ?? createEqualRequest(equal);
1909
+ // A mutation is an imperative command, so `triggerOnSameRequest` means "fire on EVERY mutate(),
1910
+ // even with an identical body". By default we dedup an identical value/request while one is in
1911
+ // flight (double-click protection); when this is set, both the `next` and `req` dedup are bypassed
1912
+ // so a repeat click isn't silently swallowed mid-flight. (Otherwise it'd be dropped until `next`
1913
+ // resets to NULL on settle — the "every other click" symptom.)
1914
+ const triggerOnSame = options.triggerOnSameRequest ?? false;
1776
1915
  const eq = equal ?? Object.is;
1777
1916
  const next = signal(NULL_VALUE, ...(ngDevMode ? [{ debugName: "next", equal: (a, b) => {
1778
1917
  if (a === NULL_VALUE && b === NULL_VALUE)
1779
1918
  return true;
1780
1919
  if (a === NULL_VALUE || b === NULL_VALUE)
1781
1920
  return false;
1921
+ if (triggerOnSame)
1922
+ return false;
1782
1923
  return eq(a, b);
1783
1924
  } }] : [{
1784
1925
  equal: (a, b) => {
@@ -1786,6 +1927,8 @@ function mutationResource(request, options = {}) {
1786
1927
  return true;
1787
1928
  if (a === NULL_VALUE || b === NULL_VALUE)
1788
1929
  return false;
1930
+ if (triggerOnSame)
1931
+ return false;
1789
1932
  return eq(a, b);
1790
1933
  },
1791
1934
  }]));
@@ -1813,8 +1956,24 @@ function mutationResource(request, options = {}) {
1813
1956
  if (nr === NULL_VALUE)
1814
1957
  return;
1815
1958
  return request(nr) ?? undefined;
1816
- }, ...(ngDevMode ? [{ debugName: "req", equal: requestEqual }] : [{
1817
- equal: requestEqual,
1959
+ }, ...(ngDevMode ? [{ debugName: "req", equal: (a, b) => {
1960
+ if (a === undefined && b === undefined)
1961
+ return true;
1962
+ if (a === undefined || b === undefined)
1963
+ return false;
1964
+ if (triggerOnSame)
1965
+ return false;
1966
+ return requestEqual(a, b);
1967
+ } }] : [{
1968
+ equal: (a, b) => {
1969
+ if (a === undefined && b === undefined)
1970
+ return true;
1971
+ if (a === undefined || b === undefined)
1972
+ return false;
1973
+ if (triggerOnSame)
1974
+ return false;
1975
+ return requestEqual(a, b);
1976
+ },
1818
1977
  }]));
1819
1978
  const lastValue = linkedSignal(...(ngDevMode ? [{ debugName: "lastValue", source: next,
1820
1979
  computation: (next, prev) => {
@@ -1834,15 +1993,29 @@ function mutationResource(request, options = {}) {
1834
1993
  if (nr === NULL_VALUE)
1835
1994
  return;
1836
1995
  return request(nr) ?? undefined;
1837
- }, ...(ngDevMode ? [{ debugName: "lastValueRequest", equal: requestEqual }] : [{
1838
- equal: requestEqual,
1996
+ }, ...(ngDevMode ? [{ debugName: "lastValueRequest", equal: (a, b) => {
1997
+ if (a === b)
1998
+ return true;
1999
+ if (a === undefined || b === undefined)
2000
+ return false;
2001
+ return requestEqual(a, b);
2002
+ } }] : [{
2003
+ equal: (a, b) => {
2004
+ if (a === b)
2005
+ return true;
2006
+ if (a === undefined || b === undefined)
2007
+ return false;
2008
+ return requestEqual(a, b);
2009
+ },
1839
2010
  }]));
1840
2011
  const cb = createCircuitBreaker(options?.circuitBreaker === true
1841
2012
  ? undefined
1842
2013
  : (options?.circuitBreaker ?? false), options?.injector);
1843
2014
  const resource = queryResource(req, {
1844
2015
  ...rest,
2016
+ register: false, // the mutation ref handles registration; never register the inner query
1845
2017
  circuitBreaker: cb,
2018
+ equalRequest: requestEqual,
1846
2019
  defaultValue: NULL_VALUE, // doesnt matter since .value is not accessible
1847
2020
  });
1848
2021
  const destroyRef = options.injector
@@ -1876,7 +2049,7 @@ function mutationResource(request, options = {}) {
1876
2049
  next.set(NULL_VALUE);
1877
2050
  });
1878
2051
  const shouldQueue = options.queue ?? false;
1879
- return {
2052
+ const ref = {
1880
2053
  ...resource,
1881
2054
  destroy: () => {
1882
2055
  statusSub.unsubscribe();
@@ -1907,11 +2080,13 @@ function mutationResource(request, options = {}) {
1907
2080
  // redeclare disabled with last value so that it is not affected by the resource's internal disablement logic
1908
2081
  disabled: computed(() => cb.isOpen() || lastValueRequest() === undefined),
1909
2082
  };
2083
+ applyResourceRegistration(ref, register, options0.injector);
2084
+ return ref;
1910
2085
  }
1911
2086
 
1912
2087
  /**
1913
2088
  * Generated bundle index. Do not edit.
1914
2089
  */
1915
2090
 
1916
- export { Cache, createCacheInterceptor, createCircuitBreaker, createDedupeRequestsInterceptor, injectQueryCache, manualQueryResource, mutationResource, noDedupe, provideCircuitBreakerDefaultOptions, provideQueryCache, queryResource };
2091
+ export { Cache, PAUSED, applyResourceRegistration, createCacheInterceptor, createCircuitBreaker, createDedupeRequestsInterceptor, injectQueryCache, injectResourceOptions, manualQueryResource, mutationResource, noDedupe, provideCircuitBreakerDefaultOptions, provideMutationResourceOptions, provideQueryCache, provideQueryResourceOptions, provideResourceOptions, provideTypedResourceOptions, queryResource };
1917
2092
  //# sourceMappingURL=mmstack-resource.mjs.map