@mmstack/primitives 19.4.1 → 19.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.md +2 -0
  2. package/fesm2022/mmstack-primitives.mjs +570 -245
  3. package/fesm2022/mmstack-primitives.mjs.map +1 -1
  4. package/index.d.ts +2 -2
  5. package/lib/chunked.d.ts +2 -2
  6. package/lib/concurrent/pausable.d.ts +3 -2
  7. package/lib/concurrent/start-transition.d.ts +5 -0
  8. package/lib/concurrent/transaction.d.ts +5 -0
  9. package/lib/concurrent/transition-scope.d.ts +15 -1
  10. package/lib/effect/index.d.ts +1 -0
  11. package/lib/effect/nested-effect.d.ts +2 -2
  12. package/lib/get-signal-equality.d.ts +1 -1
  13. package/lib/mutable.d.ts +1 -1
  14. package/lib/pipeable/{pipeble.d.ts → pipeable.d.ts} +1 -1
  15. package/lib/pipeable/public_api.d.ts +1 -1
  16. package/lib/pipeable/types.d.ts +1 -1
  17. package/lib/sensors/battery-status.d.ts +2 -1
  18. package/lib/sensors/clipboard.d.ts +2 -1
  19. package/lib/sensors/element-size.d.ts +2 -4
  20. package/lib/sensors/element-visibility.d.ts +2 -4
  21. package/lib/sensors/focus-within.d.ts +2 -1
  22. package/lib/sensors/geolocation.d.ts +2 -3
  23. package/lib/sensors/idle.d.ts +6 -4
  24. package/lib/sensors/index.d.ts +1 -0
  25. package/lib/sensors/media-query.d.ts +4 -3
  26. package/lib/sensors/mouse-position.d.ts +6 -7
  27. package/lib/sensors/network-status.d.ts +8 -3
  28. package/lib/sensors/orientation.d.ts +9 -2
  29. package/lib/sensors/page-visibility.d.ts +4 -2
  30. package/lib/sensors/scroll-position.d.ts +10 -18
  31. package/lib/sensors/sensor-options.d.ts +25 -0
  32. package/lib/sensors/sensor.d.ts +15 -30
  33. package/lib/sensors/window-size.d.ts +3 -6
  34. package/lib/store/store.d.ts +4 -4
  35. package/lib/stored.d.ts +4 -3
  36. package/lib/{tabSync.d.ts → tab-sync.d.ts} +9 -4
  37. package/lib/throttled.d.ts +2 -0
  38. package/lib/to-writable.d.ts +4 -0
  39. package/package.json +2 -2
@@ -80,7 +80,7 @@ function popFrame() {
80
80
  * ]);
81
81
  *
82
82
  * // The fine-grained mapped list
