@mmstack/primitives 19.2.3 → 19.3.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.
@@ -1,7 +1,216 @@
1
- import { untracked, signal, inject, DestroyRef, computed, PLATFORM_ID, isSignal, effect, ElementRef, linkedSignal, isDevMode, Injector, runInInjectionContext } from '@angular/core';
1
+ import * as i0 from '@angular/core';
2
+ import { isDevMode, inject, Injector, untracked, effect, DestroyRef, linkedSignal, computed, signal, isSignal, ElementRef, PLATFORM_ID, Injectable, runInInjectionContext } from '@angular/core';
2
3
  import { isPlatformServer } from '@angular/common';
3
4
  import { SIGNAL } from '@angular/core/primitives/signals';
4
5
 
6
+ const frameStack = [];
7
+ function currentFrame() {
8
+ return frameStack.at(-1) ?? null;
9
+ }
10
+ function clearFrame(frame, userCleanups) {
11
+ frame.parent = null;
12
+ for (const fn of userCleanups) {
13
+ try {
14
+ fn();
15
+ }
16
+ catch (e) {
17
+ if (isDevMode())
18
+ console.error('Error destroying nested effect:', e);
19
+ }
20
+ }
21
+ userCleanups.length = 0;
22
+ for (const child of frame.children) {
23
+ try {
24
+ child.destroy();
25
+ }
26
+ catch (e) {
27
+ if (isDevMode())
28
+ console.error('Error destroying nested effect:', e);
29
+ }
30
+ }
31
+ frame.children.clear();
32
+ }
33
+ function pushFrame(frame) {
34
+ return frameStack.push(frame);
35
+ }
36
+ function popFrame() {
37
+ return frameStack.pop();
38
+ }
39
+
40
+ /**
41
+ * Creates an effect that can be nested, similar to SolidJS's `createEffect`.
42
+ *
43
+ * This primitive enables true hierarchical reactivity. A `nestedEffect` created
44
+ * within another `nestedEffect` is automatically destroyed and recreated when
45
+ * the parent re-runs.
46
+ *
47
+ * It automatically handles injector propagation and lifetime management, allowing
48
+ * you to create fine-grained, conditional side-effects that only track
49
+ * dependencies when they are "live".
50
+ *
51
+ * @param effectFn The side-effect function, which receives a cleanup register function.
52
+ * @param options (Optional) Angular's `CreateEffectOptions`.
53
+ * @returns An `EffectRef` for the created effect.
54
+ *
55
+ * @example
56
+ * ```ts
57
+ * // Assume `coldGuard` changes rarely, but `hotSignal` changes often.
58
+ * const coldGuard = signal(false);
59
+ * const hotSignal = signal(0);
60
+ *
61
+ * nestedEffect(() => {
62
+ * // This outer effect only tracks `coldGuard`.
63
+ * if (coldGuard()) {
64
+ *
65
+ * // This inner effect is CREATED when coldGuard is true
66
+ * // and DESTROYED when it becomes false.
67
+ * nestedEffect(() => {
68
+ * // It only tracks `hotSignal` while it exists.
69
+ * console.log('Hot signal is:', hotSignal());
70
+ * });
71
+ * }
72
+ * // If `coldGuard` is false, this outer effect does not track `hotSignal`.
73
+ * });
74
+ * ```
75
+ * @example
76
+ * ```ts
77
+ * const users = signal([
78
+ * { id: 1, name: 'Alice' },
79
+ * { id: 2, name: 'Bob' }
80
+ * ]);
81
+ *
82
+ * // The fine-grained mapped list
83
+ * const mappedUsers = mapArray(
84
+ * users,
85
+ * (userSignal, index) => {
86
+ * // 1. Create a fine-grained SIDE EFFECT for *this item*
87
+ * // This effect's lifetime is now tied to this specific item. created once on init of this index.
88
+ * const effectRef = nestedEffect(() => {
89
+ * // This only runs if *this* userSignal changes,
90
+ * // not if the whole list changes.
91
+ * console.log(`User ${index} updated:`, userSignal().name);
92
+ * });
93
+ *
94
+ * // 2. Return the data AND the cleanup logic
95
+ * return {
96
+ * // The mapped data
97
+ * label: computed(() => `User: ${userSignal().name}`),
98
+ *
99
+ * // The cleanup function
100
+ * destroyEffect: () => effectRef.destroy()
101
+ * };
102
+ * },
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
105
+ * onDestroy: (mappedItem) => {
106
+ * mappedItem.destroyEffect();
107
+ * }
108
+ * }
109
+ * );
110
+ * ```
111
+ */
112
+ function nestedEffect(effectFn, options) {
113
+ const bindToFrame = options?.bindToFrame ?? ((parent) => parent);
114
+ const parent = bindToFrame(currentFrame());
115
+ const injector = options?.injector ?? parent?.injector ?? inject(Injector);
116
+ let isDestroyed = false;
117
+ const srcRef = untracked(() => {
118
+ return effect((cleanup) => {
119
+ if (isDestroyed)
120
+ return;
121
+ const frame = {
122
+ injector,
123
+ parent,
124
+ children: new Set(),
125
+ };
126
+ const userCleanups = [];
127
+ pushFrame(frame);
128
+ try {
129
+ effectFn((fn) => userCleanups.push(fn));
130
+ }
131
+ finally {
132
+ popFrame();
133
+ }
134
+ return cleanup(() => clearFrame(frame, userCleanups));
135
+ }, {
136
+ ...options,
137
+ injector,
138
+ manualCleanup: options?.manualCleanup ?? !!parent,
139
+ });
140
+ });
141
+ const ref = {
142
+ destroy: () => {
143
+ if (isDestroyed)
144
+ return;
145
+ isDestroyed = true;
146
+ parent?.children.delete(ref);
147
+ srcRef.destroy();
148
+ },
149
+ };
150
+ parent?.children.add(ref);
151
+ injector.get(DestroyRef).onDestroy(() => ref.destroy());
152
+ return ref;
153
+ }
154
+
155
+ /**
156
+ * 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.
157
+ *
158
+ * 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`.
159
+ *
160
+ * @template T The type of items in the array.
161
+ * @param source A `Signal` or a function that returns an array of items to be processed in chunks.
162
+ * @param options Configuration options for chunk size, delay duration, equality function, and injector.
163
+ * @returns A `Signal` that emits the current chunk of items being processed.
164
+ *
165
+ * @example
166
+ * const largeList = signal(Array.from({ length: 1000 }, (_, i) => i));
167
+ * const chunkedList = chunked(largeList, { chunkSize: 100, duration: 100 });
168
+ */
169
+ function chunked(source, options) {
170
+ const { chunkSize = 50, delay = 'frame', equal, injector } = options || {};
171
+ let delayFn;
172
+ if (delay === 'frame') {
173
+ delayFn = (callback) => {
174
+ const num = requestAnimationFrame(callback);
175
+ return () => cancelAnimationFrame(num);
176
+ };
177
+ }
178
+ else if (delay === 'microtask') {
179
+ delayFn = (cb) => {
180
+ let isCancelled = false;
181
+ queueMicrotask(() => {
182
+ if (isCancelled)
183
+ return;
184
+ cb();
185
+ });
186
+ return () => {
187
+ isCancelled = true;
188
+ };
189
+ };
190
+ }
191
+ else {
192
+ delayFn = (cb) => {
193
+ const num = setTimeout(cb, delay);
194
+ return () => clearTimeout(num);
195
+ };
196
+ }
197
+ const internal = linkedSignal({
198
+ source,
199
+ computation: (items) => items.slice(0, chunkSize),
200
+ equal,
201
+ });
202
+ nestedEffect((cleanup) => {
203
+ const fullList = source();
204
+ const current = internal();
205
+ if (current.length >= fullList.length)
206
+ return;
207
+ return cleanup(delayFn(() => untracked(() => internal.set(fullList.slice(0, current.length + chunkSize)))));
208
+ }, {
209
+ injector: injector,
210
+ });
211
+ return internal.asReadonly();
212
+ }
213
+
5
214
  /**
6
215
  * Converts a read-only `Signal` into a `WritableSignal` by providing custom `set` and, optionally, `update` functions.
7
216
  * This can be useful for creating controlled write access to a signal that is otherwise read-only.
@@ -30,9 +239,9 @@ import { SIGNAL } from '@angular/core/primitives/signals';
30
239
  *
31
240
  * writableSignal.set(5); // sets value of originalValue.a to 5 & triggers all signals
32
241
  */
