@mmstack/primitives 20.6.0 → 20.6.2

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.
@@ -158,7 +158,7 @@ function nestedEffect(effectFn, options) {
158
158
  /**
159
159
  * Creates a new `Signal` that processes an array of items in time-sliced chunks. This is useful for handling large lists without blocking the main thread.
160
160
  *
161
- * The returned signal will initially contain the first `chunkSize` items from the source array. It will then schedule updates to include additional chunks of items based on the specified `duration`.
161
+ * The returned signal will initially contain the first `chunkSize` items from the source array. It will then schedule updates to include additional chunks of items based on the specified `delay`.
162
162
  *
163
163
  * @template T The type of items in the array.
164
164
  * @param source A `Signal` or a function that returns an array of items to be processed in chunks.
@@ -167,16 +167,23 @@ function nestedEffect(effectFn, options) {
167
167
  *
168
168
  * @example
169
169
  * const largeList = signal(Array.from({ length: 1000 }, (_, i) => i));
170
- * const chunkedList = chunked(largeList, { chunkSize: 100, duration: 100 });
170
+ * const chunkedList = chunked(largeList, { chunkSize: 100, delay: 100 });
171
171
  */
172
172
  function chunked(source, options) {
173
173
  const { chunkSize = 50, delay = 'frame', equal, injector } = options || {};
174
174
  let delayFn;
175
175
  if (delay === 'frame') {
176
- delayFn = (callback) => {
177
- const num = requestAnimationFrame(callback);
178
- return () => cancelAnimationFrame(num);
179
- };
176
+ delayFn =
177
+ typeof requestAnimationFrame === 'function'
178
+ ? (callback) => {
179
+ const num = requestAnimationFrame(callback);
180
+ return () => cancelAnimationFrame(num);
181
+ }
182
+ : // SSR: no requestAnimationFrame — approximate a frame with a timeout
183
+ (cb) => {
184
+ const num = setTimeout(cb, 16);
185
+ return () => clearTimeout(num);
186
+ };
180
187
  }
181
188
  else if (delay === 'microtask') {
182
189
  delayFn = (cb) => {
@@ -266,7 +273,9 @@ class MmActivity {
266
273
  });
267
274
  }
268
275
  for (const node of this.view.rootNodes) {
269
- if (node instanceof HTMLElement)
276
+ // covers HTML and SVG roots; text/comment roots can't be styled — their CD is still
277
+ // detached, but prefer an element root for true visual hiding
278
+ if (node instanceof HTMLElement || node instanceof SVGElement)
270
279
  node.style.display = visible ? '' : 'none';
271
280
  }
272
281
  if (visible)
@@ -345,14 +354,25 @@ function resolvePause(opt) {
345
354
  if (pause === false)
346
355
  return null;
347
356
  const run = (fn) => opt?.injector ? runInInjectionContext(opt.injector, fn) : fn();
357
+ // `inject` requires an injection context even with `optional: true`. A bare
358
+ // `pausableSignal(0)` (documented as "like `signal`") must degrade to the unwrapped
359
+ // primitive outside DI, not throw NG0203 — so injection failures fall back gracefully.
360
+ const tryRun = (fn, fallback) => {
361
+ try {
362
+ return run(fn);
363
+ }
364
+ catch {
365
+ return fallback;
366
+ }
367
+ };
348
368
  const onServer = () => typeof pause === 'function' && !opt?.injector
349
369
  ? typeof globalThis.window === 'undefined'
350
- : run(() => isPlatformServer(inject(PLATFORM_ID, { optional: true }) ?? 'browser'));
370
+ : tryRun(() => isPlatformServer(inject(PLATFORM_ID, { optional: true }) ?? 'browser'), typeof globalThis.window === 'undefined');
351
371
  if (typeof pause === 'function')
352
372
  return onServer() ? null : pause;
353
373
  if (onServer())
354
374
  return null;
355
- const paused = run(() => inject(PAUSED_CONTEXT, { optional: true }));
375
+ const paused = tryRun(() => inject(PAUSED_CONTEXT, { optional: true }), null);
356
376
  if (!paused) {
357
377
  if (explicit === true && isDevMode())
358
378
  console.warn('[pausable] `pause: true` but no PAUSED_CONTEXT in scope — not pausing. Provide one via an ' +
@@ -381,8 +401,9 @@ function pausableEffect(effectFn, options) {
381
401
  /**
382
402
  * Like `signal`, but pausable. While paused, READS hold the last value; writes still land on the
383
403
  * underlying signal and surface on resume. Built on the `keepPrevious`/`hold` shape — a
384
- * `linkedSignal` gated on the pause predicate, with `set`/`update`/`asReadonly` forwarded to the
385
- * source signal. With no `pause` option it defaults to the ambient `PAUSED_CONTEXT`; `pause: false`
404
+ * `linkedSignal` gated on the pause predicate, with `set`/`update` forwarded to the source signal.
405
+ * `asReadonly()` returns the held (gated) view, so both views of the signal agree while paused.
406
+ * With no `pause` option it defaults to the ambient `PAUSED_CONTEXT`; `pause: false`
386
407
  * makes it a plain `signal` — no `linkedSignal` is created.
387
408
  *
388
409
  * NOTE: while paused, `set(x)` followed by a read returns the *held* (pre-pause) value, not `x` — the
@@ -403,7 +424,8 @@ function pausableSignal(initialValue, options) {
403
424
  }]));
404
425
  read.set = src.set;
405
426
  read.update = src.update;
406
- read.asReadonly = src.asReadonly;
427
+ // NOTE: `asReadonly` deliberately stays the linkedSignal's own (the held view) — the
428
+ // source's readonly view would show live values while the signal itself shows held ones.
407
429
  return read;
408
430
  }
409
431
  /**
@@ -444,8 +466,12 @@ function mutable(initial, opt) {
444
466
  const internalUpdate = sig.update;
445
467
  sig.mutate = (updater) => {
446
468
  cnt++;
447
- internalUpdate(updater);
448
- cnt--;
469
+ try {
470
+ internalUpdate(updater);
471
+ }
472
+ finally {
473
+ cnt--;
474
+ }
449
475
  };
450
476
  sig.inline = (updater) => {
451
477
  sig.mutate((prev) => {
@@ -532,7 +558,7 @@ function createNoopScope() {
532
558
  hold: (value) => value,
533
559
  };
534
560
  }
535
- const TRANSITION_SCOPE = new InjectionToken('@mmstack/resource:transition-scope');
561
+ const TRANSITION_SCOPE = new InjectionToken('@mmstack/primitives:transition-scope');
536
562
  /** Provide a fresh transition scope at a boundary so its subtree's resources are tracked independently. */
537
563
  function provideTransitionScope() {
538
564
  return { provide: TRANSITION_SCOPE, useFactory: createTransitionScope };
@@ -541,7 +567,7 @@ function injectTransitionScope() {
541
567
  const scope = inject(TRANSITION_SCOPE, { optional: true });
542
568
  if (!scope) {
543
569
  if (isDevMode())
544
- console.warn('[mmstack/resource] No transition scope in context — registration/tracking here is a no-op. ' +
570
+ console.warn('[mmstack/primitives] No transition scope in context — registration/tracking here is a no-op. ' +
545
571
  'Use a <mm-suspense> boundary or provideTransitionScope() in an ancestor.');
546
572
  return createNoopScope();
547
573
  }
@@ -575,6 +601,11 @@ function registerResource(res, opt) {
575
601
  *
576
602
  * Must be called in an injection context. This is the *async* generalization (Tier 2): it adds
577
603
  * no rendering cost and needs no fork — holding direct/sync readers is a separate, deferred tier.
604
+ *
605
+ * Caveat: work must go in flight by the first post-write render to be awaited. A loader that
606
+ * starts later (a debounced request signal, a chained/deferred resource) is not attributable to
607
+ * this transition — the no-async fallback will have already resolved `done`. Trigger such work
608
+ * eagerly inside `fn`, or coordinate it separately.
578
609
  */
579
610
  function injectStartTransition() {
580
611
  const scope = injectTransitionScope();
@@ -724,6 +755,11 @@ function runInTransaction(txn, fn) {
724
755
  * The writes land on LIVE state immediately (so derived variables and connector requests see the
725
756
  * new values and refetch); only the *display* is held, via `scope.hold`. Must run in an injection
726
757
  * context.
758
+ *
759
+ * Caveat: work must go in flight by the first post-write render to be part of the transaction. A
760
+ * loader that starts later (a debounced request signal, a chained/deferred resource) is not
761
+ * attributable to it — the no-async fallback will have already committed and released the hold,
762
+ * after which `abort()` is a no-op. Trigger such work eagerly inside `fn`.
727
763
  */
728
764
  function injectStartTransaction() {
729
765
  const scope = injectTransitionScope();
@@ -733,7 +769,15 @@ function injectStartTransaction() {
733
769
  // Hold BEFORE the writes, so the display freezes at pre-transaction values.
734
770
  scope.beginHold();
735
771
  let finished = false;
772
+ // eslint-disable-next-line prefer-const -- assigned in try/catch, but needs to be declared here for the `finally` block to see it
736
773
  let watcher;
774
+ let resolveDone;
775
+ const done = new Promise((resolve) => {
776
+ resolveDone = resolve;
777
+ });
778
+ // Every exit path funnels through here, so `done` always settles — including `abort()`
779
+ // and a throwing transaction body (which would otherwise leak the hold forever and
780
+ // freeze the boundary with no recovery).
737
781
  const finish = (restore) => {
738
782
  if (finished)
739
783
  return;
@@ -744,27 +788,28 @@ function injectStartTransaction() {
744
788
  else
745
789
  txn.clear();
746
790
  scope.endHold();
791
+ resolveDone();
747
792
  };
748
- runInTransaction(txn, fn);
793
+ try {
794
+ runInTransaction(txn, fn);
795
+ }
796
+ catch (e) {
797
+ finish(true);
798
+ throw e;
799
+ }
749
800
  let sawPending = false;
750
- const done = new Promise((resolve) => {
751
- watcher = effect(() => {
752
- const p = scope.pending();
753
- if (p)
754
- sawPending = true;
755
- if (sawPending && !p) {
756
- finish(false);
757
- resolve();
758
- }
759
- }, { injector });
760
- // no-async fallback: if nothing ever went in flight, settle once the writes are processed.
761
- afterNextRender(() => {
762
- if (!sawPending && !untracked(scope.pending)) {
763
- finish(false);
764
- resolve();
765
- }
766
- }, { injector });
767
- });
801
+ watcher = effect(() => {
802
+ const p = scope.pending();
803
+ if (p)
804
+ sawPending = true;
805
+ if (sawPending && !p)
806
+ finish(false);
807
+ }, { injector });
808
+ // no-async fallback: if nothing ever went in flight, settle once the writes are processed.
809
+ afterNextRender(() => {
810
+ if (!sawPending && !untracked(scope.pending))
811
+ finish(false);
812
+ }, { injector });
768
813
  return {
769
814
  pending: scope.pending,
770
815
  done,
@@ -773,6 +818,17 @@ function injectStartTransaction() {
773
818
  };
774
819
  }
775
820
 
821
+ /**
822
+ * @internal
823
+ */
824
+ function getSignalEquality(sig) {
825
+ const internal = sig[SIGNAL];
826
+ if (internal && typeof internal.equal === 'function') {
827
+ return internal.equal;
828
+ }
829
+ return Object.is; // Default equality check
830
+ }
831
+
776
832
  /**
777
833
  * Converts a read-only `Signal` into a `WritableSignal` by providing custom `set` and, optionally, `update` functions.
778
834
  * This can be useful for creating controlled write access to a signal that is otherwise read-only.
@@ -871,6 +927,7 @@ function debounced(initial, opt) {
871
927
  * ```
872
928
  */
873
929
  function debounce(source, opt) {
930
+ const eq = opt?.equal ?? getSignalEquality(source);
874
931
  const ms = opt?.ms ?? 0;
875
932
  const trigger = signal(false, ...(ngDevMode ? [{ debugName: "trigger" }] : []));
876
933
  let timeout;
@@ -885,25 +942,25 @@ function debounce(source, opt) {
885
942
  catch {
886
943
  // not in injection context & no destroyRef provided opting out of cleanup
887
944
  }
888
- const triggerFn = (next) => {
945
+ const set = (next) => {
946
+ const isEqual = eq(untracked(source), next);
947
+ if (!timeout && isEqual)
948
+ return; // nothing to do
889
949
  if (timeout)
890
- clearTimeout(timeout);
891
- source.set(next);
950
+ clearTimeout(timeout); // clear pending
951
+ if (!isEqual)
952
+ source.set(next);
892
953
  timeout = setTimeout(() => {
954
+ timeout = undefined;
893
955
  trigger.update((c) => !c);
894
956
  }, ms);
895
957
  };
896
- const set = (value) => {
897
- triggerFn(value);
898
- };
899
- const update = (fn) => {
900
- triggerFn(fn(untracked(source)));
901
- };
958
+ const update = (fn) => set(fn(untracked(source)));
902
959
  const writable = toWritable(computed(() => {
903
960
  trigger();
904
961
  return untracked(source);
905
962
  }, opt), set, update);
906
- writable.original = source;
963
+ writable.original = source.asReadonly();
907
964
  return writable;
908
965
  }
909
966
 
@@ -1074,8 +1131,18 @@ function derived(source, optOrKey, opt) {
1074
1131
  if (isMutable(source)) {
1075
1132
  sig.mutate = (updater) => {
1076
1133
  cnt++;
1077
- sig.update(updater);
1078
- cnt--;
1134
+ try {
1135
+ sig.update(updater);
1136
+ // The wrapped computed evaluates its `equal` lazily — at the next read, which would
1137
+ // normally happen after `cnt` has already dropped back to 0. For a reference-stable
1138
+ // mutation that read compares the same object to itself and the version never bumps,
1139
+ // so dependents are never notified. Reading here, while equality is still suppressed,
1140
+ // forces the recompute (and version bump) inside the mutate window.
1141
+ untracked(sig);
1142
+ }
1143
+ finally {
1144
+ cnt--;
1145
+ }
1079
1146
  };
1080
1147
  sig.inline = (updater) => {
1081
1148
  sig.mutate((prev) => {
@@ -1157,20 +1224,48 @@ function createSetter(source) {
1157
1224
  }
1158
1225
 
1159
1226
  function keepPrevious(src, opt) {
1227
+ const mutableSrc = isWritableSignal(src) && isMutable(src);
1228
+ // For a mutable source the linkedSignal's equality must be suppressible: a forwarded
1229
+ // `mutate` keeps the same reference, which default equality would otherwise swallow.
1230
+ let cnt = 0;
1231
+ const baseEqual = opt?.equal;
1232
+ const equal = mutableSrc
1233
+ ? (a, b) => cnt > 0 ? false : baseEqual ? baseEqual(a, b) : Object.is(a, b)
1234
+ : baseEqual;
1160
1235
  const persisted = linkedSignal(...(ngDevMode ? [{ debugName: "persisted", ...opt,
1161
1236
  source: () => src(),
1162
- computation: (next, prev) => next === undefined && prev !== undefined ? prev.value : next }] : [{
1237
+ computation: (next, prev) => next === undefined && prev !== undefined ? prev.value : next,
1238
+ equal }] : [{
1163
1239
  ...opt,
1164
1240
  source: () => src(),
1165
1241
  computation: (next, prev) => next === undefined && prev !== undefined ? prev.value : next,
1242
+ equal,
1166
1243
  }]));
1167
1244
  if (isWritableSignal(src)) {
1168
1245
  persisted.set = src.set;
1169
1246
  persisted.update = src.update;
1170
- persisted.asReadonly = src.asReadonly;
1171
- if (isMutable(src)) {
1172
- persisted.mutate = src.mutate;
1173
- persisted.inline = src.inline;
1247
+ // NOTE: `asReadonly` deliberately stays the linkedSignal's own — returning the
1248
+ // source's readonly view would reintroduce the `undefined` flashes this wrapper exists
1249
+ // to prevent.
1250
+ if (mutableSrc) {
1251
+ persisted.mutate = (updater) => {
1252
+ cnt++;
1253
+ try {
1254
+ src.mutate(updater);
1255
+ // force the recompute while equality is suppressed, so the reference-stable
1256
+ // mutation bumps the wrapper's version (see derived.ts for the same pattern)
1257
+ untracked(persisted);
1258
+ }
1259
+ finally {
1260
+ cnt--;
1261
+ }
1262
+ };
1263
+ persisted.inline = (updater) => {
1264
+ persisted.mutate((prev) => {
1265
+ updater(prev);
1266
+ return prev;
1267
+ });
1268
+ };
1174
1269
  }
1175
1270
  if (isDerivation(src)) {
1176
1271
  persisted.from = src.from;
@@ -1201,13 +1296,18 @@ function indexArray(source, map, opt = {}) {
1201
1296
  : toWritable(data, () => {
1202
1297
  // noop
1203
1298
  });
1299
+ // copy before defaulting `equal` — assigning onto `opt` would mutate a caller-owned
1300
+ // (possibly shared/reused) options object
1204
1301
  if (isWritableSignal(data) && isMutable(data) && !opt.equal) {
1205
- opt.equal = (a, b) => {
1206
- if (typeof a !== typeof b)
1207
- return false;
1208
- if (typeof a === 'object' || typeof a === 'function')
1209
- return false;
1210
- return a === b;
1302
+ opt = {
1303
+ ...opt,
1304
+ equal: (a, b) => {
1305
+ if (typeof a !== typeof b)
1306
+ return false;
1307
+ if (typeof a === 'object' || typeof a === 'function')
1308
+ return false;
1309
+ return a === b;
1310
+ },
1211
1311
  };
1212
1312
  }
1213
1313
  return linkedSignal({
@@ -1401,8 +1501,17 @@ function pooledKeys(src) {
1401
1501
  for (const k in val)
1402
1502
  if (Object.prototype.hasOwnProperty.call(val, k))
1403
1503
  spare.add(k);
1404
- if (active.size === spare.size && active.isSubsetOf(spare))
1405
- return active;
1504
+ if (active.size === spare.size) {
1505
+ let subset = true;
1506
+ for (const k of active) {
1507
+ if (!spare.has(k)) {
1508
+ subset = false;
1509
+ break;
1510
+ }
1511
+ }
1512
+ if (subset)
1513
+ return active;
1514
+ }
1406
1515
  const temp = active;
1407
1516
  active = spare;
1408
1517
  spare = temp;
@@ -1502,7 +1611,7 @@ const filter = (predicate) => (src) => linkedSignal({
1502
1611
  computation: (next, prev) => {
1503
1612
  if (predicate(next))
1504
1613
  return next;
1505
- return prev?.source;
1614
+ return prev?.value;
1506
1615
  },
1507
1616
  });
1508
1617
  /**
@@ -1538,7 +1647,7 @@ const tap = (fn, injector) => (src) => {
1538
1647
  */
1539
1648
  const filterWith = (predicate, initial) => (src) => linkedSignal({
1540
1649
  source: src,
1541
- computation: (next, prev) => predicate(next) ? next : (prev?.value ?? initial),
1650
+ computation: (next, prev) => predicate(next) ? next : prev ? prev.value : initial,
1542
1651
  });
1543
1652
  /**
1544
1653
  * Emit `initial` on the first read, then mirror the source on every subsequent
@@ -1587,7 +1696,7 @@ const pairwise = () => (src) => linkedSignal({
1587
1696
  */
1588
1697
  const scan = (reducer, seed) => (src) => linkedSignal({
1589
1698
  source: src,
1590
- computation: (next, prev) => reducer(prev?.value ?? seed, next),
1699
+ computation: (next, prev) => reducer(prev ? prev.value : seed, next),
1591
1700
  });
1592
1701
 
1593
1702
  /**
@@ -1638,7 +1747,7 @@ function pipeable(signal) {
1638
1747
  return internal;
1639
1748
  }
1640
1749
  /**
1641
- * Create a new **writable** signal and return it as a `PipableSignal`.
1750
+ * Create a new **writable** signal and return it as a `PipeableSignal`.
1642
1751
  *
1643
1752
  * The returned value is a `WritableSignal<T>` with `.set`, `.update`, `.asReadonly`
1644
1753
  * still available (via intersection type), plus a chainable `.pipe(...)`.
@@ -1742,6 +1851,20 @@ function pooledMap(optOrComputation, signalOpt) {
1742
1851
  return pooled(toPooledOptions(optOrComputation, createEmptyMap, resetClearable, signalOpt));
1743
1852
  }
1744
1853
 
1854
+ /**
1855
+ * @internal Run a sensor factory inside `injector` when provided, else in the ambient
1856
+ * injection context. Keeps every sensor's escape hatch identical and in one place.
1857
+ */
1858
+ function runInSensorContext(injector, fn) {
1859
+ return injector ? runInInjectionContext(injector, fn) : fn();
1860
+ }
1861
+ /**
1862
+ * @internal Normalize the legacy positional `debugName: string` form into {@link SensorRunOptions}.
1863
+ */
1864
+ function coerceSensorOptions(opt) {
1865
+ return typeof opt === 'string' ? { debugName: opt } : (opt ?? {});
1866
+ }
1867
+
1745
1868
  const EVENTS = [
1746
1869
  'chargingchange',
1747
1870
  'levelchange',
@@ -1763,7 +1886,11 @@ const EVENTS = [
1763
1886
  * });
1764
1887
  * ```
1765
1888
  */
1766
- function batteryStatus(debugName = 'batteryStatus') {
1889
+ function batteryStatus(opt) {
1890
+ const { debugName = 'batteryStatus', injector } = coerceSensorOptions(opt);
1891
+ return runInSensorContext(injector, () => createBatteryStatus(debugName));
1892
+ }
1893
+ function createBatteryStatus(debugName) {
1767
1894
  if (isPlatformServer(inject(PLATFORM_ID)) ||
1768
1895
  typeof navigator === 'undefined' ||
1769
1896
  typeof navigator.getBattery !== 'function') {
@@ -1772,7 +1899,9 @@ function batteryStatus(debugName = 'batteryStatus') {
1772
1899
  const state = signal(null, ...(ngDevMode ? [{ debugName: "state", debugName }] : [{ debugName }]));
1773
1900
  const abortController = new AbortController();
1774
1901
  inject(DestroyRef).onDestroy(() => abortController.abort());
1775
- navigator.getBattery().then((battery) => {
1902
+ navigator
1903
+ .getBattery()
1904
+ .then((battery) => {
1776
1905
  if (abortController.signal.aborted)
1777
1906
  return;
1778
1907
  const read = () => ({
@@ -1788,6 +1917,10 @@ function batteryStatus(debugName = 'batteryStatus') {
1788
1917
  signal: abortController.signal,
1789
1918
  });
1790
1919
  }
1920
+ })
1921
+ .catch(() => {
1922
+ // getBattery() rejects (NotAllowedError) when the `battery` permissions-policy is
1923
+ // disallowed, e.g. in cross-origin iframes — stay `null`, same as unsupported.
1791
1924
  });
1792
1925
  return state.asReadonly();
1793
1926
  }
@@ -1803,7 +1936,11 @@ function batteryStatus(debugName = 'batteryStatus') {
1803
1936
  * in browsers that gate it. Errors from `navigator.clipboard.readText` are
1804
1937
  * swallowed silently to keep the signal value stable.
1805
1938
  */
1806
- function clipboard(debugName = 'clipboard') {
1939
+ function clipboard(opt) {
1940
+ const { debugName = 'clipboard', injector } = coerceSensorOptions(opt);
1941
+ return runInSensorContext(injector, () => createClipboard(debugName));
1942
+ }
1943
+ function createClipboard(debugName) {
1807
1944
  if (isPlatformServer(inject(PLATFORM_ID)) ||
1808
1945
  typeof navigator === 'undefined' ||
1809
1946
  !navigator.clipboard) {
@@ -1853,7 +1990,13 @@ function observerSupported$1() {
1853
1990
  * });
1854
1991
  * ```
1855
1992
  */
1856
- function elementSize(target = inject(ElementRef), opt) {
1993
+ function elementSize(target, opt) {
1994
+ return runInSensorContext(opt?.injector, () =>
1995
+ // the host-element default must resolve INSIDE the sensor context, not as a
1996
+ // parameter default (which would run before the injector wrapper)
1997
+ createElementSize(target ?? inject(ElementRef), opt));
1998
+ }
1999
+ function createElementSize(target, opt) {
1857
2000
  const getElement = () => {
1858
2001
  if (isSignal(target)) {
1859
2002
  try {
@@ -1867,8 +2010,8 @@ function elementSize(target = inject(ElementRef), opt) {
1867
2010
  return target instanceof ElementRef ? target.nativeElement : target;
1868
2011
  };
1869
2012
  const resolveInitialValue = () => {
1870
- if (!observerSupported$1())
1871
- return undefined;
2013
+ // measuring needs only getBoundingClientRect — ResizeObserver support gates
2014
+ // live updates, not the initial read
1872
2015
  const el = getElement();
1873
2016
  if (el && el.getBoundingClientRect) {
1874
2017
  const rect = el.getBoundingClientRect();
@@ -1986,7 +2129,13 @@ function observerSupported() {
1986
2129
  * }
1987
2130
  * ```
1988
2131
  */
1989
- function elementVisibility(target = inject(ElementRef), opt) {
2132
+ function elementVisibility(target, opt) {
2133
+ return runInSensorContext(opt?.injector, () =>
2134
+ // the host-element default must resolve INSIDE the sensor context, not as a
2135
+ // parameter default (which would run before the injector wrapper)
2136
+ createElementVisibility(target ?? inject(ElementRef), opt));
2137
+ }
2138
+ function createElementVisibility(target, opt) {
1990
2139
  if (isPlatformServer(inject(PLATFORM_ID)) || !observerSupported()) {
1991
2140
  const base = computed(() => undefined, {
1992
2141
  debugName: opt?.debugName,
@@ -2054,11 +2203,18 @@ function unwrap$1(target) {
2054
2203
  * }
2055
2204
  * ```
2056
2205
  */
2057
- function focusWithin(target = inject(ElementRef)) {
2206
+ function focusWithin(target, opt) {
2207
+ return runInSensorContext(opt?.injector, () =>
2208
+ // the host-element default must resolve INSIDE the sensor context, not as a
2209
+ // parameter default (which would run before the injector wrapper)
2210
+ createFocusWithin(target ?? inject(ElementRef), opt));
2211
+ }
2212
+ function createFocusWithin(target, opt) {
2213
+ const debugName = opt?.debugName ?? 'focusWithin';
2058
2214
  if (isPlatformServer(inject(PLATFORM_ID))) {
2059
- return computed(() => false, { debugName: 'focusWithin' });
2215
+ return computed(() => false, { debugName });
2060
2216
  }
2061
- const state = signal(false, { debugName: 'focusWithin' });
2217
+ const state = signal(false, ...(ngDevMode ? [{ debugName: "state", debugName }] : [{ debugName }]));
2062
2218
  const attach = (el) => {
2063
2219
  state.set(el.contains(document.activeElement));
2064
2220
  const abortController = new AbortController();
@@ -2106,6 +2262,9 @@ function focusWithin(target = inject(ElementRef)) {
2106
2262
  * ```
2107
2263
  */
2108
2264
  function geolocation(opt) {
2265
+ return runInSensorContext(opt?.injector, () => createGeolocation(opt));
2266
+ }
2267
+ function createGeolocation(opt) {
2109
2268
  if (isPlatformServer(inject(PLATFORM_ID)) || typeof navigator === 'undefined' || !navigator.geolocation) {
2110
2269
  const sig = computed(() => null, {
2111
2270
  debugName: opt?.debugName ?? 'geolocation',
@@ -2165,6 +2324,9 @@ const serverDate$1 = new Date();
2165
2324
  * ```
2166
2325
  */
2167
2326
  function idle(opt) {
2327
+ return runInSensorContext(opt?.injector, () => createIdle(opt));
2328
+ }
2329
+ function createIdle(opt) {
2168
2330
  if (isPlatformServer(inject(PLATFORM_ID))) {
2169
2331
  const sig = computed(() => false, {
2170
2332
  debugName: opt?.debugName ?? 'idle',
@@ -2254,7 +2416,11 @@ function idle(opt) {
2254
2416
  * }
2255
2417
  * ```
2256
2418
  */
2257
- function mediaQuery(query, debugName = 'mediaQuery') {
2419
+ function mediaQuery(query, opt) {
2420
+ const { debugName = 'mediaQuery', injector } = coerceSensorOptions(opt);
2421
+ return runInSensorContext(injector, () => createMediaQuery(query, debugName));
2422
+ }
2423
+ function createMediaQuery(query, debugName) {
2258
2424
  if (isPlatformServer(inject(PLATFORM_ID)) ||
2259
2425
  typeof window === 'undefined' ||
2260
2426
  typeof window.matchMedia !== 'function' // jsdom doesn't implement matchMedia
@@ -2292,8 +2458,8 @@ function mediaQuery(query, debugName = 'mediaQuery') {
2292
2458
  * });
2293
2459
  * ```
2294
2460
  */
2295
- function prefersDarkMode(debugName) {
2296
- return mediaQuery('(prefers-color-scheme: dark)', debugName);
2461
+ function prefersDarkMode(opt) {
2462
+ return mediaQuery('(prefers-color-scheme: dark)', opt);
2297
2463
  }
2298
2464
  /**
2299
2465
  * Creates a read-only signal that tracks the user's OS/browser preference
@@ -2320,8 +2486,8 @@ function prefersDarkMode(debugName) {
2320
2486
  * });
2321
2487
  * ```
2322
2488
  */
2323
- function prefersReducedMotion(debugName) {
2324
- return mediaQuery('(prefers-reduced-motion: reduce)', debugName);
2489
+ function prefersReducedMotion(opt) {
2490
+ return mediaQuery('(prefers-reduced-motion: reduce)', opt);
2325
2491
  }
2326
2492
 
2327
2493
  /**
@@ -2370,6 +2536,7 @@ function throttled(initial, opt) {
2370
2536
  * // after the 500ms cooldown.
2371
2537
  */
2372
2538
  function throttle(source, opt) {
2539
+ const eq = opt?.equal ?? getSignalEquality(source);
2373
2540
  const ms = opt?.ms ?? 0;
2374
2541
  const leading = opt?.leading ?? false;
2375
2542
  const trailing = opt?.trailing ?? true;
@@ -2395,31 +2562,32 @@ function throttle(source, opt) {
2395
2562
  fire();
2396
2563
  else
2397
2564
  pendingTrailing = trailing;
2398
- timeout = setTimeout(() => {
2565
+ const onWindowEnd = () => {
2399
2566
  timeout = undefined;
2400
2567
  if (trailing && pendingTrailing) {
2401
2568
  pendingTrailing = false;
2402
2569
  fire();
2570
+ timeout = setTimeout(onWindowEnd, ms);
2403
2571
  }
2404
- }, ms);
2572
+ };
2573
+ timeout = setTimeout(onWindowEnd, ms);
2405
2574
  return;
2406
2575
  }
2407
2576
  if (trailing)
2408
2577
  pendingTrailing = true;
2409
2578
  };
2410
- const set = (value) => {
2411
- source.set(value);
2412
- tick();
2413
- };
2414
- const update = (fn) => {
2415
- source.update(fn);
2579
+ const set = (next) => {
2580
+ if (eq(untracked(source), next))
2581
+ return;
2582
+ source.set(next);
2416
2583
  tick();
2417
2584
  };
2585
+ const update = (fn) => set(fn(untracked(source)));
2418
2586
  const writable = toWritable(computed(() => {
2419
2587
  trigger();
2420
2588
  return untracked(source);
2421
2589
  }, opt), set, update);
2422
- writable.original = source;
2590
+ writable.original = source.asReadonly();
2423
2591
  return writable;
2424
2592
  }
2425
2593
 
@@ -2456,6 +2624,9 @@ function throttle(source, opt) {
2456
2624
  * ```
2457
2625
  */
2458
2626
  function mousePosition(opt) {
2627
+ return runInSensorContext(opt?.injector, () => createMousePosition(opt));
2628
+ }
2629
+ function createMousePosition(opt) {
2459
2630
  if (isPlatformServer(inject(PLATFORM_ID))) {
2460
2631
  const base = computed(() => ({
2461
2632
  x: 0,
@@ -2467,8 +2638,12 @@ function mousePosition(opt) {
2467
2638
  return base;
2468
2639
  }
2469
2640
  const { target = window, coordinateSpace = 'client', touch = false, debugName = 'mousePosition', throttle = 100, } = opt ?? {};
2470
- const eventTarget = target instanceof ElementRef ? target.nativeElement : target;
2471
- if (!eventTarget) {
2641
+ const resolve = (t) => {
2642
+ if (!t)
2643
+ return null;
2644
+ return t instanceof ElementRef ? t.nativeElement : t;
2645
+ };
2646
+ if (!isSignal(target) && !resolve(target)) {
2472
2647
  if (isDevMode())
2473
2648
  console.warn('mousePosition: Target element not found.');
2474
2649
  const base = computed(() => ({
@@ -2491,7 +2666,7 @@ function mousePosition(opt) {
2491
2666
  x = coordinateSpace === 'page' ? event.pageX : event.clientX;
2492
2667
  y = coordinateSpace === 'page' ? event.pageY : event.clientY;
2493
2668
  }
2494
- else if (event.touches.length > 0) {
2669
+ else if (event.touches?.length > 0) {
2495
2670
  const firstTouch = event.touches[0];
2496
2671
  x = coordinateSpace === 'page' ? firstTouch.pageX : firstTouch.clientX;
2497
2672
  y = coordinateSpace === 'page' ? firstTouch.pageY : firstTouch.clientY;
@@ -2501,16 +2676,36 @@ function mousePosition(opt) {
2501
2676
  }
2502
2677
  pos.set({ x, y });
2503
2678
  };
2504
- eventTarget.addEventListener('mousemove', updatePosition);
2505
- if (touch) {
2506
- eventTarget.addEventListener('touchmove', updatePosition);
2507
- }
2508
- inject(DestroyRef).onDestroy(() => {
2509
- eventTarget.removeEventListener('mousemove', updatePosition);
2679
+ // passive: the handler never calls preventDefault, and a non-passive touchmove on
2680
+ // window forces the browser to wait on JS before scrolling (scroll jank on touch)
2681
+ const attach = (el) => {
2682
+ const controller = new AbortController();
2683
+ el.addEventListener('mousemove', updatePosition, {
2684
+ passive: true,
2685
+ signal: controller.signal,
2686
+ });
2510
2687
  if (touch) {
2511
- eventTarget.removeEventListener('touchmove', updatePosition);
2688
+ el.addEventListener('touchmove', updatePosition, {
2689
+ passive: true,
2690
+ signal: controller.signal,
2691
+ });
2512
2692
  }
2513
- });
2693
+ return () => controller.abort();
2694
+ };
2695
+ if (isSignal(target)) {
2696
+ // re-attach whenever the signal resolves to a (new) element — covers viewChild
2697
+ effect((cleanup) => {
2698
+ const el = resolve(target());
2699
+ if (!el)
2700
+ return;
2701
+ cleanup(attach(el));
2702
+ });
2703
+ }
2704
+ else {
2705
+ const el = resolve(target);
2706
+ if (el)
2707
+ inject(DestroyRef).onDestroy(attach(el));
2708
+ }
2514
2709
  const base = pos.asReadonly();
2515
2710
  base.unthrottled = pos.original;
2516
2711
  return base;
@@ -2524,7 +2719,8 @@ const serverDate = new Date();
2524
2719
  * An additional `since` signal is attached, tracking when the status last changed.
2525
2720
  * It's SSR-safe and automatically cleans up its event listeners.
2526
2721
  *
2527
- * @param debugName Optional debug name for the signal.
2722
+ * @param opt Optional debug name for the signal, or a {@link SensorRunOptions} object
2723
+ * (with an optional `injector` for creation outside an injection context).
2528
2724
  * @returns A `NetworkStatusSignal` instance.
2529
2725
  *
2530
2726
  * @example
@@ -2535,7 +2731,11 @@ const serverDate = new Date();
2535
2731
  * });
2536
2732
  * ```
2537
2733
  */
2538
- function networkStatus(debugName = 'networkStatus') {
2734
+ function networkStatus(opt) {
2735
+ const { debugName = 'networkStatus', injector } = coerceSensorOptions(opt);
2736
+ return runInSensorContext(injector, () => createNetworkStatus(debugName));
2737
+ }
2738
+ function createNetworkStatus(debugName) {
2539
2739
  if (isPlatformServer(inject(PLATFORM_ID))) {
2540
2740
  const sig = computed(() => true, {
2541
2741
  debugName,
@@ -2585,7 +2785,11 @@ const SSR_FALLBACK = {
2585
2785
  * });
2586
2786
  * ```
2587
2787
  */
2588
- function orientation(debugName = 'orientation') {
2788
+ function orientation(opt) {
2789
+ const { debugName = 'orientation', injector } = coerceSensorOptions(opt);
2790
+ return runInSensorContext(injector, () => createOrientation(debugName));
2791
+ }
2792
+ function createOrientation(debugName) {
2589
2793
  if (isPlatformServer(inject(PLATFORM_ID)) ||
2590
2794
  typeof screen === 'undefined' ||
2591
2795
  !screen.orientation) {
@@ -2615,7 +2819,8 @@ function orientation(debugName = 'orientation') {
2615
2819
  * The primitive is SSR-safe and automatically cleans up its event listeners
2616
2820
  * when the creating context is destroyed.
2617
2821
  *
2618
- * @param debugName Optional debug name for the signal.
2822
+ * @param opt Optional debug name for the signal, or a {@link SensorRunOptions} object
2823
+ * (with an optional `injector` for creation outside an injection context).
2619
2824
  * @returns A read-only `Signal<DocumentVisibilityState>`. On the server,
2620
2825
  * it returns a static signal with a value of `'visible'`.
2621
2826
  *
@@ -2643,7 +2848,11 @@ function orientation(debugName = 'orientation') {
2643
2848
  * }
2644
2849
  * ```
2645
2850
  */
2646
- function pageVisibility(debugName = 'pageVisibility') {
2851
+ function pageVisibility(opt) {
2852
+ const { debugName = 'pageVisibility', injector } = coerceSensorOptions(opt);
2853
+ return runInSensorContext(injector, () => createPageVisibility(debugName));
2854
+ }
2855
+ function createPageVisibility(debugName) {
2647
2856
  if (isPlatformServer(inject(PLATFORM_ID))) {
2648
2857
  return computed(() => 'visible', { debugName });
2649
2858
  }
@@ -2675,31 +2884,25 @@ function pageVisibility(debugName = 'pageVisibility') {
2675
2884
  * selector: 'app-scroll-tracker',
2676
2885
  * template: `
2677
2886
  * <p>Window Scroll: X: {{ windowScroll().x }}, Y: {{ windowScroll().y }}</p>
2678
- * <div #scrollableDiv style="height: 200px; width: 200px; overflow: auto; border: 1px solid black;">
2679
- * <div style="height: 400px; width: 400px;">Scroll me!</div>
2680
- * </div>
2681
- * @if (divScroll()) {
2682
- * <p>Div Scroll: X: {{ divScroll().x }}, Y: {{ divScroll().y }}</p>
2683
- * }
2887
+ * <p>Host Scroll: X: {{ hostScroll().x }}, Y: {{ hostScroll().y }}</p>
2684
2888
  * `
2685
2889
  * })
2686
2890
  * export class ScrollTrackerComponent {
2687
2891
  * readonly windowScroll = scrollPosition(); // Defaults to window
2892
+ * // Signal targets (e.g. viewChild) attach once the element exists:
2688
2893
  * readonly scrollableDiv = viewChild<ElementRef<HTMLDivElement>>('scrollableDiv');
2689
- * readonly divScroll = scrollPosition({ target: this.scrollableDiv() }); // Example with element target
2894
+ * readonly divScroll = scrollPosition({ target: this.scrollableDiv });
2690
2895
  *
2691
2896
  * constructor() {
2692
- * effect(() => {
2693
- * console.log('Window scrolled to:', this.windowScroll());
2694
- * if (this.divScroll()) {
2695
- * console.log('Div scrolled to:', this.divScroll());
2696
- * }
2697
- * });
2897
+ * effect(() => console.log('Window scrolled to:', this.windowScroll()));
2698
2898
  * }
2699
2899
  * }
2700
2900
  * ```
2701
2901
  */
2702
2902
  function scrollPosition(opt) {
2903
+ return runInSensorContext(opt?.injector, () => createScrollPosition(opt));
2904
+ }
2905
+ function createScrollPosition(opt) {
2703
2906
  if (isPlatformServer(inject(PLATFORM_ID))) {
2704
2907
  const base = computed(() => ({
2705
2908
  x: 0,
@@ -2711,40 +2914,44 @@ function scrollPosition(opt) {
2711
2914
  return base;
2712
2915
  }
2713
2916
  const { target = window, throttle = 100, debugName = 'scrollPosition', } = opt || {};
2714
- let element;
2715
- let getScrollPosition;
2716
- if (target instanceof Window) {
2717
- element = target;
2718
- getScrollPosition = () => {
2719
- return { x: target.scrollX, y: target.scrollY };
2720
- };
2721
- }
2722
- else if (target instanceof ElementRef) {
2723
- element = target.nativeElement;
2724
- getScrollPosition = () => {
2725
- return {
2726
- x: target.nativeElement.scrollLeft,
2727
- y: target.nativeElement.scrollTop,
2728
- };
2729
- };
2730
- }
2731
- else {
2732
- element = target;
2733
- getScrollPosition = () => {
2734
- return {
2735
- x: target.scrollLeft,
2736
- y: target.scrollTop,
2737
- };
2738
- };
2739
- }
2740
- const state = throttled(getScrollPosition(), {
2917
+ const resolve = (t) => {
2918
+ if (!t)
2919
+ return null;
2920
+ return t instanceof ElementRef ? t.nativeElement : t;
2921
+ };
2922
+ const isWindow = (el) => el.window === el;
2923
+ const readPosition = (el) => isWindow(el)
2924
+ ? {
2925
+ x: el.scrollX ?? el.pageXOffset ?? 0,
2926
+ y: el.scrollY ?? el.pageYOffset ?? 0,
2927
+ }
2928
+ : { x: el.scrollLeft, y: el.scrollTop };
2929
+ const initial = resolve(isSignal(target) ? untracked(target) : target);
2930
+ const state = throttled(initial ? readPosition(initial) : { x: 0, y: 0 }, {
2741
2931
  debugName,
2742
2932
  equal: (a, b) => a.x === b.x && a.y === b.y,
2743
2933
  ms: throttle,
2744
2934
  });
2745
- const onScroll = () => state.set(getScrollPosition());
2746
- element.addEventListener('scroll', onScroll, { passive: true });
2747
- inject(DestroyRef).onDestroy(() => element.removeEventListener('scroll', onScroll));
2935
+ if (isSignal(target)) {
2936
+ // re-attach whenever the signal resolves to a (new) element — covers viewChild
2937
+ effect((cleanup) => {
2938
+ const el = resolve(target());
2939
+ if (!el)
2940
+ return;
2941
+ state.set(readPosition(el)); // sync to the new element immediately
2942
+ const onScroll = () => state.set(readPosition(el));
2943
+ el.addEventListener('scroll', onScroll, { passive: true });
2944
+ cleanup(() => el.removeEventListener('scroll', onScroll));
2945
+ });
2946
+ }
2947
+ else {
2948
+ const el = resolve(target);
2949
+ if (el) {
2950
+ const onScroll = () => state.set(readPosition(el));
2951
+ el.addEventListener('scroll', onScroll, { passive: true });
2952
+ inject(DestroyRef).onDestroy(() => el.removeEventListener('scroll', onScroll));
2953
+ }
2954
+ }
2748
2955
  const base = state.asReadonly();
2749
2956
  base.unthrottled = state.original;
2750
2957
  return base;
@@ -2792,6 +2999,9 @@ function scrollPosition(opt) {
2792
2999
  * ```
2793
3000
  */
2794
3001
  function windowSize(opt) {
3002
+ return runInSensorContext(opt?.injector, () => createWindowSize(opt));
3003
+ }
3004
+ function createWindowSize(opt) {
2795
3005
  if (isPlatformServer(inject(PLATFORM_ID))) {
2796
3006
  const base = computed(() => ({
2797
3007
  width: 1024,
@@ -2828,17 +3038,19 @@ function sensor(type, options) {
2828
3038
  case 'mousePosition':
2829
3039
  return mousePosition(opts);
2830
3040
  case 'networkStatus':
2831
- return networkStatus(opts?.debugName);
3041
+ return networkStatus(opts);
2832
3042
  case 'pageVisibility':
2833
- return pageVisibility(opts?.debugName);
3043
+ return pageVisibility(opts);
2834
3044
  case 'darkMode':
2835
3045
  case 'dark-mode':
2836
- return prefersDarkMode(opts?.debugName);
3046
+ return prefersDarkMode(opts);
2837
3047
  case 'reducedMotion':
2838
3048
  case 'reduced-motion':
2839
- return prefersReducedMotion(opts?.debugName);
3049
+ return prefersReducedMotion(opts);
2840
3050
  case 'mediaQuery':
2841
- return mediaQuery(opts.query, opts.debugName);
3051
+ if (typeof opts?.query !== 'string')
3052
+ throw new Error(`sensor('mediaQuery') requires a 'query' option, e.g. sensor('mediaQuery', { query: '(min-width: 1024px)' })`);
3053
+ return mediaQuery(opts.query, opts);
2842
3054
  case 'windowSize':
2843
3055
  return windowSize(opts);
2844
3056
  case 'scrollPosition':
@@ -2850,15 +3062,15 @@ function sensor(type, options) {
2850
3062
  case 'geolocation':
2851
3063
  return geolocation(opts);
2852
3064
  case 'clipboard':
2853
- return clipboard(opts?.debugName);
3065
+ return clipboard(opts);
2854
3066
  case 'orientation':
2855
- return orientation(opts?.debugName);
3067
+ return orientation(opts);
2856
3068
  case 'batteryStatus':
2857
- return batteryStatus(opts?.debugName);
3069
+ return batteryStatus(opts);
2858
3070
  case 'idle':
2859
3071
  return idle(opts);
2860
3072
  case 'focusWithin':
2861
- return focusWithin(opts?.target);
3073
+ return focusWithin(opts?.target, opts);
2862
3074
  default:
2863
3075
  throw new Error(`Unknown sensor type: ${type}`);
2864
3076
  }
@@ -2912,16 +3124,24 @@ function signalFromEvent(target, eventName, initial, projectOrOpt, maybeOpt) {
2912
3124
  else
2913
3125
  state.set(event);
2914
3126
  };
2915
- const { destroyRef: providedDestroyRef, ...listenerOpts } = opt ?? {};
3127
+ const { destroyRef: providedDestroyRef,
3128
+ // strip non-listener keys so they don't leak into addEventListener options
3129
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
3130
+ injector: _injector,
3131
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
3132
+ debugName: _debugName, ...listenerOpts } = opt ?? {};
2916
3133
  if (isSignal(target)) {
2917
3134
  const targetSig = target;
2918
- effect((cleanup) => {
3135
+ const effectRef = effect((cleanup) => {
2919
3136
  const resolved = unwrap(targetSig());
2920
3137
  if (!resolved)
2921
3138
  return;
2922
3139
  resolved.addEventListener(eventName, handler, listenerOpts);
2923
3140
  cleanup(() => resolved.removeEventListener(eventName, handler, listenerOpts));
2924
- }, { injector });
3141
+ }, ...(ngDevMode ? [{ debugName: "effectRef", injector }] : [{ injector }]));
3142
+ // honor an explicit destroyRef for signal targets too — the effect would otherwise
3143
+ // only follow the injector's lifetime, contradicting the documented option
3144
+ providedDestroyRef?.onDestroy(() => effectRef.destroy());
2925
3145
  }
2926
3146
  else {
2927
3147
  const resolved = unwrap(target);
@@ -3007,7 +3227,8 @@ function alwaysFalse() {
3007
3227
  * @internal Attaches a lazy, memoized leaf probe to a store node. The probe (`() => boolean`)
3008
3228
  * closes over the node's value signal and its (stable) vivify setting, building the backing
3009
3229
  * `computed` on first call so leaf-ness tracks the live value reactively without taxing every
3010
- * node access. Idempotent.
3230
+ * node access. Under `noUnionLeaves` the caller promises shapes never flip, so the probe is
3231
+ * resolved once from the first sample and frozen as a constant. Idempotent.
3011
3232
  */
3012
3233
  function markAsLeaf(sig, value, vivifyEnabled, noUnionLeaves) {
3013
3234
  if (typeof sig[LEAF] !== 'function') {
@@ -3015,13 +3236,11 @@ function markAsLeaf(sig, value, vivifyEnabled, noUnionLeaves) {
3015
3236
  const probe = () => {
3016
3237
  if (memo)
3017
3238
  return memo();
3018
- const v = untracked(value);
3019
- memo =
3020
- isOpaque(v) || (v == null && !vivifyEnabled) || noUnionLeaves
3021
- ? isLeafValue(v, vivifyEnabled)
3022
- ? alwaysTrue
3023
- : alwaysFalse
3024
- : computed(() => isLeafValue(value(), vivifyEnabled));
3239
+ memo = noUnionLeaves
3240
+ ? isLeafValue(untracked(value), vivifyEnabled)
3241
+ ? alwaysTrue
3242
+ : alwaysFalse
3243
+ : computed(() => isLeafValue(value(), vivifyEnabled));
3025
3244
  return memo();
3026
3245
  };
3027
3246
  Object.defineProperty(sig, LEAF, {
@@ -3109,6 +3328,40 @@ function resolveVivify(sample, option) {
3109
3328
  function hasOwnKey(value, key) {
3110
3329
  return value != null && Object.hasOwn(value, key);
3111
3330
  }
3331
+ /**
3332
+ * @internal
3333
+ * Builds the `onChange` for the fallback (non-record container) derivation branch. For an
3334
+ * immutable source the container is copied before the write — returning the same mutated
3335
+ * reference would let the source's equality cut propagation (leaving child signals permanently
3336
+ * stale) and alias the caller's original object, breaking the structural-sharing contract
3337
+ * `forkStore` relies on. For a mutable source the write goes through `mutate`, so the chain's
3338
+ * force-notify engages (plain `update` with the same reference would never notify).
3339
+ */
3340
+ function createFallbackOnChange(target, prop, vivifyFn, isMutableSource) {
3341
+ const write = (newValue) => (v) => {
3342
+ const container = vivifyFn(v, prop);
3343
+ if (container === null || container === undefined)
3344
+ return container;
3345
+ const next = isMutableSource
3346
+ ? container
3347
+ : Array.isArray(container)
3348
+ ? container.slice()
3349
+ : isRecord(container)
3350
+ ? { ...container }
3351
+ : container; // non-plain leaf (Date/class instance): legacy in-place attempt
3352
+ try {
3353
+ next[prop] = newValue;
3354
+ }
3355
+ catch (e) {
3356
+ if (isDevMode())
3357
+ console.error(`[store] Failed to set property "${String(prop)}"`, e);
3358
+ }
3359
+ return next;
3360
+ };
3361
+ return isMutableSource
3362
+ ? (newValue) => target.mutate(write(newValue))
3363
+ : (newValue) => target.update(write(newValue));
3364
+ }
3112
3365
  /**
3113
3366
  * @internal
3114
3367
  * Makes an array store
@@ -3131,7 +3384,9 @@ function toArrayStore(source, injector, vivify, noUnionLeaves = false) {
3131
3384
  const idx = +prop;
3132
3385
  return idx >= 0 && idx < untracked(lengthSignal);
3133
3386
  }
3134
- return Reflect.has(untracked(source), prop);
3387
+ const v = untracked(source);
3388
+ // nullish node values are routinely descended with vivify on — `in` must not throw
3389
+ return v == null ? false : Reflect.has(v, prop);
3135
3390
  },
3136
3391
  ownKeys() {
3137
3392
  const v = untracked(source);
@@ -3168,7 +3423,9 @@ function toArrayStore(source, injector, vivify, noUnionLeaves = false) {
3168
3423
  return lengthSignal;
3169
3424
  if (prop === Symbol.iterator) {
3170
3425
  return function* () {
3171
- for (let i = 0; i < untracked(lengthSignal); i++) {
3426
+ // read length reactively: a spread/for-of inside a computed/effect must re-run
3427
+ // when items are added or removed, not only when already-read elements change
3428
+ for (let i = 0; i < lengthSignal(); i++) {
3172
3429
  yield receiver[i];
3173
3430
  }
3174
3431
  };
@@ -3207,19 +3464,8 @@ function toArrayStore(source, injector, vivify, noUnionLeaves = false) {
3207
3464
  })
3208
3465
  : derived(target, {
3209
3466
  from: (v) => v?.[idx],
3210
- onChange: (newValue) => target.update((v) => {
3211
- const container = vivifyFn(v, idx);
3212
- if (container === null || container === undefined)
3213
- return container;
3214
- try {
3215
- container[idx] = newValue;
3216
- }
3217
- catch (e) {
3218
- if (isDevMode())
3219
- console.error(`[store] Failed to set property "${String(idx)}"`, e);
3220
- }
3221
- return container;
3222
- }),
3467
+ onChange: createFallbackOnChange(target, idx, vivifyFn, isMutableSource),
3468
+ equal: equalFn,
3223
3469
  });
3224
3470
  const childSample = untracked(computation);
3225
3471
  const childVivify = resolveVivify(childSample, vivify);
@@ -3239,6 +3485,13 @@ function toArrayStore(source, injector, vivify, noUnionLeaves = false) {
3239
3485
  /**
3240
3486
  * Converts a Signal into a deep-observable Store.
3241
3487
  * Accessing nested properties returns a derived Signal of that path.
3488
+ *
3489
+ * @remarks
3490
+ * A child's *container kind* (array store vs object store) is resolved when the child is
3491
+ * first accessed and cached with the proxy. Leaf↔substore flips are tracked reactively, but a
3492
+ * union-typed node that later flips between an array and a record keeps its original trap set —
3493
+ * if you need that, re-model the union as `{ kind: ..., value: ... }` instead.
3494
+ *
3242
3495
  * @example
3243
3496
  * const state = store({ user: { name: 'John' } });
3244
3497
  * const nameSignal = state.user.name; // WritableSignal<string>
@@ -3321,19 +3574,8 @@ function toStore(source, injector, vivify = false, noUnionLeaves = false) {
3321
3574
  ? derived(target, prop, { equal: equalFn, vivify: nodeVivify })
3322
3575
  : derived(target, {
3323
3576
  from: (v) => v?.[prop],
3324
- onChange: (newValue) => target.update((v) => {
3325
- const container = vivifyFn(v, prop);
3326
- if (container === null || container === undefined)
3327
- return container;
3328
- try {
3329
- container[prop] = newValue;
3330
- }
3331
- catch (e) {
3332
- if (isDevMode())
3333
- console.error(`[store] Failed to set property "${String(prop)}"`, e);
3334
- }
3335
- return container;
3336
- }),
3577
+ onChange: createFallbackOnChange(target, prop, vivifyFn, isMutableSource),
3578
+ equal: equalFn,
3337
3579
  });
3338
3580
  const childSample = untracked(computation);
3339
3581
  const childVivify = resolveVivify(childSample, vivify);
@@ -3475,7 +3717,12 @@ function merge3(ancestor, mine, theirs) {
3475
3717
  if (isPlainRecord(mine) && isPlainRecord(theirs) && isPlainRecord(ancestor)) {
3476
3718
  const out = { ...theirs };
3477
3719
  for (const key of new Set([...Object.keys(mine), ...Object.keys(theirs)])) {
3478
- out[key] = merge3(ancestor[key], mine[key], theirs[key]);
3720
+ const merged = merge3(ancestor[key], mine[key], theirs[key]);
3721
+ // a key deleted on the fork must commit as ABSENT, not as an explicit `undefined`
3722
+ if (merged === undefined && !(key in mine))
3723
+ delete out[key];
3724
+ else
3725
+ out[key] = merged;
3479
3726
  }
3480
3727
  return out;
3481
3728
  }
@@ -3530,8 +3777,8 @@ const noopStore = {
3530
3777
  *
3531
3778
  * @template T The type of value held by the signal and stored (after serialization).
3532
3779
  * @param fallback The default value of type `T` to use when no value is found in storage
3533
- * or when deserialization fails. The signal's value will never be `null` or `undefined`
3534
- * publicly, it will always revert to this fallback.
3780
+ * or when deserialization fails. A stored value (including a legitimate `null` for a
3781
+ * nullable `T`) always round-trips; the fallback only surfaces when the entry is absent.
3535
3782
  * @param options Configuration options (`CreateStoredOptions<T>`). Requires at least the `key`.
3536
3783
  * @returns A `StoredSignal<T>` instance. This signal behaves like a standard `WritableSignal<T>`,
3537
3784
  * but its value is persisted. It includes a `.clear()` method to remove the item from storage
@@ -3544,7 +3791,8 @@ const noopStore = {
3544
3791
  * - **Error Handling:** Catches and logs errors during serialization/deserialization in dev mode.
3545
3792
  * - **Tab Sync:** If `syncTabs` is true, listens to `storage` events to keep the signal value
3546
3793
  * consistent across browser tabs using the same key. Cleanup is handled automatically
3547
- * using `DestroyRef`.
3794
+ * using `DestroyRef`. Web Storage only: the `storage` event never fires for custom `store`
3795
+ * adapters, so `syncTabs` has no effect with one.
3548
3796
  * - **Removal:** Use the `.clear()` method on the returned signal to remove the item from storage.
3549
3797
  * Setting the signal to the fallback value will store the fallback value, not remove the item.
3550
3798
  *
@@ -3579,25 +3827,28 @@ function stored(fallback, { key, store: providedStore, serialize = JSON.stringif
3579
3827
  : isSignal(key)
3580
3828
  ? key
3581
3829
  : computed(key);
3830
+ // "no stored value" marker — distinct from `null`/`undefined`, so a nullable `T` can
3831
+ // round-trip a legitimate `null` through `set` instead of it acting like `clear()`
3832
+ const EMPTY = Symbol();
3582
3833
  const getValue = (key) => {
3583
3834
  const found = store.getItem(key);
3584
3835
  if (found === null)
3585
- return null;
3836
+ return EMPTY;
3586
3837
  try {
3587
3838
  const deserialized = deserialize(found);
3588
3839
  if (!validate(deserialized))
3589
- return null;
3840
+ return EMPTY;
3590
3841
  return deserialized;
3591
3842
  }
3592
3843
  catch (err) {
3593
3844
  if (isDevMode())
3594
3845
  console.error(`Failed to parse stored value for key "${key}":`, err);
3595
- return null;
3846
+ return EMPTY;
3596
3847
  }
3597
3848
  };
3598
3849
  const storeValue = (key, value) => {
3599
3850
  try {
3600
- if (value === null)
3851
+ if (value === EMPTY)
3601
3852
  return store.removeItem(key);
3602
3853
  const serialized = serialize(value);
3603
3854
  store.setItem(key, serialized);
@@ -3614,17 +3865,17 @@ function stored(fallback, { key, store: providedStore, serialize = JSON.stringif
3614
3865
  const initialKey = untracked(keySig);
3615
3866
  const internal = signal(getValue(initialKey), ...(ngDevMode ? [{ debugName: "internal", ...opt,
3616
3867
  equal: (a, b) => {
3617
- if (a === null && b === null)
3868
+ if (a === EMPTY && b === EMPTY)
3618
3869
  return true;
3619
- if (a === null || b === null)
3870
+ if (a === EMPTY || b === EMPTY)
3620
3871
  return false;
3621
3872
  return equal(a, b);
3622
3873
  } }] : [{
3623
3874
  ...opt,
3624
3875
  equal: (a, b) => {
3625
- if (a === null && b === null)
3876
+ if (a === EMPTY && b === EMPTY)
3626
3877
  return true;
3627
- if (a === null || b === null)
3878
+ if (a === EMPTY || b === EMPTY)
3628
3879
  return false;
3629
3880
  return equal(a, b);
3630
3881
  },
@@ -3660,19 +3911,27 @@ function stored(fallback, { key, store: providedStore, serialize = JSON.stringif
3660
3911
  if (syncTabs && !isServer) {
3661
3912
  const destroyRef = inject(DestroyRef);
3662
3913
  const sync = (e) => {
3914
+ // `storage` events only describe Web Storage — ignore events for a different
3915
+ // storage area (or any event when a custom adapter is configured), otherwise an
3916
+ // unrelated localStorage write with the same key string corrupts our state
3917
+ if (e.storageArea !== store)
3918
+ return;
3663
3919
  if (e.key !== untracked(keySig))
3664
3920
  return;
3665
3921
  if (e.newValue === null)
3666
- internal.set(null);
3922
+ internal.set(EMPTY);
3667
3923
  else
3668
3924
  internal.set(getValue(e.key));
3669
3925
  };
3670
3926
  window.addEventListener('storage', sync);
3671
3927
  destroyRef.onDestroy(() => window.removeEventListener('storage', sync));
3672
3928
  }
3673
- const writable = toWritable(computed(() => internal() ?? fallback, opt), internal.set);
3929
+ const writable = toWritable(computed(() => {
3930
+ const v = internal();
3931
+ return v === EMPTY ? fallback : v;
3932
+ }, opt), internal.set);
3674
3933
  writable.clear = () => {
3675
- internal.set(null);
3934
+ internal.set(EMPTY);
3676
3935
  };
3677
3936
  writable.key = keySig;
3678
3937
  return writable;
@@ -3682,7 +3941,6 @@ class MessageBus {
3682
3941
  channel = new BroadcastChannel('mmstack-tab-sync-bus');
3683
3942
  listeners = new Map();
3684
3943
  subscribe(id, listener) {
3685
- this.unsubscribe(id); // Ensure no duplicate listeners
3686
3944
  const wrapped = (ev) => {
3687
3945
  try {
3688
3946
  if (ev.data?.id === id)
@@ -3693,18 +3951,28 @@ class MessageBus {
3693
3951
  }
3694
3952
  };
3695
3953
  this.channel.addEventListener('message', wrapped);
3696
- this.listeners.set(id, wrapped);
3954
+ let set = this.listeners.get(id);
3955
+ if (!set) {
3956
+ set = new Set();
3957
+ this.listeners.set(id, set);
3958
+ }
3959
+ set.add(wrapped);
3697
3960
  return {
3698
- unsub: (() => this.unsubscribe(id)).bind(this),
3699
- post: ((value) => this.channel.postMessage({ id, value })).bind(this),
3961
+ unsub: () => {
3962
+ this.channel.removeEventListener('message', wrapped);
3963
+ const cur = this.listeners.get(id);
3964
+ if (!cur)
3965
+ return;
3966
+ cur.delete(wrapped);
3967
+ if (cur.size === 0)
3968
+ this.listeners.delete(id);
3969
+ },
3970
+ post: (value) => this.channel.postMessage({ id, value }),
3700
3971
  };
3701
3972
  }
3702
- unsubscribe(id) {
3703
- const listener = this.listeners.get(id);
3704
- if (!listener)
3705
- return;
3706
- this.channel.removeEventListener('message', listener);
3707
- this.listeners.delete(id);
3973
+ ngOnDestroy() {
3974
+ this.channel.close();
3975
+ this.listeners.clear();
3708
3976
  }
3709
3977
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.17", ngImport: i0, type: MessageBus, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
3710
3978
  static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.17", ngImport: i0, type: MessageBus, providedIn: 'root' });
@@ -3715,6 +3983,11 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.17", ngImpo
3715
3983
  providedIn: 'root',
3716
3984
  }]
3717
3985
  }] });
3986
+ /**
3987
+ * @deprecated The generated id hashes the call-site stack line, which collides when a shared
3988
+ * helper calls {@link tabSync} for multiple signals and diverges across minified builds during
3989
+ * a rolling deploy. Pass an explicit `{ id }` instead.
3990
+ */
3718
3991
  function generateDeterministicID() {
3719
3992
  const stack = new Error().stack;
3720
3993
  if (stack) {
@@ -3752,10 +4025,8 @@ function generateDeterministicID() {
3752
4025
  *
3753
4026
  * @example
3754
4027
  * ```typescript
3755
- * // Basic usage - auto-generates channel ID from call site
3756
- * const theme = tabSync(signal('dark'));
3757
- *
3758
- * // With explicit ID (recommended for production)
4028
+ * // With explicit ID (recommended)
4029
+ * const theme = tabSync(signal('dark'), { id: 'theme' });
3759
4030
  * const userPrefs = tabSync(signal({ lang: 'en' }), { id: 'user-preferences' });
3760
4031
  *
3761
4032
  * // Changes in one tab will sync to all other tabs
@@ -3767,6 +4038,7 @@ function generateDeterministicID() {
3767
4038
  * - Uses a single BroadcastChannel for all synchronized signals
3768
4039
  * - Automatically cleans up listeners when the injection context is destroyed
3769
4040
  * - Initial signal value after sync setup is not broadcasted to prevent loops
4041
+ * - Received values are not re-broadcast, so tabs never echo each other's updates
3770
4042
  *
3771
4043
  */
3772
4044
  function tabSync(sig, opt) {
@@ -3774,7 +4046,20 @@ function tabSync(sig, opt) {
3774
4046
  return sig;
3775
4047
  const id = typeof opt === 'string' ? opt : (opt?.id ?? generateDeterministicID());
3776
4048
  const bus = inject(MessageBus);
3777
- const { unsub, post } = bus.subscribe(id, (next) => sig.set(next));
4049
+ // The last value applied from a remote tab. The outbound effect skips (exactly) the run
4050
+ // caused by that write — without this, an inbound object (a fresh structured clone, so
4051
+ // never reference-equal) would be re-posted, and two tabs would ping-pong forever.
4052
+ const NONE = Symbol();
4053
+ let received = NONE;
4054
+ const { unsub, post } = bus.subscribe(id, (next) => {
4055
+ const before = untracked(sig);
4056
+ received = next;
4057
+ sig.set(next);
4058
+ // Equality-suppressed write (e.g. an identical primitive): no effect run will follow,
4059
+ // so clear the marker — it must not swallow a later, genuinely local change.
4060
+ if (untracked(sig) === before)
4061
+ received = NONE;
4062
+ });
3778
4063
  let first = false;
3779
4064
  const effectRef = effect(() => {
3780
4065
  const val = sig();
@@ -3782,6 +4067,11 @@ function tabSync(sig, opt) {
3782
4067
  first = true;
3783
4068
  return;
3784
4069
  }
4070
+ if (val === received) {
4071
+ received = NONE;
4072
+ return;
4073
+ }
4074
+ received = NONE;
3785
4075
  post(val);
3786
4076
  }, ...(ngDevMode ? [{ debugName: "effectRef" }] : []));
3787
4077
  inject(DestroyRef).onDestroy(() => {
@@ -3792,7 +4082,6 @@ function tabSync(sig, opt) {
3792
4082
  }
3793
4083
 
3794
4084
  function until(sourceSignal, predicate, options = {}) {
3795
- const injector = options.injector ?? inject(Injector);
3796
4085
  return new Promise((resolve, reject) => {
3797
4086
  let effectRef;
3798
4087
  let timeoutId;
@@ -3829,6 +4118,14 @@ function until(sourceSignal, predicate, options = {}) {
3829
4118
  cleanupAndResolve(initialValue);
3830
4119
  return;
3831
4120
  }
4121
+ let injector;
4122
+ try {
4123
+ injector = options.injector ?? inject(Injector);
4124
+ }
4125
+ catch {
4126
+ cleanupAndReject('until: No injector available — provide options.injector when calling outside an injection context.');
4127
+ return;
4128
+ }
3832
4129
  if (options?.timeout !== undefined) {
3833
4130
  timeoutId = setTimeout(() => cleanupAndReject(`until: Timeout after ${options.timeout}ms.`), options.timeout);
3834
4131
  }
@@ -3846,17 +4143,6 @@ function until(sourceSignal, predicate, options = {}) {
3846
4143
  });
3847
4144
  }
3848
4145
 
3849
- /**
3850
- * @interal
3851
- */
3852
- function getSignalEquality(sig) {
3853
- const internal = sig[SIGNAL];
3854
- if (internal && typeof internal.equal === 'function') {
3855
- return internal.equal;
3856
- }
3857
- return Object.is; // Default equality check
3858
- }
3859
-
3860
4146
  /**
3861
4147
  * Enhances an existing `WritableSignal` by adding a complete undo/redo history
3862
4148
  * stack and an API to control it.
@@ -3905,9 +4191,10 @@ function getSignalEquality(sig) {
3905
4191
  * ```
3906
4192
  */
3907
4193
  function withHistory(sourceOrValue, opt) {
3908
- const equal = (opt?.equal ?? isSignal(sourceOrValue))
3909
- ? getSignalEquality(sourceOrValue)
3910
- : Object.is;
4194
+ const equal = opt?.equal ??
4195
+ (isSignal(sourceOrValue)
4196
+ ? getSignalEquality(sourceOrValue)
4197
+ : Object.is);
3911
4198
  const source = isSignal(sourceOrValue)
3912
4199
  ? sourceOrValue
3913
4200
  : signal(sourceOrValue);
@@ -3952,9 +4239,8 @@ function withHistory(sourceOrValue, opt) {
3952
4239
  if (historyStack.length === 0)
3953
4240
  return;
3954
4241
  const valueForRedo = untracked(source);
3955
- const valueToRestore = historyStack.at(-1);
3956
- if (valueToRestore === undefined)
3957
- return;
4242
+ // length checked above — a legitimately `undefined` entry must still restore
4243
+ const valueToRestore = historyStack[historyStack.length - 1];
3958
4244
  originalSet.call(source, valueToRestore);
3959
4245
  history.inline((h) => h.pop());
3960
4246
  redoArray.mutate((r) => {
@@ -3968,9 +4254,8 @@ function withHistory(sourceOrValue, opt) {
3968
4254
  if (redoStack.length === 0)
3969
4255
  return;
3970
4256
  const valueForUndo = untracked(source);
3971
- const valueToRestore = redoStack.at(-1);
3972
- if (valueToRestore === undefined)
3973
- return;
4257
+ // length checked above — a legitimately `undefined` entry must still restore
4258
+ const valueToRestore = redoStack[redoStack.length - 1];
3974
4259
  originalSet.call(source, valueToRestore);
3975
4260
  redoArray.inline((r) => r.pop());
3976
4261
  history.mutate((h) => {