@mmstack/primitives 19.4.1 → 19.4.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.
Files changed (36) hide show
  1. package/fesm2022/mmstack-primitives.mjs +526 -242
  2. package/fesm2022/mmstack-primitives.mjs.map +1 -1
  3. package/index.d.ts +2 -2
  4. package/lib/chunked.d.ts +2 -2
  5. package/lib/concurrent/pausable.d.ts +3 -2
  6. package/lib/concurrent/start-transition.d.ts +5 -0
  7. package/lib/concurrent/transaction.d.ts +5 -0
  8. package/lib/effect/index.d.ts +1 -0
  9. package/lib/get-signal-equality.d.ts +1 -1
  10. package/lib/mutable.d.ts +1 -1
  11. package/lib/pipeable/{pipeble.d.ts → pipeable.d.ts} +1 -1
  12. package/lib/pipeable/public_api.d.ts +1 -1
  13. package/lib/pipeable/types.d.ts +1 -1
  14. package/lib/sensors/battery-status.d.ts +2 -1
  15. package/lib/sensors/clipboard.d.ts +2 -1
  16. package/lib/sensors/element-size.d.ts +2 -4
  17. package/lib/sensors/element-visibility.d.ts +2 -4
  18. package/lib/sensors/focus-within.d.ts +2 -1
  19. package/lib/sensors/geolocation.d.ts +2 -3
  20. package/lib/sensors/idle.d.ts +6 -4
  21. package/lib/sensors/index.d.ts +1 -0
  22. package/lib/sensors/media-query.d.ts +4 -3
  23. package/lib/sensors/mouse-position.d.ts +6 -7
  24. package/lib/sensors/network-status.d.ts +8 -3
  25. package/lib/sensors/orientation.d.ts +9 -2
  26. package/lib/sensors/page-visibility.d.ts +4 -2
  27. package/lib/sensors/scroll-position.d.ts +10 -18
  28. package/lib/sensors/sensor-options.d.ts +25 -0
  29. package/lib/sensors/sensor.d.ts +15 -30
  30. package/lib/sensors/window-size.d.ts +3 -6
  31. package/lib/store/store.d.ts +4 -4
  32. package/lib/stored.d.ts +4 -3
  33. package/lib/{tabSync.d.ts → tab-sync.d.ts} +9 -4
  34. package/lib/throttled.d.ts +2 -0
  35. package/lib/to-writable.d.ts +4 -0
  36. package/package.json +1 -1
@@ -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) => {
@@ -264,7 +271,9 @@ class MmActivity {
264
271
  });
265
272
  }
266
273
  for (const node of this.view.rootNodes) {
267
- if (node instanceof HTMLElement)
274
+ // covers HTML and SVG roots; text/comment roots can't be styled — their CD is still
275
+ // detached, but prefer an element root for true visual hiding
276
+ if (node instanceof HTMLElement || node instanceof SVGElement)
268
277
  node.style.display = visible ? '' : 'none';
269
278
  }
270
279
  if (visible)