33
- function toWritable(signal, set, update) {
34
- const internal = signal;
35
- internal.asReadonly = () => signal;
242
+ function toWritable(source, set, update, opt) {
243
+ const internal = (opt?.pure !== false ? computed(source) : source);
244
+ internal.asReadonly = () => source;
36
245
  internal.set = set;
37
246
  internal.update = update ?? ((updater) => set(updater(untracked(internal))));
38
247
  return internal;
@@ -114,19 +323,19 @@ function debounce(source, opt) {
114
323
  catch {
115
324
  // not in injection context & no destroyRef provided opting out of cleanup
116
325
  }
117
- const triggerFn = (afterClean) => {
326
+ const triggerFn = (next) => {
118
327
  if (timeout)
119
328
  clearTimeout(timeout);
120
- afterClean();
329
+ source.set(next);
121
330
  timeout = setTimeout(() => {
122
331
  trigger.update((c) => !c);
123
332
  }, ms);
124
333
  };
125
334
  const set = (value) => {
126
- triggerFn(() => source.set(value));
335
+ triggerFn(value);
127
336
  };
128
337
  const update = (fn) => {
129
- triggerFn(() => source.update(fn));
338
+ triggerFn(fn(untracked(source)));
130
339
  };
131
340
  const writable = toWritable(computed(() => {
132
341
  trigger();
@@ -136,25 +345,112 @@ function debounce(source, opt) {
136
345
  return writable;
137
346
  }
138
347
 
348
+ const { is } = Object;
349
+ function mutable(initial, opt) {
350
+ const baseEqual = opt?.equal ?? is;
351
+ let trigger = false;
352
+ const equal = (a, b) => {
353
+ if (trigger)
354
+ return false;
355
+ return baseEqual(a, b);
356
+ };
357
+ const sig = signal(initial, {
358
+ ...opt,
359
+ equal,
360
+ });
361
+ const internalUpdate = sig.update;
362
+ sig.mutate = (updater) => {
363
+ trigger = true;
364
+ internalUpdate(updater);
365
+ trigger = false;
366
+ };
367
+ sig.inline = (updater) => {
368
+ sig.mutate((prev) => {
369
+ updater(prev);
370
+ return prev;
371
+ });
372
+ };
373
+ return sig;
374
+ }
375
+ /**
376
+ * Type guard function to check if a given `WritableSignal` is a `MutableSignal`. This is useful
377
+ * for situations where you need to conditionally use the `mutate` or `inline` methods.
378
+ *
379
+ * @typeParam T - The type of the signal's value (optional, defaults to `any`).
380
+ * @param value - The `WritableSignal` to check.
381
+ * @returns `true` if the signal is a `MutableSignal`, `false` otherwise.
382
+ *
383
+ * @example
384
+ * const mySignal = signal(0);
385
+ * const myMutableSignal = mutable(0);
386
+ *
387
+ * if (isMutable(mySignal)) {
388
+ * mySignal.mutate(x => x + 1); // This would cause a type error, as mySignal is not a MutableSignal.
389
+ * }
390
+ *
391
+ * if (isMutable(myMutableSignal)) {
392
+ * myMutableSignal.mutate(x => x + 1); // This is safe.
393
+ * }
394
+ */
395
+ function isMutable(value) {
396
+ return 'mutate' in value && typeof value.mutate === 'function';
397
+ }
398
+
139
399
  function derived(source, optOrKey, opt) {
140
400
  const isArray = Array.isArray(untracked(source)) && typeof optOrKey === 'number';
141
401
  const from = typeof optOrKey === 'object' ? optOrKey.from : (v) => v[optOrKey];
142
402
  const onChange = typeof optOrKey === 'object'
143
403
  ? optOrKey.onChange
144
404
  : isArray
145
- ? (next) => {
146
- source.update((cur) => {
147
- const newArray = [...cur];
148
- newArray[optOrKey] = next;
149
- return newArray;
150
- });
151
- }
152
- : (next) => {
153
- source.update((cur) => ({ ...cur, [optOrKey]: next }));
154
- };
155
- const rest = typeof optOrKey === 'object' ? optOrKey : opt;
156
- const sig = toWritable(computed(() => from(source()), rest), (newVal) => onChange(newVal));
405
+ ? isMutable(source)
406
+ ? (next) => {
407
+ source.mutate((cur) => {
408
+ cur[optOrKey] = next;
409
+ return cur;
410
+ });
411
+ }
412
+ : (next) => {
413
+ source.update((cur) => {
414
+ const newArray = [...cur];
415
+ newArray[optOrKey] = next;
416
+ return newArray;
417
+ });
418
+ }
419
+ : isMutable(source)
420
+ ? (next) => {
421
+ source.mutate((cur) => {
422
+ cur[optOrKey] = next;
423
+ return cur;
424
+ });
425
+ }
426
+ : (next) => {
427
+ source.update((cur) => ({ ...cur, [optOrKey]: next }));
428
+ };
429
+ const rest = typeof optOrKey === 'object' ? { ...optOrKey, ...opt } : opt;
430
+ const baseEqual = rest?.equal ?? Object.is;
431
+ let trigger = false;
432
+ const equal = isMutable(source)
433
+ ? (a, b) => {
434
+ if (trigger)
435
+ return false;
436
+ return baseEqual(a, b);
437
+ }
438
+ : baseEqual;
439
+ const sig = toWritable(computed(() => from(source()), { ...rest, equal }), (newVal) => onChange(newVal), undefined, { pure: false });
157
440
  sig.from = from;
441
+ if (isMutable(source)) {
442
+ sig.mutate = (updater) => {
443
+ trigger = true;
444
+ sig.update(updater);
445
+ trigger = false;
446
+ };
447
+ sig.inline = (updater) => {
448
+ sig.mutate((prev) => {
449
+ updater(prev);
450
+ return prev;
451
+ });
452
+ };
453
+ }
158
454
  return sig;
159
455
  }
160
456
  /**
@@ -201,6 +497,445 @@ function isDerivation(sig) {
201
497
  return 'from' in sig;
202
498
  }
203
499
 
500
+ function isWritableSignal(value) {
501
+ return 'set' in value && typeof value.set === 'function';
502
+ }
503
+ /**
504
+ * @internal
505
+ * Creates a setter function for a source signal of type `Signal<T[]>` or a function returning `T[]`.
506
+ * @param source The source signal of type `Signal<T[]>` or a function returning `T[]`.
507
+ * @returns
508
+ */
509
+ function createSetter(source) {
510
+ if (!isWritableSignal(source))
511
+ return () => {
512
+ // noop;
513
+ };
514
+ if (isMutable(source))
515
+ return (value, index) => {
516
+ source.mutate((arr) => {
517
+ arr[index] = value;
518
+ return arr;
519
+ });
520
+ };
521
+ return (value, index) => {
522
+ source.update((arr) => arr.map((v, i) => (i === index ? value : v)));
523
+ };
524
+ }
525
+
526
+ /**
527
+ * Helper to create the derived signal for a specific index.
528
+ * Extracts the cast logic to keep the main loop clean.
529
+ */
530
+ function createItemSignal(source, index, setter, opt) {
531
+ return derived(
532
+ // We cast to any/Mutable to satisfy the overload signature,
533
+ // but 'derived' internally checks isMutable() for safety.
534
+ source, {
535
+ from: (src) => src[index],
536
+ onChange: (value) => setter(value, index),
537
+ }, opt);
538
+ }
539
+ function indexArray(source, map, opt = {}) {
540
+ const data = isSignal(source) ? source : computed(source);
541
+ const len = computed(() => data().length);
542
+ const setter = createSetter(data);
543
+ const writableData = isWritableSignal(data)
544
+ ? data
545
+ : toWritable(data, () => {
546
+ // noop
547
+ });
548
+ if (isWritableSignal(data) && isMutable(data) && !opt.equal) {
549
+ opt.equal = (a, b) => {
550
+ if (a !== b)
551
+ return false; // actually check primitives and references
552
+ return false; // opt out for same refs
553
+ };
554
+ }
555
+ return linkedSignal({
556
+ source: () => len(),
557
+ computation: (len, prev) => {
558
+ if (!prev)
559
+ return Array.from({ length: len }, (_, i) => map(createItemSignal(writableData, i, setter, opt), i));
560
+ if (len === prev.value.length)
561
+ return prev.value;
562
+ if (len < prev.value.length) {
563
+ if (opt.onDestroy) {
564
+ for (let i = len; i < prev.value.length; i++) {
565
+ opt.onDestroy(prev.value[i]);
566
+ }
567
+ }
568
+ return prev.value.slice(0, len);
569
+ }
570
+ const next = prev.value.slice();
571
+ for (let i = prev.value.length; i < len; i++)
572
+ next[i] = map(createItemSignal(writableData, i, setter, opt), i);
573
+ return next;
574
+ },
575
+ equal: (a, b) => a.length === b.length,
576
+ });
577
+ }
578
+ /**
579
+ * @deprecated use indexArray instead
580
+ */
581
+ const mapArray = indexArray;
582
+
583
+ /**
584
+ * Reactively maps items from a source array to a new array by value (identity).
585
+ *
586
+ * similar to `Array.prototype.map`, but:
587
+ * 1. The `mapFn` receives the `index` as a Signal.
588
+ * 2. If an item in the `source` array moves to a new position, the *result* of the map function is reused and moved.
589
+ * The `index` signal is updated to the new index.
590
+ * 3. The `mapFn` is only run for *new* items.
591
+ *
592
+ * This is useful for building efficient lists where DOM nodes or heavy instances should be reused
593
+ * when the list is reordered.
594
+ *
595
+ * @param source A `Signal<T[]>` or a function returning `T[]`.
596
+ * @param mapFn The mapping function. Receives the item and its index as a Signal.
597
+ * @param options Optional configuration:
598
+ * - `onDestroy`: A callback invoked when a mapped item is removed from the array.
599
+ * @returns A `Signal<U[]>` containing the mapped array.
600
+ */
601
+ function keyArray(source, mapFn, options = {}) {
602
+ const sourceSignal = isSignal(source) ? source : computed(source);
603
+ const items = [];
604
+ let mapped = [];
605
+ const indexes = [];
606
+ const getKey = options.key || ((v) => v);
607
+ const newIndices = new Map();
608
+ const temp = [];
609
+ const tempIndexes = [];
610
+ const newIndicesNext = [];
611
+ const newIndexesCache = new Array();
612
+ return computed(() => {
613
+ const newItems = sourceSignal() || [];
614
+ return untracked(() => {
615
+ let i;
616
+ let j;
617
+ const newLen = newItems.length;
618
+ const len = items.length;
619
+ const newMapped = new Array(newLen);
620
+ const newIndexes = newIndexesCache;
621
+ newIndexes.length = 0;
622
+ newIndexes.length = newLen;
623
+ let start;
624
+ let end;
625
+ let newEnd;
626
+ let item;
627
+ let key;
628
+ if (newLen === 0) {
629
+ if (len !== 0) {
630
+ if (options.onDestroy) {
631
+ for (let k = 0; k < len; k++)
632
+ options.onDestroy(mapped[k]);
633
+ }
634
+ items.length = 0;
635
+ mapped = [];
636
+ indexes.length = 0;
637
+ }
638
+ return mapped;
639
+ }
640
+ if (len === 0) {
641
+ for (j = 0; j < newLen; j++) {
642
+ item = newItems[j];
643
+ items[j] = item;
644
+ const indexSignal = signal(j);
645
+ newIndexes[j] = indexSignal;
646
+ newMapped[j] = mapFn(item, indexSignal);
647
+ }
648
+ }
649
+ else {
650
+ newIndices.clear();
651
+ temp.length = 0;
652
+ tempIndexes.length = 0;
653
+ newIndicesNext.length = 0;
654
+ for (start = 0, end = Math.min(len, newLen); start < end && getKey(items[start]) === getKey(newItems[start]); start++) {
655
+ newMapped[start] = mapped[start];
656
+ newIndexes[start] = indexes[start];
657
+ }
658
+ for (end = len - 1, newEnd = newLen - 1; end >= start &&
659
+ newEnd >= start &&
660
+ getKey(items[end]) === getKey(newItems[newEnd]); end--, newEnd--) {
661
+ temp[newEnd] = mapped[end];
662
+ tempIndexes[newEnd] = indexes[end];
663
+ }
664
+ for (j = newEnd; j >= start; j--) {
665
+ item = newItems[j];
666
+ key = getKey(item);
667
+ i = newIndices.get(key);
668
+ newIndicesNext[j] = i === undefined ? -1 : i;
669
+ newIndices.set(key, j);
670
+ }
671
+ for (i = start; i <= end; i++) {
672
+ item = items[i];
673
+ key = getKey(item);
674
+ j = newIndices.get(key);
675
+ if (j !== undefined && j !== -1) {
676
+ temp[j] = mapped[i];
677
+ tempIndexes[j] = indexes[i];
678
+ j = newIndicesNext[j];
679
+ newIndices.set(key, j);
680
+ }
681
+ else {
682
+ if (options.onDestroy)
683
+ options.onDestroy(mapped[i]);
684
+ }
685
+ }
686
+ // 2) Set all new values
687
+ for (j = start; j < newLen; j++) {
688
+ if (temp[j] !== undefined) {
689
+ newMapped[j] = temp[j];
690
+ newIndexes[j] = tempIndexes[j];
691
+ newIndexes[j].set(j);
692
+ }
693
+ else {
694
+ const indexSignal = signal(j);
695
+ newIndexes[j] = indexSignal;
696
+ newMapped[j] = mapFn(newItems[j], indexSignal);
697
+ }
698
+ }
699
+ items.length = newLen;
700
+ for (let k = 0; k < newLen; k++)
701
+ items[k] = newItems[k];
702
+ }
703
+ mapped = newMapped;
704
+ indexes.length = newLen;
705
+ for (let k = 0; k < newLen; k++)
706
+ indexes[k] = newIndexes[k];
707
+ return mapped;
708
+ });
709
+ });
710
+ }
711
+
712
+ function pooledKeys(src) {
713
+ const aBuf = new Set();
714
+ const bBuf = new Set();
715
+ let active = aBuf;
716
+ let spare = bBuf;
717
+ return computed(() => {
718
+ const val = src();
719
+ spare.clear();
720
+ for (const k in val)
721
+ if (Object.prototype.hasOwnProperty.call(val, k))
722
+ spare.add(k);
723
+ if (active.size === spare.size && active.isSubsetOf(spare))
724
+ return active;
725
+ const temp = active;
726
+ active = spare;
727
+ spare = temp;
728
+ return active;
729
+ });
730
+ }
731
+ function mapObject(source, mapFn, options = {}) {
732
+ const src = isSignal(source) ? source : computed(source);
733
+ const writable = (isWritableSignal(src)
734
+ ? src
735
+ : toWritable(src, () => {
736
+ // noop
737
+ })); // maximal overload internally
738
+ return linkedSignal({
739
+ source: pooledKeys(src),
740
+ computation: (next, prev) => {
741
+ const nextObj = {};
742
+ for (const k of next)
743
+ nextObj[k] =
744
+ prev && prev.source.has(k)
745
+ ? prev.value[k]
746
+ : mapFn(k, derived(writable, k));
747
+ if (options.onDestroy && prev && prev.source.size)
748
+ for (const k of prev.source)
749
+ if (!next.has(k))
750
+ options.onDestroy(prev.value[k]);
751
+ return nextObj;
752
+ },
753
+ }).asReadonly();
754
+ }
755
+
756
+ /** Project with optional equality. Pure & sync. */
757
+ const select = (projector, opt) => (src) => computed(() => projector(src()), opt);
758
+ /** Combine with another signal using a projector. */
759
+ const combineWith = (other, project, opt) => (src) => computed(() => project(src(), other()), opt);
760
+ /** Only re-emit when equal(prev, next) is false. */
761
+ const distinct = (equal = Object.is) => (src) => computed(() => src(), { equal });
762
+ /** map to new value */
763
+ const map = (fn) => (src) => computed(() => fn(src()));
764
+ /** filter values, keeping the last value if it was ever available, if first value is filtered will return undefined */
765
+ const filter = (predicate) => (src) => linkedSignal({
766
+ source: src,
767
+ computation: (next, prev) => {
768
+ if (predicate(next))
769
+ return next;
770
+ return prev?.source;
771
+ },
772
+ });
773
+ /** tap into the value */
774
+ const tap = (fn) => (src) => {
775
+ effect(() => fn(src()));
776
+ return src;
777
+ };
778
+
779
+ /**
780
+ * Decorate any `Signal<T>` with a chainable `.pipe(...)` method.
781
+ *
782
+ * @example
783
+ * const s = pipeable(signal(1)); // WritableSignal<number> (+ pipe)
784
+ * const label = s.pipe(n => n * 2, n => `#${n}`); // Signal<string> (+ pipe)
785
+ * label(); // "#2"
786
+ */
787
+ function pipeable(signal) {
788
+ const internal = signal;
789
+ const mapImpl = (...fns) => {
790
+ const last = fns.at(-1);
791
+ let opt;
792
+ if (last && typeof last !== 'function') {
793
+ fns = fns.slice(0, -1);
794
+ opt = last;
795
+ }
796
+ if (fns.length === 0)
797
+ return internal;
798
+ if (fns.length === 1) {
799
+ const fn = fns[0];
800
+ return pipeable(computed(() => fn(internal()), opt));
801
+ }
802
+ const transformer = (input) => fns.reduce((acc, fn) => fn(acc), input);
803
+ return pipeable(computed(() => transformer(internal()), opt));
804
+ };
805
+ const pipeImpl = (...ops) => {
806
+ if (ops.length === 0)
807
+ return internal;
808
+ return ops.reduce((src, op) => pipeable(op(src)), internal);
809
+ };
810
+ Object.defineProperties(internal, {
811
+ map: {
812
+ value: mapImpl,
813
+ configurable: true,
814
+ enumerable: false,
815
+ writable: false,
816
+ },
817
+ pipe: {
818
+ value: pipeImpl,
819
+ configurable: true,
820
+ enumerable: false,
821
+ writable: false,
822
+ },
823
+ });
824
+ return internal;
825
+ }
826
+ /**
827
+ * Create a new **writable** signal and return it as a `PipableSignal`.
828
+ *
829
+ * The returned value is a `WritableSignal<T>` with `.set`, `.update`, `.asReadonly`
830
+ * still available (via intersection type), plus a chainable `.pipe(...)`.
831
+ *
832
+ * @example
833
+ * const count = piped(1); // WritableSignal<number> (+ pipe)
834
+ * const even = count.pipe(n => n % 2 === 0); // Signal<boolean> (+ pipe)
835
+ * count.update(n => n + 1);
836
+ */
837
+ function piped(initial, opt) {
838
+ return pipeable(signal(initial, opt));
839
+ }
840
+
841
+ function observerSupported$1() {
842
+ return typeof ResizeObserver !== 'undefined';
843
+ }
844
+ /**
845
+ * Creates a read-only signal that tracks the size of a target DOM element.
846
+ *
847
+ * By default, it observes the `border-box` size to align with `getBoundingClientRect()`,
848
+ * which is used to provide a synchronous initial value if possible.
849
+ *
850
+ * @param target The DOM element (or `ElementRef`, or a `Signal` resolving to one) to observe.
851
+ * @param options Optional configuration including `box` (defaults to 'border-box') and `debugName`.
852
+ * @returns A `Signal<ElementSize | undefined>`.
853
+ *
854
+ * @example
855
+ * ```ts
856
+ * const size = elementSize(elementRef);
857
+ * effect(() => {
858
+ * console.log('Size:', size()?.width, size()?.height);
859
+ * });
860
+ * ```
861
+ */
862
+ function elementSize(target = inject(ElementRef), opt) {
863
+ const getElement = () => {
864
+ if (isSignal(target)) {
865
+ try {
866
+ const val = target();
867
+ return val instanceof ElementRef ? val.nativeElement : val;
868
+ }
869
+ catch {
870
+ return null;
871
+ }
872
+ }
873
+ return target instanceof ElementRef ? target.nativeElement : target;
874
+ };
875
+ const resolveInitialValue = () => {
876
+ if (!observerSupported$1())
877
+ return undefined;
878
+ const el = getElement();
879
+ if (el && el.getBoundingClientRect) {
880
+ const rect = el.getBoundingClientRect();
881
+ return { width: rect.width, height: rect.height };
882
+ }
883
+ return undefined;
884
+ };
885
+ if (isPlatformServer(inject(PLATFORM_ID))) {
886
+ return computed(() => untracked(resolveInitialValue), {
887
+ debugName: opt?.debugName,
888
+ });
889
+ }
890
+ const state = signal(untracked(resolveInitialValue), {
891
+ debugName: opt?.debugName,
892
+ equal: (a, b) => a?.width === b?.width && a?.height === b?.height,
893
+ });
894
+ const targetSignal = isSignal(target) ? target : computed(() => target);
895
+ effect((cleanup) => {
896
+ const el = targetSignal();
897
+ if (el) {
898
+ const nativeEl = el instanceof ElementRef ? el.nativeElement : el;
899
+ const rect = nativeEl.getBoundingClientRect();
900
+ untracked(() => state.set({ width: rect.width, height: rect.height }));
901
+ }
902
+ else {
903
+ untracked(() => state.set(undefined));
904
+ return;
905
+ }
906
+ if (!observerSupported$1())
907
+ return;
908
+ let observer = null;
909
+ observer = new ResizeObserver(([entry]) => {
910
+ let width = 0;
911
+ let height = 0;
912
+ const boxOption = opt?.box ?? 'border-box';
913
+ if (boxOption === 'border-box' && entry.borderBoxSize?.length > 0) {
914
+ const size = entry.borderBoxSize[0];
915
+ width = size.inlineSize;
916
+ height = size.blockSize;
917
+ }
918
+ else if (boxOption === 'content-box' &&
919
+ entry.contentBoxSize?.length > 0) {
920
+ width = entry.contentBoxSize[0].inlineSize;
921
+ height = entry.contentBoxSize[0].blockSize;
922
+ }
923
+ else {
924
+ width = entry.contentRect.width;
925
+ height = entry.contentRect.height;
926
+ }
927
+ state.set({ width, height });
928
+ });
929
+ observer.observe(el instanceof ElementRef ? el.nativeElement : el, {
930
+ box: opt?.box ?? 'border-box',
931
+ });
932
+ cleanup(() => {
933
+ observer?.disconnect();
934
+ });
935
+ });
936
+ return state.asReadonly();
937
+ }
938
+
204
939
  function observerSupported() {
205
940
  return typeof IntersectionObserver !== 'undefined';
206
941
  }
@@ -303,141 +1038,6 @@ function elementVisibility(target = inject(ElementRef), opt) {
303
1038
  return base;
304
1039
  }
305
1040
 
306
- /**
307
- * Reactively maps items from a source array (or signal of an array) using a provided mapping function.
308
- *
309
- * This function serves a similar purpose to SolidJS's `mapArray` by providing stability
310
- * for mapped items. It receives a source function returning an array (or a Signal<T[]>)
311
- * and a mapping function.
312
- *
313
- * For each item in the source array, it creates a stable `computed` signal representing
314
- * that item's value at its current index. This stable signal (`Signal<T>`) is passed
315
- * to the mapping function. This ensures that downstream computations or components
316
- * depending on the mapped result only re-render or re-calculate for the specific items
317
- * that have changed, or when items are added/removed, rather than re-evaluating everything
318
- * when the source array reference changes but items remain the same.
319
- *
320
- * It efficiently handles changes in the source array's length by reusing existing mapped
321
- * results when possible, slicing when the array shrinks, and appending new mapped items
322
- * when it grows.
323
- *
324
- * @template T The type of items in the source array.
325
- * @template U The type of items in the resulting mapped array.
326
- *
327
- * @param source A function returning the source array `T[]`, or a `Signal<T[]>` itself.
328
- * The `mapArray` function will reactively update based on changes to this source.
329
- * @param map The mapping function. It is called for each item in the source array.
330
- * It receives:
331
- * - `value`: A stable `Signal<T>` representing the item at the current index.
332
- * Use this signal within your mapping logic if you need reactivity
333
- * tied to the specific item's value changes.
334
- * - `index`: The number index of the item in the array.
335
- * It should return the mapped value `U`.
336
- * @param [opt] Optional `CreateSignalOptions<T>`. These options are passed directly
337
- * to the `computed` signal created for each individual item (`Signal<T>`).
338
- * This allows specifying options like a custom `equal` function for item comparison.
339
- *
340
- * @returns A `Signal<U[]>` containing the mapped array. This signal updates whenever
341
- * the source array changes (either length or the values of its items).
342
- *
343
- * @example
344
- * ```ts
345
- * const sourceItems = signal([
346
- * { id: 1, name: 'Apple' },
347
- * { id: 2, name: 'Banana' }
348
- * ]);
349
- *
350
- * const mappedItems = mapArray(
351
- * sourceItems,
352
- * (itemSignal, index) => {
353
- * // itemSignal is stable for a given item based on its index.
354
- * // We create a computed here to react to changes in the item's name.
355
- * return computed(() => `${index}: ${itemSignal().name.toUpperCase()}`);
356
- * },
357
- * // Example optional options (e.g., custom equality for item signals)
358
- * { equal: (a, b) => a.id === b.id && a.name === b.name }
359
- * );
360
- * ```
361
- * @remarks
362
- * This function achieves its high performance by leveraging the new `linkedSignal`
363
- * API from Angular, which allows for efficient memoization and reuse of array items.
364
- */
365
- function mapArray(source, map, opt) {
366
- const data = isSignal(source) ? source : computed(source);
367
- const len = computed(() => data().length);
368
- return linkedSignal({
369
- source: () => len(),
370
- computation: (len, prev) => {
371
- if (!prev)
372
- return Array.from({ length: len }, (_, i) => map(computed(() => source()[i], opt), i));
373
- if (len === prev.value.length)
374
- return prev.value;
375
- if (len < prev.value.length) {
376
- return prev.value.slice(0, len);
377
- }
378
- else {
379
- const next = [...prev.value];
380
- for (let i = prev.value.length; i < len; i++) {
381
- next[i] = map(computed(() => source()[i], opt), i);
382
- }
383
- return next;
384
- }
385
- },
386
- equal: (a, b) => a.length === b.length,
387
- });
388
- }
389
-
390
- const { is } = Object;
391
- function mutable(initial, opt) {
392
- const baseEqual = opt?.equal ?? is;
393
- let trigger = false;
394
- const equal = (a, b) => {
395
- if (trigger)
396
- return false;
397
- return baseEqual(a, b);
398
- };
399
- const sig = signal(initial, {
400
- ...opt,
401
- equal,
402
- });
403
- const internalUpdate = sig.update;
404
- sig.mutate = (updater) => {
405
- trigger = true;
406
- internalUpdate(updater);
407
- trigger = false;
408
- };
409
- sig.inline = (updater) => {
410
- sig.mutate((prev) => {
411
- updater(prev);
412
- return prev;
413
- });
414
- };
415
- return sig;
416
- }
417
- /**
418
- * Type guard function to check if a given `WritableSignal` is a `MutableSignal`. This is useful
419
- * for situations where you need to conditionally use the `mutate` or `inline` methods.
420
- *
421
- * @typeParam T - The type of the signal's value (optional, defaults to `any`).
422
- * @param value - The `WritableSignal` to check.
423
- * @returns `true` if the signal is a `MutableSignal`, `false` otherwise.
424
- *
425
- * @example
426
- * const mySignal = signal(0);
427
- * const myMutableSignal = mutable(0);
428
- *
429
- * if (isMutable(mySignal)) {
430
- * mySignal.mutate(x => x + 1); // This would cause a type error, as mySignal is not a MutableSignal.
431
- * }
432
- *
433
- * if (isMutable(myMutableSignal)) {
434
- * myMutableSignal.mutate(x => x + 1); // This is safe.
435
- * }
436
- */
437
- function isMutable(value) {
438
- return 'mutate' in value && typeof value.mutate === 'function';
439
- }
440
-
441
1041
  /**
442
1042
  * Creates a read-only signal that reactively tracks whether a CSS media query
443
1043
  * string currently matches.
@@ -482,11 +1082,11 @@ function isMutable(value) {
482
1082
  * }
483
1083
  * ```
484
1084
  */
485
- function mediaQuery(query, debugName) {
1085
+ function mediaQuery(query, debugName = 'mediaQuery') {
486
1086
  if (isPlatformServer(inject(PLATFORM_ID)))
487
1087
  return computed(() => false, { debugName });
488
1088
  const mediaQueryList = window.matchMedia(query);
489
- const state = signal(mediaQueryList.matches, { debugName });
1089
+ const state = signal(mediaQueryList.matches, { debugName: debugName });
490
1090
  const handleChange = (event) => {
491
1091
  state.set(event.matches);
492
1092
  };
@@ -609,8 +1209,7 @@ function throttle(source, opt) {
609
1209
  catch {
610
1210
  // not in injection context & no destroyRef provided opting out of cleanup
611
1211
  }
612
- const triggerFn = (updateSourceAction) => {
613
- updateSourceAction();
1212
+ const tick = () => {
614
1213
  if (timeout)
615
1214
  return;
616
1215
  timeout = setTimeout(() => {
@@ -619,10 +1218,12 @@ function throttle(source, opt) {
619
1218
  }, ms);
620
1219
  };
621
1220
  const set = (value) => {
622
- triggerFn(() => source.set(value));
1221
+ source.set(value);
1222
+ tick();
623
1223
  };
624
1224
  const update = (fn) => {
625
- triggerFn(() => source.update(fn));
1225
+ source.update(fn);
1226
+ tick();
626
1227
  };
627
1228
  const writable = toWritable(computed(() => {
628
1229
  trigger();
@@ -670,12 +1271,12 @@ function mousePosition(opt) {
670
1271
  x: 0,
671
1272
  y: 0,
672
1273
  }), {
673
- debugName: opt?.debugName,
1274
+ debugName: opt?.debugName ?? 'mousePosition',
674
1275
  });
675
1276
  base.unthrottled = base;
676
1277
  return base;
677
1278
  }
678
- const { target = window, coordinateSpace = 'client', touch = false, debugName, throttle = 100, } = opt ?? {};
1279
+ const { target = window, coordinateSpace = 'client', touch = false, debugName = 'mousePosition', throttle = 100, } = opt ?? {};
679
1280
  const eventTarget = target instanceof ElementRef ? target.nativeElement : target;
680
1281
  if (!eventTarget) {
681
1282
  if (isDevMode())
@@ -736,7 +1337,7 @@ const serverDate = new Date();
736
1337
  * @param debugName Optional debug name for the signal.
737
1338
  * @returns A `NetworkStatusSignal` instance.
738
1339
  */
739
- function networkStatus(debugName) {
1340
+ function networkStatus(debugName = 'networkStatus') {
740
1341
  if (isPlatformServer(inject(PLATFORM_ID))) {
741
1342
  const sig = computed(() => true, {
742
1343
  debugName,
@@ -803,7 +1404,7 @@ function networkStatus(debugName) {
803
1404
  * }
804
1405
  * ```
805
1406
  */
806
- function pageVisibility(debugName) {
1407
+ function pageVisibility(debugName = 'pageVisibility') {
807
1408
  if (isPlatformServer(inject(PLATFORM_ID))) {
808
1409
  return computed(() => 'visible', { debugName });
809
1410
  }
@@ -865,12 +1466,12 @@ function scrollPosition(opt) {
865
1466
  x: 0,
866
1467
  y: 0,
867
1468
  }), {
868
- debugName: opt?.debugName,
1469
+ debugName: opt?.debugName ?? 'scrollPosition',
869
1470
  });
870
1471
  base.unthrottled = base;
871
1472
  return base;
872
1473
  }
873
- const { target = window, throttle = 100, debugName } = opt || {};
1474
+ const { target = window, throttle = 100, debugName = 'scrollPosition', } = opt || {};
874
1475
  let element;
875
1476
  let getScrollPosition;
876
1477
  if (target instanceof Window) {
@@ -956,12 +1557,12 @@ function windowSize(opt) {
956
1557
  const base = computed(() => ({
957
1558
  width: 1024,
958
1559
  height: 768,
959
- }), { debugName: opt?.debugName });
1560
+ }), { debugName: opt?.debugName ?? 'windowSize' });
960
1561
  base.unthrottled = base;
961
1562
  return base;
962
1563
  }
963
1564
  const sizeSignal = throttled({ width: window.innerWidth, height: window.innerHeight }, {
964
- debugName: opt?.debugName,
1565
+ debugName: opt?.debugName ?? 'windowSize',
965
1566
  equal: (a, b) => a.width === b.width && a.height === b.height,
966
1567
  ms: opt?.throttle ?? 100,
967
1568
  });
@@ -990,18 +1591,303 @@ function sensor(type, options) {
990
1591
  return networkStatus(options?.debugName);
991
1592
  case 'pageVisibility':
992
1593
  return pageVisibility(options?.debugName);
1594
+ case 'darkMode':
993
1595
  case 'dark-mode':
994
1596
  return prefersDarkMode(options?.debugName);
1597
+ case 'reducedMotion':
995
1598
  case 'reduced-motion':
996
1599
  return prefersReducedMotion(options?.debugName);
1600
+ case 'mediaQuery': {
1601
+ const opt = options;
1602
+ return mediaQuery(opt.query, opt.debugName);
1603
+ }
997
1604
  case 'windowSize':
998
1605
  return windowSize(options);
999
1606
  case 'scrollPosition':
1000
1607
  return scrollPosition(options);
1608
+ case 'elementVisibility': {
1609
+ const opt = options;
1610
+ return elementVisibility(opt.target, opt);
1611
+ }
1612
+ case 'elementSize': {
1613
+ const opt = options;
1614
+ return elementSize(opt.target, opt);
1615
+ }
1001
1616
  default:
1002
1617
  throw new Error(`Unknown sensor type: ${type}`);
1003
1618
  }
1004
1619
  }
1620
+ function sensors(track, opt) {
1621
+ return track.reduce((result, key) => {
1622
+ result[key] = sensor(key, opt?.[key]);
1623
+ return result;
1624
+ }, {});
1625
+ }
1626
+
1627
+ const IS_STORE = Symbol('MMSTACK::IS_STORE');
1628
+ const PROXY_CACHE = new WeakMap();
1629
+ const SIGNAL_FN_PROP = new Set([
1630
+ 'set',
1631
+ 'update',
1632
+ 'mutate',
1633
+ 'inline',
1634
+ 'asReadonly',
1635
+ ]);
1636
+ const PROXY_CLEANUP = new FinalizationRegistry(({ target, prop }) => {
1637
+ const storeCache = PROXY_CACHE.get(target);
1638
+ if (storeCache)
1639
+ storeCache.delete(prop);
1640
+ });
1641
+ /**
1642
+ * @internal
1643
+ * Validates whether a value is a Signal Store.
1644
+ */
1645
+ function isStore(value) {
1646
+ return (typeof value === 'function' &&
1647
+ value !== null &&
1648
+ value[IS_STORE] === true);
1649
+ }
1650
+ function isIndexProp(prop) {
1651
+ return typeof prop === 'string' && prop.trim() !== '' && !isNaN(+prop);
1652
+ }
1653
+ function isRecord(value) {
1654
+ if (value === null || typeof value !== 'object')
1655
+ return false;
1656
+ const proto = Object.getPrototypeOf(value);
1657
+ return proto === Object.prototype || proto === null;
1658
+ }
1659
+ /**
1660
+ * @internal
1661
+ * Makes an array store
1662
+ */
1663
+ function toArrayStore(source, injector) {
1664
+ if (isStore(source))
1665
+ return source;
1666
+ const isMutableSource = isMutable(source);
1667
+ const lengthSignal = computed(() => {
1668
+ const v = source();
1669
+ if (!Array.isArray(v))
1670
+ return 0;
1671
+ return v.length;
1672
+ });
1673
+ return new Proxy(source, {
1674
+ has(_, prop) {
1675
+ if (prop === 'length')
1676
+ return true;
1677
+ if (isIndexProp(prop)) {
1678
+ const idx = +prop;
1679
+ return idx >= 0 && idx < untracked(lengthSignal);
1680
+ }
1681
+ return Reflect.has(untracked(source), prop);
1682
+ },
1683
+ ownKeys() {
1684
+ const v = untracked(source);
1685
+ if (!Array.isArray(v))
1686
+ return [];
1687
+ const len = v.length;
1688
+ const arr = new Array(len + 1);
1689
+ for (let i = 0; i < len; i++) {
1690
+ arr[i] = String(i);
1691
+ }
1692
+ arr[len] = 'length';
1693
+ return arr;
1694
+ },
1695
+ getPrototypeOf() {
1696
+ return Array.prototype;
1697
+ },
1698
+ getOwnPropertyDescriptor(_, prop) {
1699
+ const v = untracked(source);
1700
+ if (!Array.isArray(v))
1701
+ return;
1702
+ if (prop === 'length' ||
1703
+ (typeof prop === 'string' && !isNaN(+prop) && +prop < v.length)) {
1704
+ return {
1705
+ enumerable: true,
1706
+ configurable: true, // Required for proxies to dynamic targets
1707
+ };
1708
+ }
1709
+ return;
1710
+ },
1711
+ get(target, prop, receiver) {
1712
+ if (prop === IS_STORE)
1713
+ return true;
1714
+ if (prop === 'length')
1715
+ return lengthSignal;
1716
+ if (prop === Symbol.iterator) {
1717
+ return function* () {
1718
+ for (let i = 0; i < untracked(lengthSignal); i++) {
1719
+ yield receiver[i];
1720
+ }
1721
+ };
1722
+ }
1723
+ if (typeof prop === 'symbol' || SIGNAL_FN_PROP.has(prop))
1724
+ return target[prop];
1725
+ if (isIndexProp(prop)) {
1726
+ const idx = +prop;
1727
+ let storeCache = PROXY_CACHE.get(target);
1728
+ if (!storeCache) {
1729
+ storeCache = new Map();
1730
+ PROXY_CACHE.set(target, storeCache);
1731
+ }
1732
+ const cachedRef = storeCache.get(idx);
1733
+ if (cachedRef) {
1734
+ const cached = cachedRef.deref();
1735
+ if (cached)
1736
+ return cached;
1737
+ storeCache.delete(idx);
1738
+ PROXY_CLEANUP.unregister(cachedRef);
1739
+ }
1740
+ const value = untracked(target);
1741
+ const valueIsArray = Array.isArray(value);
1742
+ const valueIsRecord = isRecord(value);
1743
+ const equalFn = (valueIsRecord || valueIsArray) &&
1744
+ isMutableSource &&
1745
+ typeof value[idx] === 'object'
1746
+ ? () => false
1747
+ : undefined;
1748
+ const computation = valueIsRecord
1749
+ ? derived(target, idx, { equal: equalFn })
1750
+ : derived(target, {
1751
+ from: (v) => v?.[idx],
1752
+ onChange: (newValue) => target.update((v) => {
1753
+ if (v === null || v === undefined)
1754
+ return v;
1755
+ try {
1756
+ v[idx] = newValue;
1757
+ }
1758
+ catch (e) {
1759
+ if (isDevMode())
1760
+ console.error(`[store] Failed to set property "${String(idx)}"`, e);
1761
+ }
1762
+ return v;
1763
+ }),
1764
+ });
1765
+ const proxy = Array.isArray(untracked(computation))
1766
+ ? toArrayStore(computation, injector)
1767
+ : toStore(computation, injector);
1768
+ const ref = new WeakRef(proxy);
1769
+ storeCache.set(idx, ref);
1770
+ PROXY_CLEANUP.register(proxy, { target, prop: idx }, ref);
1771
+ return proxy;
1772
+ }
1773
+ return Reflect.get(target, prop, receiver);
1774
+ },
1775
+ });
1776
+ }
1777
+ /**
1778
+ * Converts a Signal into a deep-observable Store.
1779
+ * Accessing nested properties returns a derived Signal of that path.
1780
+ * @example
1781
+ * const state = store({ user: { name: 'John' } });
1782
+ * const nameSignal = state.user.name; // WritableSignal<string>
1783
+ */
1784
+ function toStore(source, injector) {
1785
+ if (isStore(source))
1786
+ return source;
1787
+ if (!injector)
1788
+ injector = inject(Injector);
1789
+ const writableSource = isWritableSignal(source)
1790
+ ? source
1791
+ : toWritable(source, () => {
1792
+ // noop
1793
+ });
1794
+ const isMutableSource = isMutable(writableSource);
1795
+ const s = new Proxy(writableSource, {
1796
+ has(_, prop) {
1797
+ return Reflect.has(untracked(source), prop);
1798
+ },
1799
+ ownKeys() {
1800
+ const v = untracked(source);
1801
+ if (!isRecord(v))
1802
+ return [];
1803
+ return Reflect.ownKeys(v);
1804
+ },
1805
+ getPrototypeOf() {
1806
+ return Object.getPrototypeOf(untracked(source));
1807
+ },
1808
+ getOwnPropertyDescriptor(_, prop) {
1809
+ const value = untracked(source);
1810
+ if (!isRecord(value) || !(prop in value))
1811
+ return;
1812
+ return {
1813
+ enumerable: true,
1814
+ configurable: true,
1815
+ };
1816
+ },
1817
+ get(target, prop) {
1818
+ if (prop === IS_STORE)
1819
+ return true;
1820
+ if (prop === 'asReadonlyStore')
1821
+ return () => {
1822
+ if (!isWritableSignal(source))
1823
+ return s;
1824
+ return untracked(() => toStore(source.asReadonly(), injector));
1825
+ };
1826
+ if (typeof prop === 'symbol' || SIGNAL_FN_PROP.has(prop))
1827
+ return target[prop];
1828
+ let storeCache = PROXY_CACHE.get(target);
1829
+ if (!storeCache) {
1830
+ storeCache = new Map();
1831
+ PROXY_CACHE.set(target, storeCache);
1832
+ }
1833
+ const cachedRef = storeCache.get(prop);
1834
+ if (cachedRef) {
1835
+ const cached = cachedRef.deref();
1836
+ if (cached)
1837
+ return cached;
1838
+ storeCache.delete(prop);
1839
+ PROXY_CLEANUP.unregister(cachedRef);
1840
+ }
1841
+ const value = untracked(target);
1842
+ const valueIsRecord = isRecord(value);
1843
+ const valueIsArray = Array.isArray(value);
1844
+ const equalFn = (valueIsRecord || valueIsArray) &&
1845
+ isMutableSource &&
1846
+ typeof value[prop] === 'object'
1847
+ ? () => false
1848
+ : undefined;
1849
+ const computation = valueIsRecord
1850
+ ? derived(target, prop, { equal: equalFn })
1851
+ : derived(target, {
1852
+ from: (v) => v?.[prop],
1853
+ onChange: (newValue) => target.update((v) => {
1854
+ if (v === null || v === undefined)
1855
+ return v;
1856
+ try {
1857
+ v[prop] = newValue;
1858
+ }
1859
+ catch (e) {
1860
+ if (isDevMode())
1861
+ console.error(`[store] Failed to set property "${String(prop)}"`, e);
1862
+ }
1863
+ return v;
1864
+ }),
1865
+ });
1866
+ const proxy = Array.isArray(untracked(computation))
1867
+ ? toArrayStore(computation, injector)
1868
+ : toStore(computation, injector);
1869
+ const ref = new WeakRef(proxy);
1870
+ storeCache.set(prop, ref);
1871
+ PROXY_CLEANUP.register(proxy, { target, prop }, ref);
1872
+ return proxy;
1873
+ },
1874
+ });
1875
+ return s;
1876
+ }
1877
+ /**
1878
+ * Creates a WritableSignalStore from a value.
1879
+ * @see {@link toStore}
1880
+ */
1881
+ function store(value, opt) {
1882
+ return toStore(signal(value, opt), opt?.injector);
1883
+ }
1884
+ /**
1885
+ * Creates a MutableSignalStore from a value.
1886
+ * @see {@link toStore}
1887
+ */
1888
+ function mutableStore(value, opt) {
1889
+ return toStore(mutable(value, opt), opt?.injector);
1890
+ }
1005
1891
 
1006
1892
  // Internal dummy store for server-side rendering
1007
1893
  const noopStore = {
@@ -1064,7 +1950,7 @@ const noopStore = {
1064
1950
  * }
1065
1951
  * ```
1066
1952
  */
1067
- function stored(fallback, { key, store: providedStore, serialize = JSON.stringify, deserialize = JSON.parse, syncTabs = false, equal = Object.is, onKeyChange = 'load', cleanupOldKey = false, ...rest }) {
1953
+ function stored(fallback, { key, store: providedStore, serialize = JSON.stringify, deserialize = JSON.parse, syncTabs = false, equal = Object.is, onKeyChange = 'load', cleanupOldKey = false, validate = () => true, ...rest }) {
1068
1954
  const isServer = isPlatformServer(inject(PLATFORM_ID));
1069
1955
  const fallbackStore = isServer ? noopStore : localStorage;
1070
1956
  const store = providedStore ?? fallbackStore;
@@ -1078,7 +1964,10 @@ function stored(fallback, { key, store: providedStore, serialize = JSON.stringif
1078
1964
  if (found === null)
1079
1965
  return null;
1080
1966
  try {
1081
- return deserialize(found);
1967
+ const deserialized = deserialize(found);
1968
+ if (!validate(deserialized))
1969
+ return null;
1970
+ return deserialized;
1082
1971
  }
1083
1972
  catch (err) {
1084
1973
  if (isDevMode())
@@ -1162,41 +2051,122 @@ function stored(fallback, { key, store: providedStore, serialize = JSON.stringif
1162
2051
  return writable;
1163
2052
  }
1164
2053
 
2054
+ class MessageBus {
2055
+ channel = new BroadcastChannel('mmstack-tab-sync-bus');
2056
+ listeners = new Map();
2057
+ subscribe(id, listener) {
2058
+ this.unsubscribe(id); // Ensure no duplicate listeners
2059
+ const wrapped = (ev) => {
2060
+ try {
2061
+ if (ev.data?.id === id)
2062
+ listener(ev.data?.value);
2063
+ }
2064
+ catch {
2065
+ // noop
2066
+ }
2067
+ };
2068
+ this.channel.addEventListener('message', wrapped);
2069
+ this.listeners.set(id, wrapped);
2070
+ return {
2071
+ unsub: (() => this.unsubscribe(id)).bind(this),
2072
+ post: ((value) => this.channel.postMessage({ id, value })).bind(this),
2073
+ };
2074
+ }
2075
+ unsubscribe(id) {
2076
+ const listener = this.listeners.get(id);
2077
+ if (!listener)
2078
+ return;
2079
+ this.channel.removeEventListener('message', listener);
2080
+ this.listeners.delete(id);
2081
+ }
2082
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.19", ngImport: i0, type: MessageBus, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
2083
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "19.2.19", ngImport: i0, type: MessageBus, providedIn: 'root' });
2084
+ }
2085
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.19", ngImport: i0, type: MessageBus, decorators: [{
2086
+ type: Injectable,
2087
+ args: [{
2088
+ providedIn: 'root',
2089
+ }]
2090
+ }] });
2091
+ function generateDeterministicID() {
2092
+ const stack = new Error().stack;
2093
+ if (stack) {
2094
+ // Look for the actual caller (first non-internal frame)
2095
+ const lines = stack.split('\n');
2096
+ for (let i = 2; i < lines.length; i++) {
2097
+ const line = lines[i];
2098
+ if (line && !line.includes('tabSync') && !line.includes('MessageBus')) {
2099
+ let hash = 0;
2100
+ for (let j = 0; j < line.length; j++) {
2101
+ const char = line.charCodeAt(j);
2102
+ hash = (hash << 5) - hash + char;
2103
+ hash = hash & hash;
2104
+ }
2105
+ return `auto-${Math.abs(hash)}`;
2106
+ }
2107
+ }
2108
+ }
2109
+ throw new Error('Could not generate deterministic ID, please provide one manually.');
2110
+ }
1165
2111
  /**
1166
- * Creates a Promise that resolves when a signal's value satisfies a given predicate.
2112
+ * Synchronizes a WritableSignal across browser tabs using BroadcastChannel API.
1167
2113
  *
1168
- * This is useful for imperatively waiting for a reactive state to change,
1169
- * for example, in tests or to orchestrate complex asynchronous operations.
2114
+ * Creates a shared signal that automatically syncs its value between all tabs
2115
+ * of the same application. When the signal is updated in one tab, all other
2116
+ * tabs will receive the new value automatically.
1170
2117
  *
1171
- * @template T The type of the signal's value.
1172
- * @param sourceSignal The signal to observe.
1173
- * @param predicate A function that takes the signal's value and returns `true` if the condition is met.
1174
- * @param options Optional configuration for timeout and explicit destruction.
1175
- * @returns A Promise that resolves with the signal's value when the predicate is true,
1176
- * or rejects on timeout or context destruction.
2118
+ * @template T - The type of the WritableSignal
2119
+ * @param sig - The WritableSignal to synchronize across tabs
2120
+ * @param opt - Optional configuration object
2121
+ * @param opt.id - Explicit channel ID for synchronization. If not provided,
2122
+ * a deterministic ID is generated based on the call site.
2123
+ * Use explicit IDs in production for reliability.
2124
+ *
2125
+ * @returns The same WritableSignal instance, now synchronized across tabs
2126
+ *
2127
+ * @throws {Error} When deterministic ID generation fails and no explicit ID is provided
1177
2128
  *
1178
2129
  * @example
1179
- * ```ts
1180
- * const count = signal(0);
1181
- *
1182
- * async function waitForCount() {
1183
- * console.log('Waiting for count to be >= 3...');
1184
- * try {
1185
- * const finalCount = await until(count, c => c >= 3, { timeout: 5000 });
1186
- * console.log(`Count reached: ${finalCount}`);
1187
- * } catch (e: any) { // Ensure 'e' is typed if you access properties like e.message
1188
- * console.error(e.message); // e.g., "until: Timeout after 5000ms."
1189
- * }
1190
- * }
2130
+ * ```typescript
2131
+ * // Basic usage - auto-generates channel ID from call site
2132
+ * const theme = tabSync(signal('dark'));
1191
2133
  *
1192
- * // Simulate updates
1193
- * setTimeout(() => count.set(1), 500);
1194
- * setTimeout(() => count.set(2), 1000);
1195
- * setTimeout(() => count.set(3), 1500);
2134
+ * // With explicit ID (recommended for production)
2135
+ * const userPrefs = tabSync(signal({ lang: 'en' }), { id: 'user-preferences' });
1196
2136
  *
1197
- * waitForCount();
2137
+ * // Changes in one tab will sync to all other tabs
2138
+ * theme.set('light'); // All tabs will update to 'light'
1198
2139
  * ```
2140
+ *
2141
+ * @remarks
2142
+ * - Only works in browser environments (returns original signal on server)
2143
+ * - Uses a single BroadcastChannel for all synchronized signals
2144
+ * - Automatically cleans up listeners when the injection context is destroyed
2145
+ * - Initial signal value after sync setup is not broadcasted to prevent loops
2146
+ *
1199
2147
  */
2148
+ function tabSync(sig, opt) {
2149
+ if (isPlatformServer(inject(PLATFORM_ID)))
2150
+ return sig;
2151
+ const id = opt?.id || generateDeterministicID();
2152
+ const bus = inject(MessageBus);
2153
+ const { unsub, post } = bus.subscribe(id, (next) => sig.set(next));
2154
+ let first = false;
2155
+ const effectRef = effect(() => {
2156
+ const val = sig();
2157
+ if (!first) {
2158
+ first = true;
2159
+ return;
2160
+ }
2161
+ post(val);
2162
+ });
2163
+ inject(DestroyRef).onDestroy(() => {
2164
+ effectRef.destroy();
2165
+ unsub();
2166
+ });
2167
+ return sig;
2168
+ }
2169
+
1200
2170
  function until(sourceSignal, predicate, options = {}) {
1201
2171
  const injector = options.injector ?? inject(Injector);
1202
2172
  return new Promise((resolve, reject) => {
@@ -1352,6 +2322,8 @@ function withHistory(source, opt) {
1352
2322
  return;
1353
2323
  const valueForRedo = untracked(source);
1354
2324
  const valueToRestore = historyStack.at(-1);
2325
+ if (valueToRestore === undefined)
2326
+ return;
1355
2327
  originalSet.call(source, valueToRestore);
1356
2328
  history.inline((h) => h.pop());
1357
2329
  redoArray.inline((r) => r.push(valueForRedo));
@@ -1362,6 +2334,8 @@ function withHistory(source, opt) {
1362
2334
  return;
1363
2335
  const valueForUndo = untracked(source);
1364
2336
  const valueToRestore = redoStack.at(-1);
2337
+ if (valueToRestore === undefined)
2338
+ return;
1365
2339
  originalSet.call(source, valueToRestore);
1366
2340
  redoArray.inline((r) => r.pop());
1367
2341
  history.mutate((h) => {
@@ -1391,5 +2365,5 @@ function withHistory(source, opt) {
1391
2365
  * Generated bundle index. Do not edit.
1392
2366
  */
1393
2367
 
1394
- export { debounce, debounced, derived, elementVisibility, isDerivation, isMutable, mapArray, mediaQuery, mousePosition, mutable, networkStatus, pageVisibility, prefersDarkMode, prefersReducedMotion, scrollPosition, sensor, stored, throttle, throttled, toFakeDerivation, toFakeSignalDerivation, toWritable, until, windowSize, withHistory };
2368
+ export { chunked, combineWith, debounce, debounced, derived, distinct, elementSize, elementVisibility, filter, indexArray, isDerivation, isMutable, isStore, keyArray, map, mapArray, mapObject, mediaQuery, mousePosition, mutable, mutableStore, nestedEffect, networkStatus, pageVisibility, pipeable, piped, prefersDarkMode, prefersReducedMotion, scrollPosition, select, sensor, sensors, store, stored, tabSync, tap, throttle, throttled, toFakeDerivation, toFakeSignalDerivation, toStore, toWritable, until, windowSize, withHistory };
1395
2369
  //# sourceMappingURL=mmstack-primitives.mjs.map