@mmstack/resource 21.1.2 → 21.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -123,15 +123,15 @@ All three return a signal-typed ref — `value()`, `status()`, `error()`, `heade
123
123
 
124
124
  When the cache interceptor is registered (`createCacheInterceptor()`) and a query resource opts in via `cache`, responses are stored in the shared `Cache` keyed by a string derived from the request.
125
125
 
126
- **Default key**: `${method} ${urlWithParams(request)}` — produced by `urlWithParams()` (`util/url-with-params.ts:24`). It includes method, URL path, and sorted query params. **It does not include headers, body, or `HttpContext`.**
126
+ **Default key**: produced by `hashRequest()` (`util/hash-request.ts`). Composition is `${method}:${url}:${responseType}[:${params}][:${body}]` sorted query params, stable body hashing (incl. `File`/`Blob`/`FormData`/`URLSearchParams`/`ArrayBuffer` markers). **It does not include headers or `HttpContext`.**
127
127
 
128
- If two requests should _not_ share a cache entry but the default key would collide (e.g. different `Authorization` headers, request body in a GET-equivalent POST), pass a custom hash:
128
+ If two requests should _not_ share a cache entry but the default key would collide (e.g. different `Authorization` headers), pass a custom hash:
129
129
 
130
130
  ```typescript
131
131
  queryResource<Post>(() => ({ url, headers }), {
132
132
  cache: {
133
133
  hash: (req) =>
134
- `${req.method}:${req.urlWithParams}:${req.headers.get('Authorization') ?? ''}`,
134
+ `${hashRequest(req)}:${(req.headers as HttpHeaders | undefined)?.get('Authorization') ?? ''}`,
135
135
  },
136
136
  });