@@ -343,14 +352,25 @@ function resolvePause(opt) {
343
352
  if (pause === false)
344
353
  return null;
345
354
  const run = (fn) => opt?.injector ? runInInjectionContext(opt.injector, fn) : fn();
355
+ // `inject` requires an injection context even with `optional: true`. A bare
356
+ // `pausableSignal(0)` (documented as "like `signal`") must degrade to the unwrapped
357
+ // primitive outside DI, not throw NG0203 — so injection failures fall back gracefully.
358
+ const tryRun = (fn, fallback) => {
359
+ try {
360
+ return run(fn);
361
+ }
362
+ catch {
363
+ return fallback;
364
+ }
365
+ };
346
366
  const onServer = () => typeof pause === 'function' && !opt?.injector
347
367
  ? typeof globalThis.window === 'undefined'
348
- : run(() => isPlatformServer(inject(PLATFORM_ID, { optional: true }) ?? 'browser'));
368
+ : tryRun(() => isPlatformServer(inject(PLATFORM_ID, { optional: true }) ?? 'browser'), typeof globalThis.window === 'undefined');
349
369
  if (typeof pause === 'function')
350
370
  return onServer() ? null : pause;
351
371
  if (onServer())
352
372
  return null;
353
- const paused = run(() => inject(PAUSED_CONTEXT, { optional: true }));
373
+ const paused = tryRun(() => inject(PAUSED_CONTEXT, { optional: true }), null);
354
374
  if (!paused) {
355
375
  if (explicit === true && isDevMode())
356
376
  console.warn('[pausable] `pause: true` but no PAUSED_CONTEXT in scope — not pausing. Provide one via an ' +
@@ -379,8 +399,9 @@ function pausableEffect(effectFn, options) {
379
399
  /**
380
400
  * Like `signal`, but pausable. While paused, READS hold the last value; writes still land on the
381
401
  * underlying signal and surface on resume. Built on the `keepPrevious`/`hold` shape — a
382
- * `linkedSignal` gated on the pause predicate, with `set`/`update`/`asReadonly` forwarded to the
383
- * source signal. With no `pause` option it defaults to the ambient `PAUSED_CONTEXT`; `pause: false`
402
+ * `linkedSignal` gated on the pause predicate, with `set`/`update` forwarded to the source signal.
403
+ * `asReadonly()` returns the held (gated) view, so both views of the signal agree while paused.
404
+ * With no `pause` option it defaults to the ambient `PAUSED_CONTEXT`; `pause: false`
384
405
  * makes it a plain `signal` — no `linkedSignal` is created.
385
406
  *
386
407
  * NOTE: while paused, `set(x)` followed by a read returns the *held* (pre-pause) value, not `x` — the
@@ -399,7 +420,8 @@ function pausableSignal(initialValue, options) {
399
420
  });
400
421
  read.set = src.set;
401
422
  read.update = src.update;
402
- read.asReadonly = src.asReadonly;
423
+ // NOTE: `asReadonly` deliberately stays the linkedSignal's own (the held view) — the
424
+ // source's readonly view would show live values while the signal itself shows held ones.
403
425
  return read;
404
426
  }
405
427
  /**
@@ -438,8 +460,12 @@ function mutable(initial, opt) {
438
460
  const internalUpdate = sig.update;
439
461
  sig.mutate = (updater) => {
440
462
  cnt++;
441
- internalUpdate(updater);
442
- cnt--;
463
+ try {
464
+ internalUpdate(updater);
465
+ }
466
+ finally {
467
+ cnt--;
468
+ }
443
469
  };
444
470
  sig.inline = (updater) => {
445
471
  sig.mutate((prev) => {
@@ -526,7 +552,7 @@ function createNoopScope() {
526
552
  hold: (value) => value,
527
553
  };
528
554
  }
529
- const TRANSITION_SCOPE = new InjectionToken('@mmstack/resource:transition-scope');
555
+ const TRANSITION_SCOPE = new InjectionToken('@mmstack/primitives:transition-scope');
530
556
  /** Provide a fresh transition scope at a boundary so its subtree's resources are tracked independently. */
531
557
  function provideTransitionScope() {
532
558
  return { provide: TRANSITION_SCOPE, useFactory: createTransitionScope };
@@ -535,7 +561,7 @@ function injectTransitionScope() {
535
561
  const scope = inject(TRANSITION_SCOPE, { optional: true });
536
562
  if (!scope) {
537
563
  if (isDevMode())
538
- console.warn('[mmstack/resource] No transition scope in context — registration/tracking here is a no-op. ' +
564
+ console.warn('[mmstack/primitives] No transition scope in context — registration/tracking here is a no-op. ' +
539
565
  'Use a <mm-suspense> boundary or provideTransitionScope() in an ancestor.');
540
566
  return createNoopScope();
541
567
  }
@@ -569,6 +595,11 @@ function registerResource(res, opt) {
569
595
  *
570
596
  * Must be called in an injection context. This is the *async* generalization (Tier 2): it adds
571
597
  * no rendering cost and needs no fork — holding direct/sync readers is a separate, deferred tier.
598
+ *
599
+ * Caveat: work must go in flight by the first post-write render to be awaited. A loader that
600
+ * starts later (a debounced request signal, a chained/deferred resource) is not attributable to
601
+ * this transition — the no-async fallback will have already resolved `done`. Trigger such work
602
+ * eagerly inside `fn`, or coordinate it separately.
572
603
  */
573
604
  function injectStartTransition() {
574
605
  const scope = injectTransitionScope();
@@ -718,6 +749,11 @@ function runInTransaction(txn, fn) {
718
749
  * The writes land on LIVE state immediately (so derived variables and connector requests see the
719
750
  * new values and refetch); only the *display* is held, via `scope.hold`. Must run in an injection
720
751
  * context.
752
+ *
753
+ * Caveat: work must go in flight by the first post-write render to be part of the transaction. A
754
+ * loader that starts later (a debounced request signal, a chained/deferred resource) is not
755
+ * attributable to it — the no-async fallback will have already committed and released the hold,
756
+ * after which `abort()` is a no-op. Trigger such work eagerly inside `fn`.
721
757
  */
722
758
  function injectStartTransaction() {
723
759
  const scope = injectTransitionScope();
@@ -727,7 +763,15 @@ function injectStartTransaction() {
727
763
  // Hold BEFORE the writes, so the display freezes at pre-transaction values.
728
764
  scope.beginHold();
729
765
  let finished = false;
766
+ // eslint-disable-next-line prefer-const -- assigned in try/catch, but needs to be declared here for the `finally` block to see it
730
767
  let watcher;
768
+ let resolveDone;
769
+ const done = new Promise((resolve) => {
770
+ resolveDone = resolve;
771
+ });
772
+ // Every exit path funnels through here, so `done` always settles — including `abort()`
773
+ // and a throwing transaction body (which would otherwise leak the hold forever and
774
+ // freeze the boundary with no recovery).
731
775
  const finish = (restore) => {
732
776
  if (finished)
733
777
  return;
@@ -738,27 +782,28 @@ function injectStartTransaction() {
738
782
  else
739
783
  txn.clear();
740
784
  scope.endHold();
785
+ resolveDone();
741
786
  };
742
- runInTransaction(txn, fn);
787
+ try {
788
+ runInTransaction(txn, fn);
789
+ }
790
+ catch (e) {
791
+ finish(true);
792
+ throw e;
793
+ }
743
794
  let sawPending = false;
744
- const done = new Promise((resolve) => {
745
- watcher = effect(() => {
746
- const p = scope.pending();
747
- if (p)
748
- sawPending = true;
749
- if (sawPending && !p) {
750
- finish(false);
751
- resolve();
752
- }
753
- }, { injector });
754
- // no-async fallback: if nothing ever went in flight, settle once the writes are processed.
755
- afterNextRender(() => {
756
- if (!sawPending && !untracked(scope.pending)) {
757
- finish(false);
758
- resolve();
759
- }
760
- }, { injector });
761
- });
795
+ watcher = effect(() => {
796
+ const p = scope.pending();
797
+ if (p)
798
+ sawPending = true;
799
+ if (sawPending && !p)
800
+ finish(false);
801
+ }, { injector });
802
+ // no-async fallback: if nothing ever went in flight, settle once the writes are processed.
803
+ afterNextRender(() => {
804
+ if (!sawPending && !untracked(scope.pending))
805
+ finish(false);
806
+ }, { injector });
762
807
  return {
763
808
  pending: scope.pending,
764
809
  done,
@@ -767,6 +812,17 @@ function injectStartTransaction() {
767
812
  };
768
813
  }
769
814
 
815
+ /**
816
+ * @internal
817
+ */
818
+ function getSignalEquality(sig) {
819
+ const internal = sig[SIGNAL];
820
+ if (internal && typeof internal.equal === 'function') {
821
+ return internal.equal;
822
+ }
823
+ return Object.is; // Default equality check
824
+ }
825
+
770
826
  /**
771
827
  * Converts a read-only `Signal` into a `WritableSignal` by providing custom `set` and, optionally, `update` functions.
772
828
  * This can be useful for creating controlled write access to a signal that is otherwise read-only.
@@ -865,6 +921,7 @@ function debounced(initial, opt) {
865
921
  * ```
866
922
  */
867
923
  function debounce(source, opt) {
924
+ const eq = opt?.equal ?? getSignalEquality(source);
868
925
  const ms = opt?.ms ?? 0;
869
926
  const trigger = signal(false);
870
927
  let timeout;
@@ -879,25 +936,25 @@ function debounce(source, opt) {
879
936
  catch {
880
937
  // not in injection context & no destroyRef provided opting out of cleanup
881
938
  }
882
- const triggerFn = (next) => {
939
+ const set = (next) => {
940
+ const isEqual = eq(untracked(source), next);
941
+ if (!timeout && isEqual)
942
+ return; // nothing to do
883
943
  if (timeout)
884
- clearTimeout(timeout);
885
- source.set(next);
944
+ clearTimeout(timeout); // clear pending
945
+ if (!isEqual)
946
+ source.set(next);
886
947
  timeout = setTimeout(() => {
948
+ timeout = undefined;
887
949
  trigger.update((c) => !c);
888
950
  }, ms);
889
951
  };
890
- const set = (value) => {
891
- triggerFn(value);
892
- };
893
- const update = (fn) => {
894
- triggerFn(fn(untracked(source)));
895
- };
952
+ const update = (fn) => set(fn(untracked(source)));
896
953
  const writable = toWritable(computed(() => {
897
954
  trigger();
898
955
  return untracked(source);
899
956
  }, opt), set, update);
900
- writable.original = source;
957
+ writable.original = source.asReadonly();
901
958
  return writable;
902
959
  }
903
960
 
@@ -1068,8 +1125,18 @@ function derived(source, optOrKey, opt) {
1068
1125
  if (isMutable(source)) {
1069
1126
  sig.mutate = (updater) => {
1070
1127
  cnt++;
1071
- sig.update(updater);
1072
- cnt--;
1128
+ try {
1129
+ sig.update(updater);
1130
+ // The wrapped computed evaluates its `equal` lazily — at the next read, which would
1131
+ // normally happen after `cnt` has already dropped back to 0. For a reference-stable
1132
+ // mutation that read compares the same object to itself and the version never bumps,
1133
+ // so dependents are never notified. Reading here, while equality is still suppressed,
1134
+ // forces the recompute (and version bump) inside the mutate window.
1135
+ untracked(sig);
1136
+ }
1137
+ finally {
1138
+ cnt--;
1139
+ }
1073
1140
  };
1074
1141
  sig.inline = (updater) => {
1075
1142
  sig.mutate((prev) => {
@@ -1151,18 +1218,45 @@ function createSetter(source) {
1151
1218
  }
1152
1219
 
1153
1220
  function keepPrevious(src, opt) {
1221
+ const mutableSrc = isWritableSignal$1(src) && isMutable(src);
1222
+ // For a mutable source the linkedSignal's equality must be suppressible: a forwarded
1223
+ // `mutate` keeps the same reference, which default equality would otherwise swallow.
1224
+ let cnt = 0;
1225
+ const baseEqual = opt?.equal;
1226
+ const equal = mutableSrc
1227
+ ? (a, b) => cnt > 0 ? false : baseEqual ? baseEqual(a, b) : Object.is(a, b)
1228
+ : baseEqual;
1154
1229
  const persisted = linkedSignal({
1155
1230
  ...opt,
1156
1231
  source: () => src(),
1157
1232
  computation: (next, prev) => next === undefined && prev !== undefined ? prev.value : next,
1233
+ equal,
1158
1234
  });
1159
1235
  if (isWritableSignal$1(src)) {
1160
1236
  persisted.set = src.set;
1161
1237
  persisted.update = src.update;
1162
- persisted.asReadonly = src.asReadonly;
1163
- if (isMutable(src)) {
1164
- persisted.mutate = src.mutate;
1165
- persisted.inline = src.inline;
1238
+ // NOTE: `asReadonly` deliberately stays the linkedSignal's own — returning the
1239
+ // source's readonly view would reintroduce the `undefined` flashes this wrapper exists
1240
+ // to prevent.
1241
+ if (mutableSrc) {
1242
+ persisted.mutate = (updater) => {
1243
+ cnt++;
1244
+ try {
1245
+ src.mutate(updater);
1246
+ // force the recompute while equality is suppressed, so the reference-stable
1247
+ // mutation bumps the wrapper's version (see derived.ts for the same pattern)
1248
+ untracked(persisted);
1249
+ }
1250
+ finally {
1251
+ cnt--;
1252
+ }
1253
+ };
1254
+ persisted.inline = (updater) => {
1255
+ persisted.mutate((prev) => {
1256
+ updater(prev);
1257
+ return prev;
1258
+ });
1259
+ };
1166
1260
  }
1167
1261
  if (isDerivation(src)) {
1168
1262
  persisted.from = src.from;
@@ -1193,13 +1287,18 @@ function indexArray(source, map, opt = {}) {
1193
1287
  : toWritable(data, () => {
1194
1288
  // noop
1195
1289
  });
1290
+ // copy before defaulting `equal` — assigning onto `opt` would mutate a caller-owned
1291
+ // (possibly shared/reused) options object
1196
1292
  if (isWritableSignal$1(data) && isMutable(data) && !opt.equal) {
1197
- opt.equal = (a, b) => {
1198
- if (typeof a !== typeof b)
1199
- return false;
1200
- if (typeof a === 'object' || typeof a === 'function')
1201
- return false;
1202
- return a === b;
1293
+ opt = {
1294
+ ...opt,
1295
+ equal: (a, b) => {
1296
+ if (typeof a !== typeof b)
1297
+ return false;
1298
+ if (typeof a === 'object' || typeof a === 'function')
1299
+ return false;
1300
+ return a === b;
1301
+ },
1203
1302
  };
1204
1303
  }
1205
1304
  return linkedSignal({
@@ -1393,8 +1492,17 @@ function pooledKeys(src) {
1393
1492
  for (const k in val)
1394
1493
  if (Object.prototype.hasOwnProperty.call(val, k))
1395
1494
  spare.add(k);
1396
- if (active.size === spare.size && active.isSubsetOf(spare))
1397
- return active;
1495
+ if (active.size === spare.size) {
1496
+ let subset = true;
1497
+ for (const k of active) {
1498
+ if (!spare.has(k)) {
1499
+ subset = false;
1500
+ break;
1501
+ }
1502
+ }
1503
+ if (subset)
1504
+ return active;
1505
+ }
1398
1506
  const temp = active;
1399
1507
  active = spare;
1400
1508
  spare = temp;
@@ -1494,7 +1602,7 @@ const filter = (predicate) => (src) => linkedSignal({
1494
1602
  computation: (next, prev) => {
1495
1603
  if (predicate(next))
1496
1604
  return next;
1497
- return prev?.source;
1605
+ return prev?.value;
1498
1606
  },
1499
1607
  });
1500
1608
  /**
@@ -1530,7 +1638,7 @@ const tap = (fn, injector) => (src) => {
1530
1638
  */
1531
1639
  const filterWith = (predicate, initial) => (src) => linkedSignal({
1532
1640
  source: src,
1533
- computation: (next, prev) => predicate(next) ? next : (prev?.value ?? initial),
1641
+ computation: (next, prev) => predicate(next) ? next : prev ? prev.value : initial,
1534
1642
  });
1535
1643
  /**
1536
1644
  * Emit `initial` on the first read, then mirror the source on every subsequent
@@ -1579,7 +1687,7 @@ const pairwise = () => (src) => linkedSignal({
1579
1687
  */
1580
1688
  const scan = (reducer, seed) => (src) => linkedSignal({
1581
1689
  source: src,
1582
- computation: (next, prev) => reducer(prev?.value ?? seed, next),
1690
+ computation: (next, prev) => reducer(prev ? prev.value : seed, next),
1583
1691
  });
1584
1692
 
1585
1693
  /**
@@ -1630,7 +1738,7 @@ function pipeable(signal) {
1630
1738
  return internal;
1631
1739
  }
1632
1740
  /**
1633
- * Create a new **writable** signal and return it as a `PipableSignal`.
1741
+ * Create a new **writable** signal and return it as a `PipeableSignal`.
1634
1742
  *
1635
1743
  * The returned value is a `WritableSignal<T>` with `.set`, `.update`, `.asReadonly`
1636
1744
  * still available (via intersection type), plus a chainable `.pipe(...)`.
@@ -1734,6 +1842,20 @@ function pooledMap(optOrComputation, signalOpt) {
1734
1842
  return pooled(toPooledOptions(optOrComputation, createEmptyMap, resetClearable, signalOpt));
1735
1843
  }
1736
1844
 
1845
+ /**
1846
+ * @internal Run a sensor factory inside `injector` when provided, else in the ambient
1847
+ * injection context. Keeps every sensor's escape hatch identical and in one place.
1848
+ */
1849
+ function runInSensorContext(injector, fn) {
1850
+ return injector ? runInInjectionContext(injector, fn) : fn();
1851
+ }
1852
+ /**
1853
+ * @internal Normalize the legacy positional `debugName: string` form into {@link SensorRunOptions}.
1854
+ */
1855
+ function coerceSensorOptions(opt) {
1856
+ return typeof opt === 'string' ? { debugName: opt } : (opt ?? {});
1857
+ }
1858
+
1737
1859
  const EVENTS = [
1738
1860
  'chargingchange',
1739
1861
  'levelchange',
@@ -1755,7 +1877,11 @@ const EVENTS = [
1755
1877
  * });
1756
1878
  * ```
1757
1879
  */
1758
- function batteryStatus(debugName = 'batteryStatus') {
1880
+ function batteryStatus(opt) {
1881
+ const { debugName = 'batteryStatus', injector } = coerceSensorOptions(opt);
1882
+ return runInSensorContext(injector, () => createBatteryStatus(debugName));
1883
+ }
1884
+ function createBatteryStatus(debugName) {
1759
1885
  if (isPlatformServer(inject(PLATFORM_ID)) ||
1760
1886
  typeof navigator === 'undefined' ||
1761
1887
  typeof navigator.getBattery !== 'function') {
@@ -1764,7 +1890,9 @@ function batteryStatus(debugName = 'batteryStatus') {
1764
1890
  const state = signal(null, { debugName });
1765
1891
  const abortController = new AbortController();
1766
1892
  inject(DestroyRef).onDestroy(() => abortController.abort());
1767
- navigator.getBattery().then((battery) => {
1893
+ navigator
1894
+ .getBattery()
1895
+ .then((battery) => {
1768
1896
  if (abortController.signal.aborted)
1769
1897
  return;
1770
1898
  const read = () => ({
@@ -1780,6 +1908,10 @@ function batteryStatus(debugName = 'batteryStatus') {
1780
1908
  signal: abortController.signal,
1781
1909
  });
1782
1910
  }
1911
+ })
1912
+ .catch(() => {
1913
+ // getBattery() rejects (NotAllowedError) when the `battery` permissions-policy is
1914
+ // disallowed, e.g. in cross-origin iframes — stay `null`, same as unsupported.
1783
1915
  });
1784
1916
  return state.asReadonly();
1785
1917
  }
@@ -1795,7 +1927,11 @@ function batteryStatus(debugName = 'batteryStatus') {
1795
1927
  * in browsers that gate it. Errors from `navigator.clipboard.readText` are
1796
1928
  * swallowed silently to keep the signal value stable.
1797
1929
  */
1798
- function clipboard(debugName = 'clipboard') {
1930
+ function clipboard(opt) {
1931
+ const { debugName = 'clipboard', injector } = coerceSensorOptions(opt);
1932
+ return runInSensorContext(injector, () => createClipboard(debugName));
1933
+ }
1934
+ function createClipboard(debugName) {
1799
1935
  if (isPlatformServer(inject(PLATFORM_ID)) ||
1800
1936
  typeof navigator === 'undefined' ||
1801
1937
  !navigator.clipboard) {
@@ -1845,7 +1981,13 @@ function observerSupported$1() {
1845
1981
  * });
1846
1982
  * ```
1847
1983
  */
1848
- function elementSize(target = inject(ElementRef), opt) {
1984
+ function elementSize(target, opt) {
1985
+ return runInSensorContext(opt?.injector, () =>
1986
+ // the host-element default must resolve INSIDE the sensor context, not as a
1987
+ // parameter default (which would run before the injector wrapper)
1988
+ createElementSize(target ?? inject(ElementRef), opt));
1989
+ }
1990
+ function createElementSize(target, opt) {
1849
1991
  const getElement = () => {
1850
1992
  if (isSignal(target)) {
1851
1993
  try {
@@ -1859,8 +2001,8 @@ function elementSize(target = inject(ElementRef), opt) {
1859
2001
  return target instanceof ElementRef ? target.nativeElement : target;
1860
2002
  };
1861
2003
  const resolveInitialValue = () => {
1862
- if (!observerSupported$1())
1863
- return undefined;
2004
+ // measuring needs only getBoundingClientRect — ResizeObserver support gates
2005
+ // live updates, not the initial read
1864
2006
  const el = getElement();
1865
2007
  if (el && el.getBoundingClientRect) {
1866
2008
  const rect = el.getBoundingClientRect();
@@ -1978,7 +2120,13 @@ function observerSupported() {
1978
2120
  * }
1979
2121
  * ```
1980
2122
  */
1981
- function elementVisibility(target = inject(ElementRef), opt) {
2123
+ function elementVisibility(target, opt) {
2124
+ return runInSensorContext(opt?.injector, () =>
2125
+ // the host-element default must resolve INSIDE the sensor context, not as a
2126
+ // parameter default (which would run before the injector wrapper)
2127
+ createElementVisibility(target ?? inject(ElementRef), opt));
2128
+ }
2129
+ function createElementVisibility(target, opt) {
1982
2130
  if (isPlatformServer(inject(PLATFORM_ID)) || !observerSupported()) {
1983
2131
  const base = computed(() => undefined, {
1984
2132
  debugName: opt?.debugName,
@@ -2046,11 +2194,18 @@ function unwrap$1(target) {
2046
2194
  * }
2047
2195
  * ```
2048
2196
  */
2049
- function focusWithin(target = inject(ElementRef)) {
2197
+ function focusWithin(target, opt) {
2198
+ return runInSensorContext(opt?.injector, () =>
2199
+ // the host-element default must resolve INSIDE the sensor context, not as a
2200
+ // parameter default (which would run before the injector wrapper)
2201
+ createFocusWithin(target ?? inject(ElementRef), opt));
2202
+ }
2203
+ function createFocusWithin(target, opt) {
2204
+ const debugName = opt?.debugName ?? 'focusWithin';
2050
2205
  if (isPlatformServer(inject(PLATFORM_ID))) {
2051
- return computed(() => false, { debugName: 'focusWithin' });
2206
+ return computed(() => false, { debugName });
2052
2207
  }
2053
- const state = signal(false, { debugName: 'focusWithin' });
2208
+ const state = signal(false, { debugName });
2054
2209
  const attach = (el) => {
2055
2210
  state.set(el.contains(document.activeElement));
2056
2211
  const abortController = new AbortController();
@@ -2098,6 +2253,9 @@ function focusWithin(target = inject(ElementRef)) {
2098
2253
  * ```
2099
2254
  */
2100
2255
  function geolocation(opt) {
2256
+ return runInSensorContext(opt?.injector, () => createGeolocation(opt));
2257
+ }
2258
+ function createGeolocation(opt) {
2101
2259
  if (isPlatformServer(inject(PLATFORM_ID)) || typeof navigator === 'undefined' || !navigator.geolocation) {
2102
2260
  const sig = computed(() => null, {
2103
2261
  debugName: opt?.debugName ?? 'geolocation',
@@ -2157,6 +2315,9 @@ const serverDate$1 = new Date();
2157
2315
  * ```
2158
2316
  */
2159
2317
  function idle(opt) {
2318
+ return runInSensorContext(opt?.injector, () => createIdle(opt));
2319
+ }
2320
+ function createIdle(opt) {
2160
2321
  if (isPlatformServer(inject(PLATFORM_ID))) {
2161
2322
  const sig = computed(() => false, {
2162
2323
  debugName: opt?.debugName ?? 'idle',
@@ -2246,7 +2407,11 @@ function idle(opt) {
2246
2407
  * }
2247
2408
  * ```
2248
2409
  */
2249
- function mediaQuery(query, debugName = 'mediaQuery') {
2410
+ function mediaQuery(query, opt) {
2411
+ const { debugName = 'mediaQuery', injector } = coerceSensorOptions(opt);
2412
+ return runInSensorContext(injector, () => createMediaQuery(query, debugName));
2413
+ }
2414
+ function createMediaQuery(query, debugName) {
2250
2415
  if (isPlatformServer(inject(PLATFORM_ID)) ||
2251
2416
  typeof window === 'undefined' ||
2252
2417
  typeof window.matchMedia !== 'function' // jsdom doesn't implement matchMedia
@@ -2284,8 +2449,8 @@ function mediaQuery(query, debugName = 'mediaQuery') {
2284
2449
  * });
2285
2450
  * ```
2286
2451
  */
2287
- function prefersDarkMode(debugName) {
2288
- return mediaQuery('(prefers-color-scheme: dark)', debugName);
2452
+ function prefersDarkMode(opt) {
2453
+ return mediaQuery('(prefers-color-scheme: dark)', opt);
2289
2454
  }
2290
2455
  /**
2291
2456
  * Creates a read-only signal that tracks the user's OS/browser preference
@@ -2312,8 +2477,8 @@ function prefersDarkMode(debugName) {
2312
2477
  * });
2313
2478
  * ```
2314
2479
  */
2315
- function prefersReducedMotion(debugName) {
2316
- return mediaQuery('(prefers-reduced-motion: reduce)', debugName);
2480
+ function prefersReducedMotion(opt) {
2481
+ return mediaQuery('(prefers-reduced-motion: reduce)', opt);
2317
2482
  }
2318
2483
 
2319
2484
  /**
@@ -2362,6 +2527,7 @@ function throttled(initial, opt) {
2362
2527
  * // after the 500ms cooldown.
2363
2528
  */
2364
2529
  function throttle(source, opt) {
2530
+ const eq = opt?.equal ?? getSignalEquality(source);
2365
2531
  const ms = opt?.ms ?? 0;
2366
2532
  const leading = opt?.leading ?? false;
2367
2533
  const trailing = opt?.trailing ?? true;
@@ -2387,31 +2553,32 @@ function throttle(source, opt) {
2387
2553
  fire();
2388
2554
  else
2389
2555
  pendingTrailing = trailing;
2390
- timeout = setTimeout(() => {
2556
+ const onWindowEnd = () => {
2391
2557
  timeout = undefined;
2392
2558
  if (trailing && pendingTrailing) {
2393
2559
  pendingTrailing = false;
2394
2560
  fire();
2561
+ timeout = setTimeout(onWindowEnd, ms);
2395
2562
  }
2396
- }, ms);
2563
+ };
2564
+ timeout = setTimeout(onWindowEnd, ms);
2397
2565
  return;
2398
2566
  }
2399
2567
  if (trailing)
2400
2568
  pendingTrailing = true;
2401
2569
  };
2402
- const set = (value) => {
2403
- source.set(value);
2404
- tick();
2405
- };
2406
- const update = (fn) => {
2407
- source.update(fn);
2570
+ const set = (next) => {
2571
+ if (eq(untracked(source), next))
2572
+ return;
2573
+ source.set(next);
2408
2574
  tick();
2409
2575
  };
2576
+ const update = (fn) => set(fn(untracked(source)));
2410
2577
  const writable = toWritable(computed(() => {
2411
2578
  trigger();
2412
2579
  return untracked(source);
2413
2580
  }, opt), set, update);
2414
- writable.original = source;
2581
+ writable.original = source.asReadonly();
2415
2582
  return writable;
2416
2583
  }
2417
2584
 
@@ -2448,6 +2615,9 @@ function throttle(source, opt) {
2448
2615
  * ```
2449
2616
  */
2450
2617
  function mousePosition(opt) {
2618
+ return runInSensorContext(opt?.injector, () => createMousePosition(opt));
2619
+ }
2620
+ function createMousePosition(opt) {
2451
2621
  if (isPlatformServer(inject(PLATFORM_ID))) {
2452
2622
  const base = computed(() => ({
2453
2623
  x: 0,
@@ -2459,8 +2629,12 @@ function mousePosition(opt) {
2459
2629
  return base;
2460
2630
  }
2461
2631
  const { target = window, coordinateSpace = 'client', touch = false, debugName = 'mousePosition', throttle = 100, } = opt ?? {};
2462
- const eventTarget = target instanceof ElementRef ? target.nativeElement : target;
2463
- if (!eventTarget) {
2632
+ const resolve = (t) => {
2633
+ if (!t)
2634
+ return null;
2635
+ return t instanceof ElementRef ? t.nativeElement : t;
2636
+ };
2637
+ if (!isSignal(target) && !resolve(target)) {
2464
2638
  if (isDevMode())
2465
2639
  console.warn('mousePosition: Target element not found.');
2466
2640
  const base = computed(() => ({
@@ -2483,7 +2657,7 @@ function mousePosition(opt) {
2483
2657
  x = coordinateSpace === 'page' ? event.pageX : event.clientX;
2484
2658
  y = coordinateSpace === 'page' ? event.pageY : event.clientY;
2485
2659
  }
2486
- else if (event.touches.length > 0) {
2660
+ else if (event.touches?.length > 0) {
2487
2661
  const firstTouch = event.touches[0];
2488
2662
  x = coordinateSpace === 'page' ? firstTouch.pageX : firstTouch.clientX;
2489
2663
  y = coordinateSpace === 'page' ? firstTouch.pageY : firstTouch.clientY;
@@ -2493,16 +2667,36 @@ function mousePosition(opt) {
2493
2667
  }
2494
2668
  pos.set({ x, y });
2495
2669
  };
2496
- eventTarget.addEventListener('mousemove', updatePosition);
2497
- if (touch) {
2498
- eventTarget.addEventListener('touchmove', updatePosition);
2499
- }
2500
- inject(DestroyRef).onDestroy(() => {
2501
- eventTarget.removeEventListener('mousemove', updatePosition);
2670
+ // passive: the handler never calls preventDefault, and a non-passive touchmove on
2671
+ // window forces the browser to wait on JS before scrolling (scroll jank on touch)
2672
+ const attach = (el) => {
2673
+ const controller = new AbortController();
2674
+ el.addEventListener('mousemove', updatePosition, {
2675
+ passive: true,
2676
+ signal: controller.signal,
2677
+ });
2502
2678
  if (touch) {
2503
- eventTarget.removeEventListener('touchmove', updatePosition);
2679
+ el.addEventListener('touchmove', updatePosition, {
2680
+ passive: true,
2681
+ signal: controller.signal,
2682
+ });
2504
2683
  }
2505
- });
2684
+ return () => controller.abort();
2685
+ };
2686
+ if (isSignal(target)) {
2687
+ // re-attach whenever the signal resolves to a (new) element — covers viewChild
2688
+ effect((cleanup) => {
2689
+ const el = resolve(target());
2690
+ if (!el)
2691
+ return;
2692
+ cleanup(attach(el));
2693
+ });
2694
+ }
2695
+ else {
2696
+ const el = resolve(target);
2697
+ if (el)
2698
+ inject(DestroyRef).onDestroy(attach(el));
2699
+ }
2506
2700
  const base = pos.asReadonly();
2507
2701
  base.unthrottled = pos.original;
2508
2702
  return base;
@@ -2516,7 +2710,8 @@ const serverDate = new Date();
2516
2710
  * An additional `since` signal is attached, tracking when the status last changed.
2517
2711
  * It's SSR-safe and automatically cleans up its event listeners.
2518
2712
  *
2519
- * @param debugName Optional debug name for the signal.
2713
+ * @param opt Optional debug name for the signal, or a {@link SensorRunOptions} object
2714
+ * (with an optional `injector` for creation outside an injection context).
2520
2715
  * @returns A `NetworkStatusSignal` instance.
2521
2716
  *
2522
2717
  * @example
@@ -2527,7 +2722,11 @@ const serverDate = new Date();
2527
2722
  * });
2528
2723
  * ```
2529
2724
  */
2530
- function networkStatus(debugName = 'networkStatus') {
2725
+ function networkStatus(opt) {
2726
+ const { debugName = 'networkStatus', injector } = coerceSensorOptions(opt);
2727
+ return runInSensorContext(injector, () => createNetworkStatus(debugName));
2728
+ }
2729
+ function createNetworkStatus(debugName) {
2531
2730
  if (isPlatformServer(inject(PLATFORM_ID))) {
2532
2731
  const sig = computed(() => true, {
2533
2732
  debugName,
@@ -2577,7 +2776,11 @@ const SSR_FALLBACK = {
2577
2776
  * });
2578
2777
  * ```
2579
2778
  */
2580
- function orientation(debugName = 'orientation') {
2779
+ function orientation(opt) {
2780
+ const { debugName = 'orientation', injector } = coerceSensorOptions(opt);
2781
+ return runInSensorContext(injector, () => createOrientation(debugName));
2782
+ }
2783
+ function createOrientation(debugName) {
2581
2784
  if (isPlatformServer(inject(PLATFORM_ID)) ||
2582
2785
  typeof screen === 'undefined' ||
2583
2786
  !screen.orientation) {
@@ -2606,7 +2809,8 @@ function orientation(debugName = 'orientation') {
2606
2809
  * The primitive is SSR-safe and automatically cleans up its event listeners
2607
2810
  * when the creating context is destroyed.
2608
2811
  *
2609
- * @param debugName Optional debug name for the signal.
2812
+ * @param opt Optional debug name for the signal, or a {@link SensorRunOptions} object
2813
+ * (with an optional `injector` for creation outside an injection context).
2610
2814
  * @returns A read-only `Signal<DocumentVisibilityState>`. On the server,
2611
2815
  * it returns a static signal with a value of `'visible'`.
2612
2816
  *
@@ -2634,7 +2838,11 @@ function orientation(debugName = 'orientation') {
2634
2838
  * }
2635
2839
  * ```
2636
2840
  */
2637
- function pageVisibility(debugName = 'pageVisibility') {
2841
+ function pageVisibility(opt) {
2842
+ const { debugName = 'pageVisibility', injector } = coerceSensorOptions(opt);
2843
+ return runInSensorContext(injector, () => createPageVisibility(debugName));
2844
+ }
2845
+ function createPageVisibility(debugName) {
2638
2846
  if (isPlatformServer(inject(PLATFORM_ID))) {
2639
2847
  return computed(() => 'visible', { debugName });
2640
2848
  }
@@ -2666,31 +2874,25 @@ function pageVisibility(debugName = 'pageVisibility') {
2666
2874
  * selector: 'app-scroll-tracker',
2667
2875
  * template: `
2668
2876
  * <p>Window Scroll: X: {{ windowScroll().x }}, Y: {{ windowScroll().y }}</p>
2669
- * <div #scrollableDiv style="height: 200px; width: 200px; overflow: auto; border: 1px solid black;">
2670
- * <div style="height: 400px; width: 400px;">Scroll me!</div>
2671
- * </div>
2672
- * @if (divScroll()) {
2673
- * <p>Div Scroll: X: {{ divScroll().x }}, Y: {{ divScroll().y }}</p>
2674
- * }
2877
+ * <p>Host Scroll: X: {{ hostScroll().x }}, Y: {{ hostScroll().y }}</p>
2675
2878
  * `
2676
2879
  * })
2677
2880
  * export class ScrollTrackerComponent {
2678
2881
  * readonly windowScroll = scrollPosition(); // Defaults to window
2882
+ * // Signal targets (e.g. viewChild) attach once the element exists:
2679
2883
  * readonly scrollableDiv = viewChild<ElementRef<HTMLDivElement>>('scrollableDiv');
2680
- * readonly divScroll = scrollPosition({ target: this.scrollableDiv() }); // Example with element target
2884
+ * readonly divScroll = scrollPosition({ target: this.scrollableDiv });
2681
2885
  *
2682
2886
  * constructor() {
2683
- * effect(() => {
2684
- * console.log('Window scrolled to:', this.windowScroll());
2685
- * if (this.divScroll()) {
2686
- * console.log('Div scrolled to:', this.divScroll());
2687
- * }
2688
- * });
2887
+ * effect(() => console.log('Window scrolled to:', this.windowScroll()));
2689
2888
  * }
2690
2889
  * }
2691
2890
  * ```
2692
2891
  */
2693
2892
  function scrollPosition(opt) {
2893
+ return runInSensorContext(opt?.injector, () => createScrollPosition(opt));
2894
+ }
2895
+ function createScrollPosition(opt) {
2694
2896
  if (isPlatformServer(inject(PLATFORM_ID))) {
2695
2897
  const base = computed(() => ({
2696
2898
  x: 0,
@@ -2702,40 +2904,44 @@ function scrollPosition(opt) {
2702
2904
  return base;
2703
2905
  }
2704
2906
  const { target = window, throttle = 100, debugName = 'scrollPosition', } = opt || {};
2705
- let element;
2706
- let getScrollPosition;
2707
- if (target instanceof Window) {
2708
- element = target;
2709
- getScrollPosition = () => {
2710
- return { x: target.scrollX, y: target.scrollY };
2711
- };
2712
- }
2713
- else if (target instanceof ElementRef) {
2714
- element = target.nativeElement;
2715
- getScrollPosition = () => {
2716
- return {
2717
- x: target.nativeElement.scrollLeft,
2718
- y: target.nativeElement.scrollTop,
2719
- };
2720
- };
2721
- }
2722
- else {
2723
- element = target;
2724
- getScrollPosition = () => {
2725
- return {
2726
- x: target.scrollLeft,
2727
- y: target.scrollTop,
2728
- };
2729
- };
2730
- }
2731
- const state = throttled(getScrollPosition(), {
2907
+ const resolve = (t) => {
2908
+ if (!t)
2909
+ return null;
2910
+ return t instanceof ElementRef ? t.nativeElement : t;
2911
+ };
2912
+ const isWindow = (el) => el.window === el;
2913
+ const readPosition = (el) => isWindow(el)
2914
+ ? {
2915
+ x: el.scrollX ?? el.pageXOffset ?? 0,
2916
+ y: el.scrollY ?? el.pageYOffset ?? 0,
2917
+ }
2918
+ : { x: el.scrollLeft, y: el.scrollTop };
2919
+ const initial = resolve(isSignal(target) ? untracked(target) : target);
2920
+ const state = throttled(initial ? readPosition(initial) : { x: 0, y: 0 }, {
2732
2921
  debugName,
2733
2922
  equal: (a, b) => a.x === b.x && a.y === b.y,
2734
2923
  ms: throttle,
2735
2924
  });
2736
- const onScroll = () => state.set(getScrollPosition());
2737
- element.addEventListener('scroll', onScroll, { passive: true });
2738
- inject(DestroyRef).onDestroy(() => element.removeEventListener('scroll', onScroll));
2925
+ if (isSignal(target)) {
2926
+ // re-attach whenever the signal resolves to a (new) element — covers viewChild
2927
+ effect((cleanup) => {
2928
+ const el = resolve(target());
2929
+ if (!el)
2930
+ return;
2931
+ state.set(readPosition(el)); // sync to the new element immediately
2932
+ const onScroll = () => state.set(readPosition(el));
2933
+ el.addEventListener('scroll', onScroll, { passive: true });
2934
+ cleanup(() => el.removeEventListener('scroll', onScroll));
2935
+ });
2936
+ }
2937
+ else {
2938
+ const el = resolve(target);
2939
+ if (el) {
2940
+ const onScroll = () => state.set(readPosition(el));
2941
+ el.addEventListener('scroll', onScroll, { passive: true });
2942
+ inject(DestroyRef).onDestroy(() => el.removeEventListener('scroll', onScroll));
2943
+ }
2944
+ }
2739
2945
  const base = state.asReadonly();
2740
2946
  base.unthrottled = state.original;
2741
2947
  return base;
@@ -2783,6 +2989,9 @@ function scrollPosition(opt) {
2783
2989
  * ```
2784
2990
  */
2785
2991
  function windowSize(opt) {
2992
+ return runInSensorContext(opt?.injector, () => createWindowSize(opt));
2993
+ }
2994
+ function createWindowSize(opt) {
2786
2995
  if (isPlatformServer(inject(PLATFORM_ID))) {
2787
2996
  const base = computed(() => ({
2788
2997
  width: 1024,
@@ -2819,17 +3028,19 @@ function sensor(type, options) {
2819
3028
  case 'mousePosition':
2820
3029
  return mousePosition(opts);
2821
3030
  case 'networkStatus':
2822
- return networkStatus(opts?.debugName);
3031
+ return networkStatus(opts);
2823
3032
  case 'pageVisibility':
2824
- return pageVisibility(opts?.debugName);
3033
+ return pageVisibility(opts);
2825
3034
  case 'darkMode':
2826
3035
  case 'dark-mode':
2827
- return prefersDarkMode(opts?.debugName);
3036
+ return prefersDarkMode(opts);
2828
3037
  case 'reducedMotion':
2829
3038
  case 'reduced-motion':
2830
- return prefersReducedMotion(opts?.debugName);
3039
+ return prefersReducedMotion(opts);
2831
3040
  case 'mediaQuery':
2832
- return mediaQuery(opts.query, opts.debugName);
3041
+ if (typeof opts?.query !== 'string')
3042
+ throw new Error(`sensor('mediaQuery') requires a 'query' option, e.g. sensor('mediaQuery', { query: '(min-width: 1024px)' })`);
3043
+ return mediaQuery(opts.query, opts);
2833
3044
  case 'windowSize':
2834
3045
  return windowSize(opts);
2835
3046
  case 'scrollPosition':
@@ -2841,15 +3052,15 @@ function sensor(type, options) {
2841
3052
  case 'geolocation':
2842
3053
  return geolocation(opts);
2843
3054
  case 'clipboard':
2844
- return clipboard(opts?.debugName);
3055
+ return clipboard(opts);
2845
3056
  case 'orientation':
2846
- return orientation(opts?.debugName);
3057
+ return orientation(opts);
2847
3058
  case 'batteryStatus':
2848
- return batteryStatus(opts?.debugName);
3059
+ return batteryStatus(opts);
2849
3060
  case 'idle':
2850
3061
  return idle(opts);
2851
3062
  case 'focusWithin':
2852
- return focusWithin(opts?.target);
3063
+ return focusWithin(opts?.target, opts);
2853
3064
  default:
2854
3065
  throw new Error(`Unknown sensor type: ${type}`);
2855
3066
  }
@@ -2903,16 +3114,24 @@ function signalFromEvent(target, eventName, initial, projectOrOpt, maybeOpt) {
2903
3114
  else
2904
3115
  state.set(event);
2905
3116
  };
2906
- const { destroyRef: providedDestroyRef, ...listenerOpts } = opt ?? {};
3117
+ const { destroyRef: providedDestroyRef,
3118
+ // strip non-listener keys so they don't leak into addEventListener options
3119
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
3120
+ injector: _injector,
3121
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
3122
+ debugName: _debugName, ...listenerOpts } = opt ?? {};
2907
3123
  if (isSignal(target)) {
2908
3124
  const targetSig = target;
2909
- effect((cleanup) => {
3125
+ const effectRef = effect((cleanup) => {
2910
3126
  const resolved = unwrap(targetSig());
2911
3127
  if (!resolved)
2912
3128
  return;
2913
3129
  resolved.addEventListener(eventName, handler, listenerOpts);
2914
3130
  cleanup(() => resolved.removeEventListener(eventName, handler, listenerOpts));
2915
3131
  }, { injector });
3132
+ // honor an explicit destroyRef for signal targets too — the effect would otherwise
3133
+ // only follow the injector's lifetime, contradicting the documented option
3134
+ providedDestroyRef?.onDestroy(() => effectRef.destroy());
2916
3135
  }
2917
3136
  else {
2918
3137
  const resolved = unwrap(target);
@@ -3001,7 +3220,8 @@ function alwaysFalse() {
3001
3220
  * @internal Attaches a lazy, memoized leaf probe to a store node. The probe (`() => boolean`)
3002
3221
  * closes over the node's value signal and its (stable) vivify setting, building the backing
3003
3222
  * `computed` on first call so leaf-ness tracks the live value reactively without taxing every
3004
- * node access. Idempotent.
3223
+ * node access. Under `noUnionLeaves` the caller promises shapes never flip, so the probe is
3224
+ * resolved once from the first sample and frozen as a constant. Idempotent.
3005
3225
  */
3006
3226
  function markAsLeaf(sig, value, vivifyEnabled, noUnionLeaves) {
3007
3227
  if (typeof sig[LEAF] !== 'function') {
@@ -3009,13 +3229,11 @@ function markAsLeaf(sig, value, vivifyEnabled, noUnionLeaves) {
3009
3229
  const probe = () => {
3010
3230
  if (memo)
3011
3231
  return memo();
3012
- const v = untracked(value);
3013
- memo =
3014
- isOpaque(v) || (v == null && !vivifyEnabled) || noUnionLeaves
3015
- ? isLeafValue(v, vivifyEnabled)
3016
- ? alwaysTrue
3017
- : alwaysFalse
3018
- : computed(() => isLeafValue(value(), vivifyEnabled));
3232
+ memo = noUnionLeaves
3233
+ ? isLeafValue(untracked(value), vivifyEnabled)
3234
+ ? alwaysTrue
3235
+ : alwaysFalse
3236
+ : computed(() => isLeafValue(value(), vivifyEnabled));
3019
3237
  return memo();
3020
3238
  };
3021
3239
  Object.defineProperty(sig, LEAF, {
@@ -3103,6 +3321,40 @@ function resolveVivify(sample, option) {
3103
3321
  function hasOwnKey(value, key) {
3104
3322
  return value != null && Object.hasOwn(value, key);
3105
3323
  }
3324
+ /**
3325
+ * @internal
3326
+ * Builds the `onChange` for the fallback (non-record container) derivation branch. For an
3327
+ * immutable source the container is copied before the write — returning the same mutated
3328
+ * reference would let the source's equality cut propagation (leaving child signals permanently
3329
+ * stale) and alias the caller's original object, breaking the structural-sharing contract
3330
+ * `forkStore` relies on. For a mutable source the write goes through `mutate`, so the chain's
3331
+ * force-notify engages (plain `update` with the same reference would never notify).
3332
+ */
3333
+ function createFallbackOnChange(target, prop, vivifyFn, isMutableSource) {
3334
+ const write = (newValue) => (v) => {
3335
+ const container = vivifyFn(v, prop);
3336
+ if (container === null || container === undefined)
3337
+ return container;
3338
+ const next = isMutableSource
3339
+ ? container
3340
+ : Array.isArray(container)
3341
+ ? container.slice()
3342
+ : isRecord(container)
3343
+ ? { ...container }
3344
+ : container; // non-plain leaf (Date/class instance): legacy in-place attempt
3345
+ try {
3346
+ next[prop] = newValue;
3347
+ }
3348
+ catch (e) {
3349
+ if (isDevMode())
3350
+ console.error(`[store] Failed to set property "${String(prop)}"`, e);
3351
+ }
3352
+ return next;
3353
+ };
3354
+ return isMutableSource
3355
+ ? (newValue) => target.mutate(write(newValue))
3356
+ : (newValue) => target.update(write(newValue));
3357
+ }
3106
3358
  /**
3107
3359
  * @internal
3108
3360
  * Makes an array store
@@ -3125,7 +3377,9 @@ function toArrayStore(source, injector, vivify, noUnionLeaves = false) {
3125
3377
  const idx = +prop;
3126
3378
  return idx >= 0 && idx < untracked(lengthSignal);
3127
3379
  }
3128
- return Reflect.has(untracked(source), prop);
3380
+ const v = untracked(source);
3381
+ // nullish node values are routinely descended with vivify on — `in` must not throw
3382
+ return v == null ? false : Reflect.has(v, prop);
3129
3383
  },
3130
3384
  ownKeys() {
3131
3385
  const v = untracked(source);
@@ -3162,7 +3416,9 @@ function toArrayStore(source, injector, vivify, noUnionLeaves = false) {
3162
3416
  return lengthSignal;
3163
3417
  if (prop === Symbol.iterator) {
3164
3418
  return function* () {
3165
- for (let i = 0; i < untracked(lengthSignal); i++) {
3419
+ // read length reactively: a spread/for-of inside a computed/effect must re-run
3420
+ // when items are added or removed, not only when already-read elements change
3421
+ for (let i = 0; i < lengthSignal(); i++) {
3166
3422
  yield receiver[i];
3167
3423
  }
3168
3424
  };
@@ -3201,19 +3457,8 @@ function toArrayStore(source, injector, vivify, noUnionLeaves = false) {
3201
3457
  })
3202
3458
  : derived(target, {
3203
3459
  from: (v) => v?.[idx],
3204
- onChange: (newValue) => target.update((v) => {
3205
- const container = vivifyFn(v, idx);
3206
- if (container === null || container === undefined)
3207
- return container;
3208
- try {
3209
- container[idx] = newValue;
3210
- }
3211
- catch (e) {
3212
- if (isDevMode())
3213
- console.error(`[store] Failed to set property "${String(idx)}"`, e);
3214
- }
3215
- return container;
3216
- }),
3460
+ onChange: createFallbackOnChange(target, idx, vivifyFn, isMutableSource),
3461
+ equal: equalFn,
3217
3462
  });
3218
3463
  const childSample = untracked(computation);
3219
3464
  const childVivify = resolveVivify(childSample, vivify);
@@ -3233,6 +3478,13 @@ function toArrayStore(source, injector, vivify, noUnionLeaves = false) {
3233
3478
  /**
3234
3479
  * Converts a Signal into a deep-observable Store.
3235
3480
  * Accessing nested properties returns a derived Signal of that path.
3481
+ *
3482
+ * @remarks
3483
+ * A child's *container kind* (array store vs object store) is resolved when the child is
3484
+ * first accessed and cached with the proxy. Leaf↔substore flips are tracked reactively, but a
3485
+ * union-typed node that later flips between an array and a record keeps its original trap set —
3486
+ * if you need that, re-model the union as `{ kind: ..., value: ... }` instead.
3487
+ *
3236
3488
  * @example
3237
3489
  * const state = store({ user: { name: 'John' } });
3238
3490
  * const nameSignal = state.user.name; // WritableSignal<string>
@@ -3315,19 +3567,8 @@ function toStore(source, injector, vivify = false, noUnionLeaves = false) {
3315
3567
  ? derived(target, prop, { equal: equalFn, vivify: nodeVivify })
3316
3568
  : derived(target, {
3317
3569
  from: (v) => v?.[prop],
3318
- onChange: (newValue) => target.update((v) => {
3319
- const container = vivifyFn(v, prop);
3320
- if (container === null || container === undefined)
3321
- return container;
3322
- try {
3323
- container[prop] = newValue;
3324
- }
3325
- catch (e) {
3326
- if (isDevMode())
3327
- console.error(`[store] Failed to set property "${String(prop)}"`, e);
3328
- }
3329
- return container;
3330
- }),
3570
+ onChange: createFallbackOnChange(target, prop, vivifyFn, isMutableSource),
3571
+ equal: equalFn,
3331
3572
  });
3332
3573
  const childSample = untracked(computation);
3333
3574
  const childVivify = resolveVivify(childSample, vivify);
@@ -3469,7 +3710,12 @@ function merge3(ancestor, mine, theirs) {
3469
3710
  if (isPlainRecord(mine) && isPlainRecord(theirs) && isPlainRecord(ancestor)) {
3470
3711
  const out = { ...theirs };
3471
3712
  for (const key of new Set([...Object.keys(mine), ...Object.keys(theirs)])) {
3472
- out[key] = merge3(ancestor[key], mine[key], theirs[key]);
3713
+ const merged = merge3(ancestor[key], mine[key], theirs[key]);
3714
+ // a key deleted on the fork must commit as ABSENT, not as an explicit `undefined`
3715
+ if (merged === undefined && !(key in mine))
3716
+ delete out[key];
3717
+ else
3718
+ out[key] = merged;
3473
3719
  }
3474
3720
  return out;
3475
3721
  }
@@ -3523,8 +3769,8 @@ const noopStore = {
3523
3769
  *
3524
3770
  * @template T The type of value held by the signal and stored (after serialization).
3525
3771
  * @param fallback The default value of type `T` to use when no value is found in storage
3526
- * or when deserialization fails. The signal's value will never be `null` or `undefined`
3527
- * publicly, it will always revert to this fallback.
3772
+ * or when deserialization fails. A stored value (including a legitimate `null` for a
3773
+ * nullable `T`) always round-trips; the fallback only surfaces when the entry is absent.
3528
3774
  * @param options Configuration options (`CreateStoredOptions<T>`). Requires at least the `key`.
3529
3775
  * @returns A `StoredSignal<T>` instance. This signal behaves like a standard `WritableSignal<T>`,
3530
3776
  * but its value is persisted. It includes a `.clear()` method to remove the item from storage
@@ -3537,7 +3783,8 @@ const noopStore = {
3537
3783
  * - **Error Handling:** Catches and logs errors during serialization/deserialization in dev mode.
3538
3784
  * - **Tab Sync:** If `syncTabs` is true, listens to `storage` events to keep the signal value
3539
3785
  * consistent across browser tabs using the same key. Cleanup is handled automatically
3540
- * using `DestroyRef`.
3786
+ * using `DestroyRef`. Web Storage only: the `storage` event never fires for custom `store`
3787
+ * adapters, so `syncTabs` has no effect with one.
3541
3788
  * - **Removal:** Use the `.clear()` method on the returned signal to remove the item from storage.
3542
3789
  * Setting the signal to the fallback value will store the fallback value, not remove the item.
3543
3790
  *
@@ -3572,25 +3819,28 @@ function stored(fallback, { key, store: providedStore, serialize = JSON.stringif
3572
3819
  : isSignal(key)
3573
3820
  ? key
3574
3821
  : computed(key);
3822
+ // "no stored value" marker — distinct from `null`/`undefined`, so a nullable `T` can
3823
+ // round-trip a legitimate `null` through `set` instead of it acting like `clear()`
3824
+ const EMPTY = Symbol();
3575
3825
  const getValue = (key) => {
3576
3826
  const found = store.getItem(key);
3577
3827
  if (found === null)
3578
- return null;
3828
+ return EMPTY;
3579
3829
  try {
3580
3830
  const deserialized = deserialize(found);
3581
3831
  if (!validate(deserialized))
3582
- return null;
3832
+ return EMPTY;
3583
3833
  return deserialized;
3584
3834
  }
3585
3835
  catch (err) {
3586
3836
  if (isDevMode())
3587
3837
  console.error(`Failed to parse stored value for key "${key}":`, err);
3588
- return null;
3838
+ return EMPTY;
3589
3839
  }
3590
3840
  };
3591
3841
  const storeValue = (key, value) => {
3592
3842
  try {
3593
- if (value === null)
3843
+ if (value === EMPTY)
3594
3844
  return store.removeItem(key);
3595
3845
  const serialized = serialize(value);
3596
3846
  store.setItem(key, serialized);
@@ -3608,9 +3858,9 @@ function stored(fallback, { key, store: providedStore, serialize = JSON.stringif
3608
3858
  const internal = signal(getValue(initialKey), {
3609
3859
  ...opt,
3610
3860
  equal: (a, b) => {
3611
- if (a === null && b === null)
3861
+ if (a === EMPTY && b === EMPTY)
3612
3862
  return true;
3613
- if (a === null || b === null)
3863
+ if (a === EMPTY || b === EMPTY)
3614
3864
  return false;
3615
3865
  return equal(a, b);
3616
3866
  },
@@ -3646,19 +3896,27 @@ function stored(fallback, { key, store: providedStore, serialize = JSON.stringif
3646
3896
  if (syncTabs && !isServer) {
3647
3897
  const destroyRef = inject(DestroyRef);
3648
3898
  const sync = (e) => {
3899
+ // `storage` events only describe Web Storage — ignore events for a different
3900
+ // storage area (or any event when a custom adapter is configured), otherwise an
3901
+ // unrelated localStorage write with the same key string corrupts our state
3902
+ if (e.storageArea !== store)
3903
+ return;
3649
3904
  if (e.key !== untracked(keySig))
3650
3905
  return;
3651
3906
  if (e.newValue === null)
3652
- internal.set(null);
3907
+ internal.set(EMPTY);
3653
3908
  else
3654
3909
  internal.set(getValue(e.key));
3655
3910
  };
3656
3911
  window.addEventListener('storage', sync);
3657
3912
  destroyRef.onDestroy(() => window.removeEventListener('storage', sync));
3658
3913
  }
3659
- const writable = toWritable(computed(() => internal() ?? fallback, opt), internal.set);
3914
+ const writable = toWritable(computed(() => {
3915
+ const v = internal();
3916
+ return v === EMPTY ? fallback : v;
3917
+ }, opt), internal.set);
3660
3918
  writable.clear = () => {
3661
- internal.set(null);
3919
+ internal.set(EMPTY);
3662
3920
  };
3663
3921
  writable.key = keySig;
3664
3922
  return writable;
@@ -3668,7 +3926,6 @@ class MessageBus {
3668
3926
  channel = new BroadcastChannel('mmstack-tab-sync-bus');
3669
3927
  listeners = new Map();
3670
3928
  subscribe(id, listener) {
3671
- this.unsubscribe(id); // Ensure no duplicate listeners
3672
3929
  const wrapped = (ev) => {
3673
3930
  try {
3674
3931
  if (ev.data?.id === id)
@@ -3679,18 +3936,28 @@ class MessageBus {
3679
3936
  }
3680
3937
  };
3681
3938
  this.channel.addEventListener('message', wrapped);
3682
- this.listeners.set(id, wrapped);
3939
+ let set = this.listeners.get(id);
3940
+ if (!set) {
3941
+ set = new Set();
3942
+ this.listeners.set(id, set);
3943
+ }
3944
+ set.add(wrapped);
3683
3945
  return {
3684
- unsub: (() => this.unsubscribe(id)).bind(this),
3685
- post: ((value) => this.channel.postMessage({ id, value })).bind(this),
3946
+ unsub: () => {
3947
+ this.channel.removeEventListener('message', wrapped);
3948
+ const cur = this.listeners.get(id);
3949
+ if (!cur)
3950
+ return;
3951
+ cur.delete(wrapped);
3952
+ if (cur.size === 0)
3953
+ this.listeners.delete(id);
3954
+ },
3955
+ post: (value) => this.channel.postMessage({ id, value }),
3686
3956
  };
3687
3957
  }
3688
- unsubscribe(id) {
3689
- const listener = this.listeners.get(id);
3690
- if (!listener)
3691
- return;
3692
- this.channel.removeEventListener('message', listener);
3693
- this.listeners.delete(id);
3958
+ ngOnDestroy() {
3959
+ this.channel.close();
3960
+ this.listeners.clear();
3694
3961
  }
3695
3962
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.19", ngImport: i0, type: MessageBus, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
3696
3963
  static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "19.2.19", ngImport: i0, type: MessageBus, providedIn: 'root' });
@@ -3701,6 +3968,11 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.19", ngImpo
3701
3968
  providedIn: 'root',
3702
3969
  }]
3703
3970
  }] });
3971
+ /**
3972
+ * @deprecated The generated id hashes the call-site stack line, which collides when a shared
3973
+ * helper calls {@link tabSync} for multiple signals and diverges across minified builds during
3974
+ * a rolling deploy. Pass an explicit `{ id }` instead.
3975
+ */
3704
3976
  function generateDeterministicID() {
3705
3977
  const stack = new Error().stack;
3706
3978
  if (stack) {
@@ -3738,10 +4010,8 @@ function generateDeterministicID() {
3738
4010
  *
3739
4011
  * @example
3740
4012
  * ```typescript
3741
- * // Basic usage - auto-generates channel ID from call site
3742
- * const theme = tabSync(signal('dark'));
3743
- *
3744
- * // With explicit ID (recommended for production)
4013
+ * // With explicit ID (recommended)
4014
+ * const theme = tabSync(signal('dark'), { id: 'theme' });
3745
4015
  * const userPrefs = tabSync(signal({ lang: 'en' }), { id: 'user-preferences' });
3746
4016
  *
3747
4017
  * // Changes in one tab will sync to all other tabs
@@ -3753,6 +4023,7 @@ function generateDeterministicID() {
3753
4023
  * - Uses a single BroadcastChannel for all synchronized signals
3754
4024
  * - Automatically cleans up listeners when the injection context is destroyed
3755
4025
  * - Initial signal value after sync setup is not broadcasted to prevent loops
4026
+ * - Received values are not re-broadcast, so tabs never echo each other's updates
3756
4027
  *
3757
4028
  */
3758
4029
  function tabSync(sig, opt) {
@@ -3760,7 +4031,20 @@ function tabSync(sig, opt) {
3760
4031
  return sig;
3761
4032
  const id = typeof opt === 'string' ? opt : (opt?.id ?? generateDeterministicID());
3762
4033
  const bus = inject(MessageBus);
3763
- const { unsub, post } = bus.subscribe(id, (next) => sig.set(next));
4034
+ // The last value applied from a remote tab. The outbound effect skips (exactly) the run
4035
+ // caused by that write — without this, an inbound object (a fresh structured clone, so
4036
+ // never reference-equal) would be re-posted, and two tabs would ping-pong forever.
4037
+ const NONE = Symbol();
4038
+ let received = NONE;
4039
+ const { unsub, post } = bus.subscribe(id, (next) => {
4040
+ const before = untracked(sig);
4041
+ received = next;
4042
+ sig.set(next);
4043
+ // Equality-suppressed write (e.g. an identical primitive): no effect run will follow,
4044
+ // so clear the marker — it must not swallow a later, genuinely local change.
4045
+ if (untracked(sig) === before)
4046
+ received = NONE;
4047
+ });
3764
4048
  let first = false;
3765
4049
  const effectRef = effect(() => {
3766
4050
  const val = sig();
@@ -3768,6 +4052,11 @@ function tabSync(sig, opt) {
3768
4052
  first = true;
3769
4053
  return;
3770
4054
  }
4055
+ if (val === received) {
4056
+ received = NONE;
4057
+ return;
4058
+ }
4059
+ received = NONE;
3771
4060
  post(val);
3772
4061
  });
3773
4062
  inject(DestroyRef).onDestroy(() => {
@@ -3778,7 +4067,6 @@ function tabSync(sig, opt) {
3778
4067
  }
3779
4068
 
3780
4069
  function until(sourceSignal, predicate, options = {}) {
3781
- const injector = options.injector ?? inject(Injector);
3782
4070
  return new Promise((resolve, reject) => {
3783
4071
  let effectRef;
3784
4072
  let timeoutId;
@@ -3815,6 +4103,14 @@ function until(sourceSignal, predicate, options = {}) {
3815
4103
  cleanupAndResolve(initialValue);
3816
4104
  return;
3817
4105
  }
4106
+ let injector;
4107
+ try {
4108
+ injector = options.injector ?? inject(Injector);
4109
+ }
4110
+ catch {
4111
+ cleanupAndReject('until: No injector available — provide options.injector when calling outside an injection context.');
4112
+ return;
4113
+ }
3818
4114
  if (options?.timeout !== undefined) {
3819
4115
  timeoutId = setTimeout(() => cleanupAndReject(`until: Timeout after ${options.timeout}ms.`), options.timeout);
3820
4116
  }
@@ -3832,17 +4128,6 @@ function until(sourceSignal, predicate, options = {}) {
3832
4128
  });
3833
4129
  }
3834
4130
 
3835
- /**
3836
- * @interal
3837
- */
3838
- function getSignalEquality(sig) {
3839
- const internal = sig[SIGNAL];
3840
- if (internal && typeof internal.equal === 'function') {
3841
- return internal.equal;
3842
- }
3843
- return Object.is; // Default equality check
3844
- }
3845
-
3846
4131
  /**
3847
4132
  * Enhances an existing `WritableSignal` by adding a complete undo/redo history
3848
4133
  * stack and an API to control it.
@@ -3891,9 +4176,10 @@ function getSignalEquality(sig) {
3891
4176
  * ```
3892
4177
  */
3893
4178
  function withHistory(sourceOrValue, opt) {
3894
- const equal = (opt?.equal ?? isSignal(sourceOrValue))
3895
- ? getSignalEquality(sourceOrValue)
3896
- : Object.is;
4179
+ const equal = opt?.equal ??
4180
+ (isSignal(sourceOrValue)
4181
+ ? getSignalEquality(sourceOrValue)
4182
+ : Object.is);
3897
4183
  const source = isSignal(sourceOrValue)
3898
4184
  ? sourceOrValue
3899
4185
  : signal(sourceOrValue);
@@ -3938,9 +4224,8 @@ function withHistory(sourceOrValue, opt) {
3938
4224
  if (historyStack.length === 0)
3939
4225
  return;
3940
4226
  const valueForRedo = untracked(source);
3941
- const valueToRestore = historyStack.at(-1);
3942
- if (valueToRestore === undefined)
3943
- return;
4227
+ // length checked above — a legitimately `undefined` entry must still restore
4228
+ const valueToRestore = historyStack[historyStack.length - 1];
3944
4229
  originalSet.call(source, valueToRestore);
3945
4230
  history.inline((h) => h.pop());
3946
4231
  redoArray.mutate((r) => {
@@ -3954,9 +4239,8 @@ function withHistory(sourceOrValue, opt) {
3954
4239
  if (redoStack.length === 0)
3955
4240
  return;
3956
4241
  const valueForUndo = untracked(source);
3957
- const valueToRestore = redoStack.at(-1);
3958
- if (valueToRestore === undefined)
3959
- return;
4242
+ // length checked above — a legitimately `undefined` entry must still restore
4243
+ const valueToRestore = redoStack[redoStack.length - 1];
3960
4244
  originalSet.call(source, valueToRestore);
3961
4245
  redoArray.inline((r) => r.pop());
3962
4246
  history.mutate((h) => {