83
- * const mappedUsers = mapArray(
83
+ * const mappedUsers = indexArray(
84
84
  * users,
85
85
  * (userSignal, index) => {
86
86
  * // 1. Create a fine-grained SIDE EFFECT for *this item*
@@ -101,7 +101,7 @@ function popFrame() {
101
101
  * };
102
102
  * },
103
103
  * {
104
- * // 3. Tell mapArray HOW to clean up when an item is removed, this needs to be manual as it's not a nestedEffect itself
104
+ * // 3. Tell indexArray HOW to clean up when an item is removed, this needs to be manual as it's not a nestedEffect itself
105
105
  * onDestroy: (mappedItem) => {
106
106
  * mappedItem.destroyEffect();
107
107
  * }
@@ -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,12 +561,53 @@ 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
  }
542
568
  return scope;
543
569
  }
570
+ function createForwardingScope() {
571
+ const own = createTransitionScope();
572
+ const target = signal(null);
573
+ const eff = () => target() ?? own;
574
+ const owners = new Map();
575
+ return {
576
+ setTarget: (t) => target.set(t),
577
+ resources: computed(() => eff().resources()),
578
+ pending: computed(() => eff().pending()),
579
+ suspended: (type) => eff().suspended(type),
580
+ add: (ref, opt) => {
581
+ const t = untracked(target) ?? own;
582
+ owners.set(ref, t);
583
+ t.add(ref, opt);
584
+ },
585
+ remove: (ref) => {
586
+ const t = owners.get(ref) ?? untracked(target) ?? own;
587
+ t.remove(ref);
588
+ owners.delete(ref);
589
+ },
590
+ commit: (value) => linkedSignal({
591
+ source: () => ({ v: value(), settled: !eff().pending() }),
592
+ computation: (curr, prev) => curr.settled || prev === undefined ? curr.v : prev.value,
593
+ }),
594
+ holding: computed(() => eff().holding()),
595
+ beginHold: () => (untracked(target) ?? own).beginHold(),
596
+ endHold: () => (untracked(target) ?? own).endHold(),
597
+ hold: (value) => linkedSignal({
598
+ source: () => ({ v: value(), held: eff().holding() }),
599
+ computation: (curr, prev) => prev !== undefined && curr.held ? prev.value : curr.v,
600
+ }),
601
+ };
602
+ }
603
+ /** Provide a forwarding transition scope at a boundary (used by the transition outlet). */
604
+ function provideForwardingTransitionScope() {
605
+ return { provide: TRANSITION_SCOPE, useFactory: createForwardingScope };
606
+ }
607
+ /** Read the transition scope reachable from `injector`, or null if none is provided there. */
608
+ function getTransitionScope(injector) {
609
+ return injector.get(TRANSITION_SCOPE, null);
610
+ }
544
611
  /**
545
612
  * Returns a register function bound to the nearest transition scope: it adds a resource
546
613
  * to the scope and removes it when the caller's injection context is destroyed. Pass any
@@ -569,6 +636,11 @@ function registerResource(res, opt) {
569
636
  *
570
637
  * Must be called in an injection context. This is the *async* generalization (Tier 2): it adds
571
638
  * no rendering cost and needs no fork — holding direct/sync readers is a separate, deferred tier.
639
+ *
640
+ * Caveat: work must go in flight by the first post-write render to be awaited. A loader that
641
+ * starts later (a debounced request signal, a chained/deferred resource) is not attributable to
642
+ * this transition — the no-async fallback will have already resolved `done`. Trigger such work
643
+ * eagerly inside `fn`, or coordinate it separately.
572
644
  */
573
645
  function injectStartTransition() {
574
646
  const scope = injectTransitionScope();
@@ -718,6 +790,11 @@ function runInTransaction(txn, fn) {
718
790
  * The writes land on LIVE state immediately (so derived variables and connector requests see the
719
791
  * new values and refetch); only the *display* is held, via `scope.hold`. Must run in an injection
720
792
  * context.
793
+ *
794
+ * Caveat: work must go in flight by the first post-write render to be part of the transaction. A
795
+ * loader that starts later (a debounced request signal, a chained/deferred resource) is not
796
+ * attributable to it — the no-async fallback will have already committed and released the hold,
797
+ * after which `abort()` is a no-op. Trigger such work eagerly inside `fn`.
721
798
  */
722
799
  function injectStartTransaction() {
723
800
  const scope = injectTransitionScope();
@@ -727,7 +804,15 @@ function injectStartTransaction() {
727
804
  // Hold BEFORE the writes, so the display freezes at pre-transaction values.
728
805
  scope.beginHold();
729
806
  let finished = false;
807
+ // eslint-disable-next-line prefer-const -- assigned in try/catch, but needs to be declared here for the `finally` block to see it
730
808
  let watcher;
809
+ let resolveDone;
810
+ const done = new Promise((resolve) => {
811
+ resolveDone = resolve;
812
+ });
813
+ // Every exit path funnels through here, so `done` always settles — including `abort()`
814
+ // and a throwing transaction body (which would otherwise leak the hold forever and
815
+ // freeze the boundary with no recovery).
731
816
  const finish = (restore) => {
732
817
  if (finished)
733
818
  return;
@@ -738,27 +823,28 @@ function injectStartTransaction() {
738
823
  else
739
824
  txn.clear();
740
825
  scope.endHold();
826
+ resolveDone();
741
827
  };
742
- runInTransaction(txn, fn);
828
+ try {
829
+ runInTransaction(txn, fn);
830
+ }
831
+ catch (e) {
832
+ finish(true);
833
+ throw e;
834
+ }
743
835
  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
- });
836
+ watcher = effect(() => {
837
+ const p = scope.pending();
838
+ if (p)
839
+ sawPending = true;
840
+ if (sawPending && !p)
841
+ finish(false);
842
+ }, { injector });
843
+ // no-async fallback: if nothing ever went in flight, settle once the writes are processed.
844
+ afterNextRender(() => {
845
+ if (!sawPending && !untracked(scope.pending))
846
+ finish(false);
847
+ }, { injector });
762
848
  return {
763
849
  pending: scope.pending,
764
850
  done,
@@ -767,6 +853,17 @@ function injectStartTransaction() {
767
853
  };
768
854
  }
769
855
 
856
+ /**
857
+ * @internal
858
+ */
859
+ function getSignalEquality(sig) {
860
+ const internal = sig[SIGNAL];
861
+ if (internal && typeof internal.equal === 'function') {
862
+ return internal.equal;
863
+ }
864
+ return Object.is; // Default equality check
865
+ }
866
+
770
867
  /**
771
868
  * Converts a read-only `Signal` into a `WritableSignal` by providing custom `set` and, optionally, `update` functions.
772
869
  * This can be useful for creating controlled write access to a signal that is otherwise read-only.
@@ -865,6 +962,7 @@ function debounced(initial, opt) {
865
962
  * ```
866
963
  */
867
964
  function debounce(source, opt) {
965
+ const eq = opt?.equal ?? getSignalEquality(source);
868
966
  const ms = opt?.ms ?? 0;
869
967
  const trigger = signal(false);
870
968
  let timeout;
@@ -879,25 +977,25 @@ function debounce(source, opt) {
879
977
  catch {
880
978
  // not in injection context & no destroyRef provided opting out of cleanup
881
979
  }
882
- const triggerFn = (next) => {
980
+ const set = (next) => {
981
+ const isEqual = eq(untracked(source), next);
982
+ if (!timeout && isEqual)
983
+ return; // nothing to do
883
984
  if (timeout)
884
- clearTimeout(timeout);
885
- source.set(next);
985
+ clearTimeout(timeout); // clear pending
986
+ if (!isEqual)
987
+ source.set(next);
886
988
  timeout = setTimeout(() => {
989
+ timeout = undefined;
887
990
  trigger.update((c) => !c);
888
991
  }, ms);
889
992
  };
890
- const set = (value) => {
891
- triggerFn(value);
892
- };
893
- const update = (fn) => {
894
- triggerFn(fn(untracked(source)));
895
- };
993
+ const update = (fn) => set(fn(untracked(source)));
896
994
  const writable = toWritable(computed(() => {
897
995
  trigger();
898
996
  return untracked(source);
899
997
  }, opt), set, update);
900
- writable.original = source;
998
+ writable.original = source.asReadonly();
901
999
  return writable;
902
1000
  }
903
1001
 
@@ -1068,8 +1166,18 @@ function derived(source, optOrKey, opt) {
1068
1166
  if (isMutable(source)) {
1069
1167
  sig.mutate = (updater) => {
1070
1168
  cnt++;
1071
- sig.update(updater);
1072
- cnt--;
1169
+ try {
1170
+ sig.update(updater);
1171
+ // The wrapped computed evaluates its `equal` lazily — at the next read, which would
1172
+ // normally happen after `cnt` has already dropped back to 0. For a reference-stable
1173
+ // mutation that read compares the same object to itself and the version never bumps,
1174
+ // so dependents are never notified. Reading here, while equality is still suppressed,
1175
+ // forces the recompute (and version bump) inside the mutate window.
1176
+ untracked(sig);
1177
+ }
1178
+ finally {
1179
+ cnt--;
1180
+ }
1073
1181
  };
1074
1182
  sig.inline = (updater) => {
1075
1183
  sig.mutate((prev) => {
@@ -1151,18 +1259,45 @@ function createSetter(source) {
1151
1259
  }
1152
1260
 
1153
1261
  function keepPrevious(src, opt) {
1262
+ const mutableSrc = isWritableSignal$1(src) && isMutable(src);
1263
+ // For a mutable source the linkedSignal's equality must be suppressible: a forwarded
1264
+ // `mutate` keeps the same reference, which default equality would otherwise swallow.
1265
+ let cnt = 0;
1266
+ const baseEqual = opt?.equal;
1267
+ const equal = mutableSrc
1268
+ ? (a, b) => cnt > 0 ? false : baseEqual ? baseEqual(a, b) : Object.is(a, b)
1269
+ : baseEqual;
1154
1270
  const persisted = linkedSignal({
1155
1271
  ...opt,
1156
1272
  source: () => src(),
1157
1273
  computation: (next, prev) => next === undefined && prev !== undefined ? prev.value : next,
1274
+ equal,
1158
1275
  });
1159
1276
  if (isWritableSignal$1(src)) {
1160
1277
  persisted.set = src.set;
1161
1278
  persisted.update = src.update;
1162
- persisted.asReadonly = src.asReadonly;
1163
- if (isMutable(src)) {
1164
- persisted.mutate = src.mutate;
1165
- persisted.inline = src.inline;
1279
+ // NOTE: `asReadonly` deliberately stays the linkedSignal's own — returning the
1280
+ // source's readonly view would reintroduce the `undefined` flashes this wrapper exists
1281
+ // to prevent.
1282
+ if (mutableSrc) {
1283
+ persisted.mutate = (updater) => {
1284
+ cnt++;
1285
+ try {
1286
+ src.mutate(updater);
1287
+ // force the recompute while equality is suppressed, so the reference-stable
1288
+ // mutation bumps the wrapper's version (see derived.ts for the same pattern)
1289
+ untracked(persisted);
1290
+ }
1291
+ finally {
1292
+ cnt--;
1293
+ }
1294
+ };
1295
+ persisted.inline = (updater) => {
1296
+ persisted.mutate((prev) => {
1297
+ updater(prev);
1298
+ return prev;
1299
+ });
1300
+ };
1166
1301
  }
1167
1302
  if (isDerivation(src)) {
1168
1303
  persisted.from = src.from;
@@ -1193,13 +1328,18 @@ function indexArray(source, map, opt = {}) {
1193
1328
  : toWritable(data, () => {
1194
1329
  // noop
1195
1330
  });
1331
+ // copy before defaulting `equal` — assigning onto `opt` would mutate a caller-owned
1332
+ // (possibly shared/reused) options object
1196
1333
  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;
1334
+ opt = {
1335
+ ...opt,
1336
+ equal: (a, b) => {
1337
+ if (typeof a !== typeof b)
1338
+ return false;
1339
+ if (typeof a === 'object' || typeof a === 'function')
1340
+ return false;
1341
+ return a === b;
1342
+ },
1203
1343
  };
1204
1344
  }
1205
1345
  return linkedSignal({
@@ -1393,8 +1533,17 @@ function pooledKeys(src) {
1393
1533
  for (const k in val)
1394
1534
  if (Object.prototype.hasOwnProperty.call(val, k))
1395
1535
  spare.add(k);
1396
- if (active.size === spare.size && active.isSubsetOf(spare))
1397
- return active;
1536
+ if (active.size === spare.size) {
1537
+ let subset = true;
1538
+ for (const k of active) {
1539
+ if (!spare.has(k)) {
1540
+ subset = false;
1541
+ break;
1542
+ }
1543
+ }
1544
+ if (subset)
1545
+ return active;
1546
+ }
1398
1547
  const temp = active;
1399
1548
  active = spare;
1400
1549
  spare = temp;
@@ -1494,7 +1643,7 @@ const filter = (predicate) => (src) => linkedSignal({
1494
1643
  computation: (next, prev) => {
1495
1644
  if (predicate(next))
1496
1645
  return next;
1497
- return prev?.source;
1646
+ return prev?.value;
1498
1647
  },
1499
1648
  });
1500
1649
  /**
@@ -1530,7 +1679,7 @@ const tap = (fn, injector) => (src) => {
1530
1679
  */
1531
1680
  const filterWith = (predicate, initial) => (src) => linkedSignal({
1532
1681
  source: src,
1533
- computation: (next, prev) => predicate(next) ? next : (prev?.value ?? initial),
1682
+ computation: (next, prev) => predicate(next) ? next : prev ? prev.value : initial,
1534
1683
  });
1535
1684
  /**
1536
1685
  * Emit `initial` on the first read, then mirror the source on every subsequent
@@ -1579,7 +1728,7 @@ const pairwise = () => (src) => linkedSignal({
1579
1728
  */
1580
1729
  const scan = (reducer, seed) => (src) => linkedSignal({
1581
1730
  source: src,
1582
- computation: (next, prev) => reducer(prev?.value ?? seed, next),
1731
+ computation: (next, prev) => reducer(prev ? prev.value : seed, next),
1583
1732
  });
1584
1733
 
1585
1734
  /**
@@ -1630,7 +1779,7 @@ function pipeable(signal) {
1630
1779
  return internal;
1631
1780
  }
1632
1781
  /**
1633
- * Create a new **writable** signal and return it as a `PipableSignal`.
1782
+ * Create a new **writable** signal and return it as a `PipeableSignal`.
1634
1783
  *
1635
1784
  * The returned value is a `WritableSignal<T>` with `.set`, `.update`, `.asReadonly`
1636
1785
  * still available (via intersection type), plus a chainable `.pipe(...)`.
@@ -1734,6 +1883,20 @@ function pooledMap(optOrComputation, signalOpt) {
1734
1883
  return pooled(toPooledOptions(optOrComputation, createEmptyMap, resetClearable, signalOpt));
1735
1884
  }
1736
1885
 
1886
+ /**
1887
+ * @internal Run a sensor factory inside `injector` when provided, else in the ambient
1888
+ * injection context. Keeps every sensor's escape hatch identical and in one place.
1889
+ */
1890
+ function runInSensorContext(injector, fn) {
1891
+ return injector ? runInInjectionContext(injector, fn) : fn();
1892
+ }
1893
+ /**
1894
+ * @internal Normalize the legacy positional `debugName: string` form into {@link SensorRunOptions}.
1895
+ */
1896
+ function coerceSensorOptions(opt) {
1897
+ return typeof opt === 'string' ? { debugName: opt } : (opt ?? {});
1898
+ }
1899
+
1737
1900
  const EVENTS = [
1738
1901
  'chargingchange',
1739
1902
  'levelchange',
@@ -1755,7 +1918,11 @@ const EVENTS = [
1755
1918
  * });
1756
1919
  * ```
1757
1920
  */
1758
- function batteryStatus(debugName = 'batteryStatus') {
1921
+ function batteryStatus(opt) {
1922
+ const { debugName = 'batteryStatus', injector } = coerceSensorOptions(opt);
1923
+ return runInSensorContext(injector, () => createBatteryStatus(debugName));
1924
+ }
1925
+ function createBatteryStatus(debugName) {
1759
1926
  if (isPlatformServer(inject(PLATFORM_ID)) ||
1760
1927
  typeof navigator === 'undefined' ||
1761
1928
  typeof navigator.getBattery !== 'function') {
@@ -1764,7 +1931,9 @@ function batteryStatus(debugName = 'batteryStatus') {
1764
1931
  const state = signal(null, { debugName });
1765
1932
  const abortController = new AbortController();
1766
1933
  inject(DestroyRef).onDestroy(() => abortController.abort());
1767
- navigator.getBattery().then((battery) => {
1934
+ navigator
1935
+ .getBattery()
1936
+ .then((battery) => {
1768
1937
  if (abortController.signal.aborted)
1769
1938
  return;
1770
1939
  const read = () => ({
@@ -1780,6 +1949,10 @@ function batteryStatus(debugName = 'batteryStatus') {
1780
1949
  signal: abortController.signal,
1781
1950
  });
1782
1951
  }
1952
+ })
1953
+ .catch(() => {
1954
+ // getBattery() rejects (NotAllowedError) when the `battery` permissions-policy is
1955
+ // disallowed, e.g. in cross-origin iframes — stay `null`, same as unsupported.
1783
1956
  });
1784
1957
  return state.asReadonly();
1785
1958
  }
@@ -1795,7 +1968,11 @@ function batteryStatus(debugName = 'batteryStatus') {
1795
1968
  * in browsers that gate it. Errors from `navigator.clipboard.readText` are
1796
1969
  * swallowed silently to keep the signal value stable.
1797
1970
  */
1798
- function clipboard(debugName = 'clipboard') {
1971
+ function clipboard(opt) {
1972
+ const { debugName = 'clipboard', injector } = coerceSensorOptions(opt);
1973
+ return runInSensorContext(injector, () => createClipboard(debugName));
1974
+ }
1975
+ function createClipboard(debugName) {
1799
1976
  if (isPlatformServer(inject(PLATFORM_ID)) ||
1800
1977
  typeof navigator === 'undefined' ||
1801
1978
  !navigator.clipboard) {
@@ -1845,7 +2022,13 @@ function observerSupported$1() {
1845
2022
  * });
1846
2023
  * ```
1847
2024
  */
1848
- function elementSize(target = inject(ElementRef), opt) {
2025
+ function elementSize(target, opt) {
2026
+ return runInSensorContext(opt?.injector, () =>
2027
+ // the host-element default must resolve INSIDE the sensor context, not as a
2028
+ // parameter default (which would run before the injector wrapper)
2029
+ createElementSize(target ?? inject(ElementRef), opt));
2030
+ }
2031
+ function createElementSize(target, opt) {
1849
2032
  const getElement = () => {
1850
2033
  if (isSignal(target)) {
1851
2034
  try {
@@ -1859,8 +2042,8 @@ function elementSize(target = inject(ElementRef), opt) {
1859
2042
  return target instanceof ElementRef ? target.nativeElement : target;
1860
2043
  };
1861
2044
  const resolveInitialValue = () => {
1862
- if (!observerSupported$1())
1863
- return undefined;
2045
+ // measuring needs only getBoundingClientRect — ResizeObserver support gates
2046
+ // live updates, not the initial read
1864
2047
  const el = getElement();
1865
2048
  if (el && el.getBoundingClientRect) {
1866
2049
  const rect = el.getBoundingClientRect();
@@ -1978,7 +2161,13 @@ function observerSupported() {
1978
2161
  * }
1979
2162
  * ```
1980
2163
  */
1981
- function elementVisibility(target = inject(ElementRef), opt) {
2164
+ function elementVisibility(target, opt) {
2165
+ return runInSensorContext(opt?.injector, () =>
2166
+ // the host-element default must resolve INSIDE the sensor context, not as a
2167
+ // parameter default (which would run before the injector wrapper)
2168
+ createElementVisibility(target ?? inject(ElementRef), opt));
2169
+ }
2170
+ function createElementVisibility(target, opt) {
1982
2171
  if (isPlatformServer(inject(PLATFORM_ID)) || !observerSupported()) {
1983
2172
  const base = computed(() => undefined, {
1984
2173
  debugName: opt?.debugName,
@@ -2046,11 +2235,18 @@ function unwrap$1(target) {
2046
2235
  * }
2047
2236
  * ```
2048
2237
  */
2049
- function focusWithin(target = inject(ElementRef)) {
2238
+ function focusWithin(target, opt) {
2239
+ return runInSensorContext(opt?.injector, () =>
2240
+ // the host-element default must resolve INSIDE the sensor context, not as a
2241
+ // parameter default (which would run before the injector wrapper)
2242
+ createFocusWithin(target ?? inject(ElementRef), opt));
2243
+ }
2244
+ function createFocusWithin(target, opt) {
2245
+ const debugName = opt?.debugName ?? 'focusWithin';
2050
2246
  if (isPlatformServer(inject(PLATFORM_ID))) {
2051
- return computed(() => false, { debugName: 'focusWithin' });
2247
+ return computed(() => false, { debugName });
2052
2248
  }
2053
- const state = signal(false, { debugName: 'focusWithin' });
2249
+ const state = signal(false, { debugName });
2054
2250
  const attach = (el) => {
2055
2251
  state.set(el.contains(document.activeElement));
2056
2252
  const abortController = new AbortController();
@@ -2098,6 +2294,9 @@ function focusWithin(target = inject(ElementRef)) {
2098
2294
  * ```
2099
2295
  */
2100
2296
  function geolocation(opt) {
2297
+ return runInSensorContext(opt?.injector, () => createGeolocation(opt));
2298
+ }
2299
+ function createGeolocation(opt) {
2101
2300
  if (isPlatformServer(inject(PLATFORM_ID)) || typeof navigator === 'undefined' || !navigator.geolocation) {
2102
2301
  const sig = computed(() => null, {
2103
2302
  debugName: opt?.debugName ?? 'geolocation',
@@ -2157,6 +2356,9 @@ const serverDate$1 = new Date();
2157
2356
  * ```
2158
2357
  */
2159
2358
  function idle(opt) {
2359
+ return runInSensorContext(opt?.injector, () => createIdle(opt));
2360
+ }
2361
+ function createIdle(opt) {
2160
2362
  if (isPlatformServer(inject(PLATFORM_ID))) {
2161
2363
  const sig = computed(() => false, {
2162
2364
  debugName: opt?.debugName ?? 'idle',
@@ -2246,7 +2448,11 @@ function idle(opt) {
2246
2448
  * }
2247
2449
  * ```
2248
2450
  */
2249
- function mediaQuery(query, debugName = 'mediaQuery') {
2451
+ function mediaQuery(query, opt) {
2452
+ const { debugName = 'mediaQuery', injector } = coerceSensorOptions(opt);
2453
+ return runInSensorContext(injector, () => createMediaQuery(query, debugName));
2454
+ }
2455
+ function createMediaQuery(query, debugName) {
2250
2456
  if (isPlatformServer(inject(PLATFORM_ID)) ||
2251
2457
  typeof window === 'undefined' ||
2252
2458
  typeof window.matchMedia !== 'function' // jsdom doesn't implement matchMedia
@@ -2284,8 +2490,8 @@ function mediaQuery(query, debugName = 'mediaQuery') {
2284
2490
  * });
2285
2491
  * ```
2286
2492
  */
2287
- function prefersDarkMode(debugName) {
2288
- return mediaQuery('(prefers-color-scheme: dark)', debugName);
2493
+ function prefersDarkMode(opt) {
2494
+ return mediaQuery('(prefers-color-scheme: dark)', opt);
2289
2495
  }
2290
2496
  /**
2291
2497
  * Creates a read-only signal that tracks the user's OS/browser preference
@@ -2312,8 +2518,8 @@ function prefersDarkMode(debugName) {
2312
2518
  * });
2313
2519
  * ```
2314
2520
  */
2315
- function prefersReducedMotion(debugName) {
2316
- return mediaQuery('(prefers-reduced-motion: reduce)', debugName);
2521
+ function prefersReducedMotion(opt) {
2522
+ return mediaQuery('(prefers-reduced-motion: reduce)', opt);
2317
2523
  }
2318
2524
 
2319
2525
  /**
@@ -2362,6 +2568,7 @@ function throttled(initial, opt) {
2362
2568
  * // after the 500ms cooldown.
2363
2569
  */
2364
2570
  function throttle(source, opt) {
2571
+ const eq = opt?.equal ?? getSignalEquality(source);
2365
2572
  const ms = opt?.ms ?? 0;
2366
2573
  const leading = opt?.leading ?? false;
2367
2574
  const trailing = opt?.trailing ?? true;
@@ -2387,31 +2594,32 @@ function throttle(source, opt) {
2387
2594
  fire();
2388
2595
  else
2389
2596
  pendingTrailing = trailing;
2390
- timeout = setTimeout(() => {
2597
+ const onWindowEnd = () => {
2391
2598
  timeout = undefined;
2392
2599
  if (trailing && pendingTrailing) {
2393
2600
  pendingTrailing = false;
2394
2601
  fire();
2602
+ timeout = setTimeout(onWindowEnd, ms);
2395
2603
  }
2396
- }, ms);
2604
+ };
2605
+ timeout = setTimeout(onWindowEnd, ms);
2397
2606
  return;
2398
2607
  }
2399
2608
  if (trailing)
2400
2609
  pendingTrailing = true;
2401
2610
  };
2402
- const set = (value) => {
2403
- source.set(value);
2404
- tick();
2405
- };
2406
- const update = (fn) => {
2407
- source.update(fn);
2611
+ const set = (next) => {
2612
+ if (eq(untracked(source), next))
2613
+ return;
2614
+ source.set(next);
2408
2615
  tick();
2409
2616
  };
2617
+ const update = (fn) => set(fn(untracked(source)));
2410
2618
  const writable = toWritable(computed(() => {
2411
2619
  trigger();
2412
2620
  return untracked(source);
2413
2621
  }, opt), set, update);
2414
- writable.original = source;
2622
+ writable.original = source.asReadonly();
2415
2623
  return writable;
2416
2624
  }
2417
2625
 
@@ -2448,6 +2656,9 @@ function throttle(source, opt) {
2448
2656
  * ```
2449
2657
  */
2450
2658
  function mousePosition(opt) {
2659
+ return runInSensorContext(opt?.injector, () => createMousePosition(opt));
2660
+ }
2661
+ function createMousePosition(opt) {
2451
2662
  if (isPlatformServer(inject(PLATFORM_ID))) {
2452
2663
  const base = computed(() => ({
2453
2664
  x: 0,
@@ -2459,8 +2670,12 @@ function mousePosition(opt) {
2459
2670
  return base;
2460
2671
  }
2461
2672
  const { target = window, coordinateSpace = 'client', touch = false, debugName = 'mousePosition', throttle = 100, } = opt ?? {};
2462
- const eventTarget = target instanceof ElementRef ? target.nativeElement : target;
2463
- if (!eventTarget) {
2673
+ const resolve = (t) => {
2674
+ if (!t)
2675
+ return null;
2676
+ return t instanceof ElementRef ? t.nativeElement : t;
2677
+ };
2678
+ if (!isSignal(target) && !resolve(target)) {
2464
2679
  if (isDevMode())
2465
2680
  console.warn('mousePosition: Target element not found.');
2466
2681
  const base = computed(() => ({
@@ -2483,7 +2698,7 @@ function mousePosition(opt) {
2483
2698
  x = coordinateSpace === 'page' ? event.pageX : event.clientX;
2484
2699
  y = coordinateSpace === 'page' ? event.pageY : event.clientY;
2485
2700
  }
2486
- else if (event.touches.length > 0) {
2701
+ else if (event.touches?.length > 0) {
2487
2702
  const firstTouch = event.touches[0];
2488
2703
  x = coordinateSpace === 'page' ? firstTouch.pageX : firstTouch.clientX;
2489
2704
  y = coordinateSpace === 'page' ? firstTouch.pageY : firstTouch.clientY;
@@ -2493,16 +2708,36 @@ function mousePosition(opt) {
2493
2708
  }
2494
2709
  pos.set({ x, y });
2495
2710
  };
2496
- eventTarget.addEventListener('mousemove', updatePosition);
2497
- if (touch) {
2498
- eventTarget.addEventListener('touchmove', updatePosition);
2499
- }
2500
- inject(DestroyRef).onDestroy(() => {
2501
- eventTarget.removeEventListener('mousemove', updatePosition);
2711
+ // passive: the handler never calls preventDefault, and a non-passive touchmove on
2712
+ // window forces the browser to wait on JS before scrolling (scroll jank on touch)
2713
+ const attach = (el) => {
2714
+ const controller = new AbortController();
2715
+ el.addEventListener('mousemove', updatePosition, {
2716
+ passive: true,
2717
+ signal: controller.signal,
2718
+ });
2502
2719
  if (touch) {
2503
- eventTarget.removeEventListener('touchmove', updatePosition);
2720
+ el.addEventListener('touchmove', updatePosition, {
2721
+ passive: true,
2722
+ signal: controller.signal,
2723
+ });
2504
2724
  }
2505
- });
2725
+ return () => controller.abort();
2726
+ };
2727
+ if (isSignal(target)) {
2728
+ // re-attach whenever the signal resolves to a (new) element — covers viewChild
2729
+ effect((cleanup) => {
2730
+ const el = resolve(target());
2731
+ if (!el)
2732
+ return;
2733
+ cleanup(attach(el));
2734
+ });
2735
+ }
2736
+ else {
2737
+ const el = resolve(target);
2738
+ if (el)
2739
+ inject(DestroyRef).onDestroy(attach(el));
2740
+ }
2506
2741
  const base = pos.asReadonly();
2507
2742
  base.unthrottled = pos.original;
2508
2743
  return base;
@@ -2516,7 +2751,8 @@ const serverDate = new Date();
2516
2751
  * An additional `since` signal is attached, tracking when the status last changed.
2517
2752
  * It's SSR-safe and automatically cleans up its event listeners.
2518
2753
  *
2519
- * @param debugName Optional debug name for the signal.
2754
+ * @param opt Optional debug name for the signal, or a {@link SensorRunOptions} object
2755
+ * (with an optional `injector` for creation outside an injection context).
2520
2756
  * @returns A `NetworkStatusSignal` instance.
2521
2757
  *
2522
2758
  * @example
@@ -2527,7 +2763,11 @@ const serverDate = new Date();
2527
2763
  * });
2528
2764
  * ```
2529
2765
  */
2530
- function networkStatus(debugName = 'networkStatus') {
2766
+ function networkStatus(opt) {
2767
+ const { debugName = 'networkStatus', injector } = coerceSensorOptions(opt);
2768
+ return runInSensorContext(injector, () => createNetworkStatus(debugName));
2769
+ }
2770
+ function createNetworkStatus(debugName) {
2531
2771
  if (isPlatformServer(inject(PLATFORM_ID))) {
2532
2772
  const sig = computed(() => true, {
2533
2773
  debugName,
@@ -2577,7 +2817,11 @@ const SSR_FALLBACK = {
2577
2817
  * });
2578
2818
  * ```
2579
2819
  */
2580
- function orientation(debugName = 'orientation') {
2820
+ function orientation(opt) {
2821
+ const { debugName = 'orientation', injector } = coerceSensorOptions(opt);
2822
+ return runInSensorContext(injector, () => createOrientation(debugName));
2823
+ }
2824
+ function createOrientation(debugName) {
2581
2825
  if (isPlatformServer(inject(PLATFORM_ID)) ||
2582
2826
  typeof screen === 'undefined' ||
2583
2827
  !screen.orientation) {
@@ -2606,7 +2850,8 @@ function orientation(debugName = 'orientation') {
2606
2850
  * The primitive is SSR-safe and automatically cleans up its event listeners
2607
2851
  * when the creating context is destroyed.
2608
2852
  *
2609
- * @param debugName Optional debug name for the signal.
2853
+ * @param opt Optional debug name for the signal, or a {@link SensorRunOptions} object
2854
+ * (with an optional `injector` for creation outside an injection context).
2610
2855
  * @returns A read-only `Signal<DocumentVisibilityState>`. On the server,
2611
2856
  * it returns a static signal with a value of `'visible'`.
2612
2857
  *
@@ -2634,7 +2879,11 @@ function orientation(debugName = 'orientation') {
2634
2879
  * }
2635
2880
  * ```
2636
2881
  */
2637
- function pageVisibility(debugName = 'pageVisibility') {
2882
+ function pageVisibility(opt) {
2883
+ const { debugName = 'pageVisibility', injector } = coerceSensorOptions(opt);
2884
+ return runInSensorContext(injector, () => createPageVisibility(debugName));
2885
+ }
2886
+ function createPageVisibility(debugName) {
2638
2887
  if (isPlatformServer(inject(PLATFORM_ID))) {
2639
2888
  return computed(() => 'visible', { debugName });
2640
2889
  }
@@ -2666,31 +2915,25 @@ function pageVisibility(debugName = 'pageVisibility') {
2666
2915
  * selector: 'app-scroll-tracker',
2667
2916
  * template: `
2668
2917
  * <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
- * }
2918
+ * <p>Host Scroll: X: {{ hostScroll().x }}, Y: {{ hostScroll().y }}</p>
2675
2919
  * `
2676
2920
  * })
2677
2921
  * export class ScrollTrackerComponent {
2678
2922
  * readonly windowScroll = scrollPosition(); // Defaults to window
2923
+ * // Signal targets (e.g. viewChild) attach once the element exists:
2679
2924
  * readonly scrollableDiv = viewChild<ElementRef<HTMLDivElement>>('scrollableDiv');
2680
- * readonly divScroll = scrollPosition({ target: this.scrollableDiv() }); // Example with element target
2925
+ * readonly divScroll = scrollPosition({ target: this.scrollableDiv });
2681
2926
  *
2682
2927
  * 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
- * });
2928
+ * effect(() => console.log('Window scrolled to:', this.windowScroll()));
2689
2929
  * }
2690
2930
  * }
2691
2931
  * ```
2692
2932
  */
2693
2933
  function scrollPosition(opt) {
2934
+ return runInSensorContext(opt?.injector, () => createScrollPosition(opt));
2935
+ }
2936
+ function createScrollPosition(opt) {
2694
2937
  if (isPlatformServer(inject(PLATFORM_ID))) {
2695
2938
  const base = computed(() => ({
2696
2939
  x: 0,
@@ -2702,40 +2945,44 @@ function scrollPosition(opt) {
2702
2945
  return base;
2703
2946
  }
2704
2947
  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(), {
2948
+ const resolve = (t) => {
2949
+ if (!t)
2950
+ return null;
2951
+ return t instanceof ElementRef ? t.nativeElement : t;
2952
+ };
2953
+ const isWindow = (el) => el.window === el;
2954
+ const readPosition = (el) => isWindow(el)
2955
+ ? {
2956
+ x: el.scrollX ?? el.pageXOffset ?? 0,
2957
+ y: el.scrollY ?? el.pageYOffset ?? 0,
2958
+ }
2959
+ : { x: el.scrollLeft, y: el.scrollTop };
2960
+ const initial = resolve(isSignal(target) ? untracked(target) : target);
2961
+ const state = throttled(initial ? readPosition(initial) : { x: 0, y: 0 }, {
2732
2962
  debugName,
2733
2963
  equal: (a, b) => a.x === b.x && a.y === b.y,
2734
2964
  ms: throttle,
2735
2965
  });
2736
- const onScroll = () => state.set(getScrollPosition());
2737
- element.addEventListener('scroll', onScroll, { passive: true });
2738
- inject(DestroyRef).onDestroy(() => element.removeEventListener('scroll', onScroll));
2966
+ if (isSignal(target)) {
2967
+ // re-attach whenever the signal resolves to a (new) element — covers viewChild
2968
+ effect((cleanup) => {
2969
+ const el = resolve(target());
2970
+ if (!el)
2971
+ return;
2972
+ state.set(readPosition(el)); // sync to the new element immediately
2973
+ const onScroll = () => state.set(readPosition(el));
2974
+ el.addEventListener('scroll', onScroll, { passive: true });
2975
+ cleanup(() => el.removeEventListener('scroll', onScroll));
2976
+ });
2977
+ }
2978
+ else {
2979
+ const el = resolve(target);
2980
+ if (el) {
2981
+ const onScroll = () => state.set(readPosition(el));
2982
+ el.addEventListener('scroll', onScroll, { passive: true });
2983
+ inject(DestroyRef).onDestroy(() => el.removeEventListener('scroll', onScroll));
2984
+ }
2985
+ }
2739
2986
  const base = state.asReadonly();
2740
2987
  base.unthrottled = state.original;
2741
2988
  return base;
@@ -2783,6 +3030,9 @@ function scrollPosition(opt) {
2783
3030
  * ```
2784
3031
  */
2785
3032
  function windowSize(opt) {
3033
+ return runInSensorContext(opt?.injector, () => createWindowSize(opt));
3034
+ }
3035
+ function createWindowSize(opt) {
2786
3036
  if (isPlatformServer(inject(PLATFORM_ID))) {
2787
3037
  const base = computed(() => ({
2788
3038
  width: 1024,
@@ -2819,17 +3069,19 @@ function sensor(type, options) {
2819
3069
  case 'mousePosition':
2820
3070
  return mousePosition(opts);
2821
3071
  case 'networkStatus':
2822
- return networkStatus(opts?.debugName);
3072
+ return networkStatus(opts);
2823
3073
  case 'pageVisibility':
2824
- return pageVisibility(opts?.debugName);
3074
+ return pageVisibility(opts);
2825
3075
  case 'darkMode':
2826
3076
  case 'dark-mode':
2827
- return prefersDarkMode(opts?.debugName);
3077
+ return prefersDarkMode(opts);
2828
3078
  case 'reducedMotion':
2829
3079
  case 'reduced-motion':
2830
- return prefersReducedMotion(opts?.debugName);
3080
+ return prefersReducedMotion(opts);
2831
3081
  case 'mediaQuery':
2832
- return mediaQuery(opts.query, opts.debugName);
3082
+ if (typeof opts?.query !== 'string')
3083
+ throw new Error(`sensor('mediaQuery') requires a 'query' option, e.g. sensor('mediaQuery', { query: '(min-width: 1024px)' })`);
3084
+ return mediaQuery(opts.query, opts);
2833
3085
  case 'windowSize':
2834
3086
  return windowSize(opts);
2835
3087
  case 'scrollPosition':
@@ -2841,15 +3093,15 @@ function sensor(type, options) {
2841
3093
  case 'geolocation':
2842
3094
  return geolocation(opts);
2843
3095
  case 'clipboard':
2844
- return clipboard(opts?.debugName);
3096
+ return clipboard(opts);
2845
3097
  case 'orientation':
2846
- return orientation(opts?.debugName);
3098
+ return orientation(opts);
2847
3099
  case 'batteryStatus':
2848
- return batteryStatus(opts?.debugName);
3100
+ return batteryStatus(opts);
2849
3101
  case 'idle':
2850
3102
  return idle(opts);
2851
3103
  case 'focusWithin':
2852
- return focusWithin(opts?.target);
3104
+ return focusWithin(opts?.target, opts);
2853
3105
  default:
2854
3106
  throw new Error(`Unknown sensor type: ${type}`);
2855
3107
  }
@@ -2903,16 +3155,24 @@ function signalFromEvent(target, eventName, initial, projectOrOpt, maybeOpt) {
2903
3155
  else
2904
3156
  state.set(event);
2905
3157
  };
2906
- const { destroyRef: providedDestroyRef, ...listenerOpts } = opt ?? {};
3158
+ const { destroyRef: providedDestroyRef,
3159
+ // strip non-listener keys so they don't leak into addEventListener options
3160
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
3161
+ injector: _injector,
3162
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
3163
+ debugName: _debugName, ...listenerOpts } = opt ?? {};
2907
3164
  if (isSignal(target)) {
2908
3165
  const targetSig = target;
2909
- effect((cleanup) => {
3166
+ const effectRef = effect((cleanup) => {
2910
3167
  const resolved = unwrap(targetSig());
2911
3168
  if (!resolved)
2912
3169
  return;
2913
3170
  resolved.addEventListener(eventName, handler, listenerOpts);
2914
3171
  cleanup(() => resolved.removeEventListener(eventName, handler, listenerOpts));
2915
3172
  }, { injector });
3173
+ // honor an explicit destroyRef for signal targets too — the effect would otherwise
3174
+ // only follow the injector's lifetime, contradicting the documented option
3175
+ providedDestroyRef?.onDestroy(() => effectRef.destroy());
2916
3176
  }
2917
3177
  else {
2918
3178
  const resolved = unwrap(target);
@@ -3001,7 +3261,8 @@ function alwaysFalse() {
3001
3261
  * @internal Attaches a lazy, memoized leaf probe to a store node. The probe (`() => boolean`)
3002
3262
  * closes over the node's value signal and its (stable) vivify setting, building the backing
3003
3263
  * `computed` on first call so leaf-ness tracks the live value reactively without taxing every
3004
- * node access. Idempotent.
3264
+ * node access. Under `noUnionLeaves` the caller promises shapes never flip, so the probe is
3265
+ * resolved once from the first sample and frozen as a constant. Idempotent.
3005
3266
  */
3006
3267
  function markAsLeaf(sig, value, vivifyEnabled, noUnionLeaves) {
3007
3268
  if (typeof sig[LEAF] !== 'function') {
@@ -3009,13 +3270,11 @@ function markAsLeaf(sig, value, vivifyEnabled, noUnionLeaves) {
3009
3270
  const probe = () => {
3010
3271
  if (memo)
3011
3272
  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));
3273
+ memo = noUnionLeaves
3274
+ ? isLeafValue(untracked(value), vivifyEnabled)
3275
+ ? alwaysTrue
3276
+ : alwaysFalse
3277
+ : computed(() => isLeafValue(value(), vivifyEnabled));
3019
3278
  return memo();
3020
3279
  };
3021
3280
  Object.defineProperty(sig, LEAF, {
@@ -3103,6 +3362,40 @@ function resolveVivify(sample, option) {
3103
3362
  function hasOwnKey(value, key) {
3104
3363
  return value != null && Object.hasOwn(value, key);
3105
3364
  }
3365
+ /**
3366
+ * @internal
3367
+ * Builds the `onChange` for the fallback (non-record container) derivation branch. For an
3368
+ * immutable source the container is copied before the write — returning the same mutated
3369
+ * reference would let the source's equality cut propagation (leaving child signals permanently
3370
+ * stale) and alias the caller's original object, breaking the structural-sharing contract
3371
+ * `forkStore` relies on. For a mutable source the write goes through `mutate`, so the chain's
3372
+ * force-notify engages (plain `update` with the same reference would never notify).
3373
+ */
3374
+ function createFallbackOnChange(target, prop, vivifyFn, isMutableSource) {
3375
+ const write = (newValue) => (v) => {
3376
+ const container = vivifyFn(v, prop);
3377
+ if (container === null || container === undefined)
3378
+ return container;
3379
+ const next = isMutableSource
3380
+ ? container
3381
+ : Array.isArray(container)
3382
+ ? container.slice()
3383
+ : isRecord(container)
3384
+ ? { ...container }
3385
+ : container; // non-plain leaf (Date/class instance): legacy in-place attempt
3386
+ try {
3387
+ next[prop] = newValue;
3388
+ }
3389
+ catch (e) {
3390
+ if (isDevMode())
3391
+ console.error(`[store] Failed to set property "${String(prop)}"`, e);
3392
+ }
3393
+ return next;
3394
+ };
3395
+ return isMutableSource
3396
+ ? (newValue) => target.mutate(write(newValue))
3397
+ : (newValue) => target.update(write(newValue));
3398
+ }
3106
3399
  /**
3107
3400
  * @internal
3108
3401
  * Makes an array store
@@ -3125,7 +3418,9 @@ function toArrayStore(source, injector, vivify, noUnionLeaves = false) {
3125
3418
  const idx = +prop;
3126
3419
  return idx >= 0 && idx < untracked(lengthSignal);
3127
3420
  }
3128
- return Reflect.has(untracked(source), prop);
3421
+ const v = untracked(source);
3422
+ // nullish node values are routinely descended with vivify on — `in` must not throw
3423
+ return v == null ? false : Reflect.has(v, prop);
3129
3424
  },
3130
3425
  ownKeys() {
3131
3426
  const v = untracked(source);
@@ -3162,7 +3457,9 @@ function toArrayStore(source, injector, vivify, noUnionLeaves = false) {
3162
3457
  return lengthSignal;
3163
3458
  if (prop === Symbol.iterator) {
3164
3459
  return function* () {
3165
- for (let i = 0; i < untracked(lengthSignal); i++) {
3460
+ // read length reactively: a spread/for-of inside a computed/effect must re-run
3461
+ // when items are added or removed, not only when already-read elements change
3462
+ for (let i = 0; i < lengthSignal(); i++) {
3166
3463
  yield receiver[i];
3167
3464
  }
3168
3465
  };
@@ -3201,19 +3498,8 @@ function toArrayStore(source, injector, vivify, noUnionLeaves = false) {
3201
3498
  })
3202
3499
  : derived(target, {
3203
3500
  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
- }),
3501
+ onChange: createFallbackOnChange(target, idx, vivifyFn, isMutableSource),
3502
+ equal: equalFn,
3217
3503
  });
3218
3504
  const childSample = untracked(computation);
3219
3505
  const childVivify = resolveVivify(childSample, vivify);
@@ -3233,6 +3519,13 @@ function toArrayStore(source, injector, vivify, noUnionLeaves = false) {
3233
3519
  /**
3234
3520
  * Converts a Signal into a deep-observable Store.
3235
3521
  * Accessing nested properties returns a derived Signal of that path.
3522
+ *
3523
+ * @remarks
3524
+ * A child's *container kind* (array store vs object store) is resolved when the child is
3525
+ * first accessed and cached with the proxy. Leaf↔substore flips are tracked reactively, but a
3526
+ * union-typed node that later flips between an array and a record keeps its original trap set —
3527
+ * if you need that, re-model the union as `{ kind: ..., value: ... }` instead.
3528
+ *
3236
3529
  * @example
3237
3530
  * const state = store({ user: { name: 'John' } });
3238
3531
  * const nameSignal = state.user.name; // WritableSignal<string>
@@ -3315,19 +3608,8 @@ function toStore(source, injector, vivify = false, noUnionLeaves = false) {
3315
3608
  ? derived(target, prop, { equal: equalFn, vivify: nodeVivify })
3316
3609
  : derived(target, {
3317
3610
  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
- }),
3611
+ onChange: createFallbackOnChange(target, prop, vivifyFn, isMutableSource),
3612
+ equal: equalFn,
3331
3613
  });
3332
3614
  const childSample = untracked(computation);
3333
3615
  const childVivify = resolveVivify(childSample, vivify);
@@ -3469,7 +3751,12 @@ function merge3(ancestor, mine, theirs) {
3469
3751
  if (isPlainRecord(mine) && isPlainRecord(theirs) && isPlainRecord(ancestor)) {
3470
3752
  const out = { ...theirs };
3471
3753
  for (const key of new Set([...Object.keys(mine), ...Object.keys(theirs)])) {
3472
- out[key] = merge3(ancestor[key], mine[key], theirs[key]);
3754
+ const merged = merge3(ancestor[key], mine[key], theirs[key]);
3755
+ // a key deleted on the fork must commit as ABSENT, not as an explicit `undefined`
3756
+ if (merged === undefined && !(key in mine))
3757
+ delete out[key];
3758
+ else
3759
+ out[key] = merged;
3473
3760
  }
3474
3761
  return out;
3475
3762
  }
@@ -3523,8 +3810,8 @@ const noopStore = {
3523
3810
  *
3524
3811
  * @template T The type of value held by the signal and stored (after serialization).
3525
3812
  * @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.
3813
+ * or when deserialization fails. A stored value (including a legitimate `null` for a
3814
+ * nullable `T`) always round-trips; the fallback only surfaces when the entry is absent.
3528
3815
  * @param options Configuration options (`CreateStoredOptions<T>`). Requires at least the `key`.
3529
3816
  * @returns A `StoredSignal<T>` instance. This signal behaves like a standard `WritableSignal<T>`,
3530
3817
  * but its value is persisted. It includes a `.clear()` method to remove the item from storage
@@ -3537,7 +3824,8 @@ const noopStore = {
3537
3824
  * - **Error Handling:** Catches and logs errors during serialization/deserialization in dev mode.
3538
3825
  * - **Tab Sync:** If `syncTabs` is true, listens to `storage` events to keep the signal value
3539
3826
  * consistent across browser tabs using the same key. Cleanup is handled automatically
3540
- * using `DestroyRef`.
3827
+ * using `DestroyRef`. Web Storage only: the `storage` event never fires for custom `store`
3828
+ * adapters, so `syncTabs` has no effect with one.
3541
3829
  * - **Removal:** Use the `.clear()` method on the returned signal to remove the item from storage.
3542
3830
  * Setting the signal to the fallback value will store the fallback value, not remove the item.
3543
3831
  *
@@ -3572,25 +3860,28 @@ function stored(fallback, { key, store: providedStore, serialize = JSON.stringif
3572
3860
  : isSignal(key)
3573
3861
  ? key
3574
3862
  : computed(key);
3863
+ // "no stored value" marker — distinct from `null`/`undefined`, so a nullable `T` can
3864
+ // round-trip a legitimate `null` through `set` instead of it acting like `clear()`
3865
+ const EMPTY = Symbol();
3575
3866
  const getValue = (key) => {
3576
3867
  const found = store.getItem(key);
3577
3868
  if (found === null)
3578
- return null;
3869
+ return EMPTY;
3579
3870
  try {
3580
3871
  const deserialized = deserialize(found);
3581
3872
  if (!validate(deserialized))
3582
- return null;
3873
+ return EMPTY;
3583
3874
  return deserialized;
3584
3875
  }
3585
3876
  catch (err) {
3586
3877
  if (isDevMode())
3587
3878
  console.error(`Failed to parse stored value for key "${key}":`, err);
3588
- return null;
3879
+ return EMPTY;
3589
3880
  }
3590
3881
  };
3591
3882
  const storeValue = (key, value) => {
3592
3883
  try {
3593
- if (value === null)
3884
+ if (value === EMPTY)
3594
3885
  return store.removeItem(key);
3595
3886
  const serialized = serialize(value);
3596
3887
  store.setItem(key, serialized);
@@ -3608,9 +3899,9 @@ function stored(fallback, { key, store: providedStore, serialize = JSON.stringif
3608
3899
  const internal = signal(getValue(initialKey), {
3609
3900
  ...opt,
3610
3901
  equal: (a, b) => {
3611
- if (a === null && b === null)
3902
+ if (a === EMPTY && b === EMPTY)
3612
3903
  return true;
3613
- if (a === null || b === null)
3904
+ if (a === EMPTY || b === EMPTY)
3614
3905
  return false;
3615
3906
  return equal(a, b);
3616
3907
  },
@@ -3646,19 +3937,27 @@ function stored(fallback, { key, store: providedStore, serialize = JSON.stringif
3646
3937
  if (syncTabs && !isServer) {
3647
3938
  const destroyRef = inject(DestroyRef);
3648
3939
  const sync = (e) => {
3940
+ // `storage` events only describe Web Storage — ignore events for a different
3941
+ // storage area (or any event when a custom adapter is configured), otherwise an
3942
+ // unrelated localStorage write with the same key string corrupts our state
3943
+ if (e.storageArea !== store)
3944
+ return;
3649
3945
  if (e.key !== untracked(keySig))
3650
3946
  return;
3651
3947
  if (e.newValue === null)
3652
- internal.set(null);
3948
+ internal.set(EMPTY);
3653
3949
  else
3654
3950
  internal.set(getValue(e.key));
3655
3951
  };
3656
3952
  window.addEventListener('storage', sync);
3657
3953
  destroyRef.onDestroy(() => window.removeEventListener('storage', sync));
3658
3954
  }
3659
- const writable = toWritable(computed(() => internal() ?? fallback, opt), internal.set);
3955
+ const writable = toWritable(computed(() => {
3956
+ const v = internal();
3957
+ return v === EMPTY ? fallback : v;
3958
+ }, opt), internal.set);
3660
3959
  writable.clear = () => {
3661
- internal.set(null);
3960
+ internal.set(EMPTY);
3662
3961
  };
3663
3962
  writable.key = keySig;
3664
3963
  return writable;
@@ -3668,7 +3967,6 @@ class MessageBus {
3668
3967
  channel = new BroadcastChannel('mmstack-tab-sync-bus');
3669
3968
  listeners = new Map();
3670
3969
  subscribe(id, listener) {
3671
- this.unsubscribe(id); // Ensure no duplicate listeners
3672
3970
  const wrapped = (ev) => {
3673
3971
  try {
3674
3972
  if (ev.data?.id === id)
@@ -3679,18 +3977,28 @@ class MessageBus {
3679
3977
  }
3680
3978
  };
3681
3979
  this.channel.addEventListener('message', wrapped);
3682
- this.listeners.set(id, wrapped);
3980
+ let set = this.listeners.get(id);
3981
+ if (!set) {
3982
+ set = new Set();
3983
+ this.listeners.set(id, set);
3984
+ }
3985
+ set.add(wrapped);
3683
3986
  return {
3684
- unsub: (() => this.unsubscribe(id)).bind(this),
3685
- post: ((value) => this.channel.postMessage({ id, value })).bind(this),
3987
+ unsub: () => {
3988
+ this.channel.removeEventListener('message', wrapped);
3989
+ const cur = this.listeners.get(id);
3990
+ if (!cur)
3991
+ return;
3992
+ cur.delete(wrapped);
3993
+ if (cur.size === 0)
3994
+ this.listeners.delete(id);
3995
+ },
3996
+ post: (value) => this.channel.postMessage({ id, value }),
3686
3997
  };
3687
3998
  }
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);
3999
+ ngOnDestroy() {
4000
+ this.channel.close();
4001
+ this.listeners.clear();
3694
4002
  }
3695
4003
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.19", ngImport: i0, type: MessageBus, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
3696
4004
  static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "19.2.19", ngImport: i0, type: MessageBus, providedIn: 'root' });
@@ -3701,6 +4009,11 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.19", ngImpo
3701
4009
  providedIn: 'root',
3702
4010
  }]
3703
4011
  }] });
4012
+ /**
4013
+ * @deprecated The generated id hashes the call-site stack line, which collides when a shared
4014
+ * helper calls {@link tabSync} for multiple signals and diverges across minified builds during
4015
+ * a rolling deploy. Pass an explicit `{ id }` instead.
4016
+ */
3704
4017
  function generateDeterministicID() {
3705
4018
  const stack = new Error().stack;
3706
4019
  if (stack) {
@@ -3738,10 +4051,8 @@ function generateDeterministicID() {
3738
4051
  *
3739
4052
  * @example
3740
4053
  * ```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)
4054
+ * // With explicit ID (recommended)
4055
+ * const theme = tabSync(signal('dark'), { id: 'theme' });
3745
4056
  * const userPrefs = tabSync(signal({ lang: 'en' }), { id: 'user-preferences' });
3746
4057
  *
3747
4058
  * // Changes in one tab will sync to all other tabs
@@ -3753,6 +4064,7 @@ function generateDeterministicID() {
3753
4064
  * - Uses a single BroadcastChannel for all synchronized signals
3754
4065
  * - Automatically cleans up listeners when the injection context is destroyed
3755
4066
  * - Initial signal value after sync setup is not broadcasted to prevent loops
4067
+ * - Received values are not re-broadcast, so tabs never echo each other's updates
3756
4068
  *
3757
4069
  */
3758
4070
  function tabSync(sig, opt) {
@@ -3760,7 +4072,20 @@ function tabSync(sig, opt) {
3760
4072
  return sig;
3761
4073
  const id = typeof opt === 'string' ? opt : (opt?.id ?? generateDeterministicID());
3762
4074
  const bus = inject(MessageBus);
3763
- const { unsub, post } = bus.subscribe(id, (next) => sig.set(next));
4075
+ // The last value applied from a remote tab. The outbound effect skips (exactly) the run
4076
+ // caused by that write — without this, an inbound object (a fresh structured clone, so
4077
+ // never reference-equal) would be re-posted, and two tabs would ping-pong forever.
4078
+ const NONE = Symbol();
4079
+ let received = NONE;
4080
+ const { unsub, post } = bus.subscribe(id, (next) => {
4081
+ const before = untracked(sig);
4082
+ received = next;
4083
+ sig.set(next);
4084
+ // Equality-suppressed write (e.g. an identical primitive): no effect run will follow,
4085
+ // so clear the marker — it must not swallow a later, genuinely local change.
4086
+ if (untracked(sig) === before)
4087
+ received = NONE;
4088
+ });
3764
4089
  let first = false;
3765
4090
  const effectRef = effect(() => {
3766
4091
  const val = sig();
@@ -3768,6 +4093,11 @@ function tabSync(sig, opt) {
3768
4093
  first = true;
3769
4094
  return;
3770
4095
  }
4096
+ if (val === received) {
4097
+ received = NONE;
4098
+ return;
4099
+ }
4100
+ received = NONE;
3771
4101
  post(val);
3772
4102
  });
3773
4103
  inject(DestroyRef).onDestroy(() => {
@@ -3778,7 +4108,6 @@ function tabSync(sig, opt) {
3778
4108
  }
3779
4109
 
3780
4110
  function until(sourceSignal, predicate, options = {}) {
3781
- const injector = options.injector ?? inject(Injector);
3782
4111
  return new Promise((resolve, reject) => {
3783
4112
  let effectRef;
3784
4113
  let timeoutId;
@@ -3815,6 +4144,14 @@ function until(sourceSignal, predicate, options = {}) {
3815
4144
  cleanupAndResolve(initialValue);
3816
4145
  return;
3817
4146
  }
4147
+ let injector;
4148
+ try {
4149
+ injector = options.injector ?? inject(Injector);
4150
+ }
4151
+ catch {
4152
+ cleanupAndReject('until: No injector available — provide options.injector when calling outside an injection context.');
4153
+ return;
4154
+ }
3818
4155
  if (options?.timeout !== undefined) {
3819
4156
  timeoutId = setTimeout(() => cleanupAndReject(`until: Timeout after ${options.timeout}ms.`), options.timeout);
3820
4157
  }
@@ -3832,17 +4169,6 @@ function until(sourceSignal, predicate, options = {}) {
3832
4169
  });
3833
4170
  }
3834
4171
 
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
4172
  /**
3847
4173
  * Enhances an existing `WritableSignal` by adding a complete undo/redo history
3848
4174
  * stack and an API to control it.
@@ -3891,9 +4217,10 @@ function getSignalEquality(sig) {
3891
4217
  * ```
3892
4218
  */
3893
4219
  function withHistory(sourceOrValue, opt) {
3894
- const equal = (opt?.equal ?? isSignal(sourceOrValue))
3895
- ? getSignalEquality(sourceOrValue)
3896
- : Object.is;
4220
+ const equal = opt?.equal ??
4221
+ (isSignal(sourceOrValue)
4222
+ ? getSignalEquality(sourceOrValue)
4223
+ : Object.is);
3897
4224
  const source = isSignal(sourceOrValue)
3898
4225
  ? sourceOrValue
3899
4226
  : signal(sourceOrValue);
@@ -3938,9 +4265,8 @@ function withHistory(sourceOrValue, opt) {
3938
4265
  if (historyStack.length === 0)
3939
4266
  return;
3940
4267
  const valueForRedo = untracked(source);
3941
- const valueToRestore = historyStack.at(-1);
3942
- if (valueToRestore === undefined)
3943
- return;
4268
+ // length checked above — a legitimately `undefined` entry must still restore
4269
+ const valueToRestore = historyStack[historyStack.length - 1];
3944
4270
  originalSet.call(source, valueToRestore);
3945
4271
  history.inline((h) => h.pop());
3946
4272
  redoArray.mutate((r) => {
@@ -3954,9 +4280,8 @@ function withHistory(sourceOrValue, opt) {
3954
4280
  if (redoStack.length === 0)
3955
4281
  return;
3956
4282
  const valueForUndo = untracked(source);
3957
- const valueToRestore = redoStack.at(-1);
3958
- if (valueToRestore === undefined)
3959
- return;
4283
+ // length checked above — a legitimately `undefined` entry must still restore
4284
+ const valueToRestore = redoStack[redoStack.length - 1];
3960
4285
  originalSet.call(source, valueToRestore);
3961
4286
  redoArray.inline((r) => r.pop());
3962
4287
  history.mutate((h) => {
@@ -3979,5 +4304,5 @@ function withHistory(sourceOrValue, opt) {
3979
4304
  * Generated bundle index. Do not edit.
3980
4305
  */
3981
4306
 
3982
- export { MmActivity, SuspenseBoundary, SuspenseBoundaryBase, UnscopedSuspenseBoundary, activeTransaction, batteryStatus, chunked, clipboard, combineWith, createTransaction, createTransitionScope, debounce, debounced, derived, distinct, elementSize, elementVisibility, filter, filterWith, focusWithin, forkStore, geolocation, holdUntilReady, idle, indexArray, injectPaused, injectRegisterResource, injectStartTransaction, injectStartTransition, injectTransitionScope, isDerivation, isLeaf, isMutable, isOpaque, isStore, keepPrevious, keyArray, map, mapArray, mapObject, mediaQuery, merge3, mousePosition, mutable, mutableStore, nestedEffect, networkStatus, opaque, orientation, pageVisibility, pairwise, pausableComputed, pausableEffect, pausableSignal, pipeable, piped, pooled, pooledArray, pooledMap, pooledSet, prefersDarkMode, prefersReducedMotion, providePaused, provideTransitionScope, registerResource, resolvePause, scan, scrollPosition, select, sensor, sensors, signalFromEvent, startWith, store, stored, tabSync, tap, throttle, throttled, toFakeDerivation, toFakeSignalDerivation, toStore, toWritable, until, windowSize, withHistory };
4307
+ export { MmActivity, SuspenseBoundary, SuspenseBoundaryBase, UnscopedSuspenseBoundary, activeTransaction, batteryStatus, chunked, clipboard, combineWith, createForwardingScope, createTransaction, createTransitionScope, debounce, debounced, derived, distinct, elementSize, elementVisibility, filter, filterWith, focusWithin, forkStore, geolocation, getTransitionScope, holdUntilReady, idle, indexArray, injectPaused, injectRegisterResource, injectStartTransaction, injectStartTransition, injectTransitionScope, isDerivation, isLeaf, isMutable, isOpaque, isStore, keepPrevious, keyArray, map, mapArray, mapObject, mediaQuery, merge3, mousePosition, mutable, mutableStore, nestedEffect, networkStatus, opaque, orientation, pageVisibility, pairwise, pausableComputed, pausableEffect, pausableSignal, pipeable, piped, pooled, pooledArray, pooledMap, pooledSet, prefersDarkMode, prefersReducedMotion, provideForwardingTransitionScope, providePaused, provideTransitionScope, registerResource, resolvePause, scan, scrollPosition, select, sensor, sensors, signalFromEvent, startWith, store, stored, tabSync, tap, throttle, throttled, toFakeDerivation, toFakeSignalDerivation, toStore, toWritable, until, windowSize, withHistory };
3983
4308
  //# sourceMappingURL=mmstack-primitives.mjs.map