@mmstack/resource 22.0.0 → 22.1.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,7 +1,7 @@
1
1
  import { HttpHeaders, HttpResponse, HttpParams, HttpContextToken, HttpContext, httpResource, HttpClient } from '@angular/common/http';
2
2
  import * as i0 from '@angular/core';
3
- import { isDevMode, untracked, computed, InjectionToken, inject, signal, effect, Injector, linkedSignal, Injectable, DestroyRef } from '@angular/core';
4
- import { mutable, toWritable, sensor, nestedEffect } from '@mmstack/primitives';
3
+ import { isDevMode, untracked, computed, InjectionToken, inject, signal, effect, Injector, Injectable, runInInjectionContext, DestroyRef, linkedSignal } from '@angular/core';
4
+ import { mutable, toWritable, keepPrevious, sensor, injectTransitionScope, nestedEffect } from '@mmstack/primitives';
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
 
@@ -655,17 +655,13 @@ function hash(...args) {
655
655
  }
656
656
 
657
657
  function normalizeParams(params) {
658
- const p = params instanceof HttpParams
659
- ? params
660
- : new HttpParams({ fromObject: params });
658
+ const p = params instanceof HttpParams ? params : new HttpParams({ fromObject: params });
661
659
  return p
662
660
  .keys()
663
661
  .toSorted()
664
662
  .map((key) => {
665
663
  const encodedKey = encodeURIComponent(key);
666
- return (p.getAll(key) ?? [])
667
- .map((v) => `${encodedKey}=${encodeURIComponent(v)}`)
668
- .join('&');
664
+ return (p.getAll(key) ?? []).map((v) => `${encodedKey}=${encodeURIComponent(v)}`).join('&');
669
665
  })
670
666
  .join('&');
671
667
  }
@@ -685,8 +681,7 @@ function hashBody(body) {
685
681
  entries.sort(([ak, av], [bk, bv]) => ak.localeCompare(bk) || av.localeCompare(bv));
686
682
  return `FormData:${entries.map(([k, v]) => `${k}=${v}`).join('&')}`;
687
683
  }
