@mmstack/primitives 21.1.1 → 21.1.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) => {
@@ -262,7 +269,9 @@ class MmActivity {
262
269
  });
263
270
  }
264
271
  for (const node of this.view.rootNodes) {
265
- if (node instanceof HTMLElement)
272
+ // covers HTML and SVG roots; text/comment roots can't be styled — their CD is still
273
+ // detached, but prefer an element root for true visual hiding
274
+ if (node instanceof HTMLElement || node instanceof SVGElement)
266
275
  node.style.display = visible ? '' : 'none';
267
276
  }
268
277
  if (visible)
@@ -341,14 +350,25 @@ function resolvePause(opt) {
341
350
  if (pause === false)
342
351
  return null;
343
352
  const run = (fn) => opt?.injector ? runInInjectionContext(opt.injector, fn) : fn();
353
+ // `inject` requires an injection context even with `optional: true`. A bare
354
+ // `pausableSignal(0)` (documented as "like `signal`") must degrade to the unwrapped
355
+ // primitive outside DI, not throw NG0203 — so injection failures fall back gracefully.
356
+ const tryRun = (fn, fallback) => {
357
+ try {
358
+ return run(fn);
359
+ }
360
+ catch {
361
+ return fallback;
362
+ }
363
+ };
344
364
  const onServer = () => typeof pause === 'function' && !opt?.injector
345
365
  ? typeof globalThis.window === 'undefined'
346
- : run(() => isPlatformServer(inject(PLATFORM_ID, { optional: true }) ?? 'browser'));
366
+ : tryRun(() => isPlatformServer(inject(PLATFORM_ID, { optional: true }) ?? 'browser'), typeof globalThis.window === 'undefined');
347
367
  if (typeof pause === 'function')
348
368
  return onServer() ? null : pause;
349
369
  if (onServer())
350
370
  return null;
351
- const paused = run(() => inject(PAUSED_CONTEXT, { optional: true }));
371
+ const paused = tryRun(() => inject(PAUSED_CONTEXT, { optional: true }), null);
352
372
  if (!paused) {
353
373
  if (explicit === true && isDevMode())
354
374
  console.warn('[pausable] `pause: true` but no PAUSED_CONTEXT in scope — not pausing. Provide one via an ' +
@@ -377,8 +397,9 @@ function pausableEffect(effectFn, options) {
377
397
  /**
378
398
  * Like `signal`, but pausable. While paused, READS hold the last value; writes still land on the
379
399
  * underlying signal and surface on resume. Built on the `keepPrevious`/`hold` shape — a
380
- * `linkedSignal` gated on the pause predicate, with `set`/`update`/`asReadonly` forwarded to the
381
- * source signal. With no `pause` option it defaults to the ambient `PAUSED_CONTEXT`; `pause: false`
400
+ * `linkedSignal` gated on the pause predicate, with `set`/`update` forwarded to the source signal.
401
+ * `asReadonly()` returns the held (gated) view, so both views of the signal agree while paused.
402
+ * With no `pause` option it defaults to the ambient `PAUSED_CONTEXT`; `pause: false`
382
403
  * makes it a plain `signal` — no `linkedSignal` is created.
383
404
  *
384
405
  * NOTE: while paused, `set(x)` followed by a read returns the *held* (pre-pause) value, not `x` — the
@@ -395,7 +416,8 @@ function pausableSignal(initialValue, options) {
395
416
  equal: options?.equal });
396
417
  read.set = src.set;
397
418
  read.update = src.update;
398
- read.asReadonly = src.asReadonly;
419
+ // NOTE: `asReadonly` deliberately stays the linkedSignal's own (the held view) — the
420
+ // source's readonly view would show live values while the signal itself shows held ones.
399
421
  return read;
400
422
  }
401
423
  /**
@@ -432,8 +454,12 @@ function mutable(initial, opt) {
432
454
  const internalUpdate = sig.update;
433
455
  sig.mutate = (updater) => {
434
456
  cnt++;
435
- internalUpdate(updater);
436
- cnt--;
457
+ try {
458
+ internalUpdate(updater);
459
+ }
460
+ finally {
461
+ cnt--;
462
+ }
437
463
  };
438
464
  sig.inline = (updater) => {
439
465
  sig.mutate((prev) => {
@@ -520,7 +546,7 @@ function createNoopScope() {
520
546
  hold: (value) => value,
521
547
  };
522
548
  }
523
- const TRANSITION_SCOPE = new InjectionToken('@mmstack/resource:transition-scope');
549
+ const TRANSITION_SCOPE = new InjectionToken('@mmstack/primitives:transition-scope');
524
550
  /** Provide a fresh transition scope at a boundary so its subtree's resources are tracked independently. */
525
551
  function provideTransitionScope() {
526
552
  return { provide: TRANSITION_SCOPE, useFactory: createTransitionScope };
@@ -529,7 +555,7 @@ function injectTransitionScope() {
529
555
  const scope = inject(TRANSITION_SCOPE, { optional: true });
530
556
  if (!scope) {
531
557
  if (isDevMode())
532
- console.warn('[mmstack/resource] No transition scope in context — registration/tracking here is a no-op. ' +
558
+ console.warn('[mmstack/primitives] No transition scope in context — registration/tracking here is a no-op. ' +
533
559
  'Use a <mm-suspense> boundary or provideTransitionScope() in an ancestor.');
534
560
  return createNoopScope();
535
561
  }
@@ -563,6 +589,11 @@ function registerResource(res, opt) {
563
589
  *
564
590
  * Must be called in an injection context. This is the *async* generalization (Tier 2): it adds
565
591
  * no rendering cost and needs no fork — holding direct/sync readers is a separate, deferred tier.
592
+ *
593
+ * Caveat: work must go in flight by the first post-write render to be awaited. A loader that
594
+ * starts later (a debounced request signal, a chained/deferred resource) is not attributable to
595
+ * this transition — the no-async fallback will have already resolved `done`. Trigger such work
596
+ * eagerly inside `fn`, or coordinate it separately.
566
597
  */
567
598
  function injectStartTransition() {
568
599
  const scope = injectTransitionScope();
@@ -712,6 +743,11 @@ function runInTransaction(txn, fn) {
712
743
  * The writes land on LIVE state immediately (so derived variables and connector requests see the
713
744
  * new values and refetch); only the *display* is held, via `scope.hold`. Must run in an injection
714
745
  * context.
746
+ *
747
+ * Caveat: work must go in flight by the first post-write render to be part of the transaction. A
748
+ * loader that starts later (a debounced request signal, a chained/deferred resource) is not
749
+ * attributable to it — the no-async fallback will have already committed and released the hold,
750
+ * after which `abort()` is a no-op. Trigger such work eagerly inside `fn`.
715
751
  */
716
752
  function injectStartTransaction() {
717
753
  const scope = injectTransitionScope();
@@ -721,7 +757,15 @@ function injectStartTransaction() {
721
757
  // Hold BEFORE the writes, so the display freezes at pre-transaction values.
722
758
  scope.beginHold();
723
759
  let finished = false;
760
+ // eslint-disable-next-line prefer-const -- assigned in try/catch, but needs to be declared here for the `finally` block to see it
724
761
  let watcher;
762
+ let resolveDone;
763
+ const done = new Promise((resolve) => {
764
+ resolveDone = resolve;
765
+ });
766
+ // Every exit path funnels through here, so `done` always settles — including `abort()`
767
+ // and a throwing transaction body (which would otherwise leak the hold forever and
768
+ // freeze the boundary with no recovery).
725
769
  const finish = (restore) => {
726
770
  if (finished)
727
771
  return;
@@ -732,27 +776,28 @@ function injectStartTransaction() {
732
776
  else
733
777
  txn.clear();
734
778
  scope.endHold();
779
+ resolveDone();
735
780
  };
736
- runInTransaction(txn, fn);
781
+ try {
782
+ runInTransaction(txn, fn);
783
+ }
784
+ catch (e) {
785
+ finish(true);
786
+ throw e;
787
+ }
737
788
  let sawPending = false;
738
- const done = new Promise((resolve) => {
739
- watcher = effect(() => {
740
- const p = scope.pending();
741
- if (p)
742
- sawPending = true;
743
- if (sawPending && !p) {
744
- finish(false);
745
- resolve();
746
- }
747
- }, { injector });
748
- // no-async fallback: if nothing ever went in flight, settle once the writes are processed.
749
- afterNextRender(() => {
750
- if (!sawPending && !untracked(scope.pending)) {
751
- finish(false);
752
- resolve();
753
- }
754
- }, { injector });
755
- });
789
+ watcher = effect(() => {
790
+ const p = scope.pending();
791
+ if (p)
792
+ sawPending = true;
793
+ if (sawPending && !p)
794
+ finish(false);
795
+ }, { injector });
796
+ // no-async fallback: if nothing ever went in flight, settle once the writes are processed.
797
+ afterNextRender(() => {
798
+ if (!sawPending && !untracked(scope.pending))
799
+ finish(false);
800
+ }, { injector });
756
801
  return {
757
802
  pending: scope.pending,
758
803
  done,
@@ -761,6 +806,17 @@ function injectStartTransaction() {
761
806
  };
762
807
  }
763
808
 
809
+ /**
810
+ * @internal
811
+ */
812
+ function getSignalEquality(sig) {
813
+ const internal = sig[SIGNAL];
814
+ if (internal && typeof internal.equal === 'function') {
815
+ return internal.equal;
816
+ }
817
+ return Object.is; // Default equality check
818
+ }
819
+
764
820
  /**
765
821
  * Converts a read-only `Signal` into a `WritableSignal` by providing custom `set` and, optionally, `update` functions.
766
822
  * This can be useful for creating controlled write access to a signal that is otherwise read-only.
@@ -859,6 +915,7 @@ function debounced(initial, opt) {
859
915
  * ```
860
916
  */
861
917
  function debounce(source, opt) {
918
+ const eq = opt?.equal ?? getSignalEquality(source);
862
919
  const ms = opt?.ms ?? 0;
863
920
  const trigger = signal(false, ...(ngDevMode ? [{ debugName: "trigger" }] : /* istanbul ignore next */ []));
864
921
  let timeout;
@@ -873,25 +930,25 @@ function debounce(source, opt) {
873
930
  catch {
874
931
  // not in injection context & no destroyRef provided opting out of cleanup
875
932
  }
876
- const triggerFn = (next) => {
933
+ const set = (next) => {
934
+ const isEqual = eq(untracked(source), next);
935
+ if (!timeout && isEqual)
936
+ return; // nothing to do
877
937
  if (timeout)
878
- clearTimeout(timeout);
879
- source.set(next);
938
+ clearTimeout(timeout); // clear pending
939
+ if (!isEqual)
940
+ source.set(next);
880
941
  timeout = setTimeout(() => {
942
+ timeout = undefined;
881
943
  trigger.update((c) => !c);
882
944
  }, ms);
883
945
  };
884
- const set = (value) => {
885
- triggerFn(value);
886
- };
887
- const update = (fn) => {
888
- triggerFn(fn(untracked(source)));
889
- };
946
+ const update = (fn) => set(fn(untracked(source)));
890
947
  const writable = toWritable(computed(() => {
891
948
  trigger();
892
949
  return untracked(source);
893
950
  }, opt), set, update);
894
- writable.original = source;
951
+ writable.original = source.asReadonly();
895
952
  return writable;
896
953
  }
897
954
 
@@ -1062,8 +1119,18 @@ function derived(source, optOrKey, opt) {
1062
1119
  if (isMutable(source)) {
1063
1120
  sig.mutate = (updater) => {
1064
1121
  cnt++;
1065
- sig.update(updater);
1066
- cnt--;
1122
+ try {
1123
+ sig.update(updater);
1124
+ // The wrapped computed evaluates its `equal` lazily — at the next read, which would
1125
+ // normally happen after `cnt` has already dropped back to 0. For a reference-stable
1126
+ // mutation that read compares the same object to itself and the version never bumps,
1127
+ // so dependents are never notified. Reading here, while equality is still suppressed,
1128
+ // forces the recompute (and version bump) inside the mutate window.
1129
+ untracked(sig);
1130
+ }
1131
+ finally {
1132
+ cnt--;
1133
+ }
1067
1134
  };
1068
1135
  sig.inline = (updater) => {
1069
1136
  sig.mutate((prev) => {
@@ -1119,16 +1186,43 @@ function isDerivation(sig) {
1119
1186
  }
1120
1187
 
1121
1188
  function keepPrevious(src, opt) {
1189
+ const mutableSrc = isWritableSignal$2(src) && isMutable(src);
1190
+ // For a mutable source the linkedSignal's equality must be suppressible: a forwarded
1191
+ // `mutate` keeps the same reference, which default equality would otherwise swallow.
1192
+ let cnt = 0;
1193
+ const baseEqual = opt?.equal;
1194
+ const equal = mutableSrc
1195
+ ? (a, b) => cnt > 0 ? false : baseEqual ? baseEqual(a, b) : Object.is(a, b)
1196
+ : baseEqual;
1122
1197
  const persisted = linkedSignal({ ...(ngDevMode ? { debugName: "persisted" } : /* istanbul ignore next */ {}), ...opt,
1123
1198
  source: () => src(),
1124
- computation: (next, prev) => next === undefined && prev !== undefined ? prev.value : next });
1199
+ computation: (next, prev) => next === undefined && prev !== undefined ? prev.value : next,
1200
+ equal });
1125
1201
  if (isWritableSignal$2(src)) {
1126
1202
  persisted.set = src.set;
1127
1203
  persisted.update = src.update;
1128
- persisted.asReadonly = src.asReadonly;
1129
- if (isMutable(src)) {
1130
- persisted.mutate = src.mutate;
1131
- persisted.inline = src.inline;
1204
+ // NOTE: `asReadonly` deliberately stays the linkedSignal's own — returning the
1205
+ // source's readonly view would reintroduce the `undefined` flashes this wrapper exists
1206
+ // to prevent.
1207
+ if (mutableSrc) {
1208
+ persisted.mutate = (updater) => {
1209
+ cnt++;
1210
+ try {
1211
+ src.mutate(updater);
1212
+ // force the recompute while equality is suppressed, so the reference-stable
1213
+ // mutation bumps the wrapper's version (see derived.ts for the same pattern)
1214
+ untracked(persisted);
1215
+ }
1216
+ finally {
1217
+ cnt--;
1218
+ }
1219
+ };
1220
+ persisted.inline = (updater) => {
1221
+ persisted.mutate((prev) => {
1222
+ updater(prev);
1223
+ return prev;
1224
+ });
1225
+ };
1132
1226
  }
1133
1227
  if (isDerivation(src)) {
1134
1228
  persisted.from = src.from;
@@ -1185,13 +1279,18 @@ function indexArray(source, map, opt = {}) {
1185
1279
  : toWritable(data, () => {
1186
1280
  // noop
1187
1281
  });
1282
+ // copy before defaulting `equal` — assigning onto `opt` would mutate a caller-owned
1283
+ // (possibly shared/reused) options object
1188
1284
  if (isWritableSignal$1(data) && isMutable(data) && !opt.equal) {
1189
- opt.equal = (a, b) => {
1190
- if (typeof a !== typeof b)
1191
- return false;
1192
- if (typeof a === 'object' || typeof a === 'function')
1193
- return false;
1194
- return a === b;
1285
+ opt = {
1286
+ ...opt,
1287
+ equal: (a, b) => {
1288
+ if (typeof a !== typeof b)
1289
+ return false;
1290
+ if (typeof a === 'object' || typeof a === 'function')
1291
+ return false;
1292
+ return a === b;
1293
+ },
1195
1294
  };
1196
1295
  }
1197
1296
  return linkedSignal({
@@ -1385,8 +1484,17 @@ function pooledKeys(src) {
1385
1484
  for (const k in val)
1386
1485
  if (Object.prototype.hasOwnProperty.call(val, k))
1387
1486
  spare.add(k);
1388
- if (active.size === spare.size && active.isSubsetOf(spare))
1389
- return active;
1487
+ if (active.size === spare.size) {
1488
+ let subset = true;
1489
+ for (const k of active) {
1490
+ if (!spare.has(k)) {
1491
+ subset = false;
1492
+ break;
1493
+ }
1494
+ }
1495
+ if (subset)
1496
+ return active;
1497
+ }
1390
1498
  const temp = active;
1391
1499
  active = spare;
1392
1500
  spare = temp;
@@ -1486,7 +1594,7 @@ const filter = (predicate) => (src) => linkedSignal({
1486
1594
  computation: (next, prev) => {
1487
1595
  if (predicate(next))
1488
1596
  return next;
1489
- return prev?.source;
1597
+ return prev?.value;
1490
1598
  },
1491
1599
  });
1492
1600
  /**
@@ -1522,7 +1630,7 @@ const tap = (fn, injector) => (src) => {
1522
1630
  */
1523
1631
  const filterWith = (predicate, initial) => (src) => linkedSignal({
1524
1632
  source: src,
1525
- computation: (next, prev) => predicate(next) ? next : (prev?.value ?? initial),
1633
+ computation: (next, prev) => predicate(next) ? next : prev ? prev.value : initial,
1526
1634
  });
1527
1635
  /**
1528
1636
  * Emit `initial` on the first read, then mirror the source on every subsequent
@@ -1571,7 +1679,7 @@ const pairwise = () => (src) => linkedSignal({
1571
1679
  */
1572
1680
  const scan = (reducer, seed) => (src) => linkedSignal({
1573
1681
  source: src,
1574
- computation: (next, prev) => reducer(prev?.value ?? seed, next),
1682
+ computation: (next, prev) => reducer(prev ? prev.value : seed, next),
1575
1683
  });
1576
1684
 
1577
1685
  /**
@@ -1622,7 +1730,7 @@ function pipeable(signal) {
1622
1730
  return internal;
1623
1731
  }
1624
1732
  /**
1625
- * Create a new **writable** signal and return it as a `PipableSignal`.
1733
+ * Create a new **writable** signal and return it as a `PipeableSignal`.
1626
1734
  *
1627
1735
  * The returned value is a `WritableSignal<T>` with `.set`, `.update`, `.asReadonly`
1628
1736
  * still available (via intersection type), plus a chainable `.pipe(...)`.
@@ -1726,6 +1834,20 @@ function pooledMap(optOrComputation, signalOpt) {
1726
1834
  return pooled(toPooledOptions(optOrComputation, createEmptyMap, resetClearable, signalOpt));
1727
1835
  }
1728
1836
 
1837
+ /**
1838
+ * @internal Run a sensor factory inside `injector` when provided, else in the ambient
1839
+ * injection context. Keeps every sensor's escape hatch identical and in one place.
1840
+ */
1841
+ function runInSensorContext(injector, fn) {
1842
+ return injector ? runInInjectionContext(injector, fn) : fn();
1843
+ }
1844
+ /**
1845
+ * @internal Normalize the legacy positional `debugName: string` form into {@link SensorRunOptions}.
1846
+ */
1847
+ function coerceSensorOptions(opt) {
1848
+ return typeof opt === 'string' ? { debugName: opt } : (opt ?? {});
1849
+ }
1850
+
1729
1851
  const EVENTS = [
1730
1852
  'chargingchange',
1731
1853
  'levelchange',
@@ -1747,7 +1869,11 @@ const EVENTS = [
1747
1869
  * });
1748
1870
  * ```
1749
1871
  */
1750
- function batteryStatus(debugName = 'batteryStatus') {
1872
+ function batteryStatus(opt) {
1873
+ const { debugName = 'batteryStatus', injector } = coerceSensorOptions(opt);
1874
+ return runInSensorContext(injector, () => createBatteryStatus(debugName));
1875
+ }
1876
+ function createBatteryStatus(debugName) {
1751
1877
  if (isPlatformServer(inject(PLATFORM_ID)) ||
1752
1878
  typeof navigator === 'undefined' ||
1753
1879
  typeof navigator.getBattery !== 'function') {
@@ -1756,7 +1882,9 @@ function batteryStatus(debugName = 'batteryStatus') {
1756
1882
  const state = signal(null, { ...(ngDevMode ? { debugName: "state" } : /* istanbul ignore next */ {}), debugName });
1757
1883
  const abortController = new AbortController();
1758
1884
  inject(DestroyRef).onDestroy(() => abortController.abort());
1759
- navigator.getBattery().then((battery) => {
1885
+ navigator
1886
+ .getBattery()
1887
+ .then((battery) => {
1760
1888
  if (abortController.signal.aborted)
1761
1889
  return;
1762
1890
  const read = () => ({
@@ -1772,6 +1900,10 @@ function batteryStatus(debugName = 'batteryStatus') {
1772
1900
  signal: abortController.signal,
1773
1901
  });
1774
1902
  }
1903
+ })
1904
+ .catch(() => {
1905
+ // getBattery() rejects (NotAllowedError) when the `battery` permissions-policy is
1906
+ // disallowed, e.g. in cross-origin iframes — stay `null`, same as unsupported.
1775
1907
  });
1776
1908
  return state.asReadonly();
1777
1909
  }
@@ -1787,7 +1919,11 @@ function batteryStatus(debugName = 'batteryStatus') {
1787
1919
  * in browsers that gate it. Errors from `navigator.clipboard.readText` are
1788
1920
  * swallowed silently to keep the signal value stable.
1789
1921
  */
1790
- function clipboard(debugName = 'clipboard') {
1922
+ function clipboard(opt) {
1923
+ const { debugName = 'clipboard', injector } = coerceSensorOptions(opt);
1924
+ return runInSensorContext(injector, () => createClipboard(debugName));
1925
+ }
1926
+ function createClipboard(debugName) {
1791
1927
  if (isPlatformServer(inject(PLATFORM_ID)) ||
1792
1928
  typeof navigator === 'undefined' ||
1793
1929
  !navigator.clipboard) {
@@ -1837,7 +1973,13 @@ function observerSupported$1() {
1837
1973
  * });
1838
1974
  * ```
1839
1975
  */
1840
- function elementSize(target = inject(ElementRef), opt) {
1976
+ function elementSize(target, opt) {
1977
+ return runInSensorContext(opt?.injector, () =>
1978
+ // the host-element default must resolve INSIDE the sensor context, not as a
1979
+ // parameter default (which would run before the injector wrapper)
1980
+ createElementSize(target ?? inject(ElementRef), opt));
1981
+ }
1982
+ function createElementSize(target, opt) {
1841
1983
  const getElement = () => {
1842
1984
  if (isSignal(target)) {
1843
1985
  try {
@@ -1851,8 +1993,8 @@ function elementSize(target = inject(ElementRef), opt) {
1851
1993
  return target instanceof ElementRef ? target.nativeElement : target;
1852
1994
  };
1853
1995
  const resolveInitialValue = () => {
1854
- if (!observerSupported$1())
1855
- return undefined;
1996
+ // measuring needs only getBoundingClientRect — ResizeObserver support gates
1997
+ // live updates, not the initial read
1856
1998
  const el = getElement();
1857
1999
  if (el && el.getBoundingClientRect) {
1858
2000
  const rect = el.getBoundingClientRect();
@@ -1970,7 +2112,13 @@ function observerSupported() {
1970
2112
  * }
1971
2113
  * ```
1972
2114
  */
1973
- function elementVisibility(target = inject(ElementRef), opt) {
2115
+ function elementVisibility(target, opt) {
2116
+ return runInSensorContext(opt?.injector, () =>
2117
+ // the host-element default must resolve INSIDE the sensor context, not as a
2118
+ // parameter default (which would run before the injector wrapper)
2119
+ createElementVisibility(target ?? inject(ElementRef), opt));
2120
+ }
2121
+ function createElementVisibility(target, opt) {
1974
2122
  if (isPlatformServer(inject(PLATFORM_ID)) || !observerSupported()) {
1975
2123
  const base = computed(() => undefined, {
1976
2124
  debugName: opt?.debugName,
@@ -2038,11 +2186,18 @@ function unwrap$1(target) {
2038
2186
  * }
2039
2187
  * ```
2040
2188
  */
2041
- function focusWithin(target = inject(ElementRef)) {
2189
+ function focusWithin(target, opt) {
2190
+ return runInSensorContext(opt?.injector, () =>
2191
+ // the host-element default must resolve INSIDE the sensor context, not as a
2192
+ // parameter default (which would run before the injector wrapper)
2193
+ createFocusWithin(target ?? inject(ElementRef), opt));
2194
+ }
2195
+ function createFocusWithin(target, opt) {
2196
+ const debugName = opt?.debugName ?? 'focusWithin';
2042
2197
  if (isPlatformServer(inject(PLATFORM_ID))) {
2043
- return computed(() => false, { debugName: 'focusWithin' });
2198
+ return computed(() => false, { debugName });
2044
2199
  }
2045
- const state = signal(false, { debugName: 'focusWithin' });
2200
+ const state = signal(false, { ...(ngDevMode ? { debugName: "state" } : /* istanbul ignore next */ {}), debugName });
2046
2201
  const attach = (el) => {
2047
2202
  state.set(el.contains(document.activeElement));
2048
2203
  const abortController = new AbortController();
@@ -2090,6 +2245,9 @@ function focusWithin(target = inject(ElementRef)) {
2090
2245
  * ```
2091
2246
  */
2092
2247
  function geolocation(opt) {
2248
+ return runInSensorContext(opt?.injector, () => createGeolocation(opt));
2249
+ }
2250
+ function createGeolocation(opt) {
2093
2251
  if (isPlatformServer(inject(PLATFORM_ID)) || typeof navigator === 'undefined' || !navigator.geolocation) {
2094
2252
  const sig = computed(() => null, {
2095
2253
  debugName: opt?.debugName ?? 'geolocation',
@@ -2149,6 +2307,9 @@ const serverDate$1 = new Date();
2149
2307
  * ```
2150
2308
  */
2151
2309
  function idle(opt) {
2310
+ return runInSensorContext(opt?.injector, () => createIdle(opt));
2311
+ }
2312
+ function createIdle(opt) {
2152
2313
  if (isPlatformServer(inject(PLATFORM_ID))) {
2153
2314
  const sig = computed(() => false, {
2154
2315
  debugName: opt?.debugName ?? 'idle',
@@ -2238,7 +2399,11 @@ function idle(opt) {
2238
2399
  * }
2239
2400
  * ```
2240
2401
  */
2241
- function mediaQuery(query, debugName = 'mediaQuery') {
2402
+ function mediaQuery(query, opt) {
2403
+ const { debugName = 'mediaQuery', injector } = coerceSensorOptions(opt);
2404
+ return runInSensorContext(injector, () => createMediaQuery(query, debugName));
2405
+ }
2406
+ function createMediaQuery(query, debugName) {
2242
2407
  if (isPlatformServer(inject(PLATFORM_ID)) ||
2243
2408
  typeof window === 'undefined' ||
2244
2409
  typeof window.matchMedia !== 'function' // jsdom doesn't implement matchMedia
@@ -2276,8 +2441,8 @@ function mediaQuery(query, debugName = 'mediaQuery') {
2276
2441
  * });
2277
2442
  * ```
2278
2443
  */
2279
- function prefersDarkMode(debugName) {
2280
- return mediaQuery('(prefers-color-scheme: dark)', debugName);
2444
+ function prefersDarkMode(opt) {
2445
+ return mediaQuery('(prefers-color-scheme: dark)', opt);
2281
2446
  }
2282
2447
  /**
2283
2448
  * Creates a read-only signal that tracks the user's OS/browser preference
@@ -2304,8 +2469,8 @@ function prefersDarkMode(debugName) {
2304
2469
  * });
2305
2470
  * ```
2306
2471
  */
2307
- function prefersReducedMotion(debugName) {
2308
- return mediaQuery('(prefers-reduced-motion: reduce)', debugName);
2472
+ function prefersReducedMotion(opt) {
2473
+ return mediaQuery('(prefers-reduced-motion: reduce)', opt);
2309
2474
  }
2310
2475
 
2311
2476
  /**
@@ -2354,6 +2519,7 @@ function throttled(initial, opt) {
2354
2519
  * // after the 500ms cooldown.
2355
2520
  */
2356
2521
  function throttle(source, opt) {
2522
+ const eq = opt?.equal ?? getSignalEquality(source);
2357
2523
  const ms = opt?.ms ?? 0;
2358
2524
  const leading = opt?.leading ?? false;
2359
2525
  const trailing = opt?.trailing ?? true;
@@ -2379,31 +2545,32 @@ function throttle(source, opt) {
2379
2545
  fire();
2380
2546
  else
2381
2547
  pendingTrailing = trailing;
2382
- timeout = setTimeout(() => {
2548
+ const onWindowEnd = () => {
2383
2549
  timeout = undefined;
2384
2550
  if (trailing && pendingTrailing) {
2385
2551
  pendingTrailing = false;
2386
2552
  fire();
2553
+ timeout = setTimeout(onWindowEnd, ms);
2387
2554
  }
2388
- }, ms);
2555
+ };
2556
+ timeout = setTimeout(onWindowEnd, ms);
2389
2557
  return;
2390
2558
  }
2391
2559
  if (trailing)
2392
2560
  pendingTrailing = true;
2393
2561
  };
2394
- const set = (value) => {
2395
- source.set(value);
2396
- tick();
2397
- };
2398
- const update = (fn) => {
2399
- source.update(fn);
2562
+ const set = (next) => {
2563
+ if (eq(untracked(source), next))
2564
+ return;
2565
+ source.set(next);
2400
2566
  tick();
2401
2567
  };
2568
+ const update = (fn) => set(fn(untracked(source)));
2402
2569
  const writable = toWritable(computed(() => {
2403
2570
  trigger();
2404
2571
  return untracked(source);
2405
2572
  }, opt), set, update);
2406
- writable.original = source;
2573
+ writable.original = source.asReadonly();
2407
2574
  return writable;
2408
2575
  }
2409
2576
 
@@ -2440,6 +2607,9 @@ function throttle(source, opt) {
2440
2607
  * ```
2441
2608
  */
2442
2609
  function mousePosition(opt) {
2610
+ return runInSensorContext(opt?.injector, () => createMousePosition(opt));
2611
+ }
2612
+ function createMousePosition(opt) {
2443
2613
  if (isPlatformServer(inject(PLATFORM_ID))) {
2444
2614
  const base = computed(() => ({
2445
2615
  x: 0,
@@ -2451,8 +2621,12 @@ function mousePosition(opt) {
2451
2621
  return base;
2452
2622
  }
2453
2623
  const { target = window, coordinateSpace = 'client', touch = false, debugName = 'mousePosition', throttle = 100, } = opt ?? {};
2454
- const eventTarget = target instanceof ElementRef ? target.nativeElement : target;
2455
- if (!eventTarget) {
2624
+ const resolve = (t) => {
2625
+ if (!t)
2626
+ return null;
2627
+ return t instanceof ElementRef ? t.nativeElement : t;
2628
+ };
2629
+ if (!isSignal(target) && !resolve(target)) {
2456
2630
  if (isDevMode())
2457
2631
  console.warn('mousePosition: Target element not found.');
2458
2632
  const base = computed(() => ({
@@ -2475,7 +2649,7 @@ function mousePosition(opt) {
2475
2649
  x = coordinateSpace === 'page' ? event.pageX : event.clientX;
2476
2650
  y = coordinateSpace === 'page' ? event.pageY : event.clientY;
2477
2651
  }
2478
- else if (event.touches.length > 0) {
2652
+ else if (event.touches?.length > 0) {
2479
2653
  const firstTouch = event.touches[0];
2480
2654
  x = coordinateSpace === 'page' ? firstTouch.pageX : firstTouch.clientX;
2481
2655
  y = coordinateSpace === 'page' ? firstTouch.pageY : firstTouch.clientY;
@@ -2485,16 +2659,36 @@ function mousePosition(opt) {
2485
2659
  }
2486
2660
  pos.set({ x, y });
2487
2661
  };
2488
- eventTarget.addEventListener('mousemove', updatePosition);
2489
- if (touch) {
2490
- eventTarget.addEventListener('touchmove', updatePosition);
2491
- }
2492
- inject(DestroyRef).onDestroy(() => {
2493
- eventTarget.removeEventListener('mousemove', updatePosition);
2662
+ // passive: the handler never calls preventDefault, and a non-passive touchmove on
2663
+ // window forces the browser to wait on JS before scrolling (scroll jank on touch)
2664
+ const attach = (el) => {
2665
+ const controller = new AbortController();
2666
+ el.addEventListener('mousemove', updatePosition, {
2667
+ passive: true,
2668
+ signal: controller.signal,
2669
+ });
2494
2670
  if (touch) {
2495
- eventTarget.removeEventListener('touchmove', updatePosition);
2671
+ el.addEventListener('touchmove', updatePosition, {
2672
+ passive: true,
2673
+ signal: controller.signal,
2674
+ });
2496
2675
  }
2497
- });
2676
+ return () => controller.abort();
2677
+ };
2678
+ if (isSignal(target)) {
2679
+ // re-attach whenever the signal resolves to a (new) element — covers viewChild
2680
+ effect((cleanup) => {
2681
+ const el = resolve(target());
2682
+ if (!el)
2683
+ return;
2684
+ cleanup(attach(el));
2685
+ });
2686
+ }
2687
+ else {
2688
+ const el = resolve(target);
2689
+ if (el)
2690
+ inject(DestroyRef).onDestroy(attach(el));
2691
+ }
2498
2692
  const base = pos.asReadonly();
2499
2693
  base.unthrottled = pos.original;
2500
2694
  return base;
@@ -2508,7 +2702,8 @@ const serverDate = new Date();
2508
2702
  * An additional `since` signal is attached, tracking when the status last changed.
2509
2703
  * It's SSR-safe and automatically cleans up its event listeners.
2510
2704
  *
2511
- * @param debugName Optional debug name for the signal.
2705
+ * @param opt Optional debug name for the signal, or a {@link SensorRunOptions} object
2706
+ * (with an optional `injector` for creation outside an injection context).
2512
2707
  * @returns A `NetworkStatusSignal` instance.
2513
2708
  *
2514
2709
  * @example
@@ -2519,7 +2714,11 @@ const serverDate = new Date();
2519
2714
  * });
2520
2715
  * ```
2521
2716
  */
2522
- function networkStatus(debugName = 'networkStatus') {
2717
+ function networkStatus(opt) {
2718
+ const { debugName = 'networkStatus', injector } = coerceSensorOptions(opt);
2719
+ return runInSensorContext(injector, () => createNetworkStatus(debugName));
2720
+ }
2721
+ function createNetworkStatus(debugName) {
2523
2722
  if (isPlatformServer(inject(PLATFORM_ID))) {
2524
2723
  const sig = computed(() => true, {
2525
2724
  debugName,
@@ -2567,7 +2766,11 @@ const SSR_FALLBACK = {
2567
2766
  * });
2568
2767
  * ```
2569
2768
  */
2570
- function orientation(debugName = 'orientation') {
2769
+ function orientation(opt) {
2770
+ const { debugName = 'orientation', injector } = coerceSensorOptions(opt);
2771
+ return runInSensorContext(injector, () => createOrientation(debugName));
2772
+ }
2773
+ function createOrientation(debugName) {
2571
2774
  if (isPlatformServer(inject(PLATFORM_ID)) ||
2572
2775
  typeof screen === 'undefined' ||
2573
2776
  !screen.orientation) {
@@ -2594,7 +2797,8 @@ function orientation(debugName = 'orientation') {
2594
2797
  * The primitive is SSR-safe and automatically cleans up its event listeners
2595
2798
  * when the creating context is destroyed.
2596
2799
  *
2597
- * @param debugName Optional debug name for the signal.
2800
+ * @param opt Optional debug name for the signal, or a {@link SensorRunOptions} object
2801
+ * (with an optional `injector` for creation outside an injection context).
2598
2802
  * @returns A read-only `Signal<DocumentVisibilityState>`. On the server,
2599
2803
  * it returns a static signal with a value of `'visible'`.
2600
2804
  *
@@ -2622,7 +2826,11 @@ function orientation(debugName = 'orientation') {
2622
2826
  * }
2623
2827
  * ```
2624
2828
  */
2625
- function pageVisibility(debugName = 'pageVisibility') {
2829
+ function pageVisibility(opt) {
2830
+ const { debugName = 'pageVisibility', injector } = coerceSensorOptions(opt);
2831
+ return runInSensorContext(injector, () => createPageVisibility(debugName));
2832
+ }
2833
+ function createPageVisibility(debugName) {
2626
2834
  if (isPlatformServer(inject(PLATFORM_ID))) {
2627
2835
  return computed(() => 'visible', { debugName });
2628
2836
  }
@@ -2654,31 +2862,25 @@ function pageVisibility(debugName = 'pageVisibility') {
2654
2862
  * selector: 'app-scroll-tracker',
2655
2863
  * template: `
2656
2864
  * <p>Window Scroll: X: {{ windowScroll().x }}, Y: {{ windowScroll().y }}</p>
2657
- * <div #scrollableDiv style="height: 200px; width: 200px; overflow: auto; border: 1px solid black;">
2658
- * <div style="height: 400px; width: 400px;">Scroll me!</div>
2659
- * </div>
2660
- * @if (divScroll()) {
2661
- * <p>Div Scroll: X: {{ divScroll().x }}, Y: {{ divScroll().y }}</p>
2662
- * }
2865
+ * <p>Host Scroll: X: {{ hostScroll().x }}, Y: {{ hostScroll().y }}</p>
2663
2866
  * `
2664
2867
  * })
2665
2868
  * export class ScrollTrackerComponent {
2666
2869
  * readonly windowScroll = scrollPosition(); // Defaults to window
2870
+ * // Signal targets (e.g. viewChild) attach once the element exists:
2667
2871
  * readonly scrollableDiv = viewChild<ElementRef<HTMLDivElement>>('scrollableDiv');
2668
- * readonly divScroll = scrollPosition({ target: this.scrollableDiv() }); // Example with element target
2872
+ * readonly divScroll = scrollPosition({ target: this.scrollableDiv });
2669
2873
  *
2670
2874
  * constructor() {
2671
- * effect(() => {
2672
- * console.log('Window scrolled to:', this.windowScroll());
2673
- * if (this.divScroll()) {
2674
- * console.log('Div scrolled to:', this.divScroll());
2675
- * }
2676
- * });
2875
+ * effect(() => console.log('Window scrolled to:', this.windowScroll()));
2677
2876
  * }
2678
2877
  * }
2679
2878
  * ```
2680
2879
  */
2681
2880
  function scrollPosition(opt) {
2881
+ return runInSensorContext(opt?.injector, () => createScrollPosition(opt));
2882
+ }
2883
+ function createScrollPosition(opt) {
2682
2884
  if (isPlatformServer(inject(PLATFORM_ID))) {
2683
2885
  const base = computed(() => ({
2684
2886
  x: 0,
@@ -2690,43 +2892,44 @@ function scrollPosition(opt) {
2690
2892
  return base;
2691
2893
  }
2692
2894
  const { target = globalThis.window, throttle = 100, debugName = 'scrollPosition', } = opt || {};
2693
- let element;
2694
- let getScrollPosition;
2695
- if (target === globalThis.window || target.window === target) {
2696
- element = target;
2697
- getScrollPosition = () => {
2698
- return {
2699
- x: target.scrollX ?? target.pageXOffset ?? 0,
2700
- y: target.scrollY ?? target.pageYOffset ?? 0,
2701
- };
2702
- };
2703
- }
2704
- else if (target instanceof ElementRef) {
2705
- element = target.nativeElement;
2706
- getScrollPosition = () => {
2707
- return {
2708
- x: target.nativeElement.scrollLeft,
2709
- y: target.nativeElement.scrollTop,
2710
- };
2711
- };
2712
- }
2713
- else {
2714
- element = target;
2715
- getScrollPosition = () => {
2716
- return {
2717
- x: target.scrollLeft,
2718
- y: target.scrollTop,
2719
- };
2720
- };
2721
- }
2722
- const state = throttled(getScrollPosition(), {
2895
+ const resolve = (t) => {
2896
+ if (!t)
2897
+ return null;
2898
+ return t instanceof ElementRef ? t.nativeElement : t;
2899
+ };
2900
+ const isWindow = (el) => el.window === el;
2901
+ const readPosition = (el) => isWindow(el)
2902
+ ? {
2903
+ x: el.scrollX ?? el.pageXOffset ?? 0,
2904
+ y: el.scrollY ?? el.pageYOffset ?? 0,
2905
+ }
2906
+ : { x: el.scrollLeft, y: el.scrollTop };
2907
+ const initial = resolve(isSignal(target) ? untracked(target) : target);
2908
+ const state = throttled(initial ? readPosition(initial) : { x: 0, y: 0 }, {
2723
2909
  debugName,
2724
2910
  equal: (a, b) => a.x === b.x && a.y === b.y,
2725
2911
  ms: throttle,
2726
2912
  });
2727
- const onScroll = () => state.set(getScrollPosition());
2728
- element.addEventListener('scroll', onScroll, { passive: true });
2729
- inject(DestroyRef).onDestroy(() => element.removeEventListener('scroll', onScroll));
2913
+ if (isSignal(target)) {
2914
+ // re-attach whenever the signal resolves to a (new) element — covers viewChild
2915
+ effect((cleanup) => {
2916
+ const el = resolve(target());
2917
+ if (!el)
2918
+ return;
2919
+ state.set(readPosition(el)); // sync to the new element immediately
2920
+ const onScroll = () => state.set(readPosition(el));
2921
+ el.addEventListener('scroll', onScroll, { passive: true });
2922
+ cleanup(() => el.removeEventListener('scroll', onScroll));
2923
+ });
2924
+ }
2925
+ else {
2926
+ const el = resolve(target);
2927
+ if (el) {
2928
+ const onScroll = () => state.set(readPosition(el));
2929
+ el.addEventListener('scroll', onScroll, { passive: true });
2930
+ inject(DestroyRef).onDestroy(() => el.removeEventListener('scroll', onScroll));
2931
+ }
2932
+ }
2730
2933
  const base = state.asReadonly();
2731
2934
  base.unthrottled = state.original;
2732
2935
  return base;
@@ -2774,6 +2977,9 @@ function scrollPosition(opt) {
2774
2977
  * ```
2775
2978
  */
2776
2979
  function windowSize(opt) {
2980
+ return runInSensorContext(opt?.injector, () => createWindowSize(opt));
2981
+ }
2982
+ function createWindowSize(opt) {
2777
2983
  if (isPlatformServer(inject(PLATFORM_ID))) {
2778
2984
  const base = computed(() => ({
2779
2985
  width: 1024,
@@ -2810,17 +3016,19 @@ function sensor(type, options) {
2810
3016
  case 'mousePosition':
2811
3017
  return mousePosition(opts);
2812
3018
  case 'networkStatus':
2813
- return networkStatus(opts?.debugName);
3019
+ return networkStatus(opts);
2814
3020
  case 'pageVisibility':
2815
- return pageVisibility(opts?.debugName);
3021
+ return pageVisibility(opts);
2816
3022
  case 'darkMode':
2817
3023
  case 'dark-mode':
2818
- return prefersDarkMode(opts?.debugName);
3024
+ return prefersDarkMode(opts);
2819
3025
  case 'reducedMotion':
2820
3026
  case 'reduced-motion':
2821
- return prefersReducedMotion(opts?.debugName);
3027
+ return prefersReducedMotion(opts);
2822
3028
  case 'mediaQuery':
2823
- return mediaQuery(opts.query, opts.debugName);
3029
+ if (typeof opts?.query !== 'string')
3030
+ throw new Error(`sensor('mediaQuery') requires a 'query' option, e.g. sensor('mediaQuery', { query: '(min-width: 1024px)' })`);
3031
+ return mediaQuery(opts.query, opts);
2824
3032
  case 'windowSize':
2825
3033
  return windowSize(opts);
2826
3034
  case 'scrollPosition':
@@ -2832,15 +3040,15 @@ function sensor(type, options) {
2832
3040
  case 'geolocation':
2833
3041
  return geolocation(opts);
2834
3042
  case 'clipboard':
2835
- return clipboard(opts?.debugName);
3043
+ return clipboard(opts);
2836
3044
  case 'orientation':
2837
- return orientation(opts?.debugName);
3045
+ return orientation(opts);
2838
3046
  case 'batteryStatus':
2839
- return batteryStatus(opts?.debugName);
3047
+ return batteryStatus(opts);
2840
3048
  case 'idle':
2841
3049
  return idle(opts);
2842
3050
  case 'focusWithin':
2843
- return focusWithin(opts?.target);
3051
+ return focusWithin(opts?.target, opts);
2844
3052
  default:
2845
3053
  throw new Error(`Unknown sensor type: ${type}`);
2846
3054
  }
@@ -2894,16 +3102,24 @@ function signalFromEvent(target, eventName, initial, projectOrOpt, maybeOpt) {
2894
3102
  else
2895
3103
  state.set(event);
2896
3104
  };
2897
- const { destroyRef: providedDestroyRef, ...listenerOpts } = opt ?? {};
3105
+ const { destroyRef: providedDestroyRef,
3106
+ // strip non-listener keys so they don't leak into addEventListener options
3107
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
3108
+ injector: _injector,
3109
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
3110
+ debugName: _debugName, ...listenerOpts } = opt ?? {};
2898
3111
  if (isSignal(target)) {
2899
3112
  const targetSig = target;
2900
- effect((cleanup) => {
3113
+ const effectRef = effect((cleanup) => {
2901
3114
  const resolved = unwrap(targetSig());
2902
3115
  if (!resolved)
2903
3116
  return;
2904
3117
  resolved.addEventListener(eventName, handler, listenerOpts);
2905
3118
  cleanup(() => resolved.removeEventListener(eventName, handler, listenerOpts));
2906
- }, { injector });
3119
+ }, { ...(ngDevMode ? { debugName: "effectRef" } : /* istanbul ignore next */ {}), injector });
3120
+ // honor an explicit destroyRef for signal targets too — the effect would otherwise
3121
+ // only follow the injector's lifetime, contradicting the documented option
3122
+ providedDestroyRef?.onDestroy(() => effectRef.destroy());
2907
3123
  }
2908
3124
  else {
2909
3125
  const resolved = unwrap(target);
@@ -2992,7 +3208,8 @@ function alwaysFalse() {
2992
3208
  * @internal Attaches a lazy, memoized leaf probe to a store node. The probe (`() => boolean`)
2993
3209
  * closes over the node's value signal and its (stable) vivify setting, building the backing
2994
3210
  * `computed` on first call so leaf-ness tracks the live value reactively without taxing every
2995
- * node access. Idempotent.
3211
+ * node access. Under `noUnionLeaves` the caller promises shapes never flip, so the probe is
3212
+ * resolved once from the first sample and frozen as a constant. Idempotent.
2996
3213
  */
2997
3214
  function markAsLeaf(sig, value, vivifyEnabled, noUnionLeaves) {
2998
3215
  if (typeof sig[LEAF] !== 'function') {
@@ -3000,13 +3217,11 @@ function markAsLeaf(sig, value, vivifyEnabled, noUnionLeaves) {
3000
3217
  const probe = () => {
3001
3218
  if (memo)
3002
3219
  return memo();
3003
- const v = untracked(value);
3004
- memo =
3005
- isOpaque(v) || (v == null && !vivifyEnabled) || noUnionLeaves
3006
- ? isLeafValue(v, vivifyEnabled)
3007
- ? alwaysTrue
3008
- : alwaysFalse
3009
- : computed(() => isLeafValue(value(), vivifyEnabled));
3220
+ memo = noUnionLeaves
3221
+ ? isLeafValue(untracked(value), vivifyEnabled)
3222
+ ? alwaysTrue
3223
+ : alwaysFalse
3224
+ : computed(() => isLeafValue(value(), vivifyEnabled));
3010
3225
  return memo();
3011
3226
  };
3012
3227
  Object.defineProperty(sig, LEAF, {
@@ -3094,6 +3309,40 @@ function resolveVivify(sample, option) {
3094
3309
  function hasOwnKey(value, key) {
3095
3310
  return value != null && Object.hasOwn(value, key);
3096
3311
  }
3312
+ /**
3313
+ * @internal
3314
+ * Builds the `onChange` for the fallback (non-record container) derivation branch. For an
3315
+ * immutable source the container is copied before the write — returning the same mutated
3316
+ * reference would let the source's equality cut propagation (leaving child signals permanently
3317
+ * stale) and alias the caller's original object, breaking the structural-sharing contract
3318
+ * `forkStore` relies on. For a mutable source the write goes through `mutate`, so the chain's
3319
+ * force-notify engages (plain `update` with the same reference would never notify).
3320
+ */
3321
+ function createFallbackOnChange(target, prop, vivifyFn, isMutableSource) {
3322
+ const write = (newValue) => (v) => {
3323
+ const container = vivifyFn(v, prop);
3324
+ if (container === null || container === undefined)
3325
+ return container;
3326
+ const next = isMutableSource
3327
+ ? container
3328
+ : Array.isArray(container)
3329
+ ? container.slice()
3330
+ : isRecord(container)
3331
+ ? { ...container }
3332
+ : container; // non-plain leaf (Date/class instance): legacy in-place attempt
3333
+ try {
3334
+ next[prop] = newValue;
3335
+ }
3336
+ catch (e) {
3337
+ if (isDevMode())
3338
+ console.error(`[store] Failed to set property "${String(prop)}"`, e);
3339
+ }
3340
+ return next;
3341
+ };
3342
+ return isMutableSource
3343
+ ? (newValue) => target.mutate(write(newValue))
3344
+ : (newValue) => target.update(write(newValue));
3345
+ }
3097
3346
  /**
3098
3347
  * @internal
3099
3348
  * Makes an array store
@@ -3116,7 +3365,9 @@ function toArrayStore(source, injector, vivify, noUnionLeaves = false) {
3116
3365
  const idx = +prop;
3117
3366
  return idx >= 0 && idx < untracked(lengthSignal);
3118
3367
  }
3119
- return Reflect.has(untracked(source), prop);
3368
+ const v = untracked(source);
3369
+ // nullish node values are routinely descended with vivify on — `in` must not throw
3370
+ return v == null ? false : Reflect.has(v, prop);
3120
3371
  },
3121
3372
  ownKeys() {
3122
3373
  const v = untracked(source);
@@ -3153,7 +3404,9 @@ function toArrayStore(source, injector, vivify, noUnionLeaves = false) {
3153
3404
  return lengthSignal;
3154
3405
  if (prop === Symbol.iterator) {
3155
3406
  return function* () {
3156
- for (let i = 0; i < untracked(lengthSignal); i++) {
3407
+ // read length reactively: a spread/for-of inside a computed/effect must re-run
3408
+ // when items are added or removed, not only when already-read elements change
3409
+ for (let i = 0; i < lengthSignal(); i++) {
3157
3410
  yield receiver[i];
3158
3411
  }
3159
3412
  };
@@ -3192,19 +3445,8 @@ function toArrayStore(source, injector, vivify, noUnionLeaves = false) {
3192
3445
  })
3193
3446
  : derived(target, {
3194
3447
  from: (v) => v?.[idx],
3195
- onChange: (newValue) => target.update((v) => {
3196
- const container = vivifyFn(v, idx);
3197
- if (container === null || container === undefined)
3198
- return container;
3199
- try {
3200
- container[idx] = newValue;
3201
- }
3202
- catch (e) {
3203
- if (isDevMode())
3204
- console.error(`[store] Failed to set property "${String(idx)}"`, e);
3205
- }
3206
- return container;
3207
- }),
3448
+ onChange: createFallbackOnChange(target, idx, vivifyFn, isMutableSource),
3449
+ equal: equalFn,
3208
3450
  });
3209
3451
  const childSample = untracked(computation);
3210
3452
  const childVivify = resolveVivify(childSample, vivify);
@@ -3224,6 +3466,13 @@ function toArrayStore(source, injector, vivify, noUnionLeaves = false) {
3224
3466
  /**
3225
3467
  * Converts a Signal into a deep-observable Store.
3226
3468
  * Accessing nested properties returns a derived Signal of that path.
3469
+ *
3470
+ * @remarks
3471
+ * A child's *container kind* (array store vs object store) is resolved when the child is
3472
+ * first accessed and cached with the proxy. Leaf↔substore flips are tracked reactively, but a
3473
+ * union-typed node that later flips between an array and a record keeps its original trap set —
3474
+ * if you need that, re-model the union as `{ kind: ..., value: ... }` instead.
3475
+ *
3227
3476
  * @example
3228
3477
  * const state = store({ user: { name: 'John' } });
3229
3478
  * const nameSignal = state.user.name; // WritableSignal<string>
@@ -3306,19 +3555,8 @@ function toStore(source, injector, vivify = false, noUnionLeaves = false) {
3306
3555
  ? derived(target, prop, { equal: equalFn, vivify: nodeVivify })
3307
3556
  : derived(target, {
3308
3557
  from: (v) => v?.[prop],
3309
- onChange: (newValue) => target.update((v) => {
3310
- const container = vivifyFn(v, prop);
3311
- if (container === null || container === undefined)
3312
- return container;
3313
- try {
3314
- container[prop] = newValue;
3315
- }
3316
- catch (e) {
3317
- if (isDevMode())
3318
- console.error(`[store] Failed to set property "${String(prop)}"`, e);
3319
- }
3320
- return container;
3321
- }),
3558
+ onChange: createFallbackOnChange(target, prop, vivifyFn, isMutableSource),
3559
+ equal: equalFn,
3322
3560
  });
3323
3561
  const childSample = untracked(computation);
3324
3562
  const childVivify = resolveVivify(childSample, vivify);
@@ -3460,7 +3698,12 @@ function merge3(ancestor, mine, theirs) {
3460
3698
  if (isPlainRecord(mine) && isPlainRecord(theirs) && isPlainRecord(ancestor)) {
3461
3699
  const out = { ...theirs };
3462
3700
  for (const key of new Set([...Object.keys(mine), ...Object.keys(theirs)])) {
3463
- out[key] = merge3(ancestor[key], mine[key], theirs[key]);
3701
+ const merged = merge3(ancestor[key], mine[key], theirs[key]);
3702
+ // a key deleted on the fork must commit as ABSENT, not as an explicit `undefined`
3703
+ if (merged === undefined && !(key in mine))
3704
+ delete out[key];
3705
+ else
3706
+ out[key] = merged;
3464
3707
  }
3465
3708
  return out;
3466
3709
  }
@@ -3512,8 +3755,8 @@ const noopStore = {
3512
3755
  *
3513
3756
  * @template T The type of value held by the signal and stored (after serialization).
3514
3757
  * @param fallback The default value of type `T` to use when no value is found in storage
3515
- * or when deserialization fails. The signal's value will never be `null` or `undefined`
3516
- * publicly, it will always revert to this fallback.
3758
+ * or when deserialization fails. A stored value (including a legitimate `null` for a
3759
+ * nullable `T`) always round-trips; the fallback only surfaces when the entry is absent.
3517
3760
  * @param options Configuration options (`CreateStoredOptions<T>`). Requires at least the `key`.
3518
3761
  * @returns A `StoredSignal<T>` instance. This signal behaves like a standard `WritableSignal<T>`,
3519
3762
  * but its value is persisted. It includes a `.clear()` method to remove the item from storage
@@ -3526,7 +3769,8 @@ const noopStore = {
3526
3769
  * - **Error Handling:** Catches and logs errors during serialization/deserialization in dev mode.
3527
3770
  * - **Tab Sync:** If `syncTabs` is true, listens to `storage` events to keep the signal value
3528
3771
  * consistent across browser tabs using the same key. Cleanup is handled automatically
3529
- * using `DestroyRef`.
3772
+ * using `DestroyRef`. Web Storage only: the `storage` event never fires for custom `store`
3773
+ * adapters, so `syncTabs` has no effect with one.
3530
3774
  * - **Removal:** Use the `.clear()` method on the returned signal to remove the item from storage.
3531
3775
  * Setting the signal to the fallback value will store the fallback value, not remove the item.
3532
3776
  *
@@ -3561,25 +3805,28 @@ function stored(fallback, { key, store: providedStore, serialize = JSON.stringif
3561
3805
  : isSignal(key)
3562
3806
  ? key
3563
3807
  : computed(key);
3808
+ // "no stored value" marker — distinct from `null`/`undefined`, so a nullable `T` can
3809
+ // round-trip a legitimate `null` through `set` instead of it acting like `clear()`
3810
+ const EMPTY = Symbol();
3564
3811
  const getValue = (key) => {
3565
3812
  const found = store.getItem(key);
3566
3813
  if (found === null)
3567
- return null;
3814
+ return EMPTY;
3568
3815
  try {
3569
3816
  const deserialized = deserialize(found);
3570
3817
  if (!validate(deserialized))
3571
- return null;
3818
+ return EMPTY;
3572
3819
  return deserialized;
3573
3820
  }
3574
3821
  catch (err) {
3575
3822
  if (isDevMode())
3576
3823
  console.error(`Failed to parse stored value for key "${key}":`, err);
3577
- return null;
3824
+ return EMPTY;
3578
3825
  }
3579
3826
  };
3580
3827
  const storeValue = (key, value) => {
3581
3828
  try {
3582
- if (value === null)
3829
+ if (value === EMPTY)
3583
3830
  return store.removeItem(key);
3584
3831
  const serialized = serialize(value);
3585
3832
  store.setItem(key, serialized);
@@ -3596,9 +3843,9 @@ function stored(fallback, { key, store: providedStore, serialize = JSON.stringif
3596
3843
  const initialKey = untracked(keySig);
3597
3844
  const internal = signal(getValue(initialKey), { ...(ngDevMode ? { debugName: "internal" } : /* istanbul ignore next */ {}), ...opt,
3598
3845
  equal: (a, b) => {
3599
- if (a === null && b === null)
3846
+ if (a === EMPTY && b === EMPTY)
3600
3847
  return true;
3601
- if (a === null || b === null)
3848
+ if (a === EMPTY || b === EMPTY)
3602
3849
  return false;
3603
3850
  return equal(a, b);
3604
3851
  } });
@@ -3633,19 +3880,27 @@ function stored(fallback, { key, store: providedStore, serialize = JSON.stringif
3633
3880
  if (syncTabs && !isServer) {
3634
3881
  const destroyRef = inject(DestroyRef);
3635
3882
  const sync = (e) => {
3883
+ // `storage` events only describe Web Storage — ignore events for a different
3884
+ // storage area (or any event when a custom adapter is configured), otherwise an
3885
+ // unrelated localStorage write with the same key string corrupts our state
3886
+ if (e.storageArea !== store)
3887
+ return;
3636
3888
  if (e.key !== untracked(keySig))
3637
3889
  return;
3638
3890
  if (e.newValue === null)
3639
- internal.set(null);
3891
+ internal.set(EMPTY);
3640
3892
  else
3641
3893
  internal.set(getValue(e.key));
3642
3894
  };
3643
3895
  window.addEventListener('storage', sync);
3644
3896
  destroyRef.onDestroy(() => window.removeEventListener('storage', sync));
3645
3897
  }
3646
- const writable = toWritable(computed(() => internal() ?? fallback, opt), internal.set);
3898
+ const writable = toWritable(computed(() => {
3899
+ const v = internal();
3900
+ return v === EMPTY ? fallback : v;
3901
+ }, opt), internal.set);
3647
3902
  writable.clear = () => {
3648
- internal.set(null);
3903
+ internal.set(EMPTY);
3649
3904
  };
3650
3905
  writable.key = keySig;
3651
3906
  return writable;
@@ -3655,7 +3910,6 @@ class MessageBus {
3655
3910
  channel = new BroadcastChannel('mmstack-tab-sync-bus');
3656
3911
  listeners = new Map();
3657
3912
  subscribe(id, listener) {
3658
- this.unsubscribe(id); // Ensure no duplicate listeners
3659
3913
  const wrapped = (ev) => {
3660
3914
  try {
3661
3915
  if (ev.data?.id === id)
@@ -3666,18 +3920,28 @@ class MessageBus {
3666
3920
  }
3667
3921
  };
3668
3922
  this.channel.addEventListener('message', wrapped);
3669
- this.listeners.set(id, wrapped);
3923
+ let set = this.listeners.get(id);
3924
+ if (!set) {
3925
+ set = new Set();
3926
+ this.listeners.set(id, set);
3927
+ }
3928
+ set.add(wrapped);
3670
3929
  return {
3671
- unsub: (() => this.unsubscribe(id)).bind(this),
3672
- post: ((value) => this.channel.postMessage({ id, value })).bind(this),
3930
+ unsub: () => {
3931
+ this.channel.removeEventListener('message', wrapped);
3932
+ const cur = this.listeners.get(id);
3933
+ if (!cur)
3934
+ return;
3935
+ cur.delete(wrapped);
3936
+ if (cur.size === 0)
3937
+ this.listeners.delete(id);
3938
+ },
3939
+ post: (value) => this.channel.postMessage({ id, value }),
3673
3940
  };
3674
3941
  }
3675
- unsubscribe(id) {
3676
- const listener = this.listeners.get(id);
3677
- if (!listener)
3678
- return;
3679
- this.channel.removeEventListener('message', listener);
3680
- this.listeners.delete(id);
3942
+ ngOnDestroy() {
3943
+ this.channel.close();
3944
+ this.listeners.clear();
3681
3945
  }
3682
3946
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.12", ngImport: i0, type: MessageBus, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
3683
3947
  static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.12", ngImport: i0, type: MessageBus, providedIn: 'root' });
@@ -3688,6 +3952,11 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
3688
3952
  providedIn: 'root',
3689
3953
  }]
3690
3954
  }] });
3955
+ /**
3956
+ * @deprecated The generated id hashes the call-site stack line, which collides when a shared
3957
+ * helper calls {@link tabSync} for multiple signals and diverges across minified builds during
3958
+ * a rolling deploy. Pass an explicit `{ id }` instead.
3959
+ */
3691
3960
  function generateDeterministicID() {
3692
3961
  const stack = new Error().stack;
3693
3962
  if (stack) {
@@ -3725,10 +3994,8 @@ function generateDeterministicID() {
3725
3994
  *
3726
3995
  * @example
3727
3996
  * ```typescript
3728
- * // Basic usage - auto-generates channel ID from call site
3729
- * const theme = tabSync(signal('dark'));
3730
- *
3731
- * // With explicit ID (recommended for production)
3997
+ * // With explicit ID (recommended)
3998
+ * const theme = tabSync(signal('dark'), { id: 'theme' });
3732
3999
  * const userPrefs = tabSync(signal({ lang: 'en' }), { id: 'user-preferences' });
3733
4000
  *
3734
4001
  * // Changes in one tab will sync to all other tabs
@@ -3740,6 +4007,7 @@ function generateDeterministicID() {
3740
4007
  * - Uses a single BroadcastChannel for all synchronized signals
3741
4008
  * - Automatically cleans up listeners when the injection context is destroyed
3742
4009
  * - Initial signal value after sync setup is not broadcasted to prevent loops
4010
+ * - Received values are not re-broadcast, so tabs never echo each other's updates
3743
4011
  *
3744
4012
  */
3745
4013
  function tabSync(sig, opt) {
@@ -3747,7 +4015,20 @@ function tabSync(sig, opt) {
3747
4015
  return sig;
3748
4016
  const id = typeof opt === 'string' ? opt : (opt?.id ?? generateDeterministicID());
3749
4017
  const bus = inject(MessageBus);
3750
- const { unsub, post } = bus.subscribe(id, (next) => sig.set(next));
4018
+ // The last value applied from a remote tab. The outbound effect skips (exactly) the run
4019
+ // caused by that write — without this, an inbound object (a fresh structured clone, so
4020
+ // never reference-equal) would be re-posted, and two tabs would ping-pong forever.
4021
+ const NONE = Symbol();
4022
+ let received = NONE;
4023
+ const { unsub, post } = bus.subscribe(id, (next) => {
4024
+ const before = untracked(sig);
4025
+ received = next;
4026
+ sig.set(next);
4027
+ // Equality-suppressed write (e.g. an identical primitive): no effect run will follow,
4028
+ // so clear the marker — it must not swallow a later, genuinely local change.
4029
+ if (untracked(sig) === before)
4030
+ received = NONE;
4031
+ });
3751
4032
  let first = false;
3752
4033
  const effectRef = effect(() => {
3753
4034
  const val = sig();
@@ -3755,6 +4036,11 @@ function tabSync(sig, opt) {
3755
4036
  first = true;
3756
4037
  return;
3757
4038
  }
4039
+ if (val === received) {
4040
+ received = NONE;
4041
+ return;
4042
+ }
4043
+ received = NONE;
3758
4044
  post(val);
3759
4045
  }, ...(ngDevMode ? [{ debugName: "effectRef" }] : /* istanbul ignore next */ []));
3760
4046
  inject(DestroyRef).onDestroy(() => {
@@ -3765,7 +4051,6 @@ function tabSync(sig, opt) {
3765
4051
  }
3766
4052
 
3767
4053
  function until(sourceSignal, predicate, options = {}) {
3768
- const injector = options.injector ?? inject(Injector);
3769
4054
  return new Promise((resolve, reject) => {
3770
4055
  let effectRef;
3771
4056
  let timeoutId;
@@ -3802,6 +4087,14 @@ function until(sourceSignal, predicate, options = {}) {
3802
4087
  cleanupAndResolve(initialValue);
3803
4088
  return;
3804
4089
  }
4090
+ let injector;
4091
+ try {
4092
+ injector = options.injector ?? inject(Injector);
4093
+ }
4094
+ catch {
4095
+ cleanupAndReject('until: No injector available — provide options.injector when calling outside an injection context.');
4096
+ return;
4097
+ }
3805
4098
  if (options?.timeout !== undefined) {
3806
4099
  timeoutId = setTimeout(() => cleanupAndReject(`until: Timeout after ${options.timeout}ms.`), options.timeout);
3807
4100
  }
@@ -3819,17 +4112,6 @@ function until(sourceSignal, predicate, options = {}) {
3819
4112
  });
3820
4113
  }
3821
4114
 
3822
- /**
3823
- * @interal
3824
- */
3825
- function getSignalEquality(sig) {
3826
- const internal = sig[SIGNAL];
3827
- if (internal && typeof internal.equal === 'function') {
3828
- return internal.equal;
3829
- }
3830
- return Object.is; // Default equality check
3831
- }
3832
-
3833
4115
  /**
3834
4116
  * Enhances an existing `WritableSignal` by adding a complete undo/redo history
3835
4117
  * stack and an API to control it.
@@ -3878,9 +4160,10 @@ function getSignalEquality(sig) {
3878
4160
  * ```
3879
4161
  */
3880
4162
  function withHistory(sourceOrValue, opt) {
3881
- const equal = (opt?.equal ?? isSignal(sourceOrValue))
3882
- ? getSignalEquality(sourceOrValue)
3883
- : Object.is;
4163
+ const equal = opt?.equal ??
4164
+ (isSignal(sourceOrValue)
4165
+ ? getSignalEquality(sourceOrValue)
4166
+ : Object.is);
3884
4167
  const source = isSignal(sourceOrValue)
3885
4168
  ? sourceOrValue
3886
4169
  : signal(sourceOrValue);
@@ -3925,9 +4208,8 @@ function withHistory(sourceOrValue, opt) {
3925
4208
  if (historyStack.length === 0)
3926
4209
  return;
3927
4210
  const valueForRedo = untracked(source);
3928
- const valueToRestore = historyStack.at(-1);
3929
- if (valueToRestore === undefined)
3930
- return;
4211
+ // length checked above — a legitimately `undefined` entry must still restore
4212
+ const valueToRestore = historyStack[historyStack.length - 1];
3931
4213
  originalSet.call(source, valueToRestore);
3932
4214
  history.inline((h) => h.pop());
3933
4215
  redoArray.mutate((r) => {
@@ -3941,9 +4223,8 @@ function withHistory(sourceOrValue, opt) {
3941
4223
  if (redoStack.length === 0)
3942
4224
  return;
3943
4225
  const valueForUndo = untracked(source);
3944
- const valueToRestore = redoStack.at(-1);
3945
- if (valueToRestore === undefined)
3946
- return;
4226
+ // length checked above — a legitimately `undefined` entry must still restore
4227
+ const valueToRestore = redoStack[redoStack.length - 1];
3947
4228
  originalSet.call(source, valueToRestore);
3948
4229
  redoArray.inline((r) => r.pop());
3949
4230
  history.mutate((h) => {