137
137
  ```
@@ -321,7 +321,7 @@ provideQueryCache({
321
321
  | -------------------- | ------------------------ | -------------------------------------------------------------------------------------------------------------------- |
322
322
  | `staleTime` | from `provideQueryCache` | Per-resource override. |
323
323
  | `ttl` | from `provideQueryCache` | Per-resource override. |
324
- | `hash` | `urlWithParams` | Custom cache key function. See [cache + cache keys](#cache--cache-keys). |
324
+ | `hash` | `hashRequest` | Custom cache key function. See [cache + cache keys](#cache--cache-keys). |
325
325
  | `persist` | `false` | Mirror this resource's responses to IndexedDB (only effective if the cache itself was created with `persist: true`). |
326
326
  | `ignoreCacheControl` | `false` | Ignore HTTP `Cache-Control` directives and use only `staleTime`/`ttl`. |
327
327
 
@@ -1,4 +1,4 @@
1
- import { HttpHeaders, HttpResponse, HttpContextToken, HttpContext, HttpParams, httpResource, HttpClient } from '@angular/common/http';
1
+ import { HttpHeaders, HttpResponse, HttpParams, HttpContextToken, HttpContext, httpResource, HttpClient } from '@angular/common/http';
2
2
  import * as i0 from '@angular/core';
3
3
  import { isDevMode, untracked, computed, InjectionToken, inject, signal, effect, Injector, linkedSignal, Injectable, DestroyRef } from '@angular/core';
4
4
  import { mutable, toWritable, sensor, nestedEffect } from '@mmstack/primitives';
@@ -551,6 +551,173 @@ function injectQueryCache(injector) {
551
551
  return cache;
552
552
  }
553
553
 
554
+ /**
555
+ * Returns `true` for any object-like value whose own enumerable keys should
556
+ * be sorted for stable hashing. Excludes arrays (positional), `Date`
557
+ * (handled by `toJSON`), `Map`/`Set` (handled explicitly), and binary types
558
+ * (`Blob`/`FormData`/`URLSearchParams`/`ArrayBuffer`/typed arrays — these
559
+ * should be branched on before reaching `hash()`, typically by `hashRequest`).
560
+ *
561
+ * Plain objects, class instances, and `Object.create(null)` all qualify.
562
+ */
563
+ function isHashableObject(value) {
564
+ if (value === null || typeof value !== 'object')
565
+ return false;
566
+ if (Array.isArray(value))
567
+ return false;
568
+ if (value instanceof Date)
569
+ return false;
570
+ if (value instanceof Map)
571
+ return false;
572
+ if (value instanceof Set)
573
+ return false;
574
+ if (typeof Blob !== 'undefined' && value instanceof Blob)
575
+ return false;
576
+ if (typeof FormData !== 'undefined' && value instanceof FormData)
577
+ return false;
578
+ if (typeof URLSearchParams !== 'undefined' &&
579
+ value instanceof URLSearchParams)
580
+ return false;
581
+ if (value instanceof ArrayBuffer)
582
+ return false;
583
+ if (ArrayBuffer.isView(value))
584
+ return false;
585
+ return true;
586
+ }
587
+ function sortKeys(val) {
588
+ return Object.keys(val)
589
+ .toSorted()
590
+ .reduce((result, key) => {
591
+ result[key] = val[key];
592
+ return result;
593
+ }, {});
594
+ }
595
+ /**
596
+ * Internal helper to generate a stable JSON string from an array.
597
+ * - Object-like values (plain, class instances, null-proto) get their own
598
+ * enumerable keys sorted alphabetically.
599
+ * - `Map` → marker object with sorted entries (sorted by `JSON.stringify(key)`).
600
+ * - `Set` → marker object with sorted values (sorted by `JSON.stringify(value)`).
601
+ * - Arrays preserve order. `Date` serializes via `toJSON`.
602
+ *
603
+ * @internal
604
+ */
605
+ function hashKey(queryKey) {
606
+ return JSON.stringify(queryKey, (_, val) => {
607
+ if (val instanceof Map) {
608
+ // Schwartzian: compute each entry's sort key (recursive hash of the
609
+ // Map key) once, then sort by the cheap string compare.
610
+ const entries = [...val.entries()]
611
+ .map((e) => [hash(e[0]), e])
612
+ .sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
613
+ .map(([, e]) => e);
614
+ return { __map__: entries };
615
+ }
616
+ if (val instanceof Set) {
617
+ const values = [...val]
618
+ .map((v) => [hash(v), v])
619
+ .sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
620
+ .map(([, v]) => v);
621
+ return { __set__: values };
622
+ }
623
+ if (isHashableObject(val))
624
+ return sortKeys(val);
625
+ return val;
626
+ });
627
+ }
628
+ /**
629
+ * Generates a stable, unique string hash from one or more arguments.
630
+ * Useful for creating cache keys or identifiers where object key order shouldn't matter.
631
+ *
632
+ * How it works:
633
+ * - Object-like values (plain objects, class instances, `Object.create(null)`) have
634
+ * their own enumerable keys sorted alphabetically before hashing. This ensures
635
+ * `{ a: 1, b: 2 }` and `{ b: 2, a: 1 }` produce the same hash.
636
+ * - `Map` and `Set` are serialized via stable, sorted markers (`__map__` / `__set__`).
637
+ * - Arrays preserve positional order; `Date` uses its ISO string via `toJSON`.
638
+ *
639
+ * @param {...unknown} args Values to include in the hash.
640
+ * @returns A stable string hash representing the input arguments.
641
+ * @example
642
+ * hash('posts', 10);
643
+ * // => '["posts",10]'
644
+ *
645
+ * hash({ a: 1, b: 2 }) === hash({ b: 2, a: 1 }); // true
646
+ *
647
+ * hash(new Map([['a', 1]])) === hash(new Map([['a', 1]])); // true
648
+ *
649
+ * // Be mindful of values JSON.stringify cannot handle (functions, undefined, Symbols)
650
+ * // hash('a', undefined, function() {}) => '["a",null,null]'
651
+ */
652
+ function hash(...args) {
653
+ return hashKey(args);
654
+ }
655
+
656
+ function normalizeParams(params) {
657
+ const p = params instanceof HttpParams
658
+ ? params
659
+ : new HttpParams({ fromObject: params });
660
+ return p
661
+ .keys()
662
+ .toSorted()
663
+ .map((key) => {
664
+ const encodedKey = encodeURIComponent(key);
665
+ return (p.getAll(key) ?? [])
666
+ .map((v) => `${encodedKey}=${encodeURIComponent(v)}`)
667
+ .join('&');
668
+ })
669
+ .join('&');
670
+ }
671
+ function hashBody(body) {
672
+ // File extends Blob — must check File first
673
+ if (typeof File !== 'undefined' && body instanceof File) {
674
+ return `File:${body.name}:${body.type}:${body.size}:${body.lastModified}`;
675
+ }
676
+ if (typeof Blob !== 'undefined' && body instanceof Blob) {
677
+ return `Blob:${body.type}:${body.size}`;
678
+ }
679
+ if (typeof FormData !== 'undefined' && body instanceof FormData) {
680
+ const entries = [];
681
+ body.forEach((value, key) => {
682
+ entries.push([key, hashBody(value)]);
683
+ });
684
+ entries.sort(([ak, av], [bk, bv]) => ak.localeCompare(bk) || av.localeCompare(bv));
685
+ return `FormData:${entries.map(([k, v]) => `${k}=${v}`).join('&')}`;
686
+ }
687
+ if (typeof URLSearchParams !== 'undefined' &&
688
+ body instanceof URLSearchParams) {
689
+ const sp = new URLSearchParams(body);
690
+ sp.sort();
691
+ return `URLSearchParams:${sp.toString()}`;
692
+ }
693
+ if (body instanceof ArrayBuffer) {
694
+ return `ArrayBuffer:${body.byteLength}`;
695
+ }
696
+ if (ArrayBuffer.isView(body)) {
697
+ return `${body.constructor.name}:${body.byteLength}`;
698
+ }
699
+ return hash(body);
700
+ }
701
+ /**
702
+ * Builds a stable cache/dedupe key from an HTTP request shape (accepts both
703
+ * `HttpRequest` and `HttpResourceRequest`).
704
+ *
705
+ * Key composition: `${method}:${url}:${responseType}[:${params}][:${body}]`
706
+ * - `method` defaults to `'GET'`, `responseType` to `'json'` (Angular defaults).
707
+ * - Query params are sorted alphabetically and URL-encoded for stability.
708
+ * - Body hashing handles `File`/`Blob`/`FormData`/`URLSearchParams`/`ArrayBuffer`
709
+ * and typed arrays explicitly; everything else flows through key-sorted
710
+ * `JSON.stringify` via `hash()`.
711
+ */
712
+ function hashRequest(req) {
713
+ const method = req.method ?? 'GET';
714
+ const responseType = req.responseType ?? 'json';
715
+ const base = `${method}:${req.url}:${responseType}`;
716
+ const params = req.params ? `:${normalizeParams(req.params)}` : '';
717
+ const body = req.body != null ? `:${hashBody(req.body)}` : '';
718
+ return base + params + body;
719
+ }
720
+
554
721
  const CACHE_CONTEXT = new HttpContextToken(() => ({
555
722
  cache: false,
556
723
  }));
@@ -694,7 +861,7 @@ function createCacheInterceptor(allowedMethods = ['GET', 'HEAD', 'OPTIONS']) {
694
861
  const opt = getCacheContext(req.context);
695
862
  if (!opt.cache)
696
863
  return next(req);
697
- const key = opt.key ?? req.urlWithParams;
864
+ const key = opt.key ?? hashRequest(req);
698
865
  const entry = cache.getUntracked(key); // null if expired or not found
699
866
  // If the entry is not stale, return it
700
867
  if (entry && !entry.isStale)
@@ -977,6 +1144,9 @@ function noDedupe(ctx = new HttpContext()) {
977
1144
  *
978
1145
  * @param allowed - An array of HTTP methods for which deduplication should be enabled.
979
1146
  * Defaults to `['GET', 'DELETE', 'HEAD', 'OPTIONS']`.
1147
+ * @param keyFn - Optional function to compute the dedupe key from a request.
1148
+ * Defaults to `hashRequest`, which includes method, URL,
1149
+ * response type, params, and body.
980
1150
  *
981
1151
  * @returns An `HttpInterceptorFn` that implements the request deduplication logic.
982
1152
  *
@@ -1000,97 +1170,22 @@ function noDedupe(ctx = new HttpContext()) {
1000
1170
  * ],
1001
1171
  * };
1002
1172
  */
1003
- function createDedupeRequestsInterceptor(allowed = ['GET', 'DELETE', 'HEAD', 'OPTIONS']) {
1173
+ function createDedupeRequestsInterceptor(allowed = ['GET', 'DELETE', 'HEAD', 'OPTIONS'], keyFn = hashRequest) {
1004
1174
  const inFlight = new Map();
1005
1175
  const DEDUPE_METHODS = new Set(allowed);
1006
1176
  return (req, next) => {
1007
1177
  if (!DEDUPE_METHODS.has(req.method) || req.context.get(NO_DEDUPE))
1008
1178
  return next(req);
1009
- const found = inFlight.get(req.urlWithParams);
1179
+ const key = keyFn(req);
1180
+ const found = inFlight.get(key);
1010
1181
  if (found)
1011
1182
  return found;
1012
- const request = next(req).pipe(finalize(() => inFlight.delete(req.urlWithParams)), shareReplay());
1013
- inFlight.set(req.urlWithParams, request);
1183
+ const request = next(req).pipe(finalize(() => inFlight.delete(key)), shareReplay({ bufferSize: 1, refCount: true }));
1184
+ inFlight.set(key, request);
1014
1185
  return request;
1015
1186
  };
1016
1187
  }
1017
1188
 
1018
- /**
1019
- * Checks if `value` is a plain JavaScript object (e.g., `{}` or `new Object()`).
1020
- * Distinguishes from arrays, null, and class instances. Acts as a type predicate,
1021
- * narrowing `value` to `UnknownObject` if `true`.
1022
- *
1023
- * @param value The value to check.
1024
- * @returns {value is UnknownObject} `true` if `value` is a plain object, otherwise `false`.
1025
- * @example
1026
- * isPlainObject({}) // => true
1027
- * isPlainObject([]) // => false
1028
- * isPlainObject(null) // => false
1029
- * isPlainObject(new Date()) // => false
1030
- */
1031
- function isPlainObject(value) {
1032
- if (value === null || typeof value !== 'object')
1033
- return false;
1034
- const proto = Object.getPrototypeOf(value);
1035
- if (proto === null)
1036
- return false; // remove Object.create(null);
1037
- return proto === Object.prototype;
1038
- }
1039
- /**
1040
- * Internal helper to generate a stable JSON string from an array.
1041
- * Sorts keys of plain objects within the array alphabetically before serialization
1042
- * to ensure hash stability regardless of key order.
1043
- *
1044
- * @param queryKey The array of values to serialize.
1045
- * @returns A stable JSON string representation.
1046
- * @internal
1047
- */
1048
- function hashKey(queryKey) {
1049
- return JSON.stringify(queryKey, (_, val) => isPlainObject(val)
1050
- ? Object.keys(val)
1051
- .toSorted()
1052
- .reduce((result, key) => {
1053
- result[key] = val[key];
1054
- return result;
1055
- }, {})
1056
- : val);
1057
- }
1058
- /**
1059
- * Generates a stable, unique string hash from one or more arguments.
1060
- * Useful for creating cache keys or identifiers where object key order shouldn't matter.
1061
- *
1062
- * How it works:
1063
- * - Plain objects within the arguments have their keys sorted alphabetically before hashing.
1064
- * This ensures that `{ a: 1, b: 2 }` and `{ b: 2, a: 1 }` produce the same hash.
1065
- * - Uses `JSON.stringify` internally with custom sorting for plain objects via `hashKey`.
1066
- * - Non-plain objects (arrays, Dates, etc.) and primitives are serialized naturally.
1067
- *
1068
- * @param {...unknown} args Values to include in the hash.
1069
- * @returns A stable string hash representing the input arguments.
1070
- * @example
1071
- * const userQuery = (id: number) => ['user', { id, timestamp: Date.now() }];
1072
- *
1073
- * const obj1 = { a: 1, b: 2 };
1074
- * const obj2 = { b: 2, a: 1 }; // Same keys/values, different order
1075
- *
1076
- * hash('posts', 10);
1077
- * // => '["posts",10]'
1078
- *
1079
- * hash('config', obj1);
1080
- * // => '["config",{"a":1,"b":2}]'
1081
- *
1082
- * hash('config', obj2);
1083
- * // => '["config",{"a":1,"b":2}]' (Same as above due to key sorting)
1084
- *
1085
- * hash(['todos', { status: 'done', owner: obj1 }]);
1086
- * // => '[["todos",{"owner":{"a":1,"b":2},"status":"done"}]]'
1087
- *
1088
- * // Be mindful of values JSON.stringify cannot handle (functions, undefined, Symbols)
1089
- * // hash('a', undefined, function() {}) => '["a",null,null]'
1090
- */
1091
- function hash(...args) {
1092
- return hashKey(args);
1093
- }
1094
1189
  function equalTransferCache(a, b) {
1095
1190
  if (!a && !b)
1096
1191
  return true;
@@ -1330,7 +1425,7 @@ function retryOnError(res, opt, onError) {
1330
1425
  retries++;
1331
1426
  if (timeout)
1332
1427
  clearTimeout(timeout);
1333
- setTimeout(() => res.reload(), retries <= 0 ? 0 : backoff * Math.pow(2, retries - 1));
1428
+ timeout = setTimeout(() => res.reload(), retries <= 0 ? 0 : backoff * Math.pow(2, retries - 1));
1334
1429
  };
1335
1430
  const onSuccess = () => {
1336
1431
  if (timeout)
@@ -1388,29 +1483,6 @@ function toResourceObject(res) {
1388
1483
  };
1389
1484
  }
1390
1485
 
1391
- function normalizeParams(params) {
1392
- if (params instanceof HttpParams)
1393
- return params.toString();
1394
- const paramMap = new Map();
1395
- for (const [key, value] of Object.entries(params)) {
1396
- if (Array.isArray(value)) {
1397
- paramMap.set(key, value.map(encodeURIComponent).join(','));
1398
- }
1399
- else {
1400
- paramMap.set(key, encodeURIComponent(value.toString()));
1401
- }
1402
- }
1403
- return Array.from(paramMap.entries())
1404
- .sort(([a], [b]) => a.localeCompare(b))
1405
- .map(([key, value]) => `${key}=${value}`)
1406
- .join('&');
1407
- }
1408
- function urlWithParams(req) {
1409
- if (!req.params)
1410
- return req.url;
1411
- return `${req.url}?${normalizeParams(req.params)}`;
1412
- }
1413
-
1414
1486
  function queryResource(request, options) {
1415
1487
  const cache = injectQueryCache(options?.injector);
1416
1488
  const destroyRef = options?.injector
@@ -1448,8 +1520,8 @@ function queryResource(request, options) {
1448
1520
  return a === b;
1449
1521
  } });
1450
1522
  const hashFn = typeof options?.cache === 'object'
1451
- ? (options.cache.hash ?? urlWithParams)
1452
- : urlWithParams;
1523
+ ? (options.cache.hash ?? hashRequest)
1524
+ : hashRequest;
1453
1525
  const staleTime = typeof options?.cache === 'object' ? options.cache.staleTime : 0;
1454
1526
  const ttl = typeof options?.cache === 'object' ? options.cache.ttl : undefined;
1455
1527
  const cacheKey = computed(() => {
@@ -1646,6 +1718,7 @@ function manualQueryResource(request, options) {
1646
1718
  };
1647
1719
  }
1648
1720
 
1721
+ const NULL_VALUE = Symbol('@mmstack/resource:null');
1649
1722
  /**
1650
1723
  * Creates a resource for performing mutations (e.g., POST, PUT, PATCH, DELETE requests).
1651
1724
  * Unlike `queryResource`, `mutationResource` is designed for one-off operations that change data.
@@ -1669,10 +1742,10 @@ function mutationResource(request, options = {}) {
1669
1742
  const { onMutate, onError, onSuccess, onSettled, equal, ...rest } = options;
1670
1743
  const requestEqual = createEqualRequest(equal);
1671
1744
  const eq = equal ?? Object.is;
1672
- const next = signal(null, { ...(ngDevMode ? { debugName: "next" } : /* istanbul ignore next */ {}), equal: (a, b) => {
1673
- if (!a && !b)
1745
+ const next = signal(NULL_VALUE, { ...(ngDevMode ? { debugName: "next" } : /* istanbul ignore next */ {}), equal: (a, b) => {
1746
+ if (a === NULL_VALUE && b === NULL_VALUE)
1674
1747
  return true;
1675
- if (!a || !b)
1748
+ if (a === NULL_VALUE || b === NULL_VALUE)
1676
1749
  return false;
1677
1750
  return eq(a, b);
1678
1751
  } });
@@ -1680,28 +1753,36 @@ function mutationResource(request, options = {}) {
1680
1753
  let ctx = undefined;
1681
1754
  const queueRef = effect(() => {
1682
1755
  const nextInQueue = queue().at(0);
1683
- if (!nextInQueue || next() !== null)
1756
+ if (nextInQueue === undefined || next() !== NULL_VALUE)
1684
1757
  return;
1685
1758
  queue.update((q) => q.slice(1));
1686
1759
  const [value, ictx] = nextInQueue;
1687
- ctx = onMutate?.(value, ictx);
1688
- next.set(value);
1760
+ try {
1761
+ ctx = onMutate?.(value, ictx);
1762
+ next.set(value);
1763
+ }
1764
+ catch (mutationErr) {
1765
+ ctx = undefined;
1766
+ next.set(NULL_VALUE);
1767
+ if (isDevMode())
1768
+ console.error('[@mmstack/resource]: error thrown in onMutate hook, mutation was not applied', mutationErr);
1769
+ }
1689
1770
  }, ...(ngDevMode ? [{ debugName: "queueRef" }] : /* istanbul ignore next */ []));
1690
1771
  const req = computed(() => {
1691
1772
  const nr = next();
1692
- if (!nr)
1773
+ if (nr === NULL_VALUE)
1693
1774
  return;
1694
1775
  return request(nr) ?? undefined;
1695
1776
  }, { ...(ngDevMode ? { debugName: "req" } : /* istanbul ignore next */ {}), equal: requestEqual });
1696
1777
  const lastValue = linkedSignal({ ...(ngDevMode ? { debugName: "lastValue" } : /* istanbul ignore next */ {}), source: next,
1697
1778
  computation: (next, prev) => {
1698
- if (next === null && !!prev)
1779
+ if (next === NULL_VALUE && !!prev)
1699
1780
  return prev.value;
1700
1781
  return next;
1701
1782
  } });
1702
1783
  const lastValueRequest = computed(() => {
1703
1784
  const nr = lastValue();
1704
- if (!nr)
1785
+ if (nr === NULL_VALUE)
1705
1786
  return;
1706
1787
  return request(nr) ?? undefined;
1707
1788
  }, { ...(ngDevMode ? { debugName: "lastValueRequest" } : /* istanbul ignore next */ {}), equal: requestEqual });
@@ -1711,13 +1792,13 @@ function mutationResource(request, options = {}) {
1711
1792
  const resource = queryResource(req, {
1712
1793
  ...rest,
1713
1794
  circuitBreaker: cb,
1714
- defaultValue: null, // doesnt matter since .value is not accessible
1795
+ defaultValue: NULL_VALUE, // doesnt matter since .value is not accessible
1715
1796
  });
1716
1797
  const destroyRef = options.injector
1717
1798
  ? options.injector.get(DestroyRef)
1718
1799
  : inject(DestroyRef);
1719
1800
  const error$ = toObservable(resource.error);
1720
- const value$ = toObservable(resource.value).pipe(catchError(() => of(null)));
1801
+ const value$ = toObservable(resource.value).pipe(catchError(() => of(NULL_VALUE)));
1721
1802
  const statusSub = toObservable(resource.status)
1722
1803
  .pipe(combineLatestWith(error$, value$), map(([status, error, value]) => {
1723
1804
  if (status === 'error' && error) {
@@ -1726,14 +1807,14 @@ function mutationResource(request, options = {}) {
1726
1807
  error,
1727
1808
  };
1728
1809
  }
1729
- if (status === 'resolved' && value !== null) {
1810
+ if (status === 'resolved' && value !== NULL_VALUE) {
1730
1811
  return {
1731
1812
  status: 'resolved',
1732
1813
  value,
1733
1814
  };
1734
1815
  }
1735
- return null;
1736
- }), filter((v) => v !== null), takeUntilDestroyed(destroyRef))
1816
+ return NULL_VALUE;
1817
+ }), filter((v) => v !== NULL_VALUE), takeUntilDestroyed(destroyRef))
1737
1818
  .subscribe((result) => {
1738
1819
  if (result.status === 'error')
1739
1820
  onError?.(result.error, ctx);
@@ -1741,7 +1822,7 @@ function mutationResource(request, options = {}) {
1741
1822
  onSuccess?.(result.value, ctx);
1742
1823
  onSettled?.(ctx);
1743
1824
  ctx = undefined;
1744
- next.set(null);
1825
+ next.set(NULL_VALUE);
1745
1826
  });
1746
1827
  const shouldQueue = options.queue ?? false;
1747
1828
  return {
@@ -1756,11 +1837,22 @@ function mutationResource(request, options = {}) {
1756
1837
  return queue.update((q) => [...q, [value, ictx]]);
1757
1838
  }
1758
1839
  else {
1759
- ctx = onMutate?.(value, ictx);
1760
- next.set(value);
1840
+ try {
1841
+ ctx = onMutate?.(value, ictx);
1842
+ next.set(value);
1843
+ }
1844
+ catch (mutationErr) {
1845
+ ctx = undefined;
1846
+ next.set(NULL_VALUE);
1847
+ if (isDevMode())
1848
+ console.error('[@mmstack/resource]: error thrown in onMutate hook, mutation was not applied', mutationErr);
1849
+ }
1761
1850
  }
1762
1851
  },
1763
- current: next,
1852
+ current: computed(() => {
1853
+ const nv = next();
1854
+ return nv === NULL_VALUE ? null : nv;
1855
+ }),
1764
1856
  // redeclare disabled with last value so that it is not affected by the resource's internal disablement logic
1765
1857
  disabled: computed(() => cb.isOpen() || lastValueRequest() === undefined),
1766
1858
  };