688
- if (typeof URLSearchParams !== 'undefined' &&
689
- body instanceof URLSearchParams) {
684
+ if (typeof URLSearchParams !== 'undefined' && body instanceof URLSearchParams) {
690
685
  const sp = new URLSearchParams(body);
691
686
  sp.sort();
692
687
  return `URLSearchParams:${sp.toString()}`;
@@ -1363,49 +1358,37 @@ function hasSlowConnection() {
1363
1358
  return false;
1364
1359
  }
1365
1360
 
1366
- function persist(src, equal) {
1367
- // linkedSignal allows us to access previous source value
1368
- const persisted = linkedSignal({ ...(ngDevMode ? { debugName: "persisted" } : /* istanbul ignore next */ {}), source: () => src(),
1369
- computation: (next, prev) => {
1370
- if (next === undefined && prev !== undefined)
1371
- return prev.value;
1372
- return next;
1373
- },
1374
- equal });
1375
- // if original value was WritableSignal then override linkedSignal methods to original...angular uses linkedSignal under the hood in ResourceImpl, this applies to that.
1376
- if ('set' in src) {
1377
- persisted.set = src.set;
1378
- persisted.update = src.update;
1379
- persisted.asReadonly = src.asReadonly;
1380
- }
1381
- return persisted;
1382
- }
1383
1361
  function persistResourceValues(resource, shouldPersist = false, equal) {
1384
1362
  if (!shouldPersist)
1385
1363
  return resource;
1386
1364
  return {
1387
1365
  ...resource,
1388
- statusCode: persist(resource.statusCode),
1389
- headers: persist(resource.headers),
1390
- value: persist(resource.value, equal),
1366
+ statusCode: keepPrevious(resource.statusCode),
1367
+ headers: keepPrevious(resource.headers),
1368
+ value: keepPrevious(resource.value, { equal }),
1391
1369
  };
1392
1370
  }
1393
1371
 
1394
- // refresh resource every n miliseconds or don't refresh if undefined provided. 0 also excluded, due to it not being a valid usecase
1395
- function refresh(resource, destroyRef, refresh) {
1372
+ // refresh resource every n miliseconds or don't refresh if undefined provided. 0 also excluded, due to it not being a valid usecase.
1373
+ function refresh(resource, destroyRef, refresh, inactive) {
1396
1374
  if (!refresh)
1397
1375
  return resource; // no refresh requested
1376
+ const tick = () => {
1377
+ if (inactive?.())
1378
+ return; // disabled / paused → skip the poll
1379
+ resource.reload();
1380
+ };
1398
1381
  // 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.
1399
1382
  let sub = interval(refresh)
1400
1383
  .pipe(takeUntilDestroyed(destroyRef))
1401
- .subscribe(() => resource.reload());
1384
+ .subscribe(tick);
1402
1385
  const reload = () => {
1403
1386
  sub.unsubscribe(); // do not conflict with manual reload
1404
1387
  const hasReloaded = resource.reload();
1405
1388
  // resubscribe after manual reload
1406
1389
  sub = interval(refresh)
1407
1390
  .pipe(takeUntilDestroyed(destroyRef))
1408
- .subscribe(() => resource.reload());
1391
+ .subscribe(tick);
1409
1392
  return hasReloaded;
1410
1393
  };
1411
1394
  return {
@@ -1492,7 +1475,72 @@ function toResourceObject(res) {
1492
1475
  };
1493
1476
  }
1494
1477
 
1495
- function queryResource(request, options) {
1478
+ const RESOURCE_OPTIONS = new InjectionToken('@mmstack/resource:resource-options', { factory: () => ({}) });
1479
+ function asProvider(token, valueOrFn) {
1480
+ return typeof valueOrFn === 'function'
1481
+ ? { provide: token, useFactory: valueOrFn }
1482
+ : { provide: token, useValue: valueOrFn };
1483
+ }
1484
+ /** Layer 1: defaults that apply to ALL resource kinds. Type-specific providers inherit + override these. */
1485
+ function provideResourceOptions(valueOrFn) {
1486
+ return asProvider(RESOURCE_OPTIONS, valueOrFn);
1487
+ }
1488
+ function injectResourceOptions(injector) {
1489
+ return injector ? injector.get(RESOURCE_OPTIONS) : inject(RESOURCE_OPTIONS);
1490
+ }
1491
+ /** Shared helper for the type-specific providers (query/mutation), so precedence is identical. */
1492
+ function provideTypedResourceOptions(token, valueOrFn) {
1493
+ return asProvider(token, valueOrFn);
1494
+ }
1495
+ /**
1496
+ * Applies a resolved `register` option to a freshly-created resource — adds it to the nearest
1497
+ * transition scope and removes it on destroy. Runs in the resource's injection context (or the
1498
+ * provided `injector`), since registration needs `TRANSITION_SCOPE` + `DestroyRef`.
1499
+ */
1500
+ function applyResourceRegistration(ref, register, injector) {
1501
+ if (!register)
1502
+ return;
1503
+ const opt = register === true ? { suspends: false } : register;
1504
+ const run = injector
1505
+ ? (fn) => runInInjectionContext(injector, fn)
1506
+ : (fn) => fn();
1507
+ run(() => {
1508
+ const scope = injectTransitionScope();
1509
+ const destroyRef = inject(DestroyRef);
1510
+ scope.add(ref, opt);
1511
+ destroyRef.onDestroy(() => scope.remove(ref));
1512
+ });
1513
+ }
1514
+
1515
+ const QUERY_RESOURCE_OPTIONS = new InjectionToken('@mmstack/resource:query-resource-options', { factory: () => ({}) });
1516
+ /**
1517
+ * Layer 2 (query): default options for every `queryResource`, inheriting + overriding the
1518
+ * common defaults from `provideResourceOptions`. Per-call options override these in turn.
1519
+ */
1520
+ function provideQueryResourceOptions(valueOrFn) {
1521
+ return provideTypedResourceOptions(QUERY_RESOURCE_OPTIONS, valueOrFn);
1522
+ }
1523
+ function injectQueryResourceOptions(injector) {
1524
+ return injector
1525
+ ? injector.get(QUERY_RESOURCE_OPTIONS)
1526
+ : inject(QUERY_RESOURCE_OPTIONS);
1527
+ }
1528
+ /**
1529
+ * Returned from a resource's request fn to PAUSE it: the resource holds its current value and last
1530
+ * request (so it does not refetch on resume), and stops background work (no polling, no refetch
1531
+ * while paused). Distinct from returning `undefined` (DISABLE), which drops the request — a
1532
+ * disabled resource may refetch when re-enabled, a paused one resumes exactly where it left off.
1533
+ *
1534
+ * The request fn receives a {@link RequestContext} and can just return `ctx.paused`.
1535
+ */
1536
+ const PAUSED = Symbol('@mmstack/resource:paused');
1537
+ function queryResource(request, options0) {
1538
+ // Two-layer option injection: per-call > provideQueryResourceOptions > provideResourceOptions.
1539
+ const options = {
1540
+ ...injectResourceOptions(options0?.injector),
1541
+ ...injectQueryResourceOptions(options0?.injector),
1542
+ ...options0,
1543
+ };
1496
1544
  const cache = injectQueryCache(options?.injector);
1497
1545
  const destroyRef = options?.injector
1498
1546
  ? options.injector.get(DestroyRef)
@@ -1503,29 +1551,51 @@ function queryResource(request, options) {
1503
1551
  const networkAvailable = injectNetworkStatus();
1504
1552
  const eq = options?.triggerOnSameRequest
1505
1553
  ? undefined
1506
- : createEqualRequest(options?.equal);
1507
- const rawRequest = computed(() => request() ?? undefined, /* @ts-ignore */
1554
+ : (options?.equalRequest ?? createEqualRequest());
1555
+ const requestCtx = { paused: PAUSED };
1556
+ const rawResult = computed(() => request(requestCtx), /* @ts-ignore */
1557
+ ...(ngDevMode ? [{ debugName: "rawResult" }] : /* istanbul ignore next */ []));
1558
+ const paused = computed(() => rawResult() === PAUSED, /* @ts-ignore */
1559
+ ...(ngDevMode ? [{ debugName: "paused" }] : /* istanbul ignore next */ []));
1560
+ const rawRequest = computed(() => {
1561
+ const r = rawResult();
1562
+ return r === PAUSED ? undefined : (r ?? undefined);
1563
+ }, /* @ts-ignore */
1508
1564
  ...(ngDevMode ? [{ debugName: "rawRequest" }] : /* istanbul ignore next */ []));
1509
1565
  const disabledReason = computed(() => {
1510
1566
  if (!networkAvailable())
1511
1567
  return 'offline';
1512
1568
  if (cb.isOpen())
1513
1569
  return 'circuit-open';
1570
+ // PAUSED makes rawRequest undefined, so it reports 'no-request' here (and skips polling),
1571
+ // while stableRequest below HOLDS the last request so the value is kept (no refetch on resume).
1514
1572
  if (!rawRequest())
1515
1573
  return 'no-request';
1516
1574
  return null;
1517
1575
  }, /* @ts-ignore */
1518
1576
  ...(ngDevMode ? [{ debugName: "disabledReason" }] : /* istanbul ignore next */ []));
1519
- const stableRequest = computed(() => {
1520
- if (disabledReason() !== null)
1521
- return undefined;
1522
- const req = rawRequest();
1523
- if (!req)
1524
- return undefined;
1525
- if (typeof req === 'string')
1526
- return { method: 'GET', url: req };
1527
- return req;
1528
- }, { ...(ngDevMode ? { debugName: "stableRequest" } : /* istanbul ignore next */ {}), equal: (a, b) => {
1577
+ // While PAUSED, hold the previous request so httpResource sees no change — it keeps its value and
1578
+ // does NOT refetch. On resume the request is re-evaluated, so it refetches only if it changed.
1579
+ const heldRequest = linkedSignal({ ...(ngDevMode ? { debugName: "heldRequest" } : /* istanbul ignore next */ {}), source: () => {
1580
+ if (paused())
1581
+ return { req: undefined, held: true };
1582
+ if (disabledReason() !== null)
1583
+ return { req: undefined, held: false };
1584
+ const req = rawRequest();
1585
+ if (!req)
1586
+ return { req: undefined, held: false };
1587
+ if (typeof req === 'string')
1588
+ return { req: { method: 'GET', url: req }, held: false };
1589
+ return { req, held: false };
1590
+ },
1591
+ computation: (curr, prev) => curr.held && prev !== undefined ? prev.value : curr.req });
1592
+ // Dedup via the request-equality (the linkedSignal re-runs on every source tick; this computed
1593
+ // is what actually gates httpResource — so an equal/held request never triggers a refetch).
1594
+ const stableRequest = computed(() => heldRequest(), { ...(ngDevMode ? { debugName: "stableRequest" } : /* istanbul ignore next */ {}), equal: (a, b) => {
1595
+ if (a === b)
1596
+ return true;
1597
+ if (a === undefined || b === undefined)
1598
+ return false;
1529
1599
  if (eq)
1530
1600
  return eq(a, b);
1531
1601
  return a === b;
@@ -1591,7 +1661,8 @@ function queryResource(request, options) {
1591
1661
  key: entry.key,
1592
1662
  };
1593
1663
  } });
1594
- resource = refresh(resource, destroyRef, options?.refresh);
1664
+ // A disabled (offline / circuit-open / no-request) or PAUSED resource must not poll.
1665
+ resource = refresh(resource, destroyRef, options?.refresh, () => disabledReason() !== null);
1595
1666
  resource = retryOnError(resource, options?.retry, options?.onError);
1596
1667
  resource = persistResourceValues(resource, options?.keepPrevious, options?.equal);
1597
1668
  const set = (value) => {
@@ -1622,7 +1693,7 @@ function queryResource(request, options) {
1622
1693
  const client = options?.injector
1623
1694
  ? options.injector.get(HttpClient)
1624
1695
  : inject(HttpClient);
1625
- return {
1696
+ const ref = {
1626
1697
  ...resource,
1627
1698
  value,
1628
1699
  set,
@@ -1690,6 +1761,9 @@ function queryResource(request, options) {
1690
1761
  }
1691
1762
  },
1692
1763
  };
1764
+ // Auto-register into the nearest transition scope if the (merged) options ask for it.
1765
+ applyResourceRegistration(ref, options.register, options?.injector);
1766
+ return ref;
1693
1767
  }
1694
1768
 
1695
1769
  function manualQueryResource(request, options) {
@@ -1729,6 +1803,19 @@ function manualQueryResource(request, options) {
1729
1803
  }
1730
1804
 
1731
1805
  const NULL_VALUE = Symbol('@mmstack/resource:null');
1806
+ const MUTATION_RESOURCE_OPTIONS = new InjectionToken('@mmstack/resource:mutation-resource-options', { factory: () => ({}) });
1807
+ /**
1808
+ * Layer 2 (mutation): default options for every `mutationResource`, inheriting + overriding the
1809
+ * common defaults from `provideResourceOptions`. Per-call options override these in turn.
1810
+ */
1811
+ function provideMutationResourceOptions(valueOrFn) {
1812
+ return provideTypedResourceOptions(MUTATION_RESOURCE_OPTIONS, valueOrFn);
1813
+ }
1814
+ function injectMutationResourceOptions(injector) {
1815
+ return injector
1816
+ ? injector.get(MUTATION_RESOURCE_OPTIONS)
1817
+ : inject(MUTATION_RESOURCE_OPTIONS);
1818
+ }
1732
1819
  /**
1733
1820
  * Creates a resource for performing mutations (e.g., POST, PUT, PATCH, DELETE requests).
1734
1821
  * Unlike `queryResource`, `mutationResource` is designed for one-off operations that change data.
@@ -1778,15 +1865,31 @@ const NULL_VALUE = Symbol('@mmstack/resource:null');
1778
1865
  * );
1779
1866
  * ```
1780
1867
  */
1781
- function mutationResource(request, options = {}) {
1782
- const { onMutate, onError, onSuccess, onSettled, equal, ...rest } = options;
1783
- const requestEqual = createEqualRequest(equal);
1868
+ function mutationResource(request, options0 = {}) {
1869
+ // Two-layer option injection: per-call > provideMutationResourceOptions > provideResourceOptions.
1870
+ const options = {
1871
+ ...injectResourceOptions(options0.injector),
1872
+ ...injectMutationResourceOptions(options0.injector),
1873
+ ...options0,
1874
+ };
1875
+ // `register` is pulled out (and forced off on the inner query below) so the mutation ref is
1876
+ // the only thing registered into the transition scope, not its internal query resource.
1877
+ const { onMutate, onError, onSuccess, onSettled, equal, register, equalRequest, ...rest } = options;
1878
+ const requestEqual = equalRequest ?? createEqualRequest(equal);
1879
+ // A mutation is an imperative command, so `triggerOnSameRequest` means "fire on EVERY mutate(),
1880
+ // even with an identical body". By default we dedup an identical value/request while one is in
1881
+ // flight (double-click protection); when this is set, both the `next` and `req` dedup are bypassed
1882
+ // so a repeat click isn't silently swallowed mid-flight. (Otherwise it'd be dropped until `next`
1883
+ // resets to NULL on settle — the "every other click" symptom.)
1884
+ const triggerOnSame = options.triggerOnSameRequest ?? false;
1784
1885
  const eq = equal ?? Object.is;
1785
1886
  const next = signal(NULL_VALUE, { ...(ngDevMode ? { debugName: "next" } : /* istanbul ignore next */ {}), equal: (a, b) => {
1786
1887
  if (a === NULL_VALUE && b === NULL_VALUE)
1787
1888
  return true;
1788
1889
  if (a === NULL_VALUE || b === NULL_VALUE)
1789
1890
  return false;
1891
+ if (triggerOnSame)
1892
+ return false;
1790
1893
  return eq(a, b);
1791
1894
  } });
1792
1895
  const queue = signal([], /* @ts-ignore */
@@ -1815,7 +1918,15 @@ function mutationResource(request, options = {}) {
1815
1918
  if (nr === NULL_VALUE)
1816
1919
  return;
1817
1920
  return request(nr) ?? undefined;
1818
- }, { ...(ngDevMode ? { debugName: "req" } : /* istanbul ignore next */ {}), equal: requestEqual });
1921
+ }, { ...(ngDevMode ? { debugName: "req" } : /* istanbul ignore next */ {}), equal: (a, b) => {
1922
+ if (a === undefined && b === undefined)
1923
+ return true;
1924
+ if (a === undefined || b === undefined)
1925
+ return false;
1926
+ if (triggerOnSame)
1927
+ return false;
1928
+ return requestEqual(a, b);
1929
+ } });
1819
1930
  const lastValue = linkedSignal({ ...(ngDevMode ? { debugName: "lastValue" } : /* istanbul ignore next */ {}), source: next,
1820
1931
  computation: (next, prev) => {
1821
1932
  if (next === NULL_VALUE && !!prev)
@@ -1827,13 +1938,21 @@ function mutationResource(request, options = {}) {
1827
1938
  if (nr === NULL_VALUE)
1828
1939
  return;
1829
1940
  return request(nr) ?? undefined;
1830
- }, { ...(ngDevMode ? { debugName: "lastValueRequest" } : /* istanbul ignore next */ {}), equal: requestEqual });
1941
+ }, { ...(ngDevMode ? { debugName: "lastValueRequest" } : /* istanbul ignore next */ {}), equal: (a, b) => {
1942
+ if (a === b)
1943
+ return true;
1944
+ if (a === undefined || b === undefined)
1945
+ return false;
1946
+ return requestEqual(a, b);
1947
+ } });
1831
1948
  const cb = createCircuitBreaker(options?.circuitBreaker === true
1832
1949
  ? undefined
1833
1950
  : (options?.circuitBreaker ?? false), options?.injector);
1834
1951
  const resource = queryResource(req, {
1835
1952
  ...rest,
1953
+ register: false, // the mutation ref handles registration; never register the inner query
1836
1954
  circuitBreaker: cb,
1955
+ equalRequest: requestEqual,
1837
1956
  defaultValue: NULL_VALUE, // doesnt matter since .value is not accessible
1838
1957
  });
1839
1958
  const destroyRef = options.injector
@@ -1867,7 +1986,7 @@ function mutationResource(request, options = {}) {
1867
1986
  next.set(NULL_VALUE);
1868
1987
  });
1869
1988
  const shouldQueue = options.queue ?? false;
1870
- return {
1989
+ const ref = {
1871
1990
  ...resource,
1872
1991
  destroy: () => {
1873
1992
  statusSub.unsubscribe();
@@ -1898,11 +2017,13 @@ function mutationResource(request, options = {}) {
1898
2017
  // redeclare disabled with last value so that it is not affected by the resource's internal disablement logic
1899
2018
  disabled: computed(() => cb.isOpen() || lastValueRequest() === undefined),
1900
2019
  };
2020
+ applyResourceRegistration(ref, register, options0.injector);
2021
+ return ref;
1901
2022
  }
1902
2023
 
1903
2024
  /**
1904
2025
  * Generated bundle index. Do not edit.
1905
2026
  */
1906
2027
 
1907
- export { Cache, createCacheInterceptor, createCircuitBreaker, createDedupeRequestsInterceptor, injectQueryCache, manualQueryResource, mutationResource, noDedupe, provideCircuitBreakerDefaultOptions, provideQueryCache, queryResource };
2028
+ export { Cache, PAUSED, applyResourceRegistration, createCacheInterceptor, createCircuitBreaker, createDedupeRequestsInterceptor, injectQueryCache, injectResourceOptions, manualQueryResource, mutationResource, noDedupe, provideCircuitBreakerDefaultOptions, provideMutationResourceOptions, provideQueryCache, provideQueryResourceOptions, provideResourceOptions, provideTypedResourceOptions, queryResource };
1908
2029
  //# sourceMappingURL=mmstack-resource.mjs.map