@mmstack/primitives 19.2.3 → 19.3.1

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,113 @@ 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] =
423
+ next;
424
+ return cur;
425
+ });
426
+ }
427
+ : (next) => {
428
+ source.update((cur) => ({ ...cur, [optOrKey]: next }));
429
+ };
430
+ const rest = typeof optOrKey === 'object' ? { ...optOrKey, ...opt } : opt;
431
+ const baseEqual = rest?.equal ?? Object.is;
432
+ let trigger = false;
433
+ const equal = isMutable(source)
434
+ ? (a, b) => {
435
+ if (trigger)
436
+ return false;
437
+ return baseEqual(a, b);
438
+ }
439
+ : baseEqual;
440
+ const sig = toWritable(computed(() => from(source()), { ...rest, equal }), (newVal) => onChange(newVal), undefined, { pure: false });
157
441
  sig.from = from;
442
+ if (isMutable(source)) {
443
+ sig.mutate = (updater) => {
444
+ trigger = true;
445
+ sig.update(updater);
446
+ trigger = false;
447
+ };
448
+ sig.inline = (updater) => {
449
+ sig.mutate((prev) => {
450
+ updater(prev);
451
+ return prev;
452
+ });
453
+ };
454
+ }
158
455
  return sig;
159
456
  }
160
457
  /**
@@ -201,6 +498,445 @@ function isDerivation(sig) {
201
498
  return 'from' in sig;
202
499
  }
203
500
 
501
+ function isWritableSignal(value) {
502
+ return 'set' in value && typeof value.set === 'function';
503
+ }
504
+ /**
505
+ * @internal
506
+ * Creates a setter function for a source signal of type `Signal<T[]>` or a function returning `T[]`.
507
+ * @param source The source signal of type `Signal<T[]>` or a function returning `T[]`.
508
+ * @returns
509
+ */
510
+ function createSetter(source) {
511
+ if (!isWritableSignal(source))
512
+ return () => {
513
+ // noop;
514
+ };
515
+ if (isMutable(source))
516
+ return (value, index) => {
517
+ source.mutate((arr) => {
518
+ arr[index] = value;
519
+ return arr;
520
+ });
521
+ };
522
+ return (value, index) => {
523
+ source.update((arr) => arr.map((v, i) => (i === index ? value : v)));
524
+ };
525
+ }
526
+
527
+ /**
528
+ * Helper to create the derived signal for a specific index.
529
+ * Extracts the cast logic to keep the main loop clean.
530
+ */
531
+ function createItemSignal(source, index, setter, opt) {
532
+ return derived(
533
+ // We cast to any/Mutable to satisfy the overload signature,
534
+ // but 'derived' internally checks isMutable() for safety.
535
+ source, {
536
+ from: (src) => src[index],
537
+ onChange: (value) => setter(value, index),
538
+ }, opt);
539
+ }
540
+ function indexArray(source, map, opt = {}) {
541
+ const data = isSignal(source) ? source : computed(source);
542
+ const len = computed(() => data().length);
543
+ const setter = createSetter(data);
544
+ const writableData = isWritableSignal(data)
545
+ ? data
546
+ : toWritable(data, () => {
547
+ // noop
548
+ });
549
+ if (isWritableSignal(data) && isMutable(data) && !opt.equal) {
550
+ opt.equal = (a, b) => {
551
+ if (a !== b)
552
+ return false; // actually check primitives and references
553
+ return false; // opt out for same refs
554
+ };
555
+ }
556
+ return linkedSignal({
557
+ source: () => len(),
558
+ computation: (len, prev) => {
559
+ if (!prev)
560
+ return Array.from({ length: len }, (_, i) => map(createItemSignal(writableData, i, setter, opt), i));
561
+ if (len === prev.value.length)
562
+ return prev.value;
563
+ if (len < prev.value.length) {
564
+ if (opt.onDestroy) {
565
+ for (let i = len; i < prev.value.length; i++) {
566
+ opt.onDestroy(prev.value[i]);
567
+ }
568
+ }
569
+ return prev.value.slice(0, len);
570
+ }
571
+ const next = prev.value.slice();
572
+ for (let i = prev.value.length; i < len; i++)
573
+ next[i] = map(createItemSignal(writableData, i, setter, opt), i);
574
+ return next;
575
+ },
576
+ equal: (a, b) => a.length === b.length,
577
+ });
578
+ }
579
+ /**
580
+ * @deprecated use indexArray instead
581
+ */
582
+ const mapArray = indexArray;
583
+
584
+ /**
585
+ * Reactively maps items from a source array to a new array by value (identity).
586
+ *
587
+ * similar to `Array.prototype.map`, but:
588
+ * 1. The `mapFn` receives the `index` as a Signal.
589
+ * 2. If an item in the `source` array moves to a new position, the *result* of the map function is reused and moved.
590
+ * The `index` signal is updated to the new index.
591
+ * 3. The `mapFn` is only run for *new* items.
592
+ *
593
+ * This is useful for building efficient lists where DOM nodes or heavy instances should be reused
594
+ * when the list is reordered.
595
+ *
596
+ * @param source A `Signal<T[]>` or a function returning `T[]`.
597
+ * @param mapFn The mapping function. Receives the item and its index as a Signal.
598
+ * @param options Optional configuration:
599
+ * - `onDestroy`: A callback invoked when a mapped item is removed from the array.
600
+ * @returns A `Signal<U[]>` containing the mapped array.
601
+ */
602
+ function keyArray(source, mapFn, options = {}) {
603
+ const sourceSignal = isSignal(source) ? source : computed(source);
604
+ const items = [];
605
+ let mapped = [];
606
+ const indexes = [];
607
+ const getKey = options.key || ((v) => v);
608
+ const newIndices = new Map();
609
+ const temp = [];
610
+ const tempIndexes = [];
611
+ const newIndicesNext = [];
612
+ const newIndexesCache = new Array();
613
+ return computed(() => {
614
+ const newItems = sourceSignal() || [];
615
+ return untracked(() => {
616
+ let i;
617
+ let j;
618
+ const newLen = newItems.length;
619
+ const len = items.length;
620
+ const newMapped = new Array(newLen);
621
+ const newIndexes = newIndexesCache;
622
+ newIndexes.length = 0;
623
+ newIndexes.length = newLen;
624
+ let start;
625
+ let end;
626
+ let newEnd;
627
+ let item;
628
+ let key;
629
+ if (newLen === 0) {
630
+ if (len !== 0) {
631
+ if (options.onDestroy) {
632
+ for (let k = 0; k < len; k++)
633
+ options.onDestroy(mapped[k]);
634
+ }
635
+ items.length = 0;
636
+ mapped = [];
637
+ indexes.length = 0;
638
+ }
639
+ return mapped;
640
+ }
641
+ if (len === 0) {
642
+ for (j = 0; j < newLen; j++) {
643
+ item = newItems[j];
644
+ items[j] = item;
645
+ const indexSignal = signal(j);
646
+ newIndexes[j] = indexSignal;
647
+ newMapped[j] = mapFn(item, indexSignal);
648
+ }
649
+ }
650
+ else {
651
+ newIndices.clear();
652
+ temp.length = 0;
653
+ tempIndexes.length = 0;
654
+ newIndicesNext.length = 0;
655
+ for (start = 0, end = Math.min(len, newLen); start < end && getKey(items[start]) === getKey(newItems[start]); start++) {
656
+ newMapped[start] = mapped[start];
657
+ newIndexes[start] = indexes[start];
658
+ }
659
+ for (end = len - 1, newEnd = newLen - 1; end >= start &&
660
+ newEnd >= start &&
661
+ getKey(items[end]) === getKey(newItems[newEnd]); end--, newEnd--) {
662
+ temp[newEnd] = mapped[end];
663
+ tempIndexes[newEnd] = indexes[end];
664
+ }
665
+ for (j = newEnd; j >= start; j--) {
666
+ item = newItems[j];
667
+ key = getKey(item);
668
+ i = newIndices.get(key);
669
+ newIndicesNext[j] = i === undefined ? -1 : i;
670
+ newIndices.set(key, j);
671
+ }
672
+ for (i = start; i <= end; i++) {
673
+ item = items[i];
674
+ key = getKey(item);
675
+ j = newIndices.get(key);
676
+ if (j !== undefined && j !== -1) {
677
+ temp[j] = mapped[i];
678
+ tempIndexes[j] = indexes[i];
679
+ j = newIndicesNext[j];
680
+ newIndices.set(key, j);
681
+ }
682
+ else {
683
+ if (options.onDestroy)
684
+ options.onDestroy(mapped[i]);
685
+ }
686
+ }
687
+ // 2) Set all new values
688
+ for (j = start; j < newLen; j++) {
689
+ if (temp[j] !== undefined) {
690
+ newMapped[j] = temp[j];
691
+ newIndexes[j] = tempIndexes[j];
692
+ newIndexes[j].set(j);
693
+ }
694
+ else {
695
+ const indexSignal = signal(j);
696
+ newIndexes[j] = indexSignal;
697
+ newMapped[j] = mapFn(newItems[j], indexSignal);
698
+ }
699
+ }
700
+ items.length = newLen;
701
+ for (let k = 0; k < newLen; k++)
702
+ items[k] = newItems[k];
703
+ }
704
+ mapped = newMapped;
705
+ indexes.length = newLen;
706
+ for (let k = 0; k < newLen; k++)
707
+ indexes[k] = newIndexes[k];
708
+ return mapped;
709
+ });
710
+ });
711
+ }
712
+
713
+ function pooledKeys(src) {
714
+ const aBuf = new Set();
715
+ const bBuf = new Set();
716
+ let active = aBuf;
717
+ let spare = bBuf;
718
+ return computed(() => {
719
+ const val = src();
720
+ spare.clear();
721
+ for (const k in val)
722
+ if (Object.prototype.hasOwnProperty.call(val, k))
723
+ spare.add(k);
724
+ if (active.size === spare.size && active.isSubsetOf(spare))
725
+ return active;
726
+ const temp = active;
727
+ active = spare;
728
+ spare = temp;
729
+ return active;
730
+ });
731
+ }
732
+ function mapObject(source, mapFn, options = {}) {
733
+ const src = isSignal(source) ? source : computed(source);
734
+ const writable = (isWritableSignal(src)
735
+ ? src
736
+ : toWritable(src, () => {
737
+ // noop
738
+ })); // maximal overload internally
739
+ return linkedSignal({
740
+ source: pooledKeys(src),
741
+ computation: (next, prev) => {
742
+ const nextObj = {};
743
+ for (const k of next)
744
+ nextObj[k] =
745
+ prev && prev.source.has(k)
746
+ ? prev.value[k]
747
+ : mapFn(k, derived(writable, k));
748
+ if (options.onDestroy && prev && prev.source.size)
749
+ for (const k of prev.source)
750
+ if (!next.has(k))
751
+ options.onDestroy(prev.value[k]);
752
+ return nextObj;
753
+ },
754
+ }).asReadonly();
755
+ }
756
+
757
+ /** Project with optional equality. Pure & sync. */
758
+ const select = (projector, opt) => (src) => computed(() => projector(src()), opt);
759
+ /** Combine with another signal using a projector. */
760
+ const combineWith = (other, project, opt) => (src) => computed(() => project(src(), other()), opt);
761
+ /** Only re-emit when equal(prev, next) is false. */
762
+ const distinct = (equal = Object.is) => (src) => computed(() => src(), { equal });
763
+ /** map to new value */
764
+ const map = (fn) => (src) => computed(() => fn(src()));
765
+ /** filter values, keeping the last value if it was ever available, if first value is filtered will return undefined */
766
+ const filter = (predicate) => (src) => linkedSignal({
767
+ source: src,
768
+ computation: (next, prev) => {
769
+ if (predicate(next))
770
+ return next;
771
+ return prev?.source;
772
+ },
773
+ });
774
+ /** tap into the value */
775
+ const tap = (fn) => (src) => {
776
+ effect(() => fn(src()));
777
+ return src;
778
+ };
779
+
780
+ /**
781
+ * Decorate any `Signal<T>` with a chainable `.pipe(...)` method.
782
+ *
783
+ * @example
784
+ * const s = pipeable(signal(1)); // WritableSignal<number> (+ pipe)
785
+ * const label = s.pipe(n => n * 2, n => `#${n}`); // Signal<string> (+ pipe)
786
+ * label(); // "#2"
787
+ */
788
+ function pipeable(signal) {
789
+ const internal = signal;
790
+ const mapImpl = (...fns) => {
791
+ const last = fns.at(-1);
792
+ let opt;
793
+ if (last && typeof last !== 'function') {
794
+ fns = fns.slice(0, -1);
795
+ opt = last;
796
+ }
797
+ if (fns.length === 0)
798
+ return internal;
799
+ if (fns.length === 1) {
800
+ const fn = fns[0];
801
+ return pipeable(computed(() => fn(internal()), opt));
802
+ }
803
+ const transformer = (input) => fns.reduce((acc, fn) => fn(acc), input);
804
+ return pipeable(computed(() => transformer(internal()), opt));
805
+ };
806
+ const pipeImpl = (...ops) => {
807
+ if (ops.length === 0)
808
+ return internal;
809
+ return ops.reduce((src, op) => pipeable(op(src)), internal);
810
+ };
811
+ Object.defineProperties(internal, {
812
+ map: {
813
+ value: mapImpl,
814
+ configurable: true,
815
+ enumerable: false,
816
+ writable: false,
817
+ },
818
+ pipe: {
819
+ value: pipeImpl,
820
+ configurable: true,
821
+ enumerable: false,
822
+ writable: false,
823
+ },
824
+ });
825
+ return internal;
826
+ }
827
+ /**
828
+ * Create a new **writable** signal and return it as a `PipableSignal`.
829
+ *
830
+ * The returned value is a `WritableSignal<T>` with `.set`, `.update`, `.asReadonly`
831
+ * still available (via intersection type), plus a chainable `.pipe(...)`.
832
+ *
833
+ * @example
834
+ * const count = piped(1); // WritableSignal<number> (+ pipe)
835
+ * const even = count.pipe(n => n % 2 === 0); // Signal<boolean> (+ pipe)
836
+ * count.update(n => n + 1);
837
+ */
838
+ function piped(initial, opt) {
839
+ return pipeable(signal(initial, opt));
840
+ }
841
+
842
+ function observerSupported$1() {
843
+ return typeof ResizeObserver !== 'undefined';
844
+ }
845
+ /**
846
+ * Creates a read-only signal that tracks the size of a target DOM element.
847
+ *
848
+ * By default, it observes the `border-box` size to align with `getBoundingClientRect()`,
849
+ * which is used to provide a synchronous initial value if possible.
850
+ *
851
+ * @param target The DOM element (or `ElementRef`, or a `Signal` resolving to one) to observe.
852
+ * @param options Optional configuration including `box` (defaults to 'border-box') and `debugName`.
853
+ * @returns A `Signal<ElementSize | undefined>`.
854
+ *
855
+ * @example
856
+ * ```ts
857
+ * const size = elementSize(elementRef);
858
+ * effect(() => {
859
+ * console.log('Size:', size()?.width, size()?.height);
860
+ * });
861
+ * ```
862
+ */
863
+ function elementSize(target = inject(ElementRef), opt) {
864
+ const getElement = () => {
865
+ if (isSignal(target)) {
866
+ try {
867
+ const val = target();
868
+ return val instanceof ElementRef ? val.nativeElement : val;
869
+ }
870
+ catch {
871
+ return null;
872
+ }
873
+ }
874
+ return target instanceof ElementRef ? target.nativeElement : target;
875
+ };
876
+ const resolveInitialValue = () => {
877
+ if (!observerSupported$1())
878
+ return undefined;
879
+ const el = getElement();
880
+ if (el && el.getBoundingClientRect) {
881
+ const rect = el.getBoundingClientRect();
882
+ return { width: rect.width, height: rect.height };
883
+ }
884
+ return undefined;
885
+ };
886
+ if (isPlatformServer(inject(PLATFORM_ID))) {
887
+ return computed(() => untracked(resolveInitialValue), {
888
+ debugName: opt?.debugName,
889
+ });
890
+ }
891
+ const state = signal(untracked(resolveInitialValue), {
892
+ debugName: opt?.debugName,
893
+ equal: (a, b) => a?.width === b?.width && a?.height === b?.height,
894
+ });
895
+ const targetSignal = isSignal(target) ? target : computed(() => target);
896
+ effect((cleanup) => {
897
+ const el = targetSignal();
898
+ if (el) {
899
+ const nativeEl = el instanceof ElementRef ? el.nativeElement : el;
900
+ const rect = nativeEl.getBoundingClientRect();
901
+ untracked(() => state.set({ width: rect.width, height: rect.height }));
902
+ }
903
+ else {
904
+ untracked(() => state.set(undefined));
905
+ return;
906
+ }
907
+ if (!observerSupported$1())
908
+ return;
909
+ let observer = null;
910
+ observer = new ResizeObserver(([entry]) => {
911
+ let width = 0;
912
+ let height = 0;
913
+ const boxOption = opt?.box ?? 'border-box';
914
+ if (boxOption === 'border-box' && entry.borderBoxSize?.length > 0) {
915
+ const size = entry.borderBoxSize[0];
916
+ width = size.inlineSize;
917
+ height = size.blockSize;
918
+ }
919
+ else if (boxOption === 'content-box' &&
920
+ entry.contentBoxSize?.length > 0) {
921
+ width = entry.contentBoxSize[0].inlineSize;
922
+ height = entry.contentBoxSize[0].blockSize;
923
+ }
924
+ else {
925
+ width = entry.contentRect.width;
926
+ height = entry.contentRect.height;
927
+ }
928
+ state.set({ width, height });
929
+ });
930
+ observer.observe(el instanceof ElementRef ? el.nativeElement : el, {
931
+ box: opt?.box ?? 'border-box',
932
+ });
933
+ cleanup(() => {
934
+ observer?.disconnect();
935
+ });
936
+ });
937
+ return state.asReadonly();
938
+ }
939
+
204
940
  function observerSupported() {
205
941
  return typeof IntersectionObserver !== 'undefined';
206
942
  }
@@ -303,141 +1039,6 @@ function elementVisibility(target = inject(ElementRef), opt) {
303
1039
  return base;
304
1040
  }
305
1041
 
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
1042
  /**
442
1043
  * Creates a read-only signal that reactively tracks whether a CSS media query
443
1044
  * string currently matches.
@@ -482,11 +1083,11 @@ function isMutable(value) {
482
1083
  * }
483
1084
  * ```
484
1085
  */
485
- function mediaQuery(query, debugName) {
1086
+ function mediaQuery(query, debugName = 'mediaQuery') {
486
1087
  if (isPlatformServer(inject(PLATFORM_ID)))
487
1088
  return computed(() => false, { debugName });
488
1089
  const mediaQueryList = window.matchMedia(query);
489
- const state = signal(mediaQueryList.matches, { debugName });
1090
+ const state = signal(mediaQueryList.matches, { debugName: debugName });
490
1091
  const handleChange = (event) => {
491
1092
  state.set(event.matches);
492
1093
  };
@@ -609,8 +1210,7 @@ function throttle(source, opt) {
609
1210
  catch {
610
1211
  // not in injection context & no destroyRef provided opting out of cleanup
611
1212
  }
612
- const triggerFn = (updateSourceAction) => {
613
- updateSourceAction();
1213
+ const tick = () => {
614
1214
  if (timeout)
615
1215
  return;
616
1216
  timeout = setTimeout(() => {
@@ -619,10 +1219,12 @@ function throttle(source, opt) {
619
1219
  }, ms);
620
1220
  };
621
1221
  const set = (value) => {
622
- triggerFn(() => source.set(value));
1222
+ source.set(value);
1223
+ tick();
623
1224
  };
624
1225
  const update = (fn) => {
625
- triggerFn(() => source.update(fn));
1226
+ source.update(fn);
1227
+ tick();
626
1228
  };
627
1229
  const writable = toWritable(computed(() => {
628
1230
  trigger();
@@ -670,12 +1272,12 @@ function mousePosition(opt) {
670
1272
  x: 0,
671
1273
  y: 0,
672
1274
  }), {
673
- debugName: opt?.debugName,
1275
+ debugName: opt?.debugName ?? 'mousePosition',
674
1276
  });
675
1277
  base.unthrottled = base;
676
1278
  return base;
677
1279
  }
678
- const { target = window, coordinateSpace = 'client', touch = false, debugName, throttle = 100, } = opt ?? {};
1280
+ const { target = window, coordinateSpace = 'client', touch = false, debugName = 'mousePosition', throttle = 100, } = opt ?? {};
679
1281
  const eventTarget = target instanceof ElementRef ? target.nativeElement : target;
680
1282
  if (!eventTarget) {
681
1283
  if (isDevMode())
@@ -736,7 +1338,7 @@ const serverDate = new Date();
736
1338
  * @param debugName Optional debug name for the signal.
737
1339
  * @returns A `NetworkStatusSignal` instance.
738
1340
  */
739
- function networkStatus(debugName) {
1341
+ function networkStatus(debugName = 'networkStatus') {
740
1342
  if (isPlatformServer(inject(PLATFORM_ID))) {
741
1343
  const sig = computed(() => true, {
742
1344
  debugName,
@@ -803,7 +1405,7 @@ function networkStatus(debugName) {
803
1405
  * }
804
1406
  * ```
805
1407
  */
806
- function pageVisibility(debugName) {
1408
+ function pageVisibility(debugName = 'pageVisibility') {
807
1409
  if (isPlatformServer(inject(PLATFORM_ID))) {
808
1410
  return computed(() => 'visible', { debugName });
809
1411
  }
@@ -865,12 +1467,12 @@ function scrollPosition(opt) {
865
1467
  x: 0,
866
1468
  y: 0,
867
1469
  }), {
868
- debugName: opt?.debugName,
1470
+ debugName: opt?.debugName ?? 'scrollPosition',
869
1471
  });
870
1472
  base.unthrottled = base;
871
1473
  return base;
872
1474
  }
873
- const { target = window, throttle = 100, debugName } = opt || {};
1475
+ const { target = window, throttle = 100, debugName = 'scrollPosition', } = opt || {};
874
1476
  let element;
875
1477
  let getScrollPosition;
876
1478
  if (target instanceof Window) {
@@ -956,12 +1558,12 @@ function windowSize(opt) {
956
1558
  const base = computed(() => ({
957
1559
  width: 1024,
958
1560
  height: 768,
959
- }), { debugName: opt?.debugName });
1561
+ }), { debugName: opt?.debugName ?? 'windowSize' });
960
1562
  base.unthrottled = base;
961
1563
  return base;
962
1564
  }
963
1565
  const sizeSignal = throttled({ width: window.innerWidth, height: window.innerHeight }, {
964
- debugName: opt?.debugName,
1566
+ debugName: opt?.debugName ?? 'windowSize',
965
1567
  equal: (a, b) => a.width === b.width && a.height === b.height,
966
1568
  ms: opt?.throttle ?? 100,
967
1569
  });
@@ -990,18 +1592,303 @@ function sensor(type, options) {
990
1592
  return networkStatus(options?.debugName);
991
1593
  case 'pageVisibility':
992
1594
  return pageVisibility(options?.debugName);
1595
+ case 'darkMode':
993
1596
  case 'dark-mode':
994
1597
  return prefersDarkMode(options?.debugName);
1598
+ case 'reducedMotion':
995
1599
  case 'reduced-motion':
996
1600
  return prefersReducedMotion(options?.debugName);
1601
+ case 'mediaQuery': {
1602
+ const opt = options;
1603
+ return mediaQuery(opt.query, opt.debugName);
1604
+ }
997
1605
  case 'windowSize':
998
1606
  return windowSize(options);
999
1607
  case 'scrollPosition':
1000
1608
  return scrollPosition(options);
1609
+ case 'elementVisibility': {
1610
+ const opt = options;
1611
+ return elementVisibility(opt.target, opt);
1612
+ }
1613
+ case 'elementSize': {
1614
+ const opt = options;
1615
+ return elementSize(opt.target, opt);
1616
+ }
1001
1617
  default:
1002
1618
  throw new Error(`Unknown sensor type: ${type}`);
1003
1619
  }
1004
1620
  }
1621
+ function sensors(track, opt) {
1622
+ return track.reduce((result, key) => {
1623
+ result[key] = sensor(key, opt?.[key]);
1624
+ return result;
1625
+ }, {});
1626
+ }
1627
+
1628
+ const IS_STORE = Symbol('MMSTACK::IS_STORE');
1629
+ const PROXY_CACHE = new WeakMap();
1630
+ const SIGNAL_FN_PROP = new Set([
1631
+ 'set',
1632
+ 'update',
1633
+ 'mutate',
1634
+ 'inline',
1635
+ 'asReadonly',
1636
+ ]);
1637
+ const PROXY_CLEANUP = new FinalizationRegistry(({ target, prop }) => {
1638
+ const storeCache = PROXY_CACHE.get(target);
1639
+ if (storeCache)
1640
+ storeCache.delete(prop);
1641
+ });
1642
+ /**
1643
+ * @internal
1644
+ * Validates whether a value is a Signal Store.
1645
+ */
1646
+ function isStore(value) {
1647
+ return (typeof value === 'function' &&
1648
+ value !== null &&
1649
+ value[IS_STORE] === true);
1650
+ }
1651
+ function isIndexProp(prop) {
1652
+ return typeof prop === 'string' && prop.trim() !== '' && !isNaN(+prop);
1653
+ }
1654
+ function isRecord(value) {
1655
+ if (value === null || typeof value !== 'object')
1656
+ return false;
1657
+ const proto = Object.getPrototypeOf(value);
1658
+ return proto === Object.prototype || proto === null;
1659
+ }
1660
+ /**
1661
+ * @internal
1662
+ * Makes an array store
1663
+ */
1664
+ function toArrayStore(source, injector) {
1665
+ if (isStore(source))
1666
+ return source;
1667
+ const isMutableSource = isMutable(source);
1668
+ const lengthSignal = computed(() => {
1669
+ const v = source();
1670
+ if (!Array.isArray(v))
1671
+ return 0;
1672
+ return v.length;
1673
+ });
1674
+ return new Proxy(source, {
1675
+ has(_, prop) {
1676
+ if (prop === 'length')
1677
+ return true;
1678
+ if (isIndexProp(prop)) {
1679
+ const idx = +prop;
1680
+ return idx >= 0 && idx < untracked(lengthSignal);
1681
+ }
1682
+ return Reflect.has(untracked(source), prop);
1683
+ },
1684
+ ownKeys() {
1685
+ const v = untracked(source);
1686
+ if (!Array.isArray(v))
1687
+ return [];
1688
+ const len = v.length;
1689
+ const arr = new Array(len + 1);
1690
+ for (let i = 0; i < len; i++) {
1691
+ arr[i] = String(i);
1692
+ }
1693
+ arr[len] = 'length';
1694
+ return arr;
1695
+ },
1696
+ getPrototypeOf() {
1697
+ return Array.prototype;
1698
+ },
1699
+ getOwnPropertyDescriptor(_, prop) {
1700
+ const v = untracked(source);
1701
+ if (!Array.isArray(v))
1702
+ return;
1703
+ if (prop === 'length' ||
1704
+ (typeof prop === 'string' && !isNaN(+prop) && +prop < v.length)) {
1705
+ return {
1706
+ enumerable: true,
1707
+ configurable: true, // Required for proxies to dynamic targets
1708
+ };
1709
+ }
1710
+ return;
1711
+ },
1712
+ get(target, prop, receiver) {
1713
+ if (prop === IS_STORE)
1714
+ return true;
1715
+ if (prop === 'length')
1716
+ return lengthSignal;
1717
+ if (prop === Symbol.iterator) {
1718
+ return function* () {
1719
+ for (let i = 0; i < untracked(lengthSignal); i++) {
1720
+ yield receiver[i];
1721
+ }
1722
+ };
1723
+ }
1724
+ if (typeof prop === 'symbol' || SIGNAL_FN_PROP.has(prop))
1725
+ return target[prop];
1726
+ if (isIndexProp(prop)) {
1727
+ const idx = +prop;
1728
+ let storeCache = PROXY_CACHE.get(target);
1729
+ if (!storeCache) {
1730
+ storeCache = new Map();
1731
+ PROXY_CACHE.set(target, storeCache);
1732
+ }
1733
+ const cachedRef = storeCache.get(idx);
1734
+ if (cachedRef) {
1735
+ const cached = cachedRef.deref();
1736
+ if (cached)
1737
+ return cached;
1738
+ storeCache.delete(idx);
1739
+ PROXY_CLEANUP.unregister(cachedRef);
1740
+ }
1741
+ const value = untracked(target);
1742
+ const valueIsArray = Array.isArray(value);
1743
+ const valueIsRecord = isRecord(value);
1744
+ const equalFn = (valueIsRecord || valueIsArray) &&
1745
+ isMutableSource &&
1746
+ typeof value[idx] === 'object'
1747
+ ? () => false
1748
+ : undefined;
1749
+ const computation = valueIsRecord
1750
+ ? derived(target, idx, { equal: equalFn })
1751
+ : derived(target, {
1752
+ from: (v) => v?.[idx],
1753
+ onChange: (newValue) => target.update((v) => {
1754
+ if (v === null || v === undefined)
1755
+ return v;
1756
+ try {
1757
+ v[idx] = newValue;
1758
+ }
1759
+ catch (e) {
1760
+ if (isDevMode())
1761
+ console.error(`[store] Failed to set property "${String(idx)}"`, e);
1762
+ }
1763
+ return v;
1764
+ }),
1765
+ });
1766
+ const proxy = Array.isArray(untracked(computation))
1767
+ ? toArrayStore(computation, injector)
1768
+ : toStore(computation, injector);
1769
+ const ref = new WeakRef(proxy);
1770
+ storeCache.set(idx, ref);
1771
+ PROXY_CLEANUP.register(proxy, { target, prop: idx }, ref);
1772
+ return proxy;
1773
+ }
1774
+ return Reflect.get(target, prop, receiver);
1775
+ },
1776
+ });
1777
+ }
1778
+ /**
1779
+ * Converts a Signal into a deep-observable Store.
1780
+ * Accessing nested properties returns a derived Signal of that path.
1781
+ * @example
1782
+ * const state = store({ user: { name: 'John' } });
1783
+ * const nameSignal = state.user.name; // WritableSignal<string>
1784
+ */
1785
+ function toStore(source, injector) {
1786
+ if (isStore(source))
1787
+ return source;
1788
+ if (!injector)
1789
+ injector = inject(Injector);
1790
+ const writableSource = isWritableSignal(source)
1791
+ ? source
1792
+ : toWritable(source, () => {
1793
+ // noop
1794
+ });
1795
+ const isMutableSource = isMutable(writableSource);
1796
+ const s = new Proxy(writableSource, {
1797
+ has(_, prop) {
1798
+ return Reflect.has(untracked(source), prop);
1799
+ },
1800
+ ownKeys() {
1801
+ const v = untracked(source);
1802
+ if (!isRecord(v))
1803
+ return [];
1804
+ return Reflect.ownKeys(v);
1805
+ },
1806
+ getPrototypeOf() {
1807
+ return Object.getPrototypeOf(untracked(source));
1808
+ },
1809
+ getOwnPropertyDescriptor(_, prop) {
1810
+ const value = untracked(source);
1811
+ if (!isRecord(value) || !(prop in value))
1812
+ return;
1813
+ return {
1814
+ enumerable: true,
1815
+ configurable: true,
1816
+ };
1817
+ },
1818
+ get(target, prop) {
1819
+ if (prop === IS_STORE)
1820
+ return true;
1821
+ if (prop === 'asReadonlyStore')
1822
+ return () => {
1823
+ if (!isWritableSignal(source))
1824
+ return s;
1825
+ return untracked(() => toStore(source.asReadonly(), injector));
1826
+ };
1827
+ if (typeof prop === 'symbol' || SIGNAL_FN_PROP.has(prop))
1828
+ return target[prop];
1829
+ let storeCache = PROXY_CACHE.get(target);
1830
+ if (!storeCache) {
1831
+ storeCache = new Map();
1832
+ PROXY_CACHE.set(target, storeCache);
1833
+ }
1834
+ const cachedRef = storeCache.get(prop);
1835
+ if (cachedRef) {
1836
+ const cached = cachedRef.deref();
1837
+ if (cached)
1838
+ return cached;
1839
+ storeCache.delete(prop);
1840
+ PROXY_CLEANUP.unregister(cachedRef);
1841
+ }
1842
+ const value = untracked(target);
1843
+ const valueIsRecord = isRecord(value);
1844
+ const valueIsArray = Array.isArray(value);
1845
+ const equalFn = (valueIsRecord || valueIsArray) &&
1846
+ isMutableSource &&
1847
+ typeof value[prop] === 'object'
1848
+ ? () => false
1849
+ : undefined;
1850
+ const computation = valueIsRecord
1851
+ ? derived(target, prop, { equal: equalFn })
1852
+ : derived(target, {
1853
+ from: (v) => v?.[prop],
1854
+ onChange: (newValue) => target.update((v) => {
1855
+ if (v === null || v === undefined)
1856
+ return v;
1857
+ try {
1858
+ v[prop] = newValue;
1859
+ }
1860
+ catch (e) {
1861
+ if (isDevMode())
1862
+ console.error(`[store] Failed to set property "${String(prop)}"`, e);
1863
+ }
1864
+ return v;
1865
+ }),
1866
+ });
1867
+ const proxy = Array.isArray(untracked(computation))
1868
+ ? toArrayStore(computation, injector)
1869
+ : toStore(computation, injector);
1870
+ const ref = new WeakRef(proxy);
1871
+ storeCache.set(prop, ref);
1872
+ PROXY_CLEANUP.register(proxy, { target, prop }, ref);
1873
+ return proxy;
1874
+ },
1875
+ });
1876
+ return s;
1877
+ }
1878
+ /**
1879
+ * Creates a WritableSignalStore from a value.
1880
+ * @see {@link toStore}
1881
+ */
1882
+ function store(value, opt) {
1883
+ return toStore(signal(value, opt), opt?.injector);
1884
+ }
1885
+ /**
1886
+ * Creates a MutableSignalStore from a value.
1887
+ * @see {@link toStore}
1888
+ */
1889
+ function mutableStore(value, opt) {
1890
+ return toStore(mutable(value, opt), opt?.injector);
1891
+ }
1005
1892
 
1006
1893
  // Internal dummy store for server-side rendering
1007
1894
  const noopStore = {
@@ -1064,7 +1951,7 @@ const noopStore = {
1064
1951
  * }
1065
1952
  * ```
1066
1953
  */
1067
- function stored(fallback, { key, store: providedStore, serialize = JSON.stringify, deserialize = JSON.parse, syncTabs = false, equal = Object.is, onKeyChange = 'load', cleanupOldKey = false, ...rest }) {
1954
+ 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
1955
  const isServer = isPlatformServer(inject(PLATFORM_ID));
1069
1956
  const fallbackStore = isServer ? noopStore : localStorage;
1070
1957
  const store = providedStore ?? fallbackStore;
@@ -1078,7 +1965,10 @@ function stored(fallback, { key, store: providedStore, serialize = JSON.stringif
1078
1965
  if (found === null)
1079
1966
  return null;
1080
1967
  try {
1081
- return deserialize(found);
1968
+ const deserialized = deserialize(found);
1969
+ if (!validate(deserialized))
1970
+ return null;
1971
+ return deserialized;
1082
1972
  }
1083
1973
  catch (err) {
1084
1974
  if (isDevMode())
@@ -1162,41 +2052,122 @@ function stored(fallback, { key, store: providedStore, serialize = JSON.stringif
1162
2052
  return writable;
1163
2053
  }
1164
2054
 
2055
+ class MessageBus {
2056
+ channel = new BroadcastChannel('mmstack-tab-sync-bus');
2057
+ listeners = new Map();
2058
+ subscribe(id, listener) {
2059
+ this.unsubscribe(id); // Ensure no duplicate listeners
2060
+ const wrapped = (ev) => {
2061
+ try {
2062
+ if (ev.data?.id === id)
2063
+ listener(ev.data?.value);
2064
+ }
2065
+ catch {
2066
+ // noop
2067
+ }
2068
+ };
2069
+ this.channel.addEventListener('message', wrapped);
2070
+ this.listeners.set(id, wrapped);
2071
+ return {
2072
+ unsub: (() => this.unsubscribe(id)).bind(this),
2073
+ post: ((value) => this.channel.postMessage({ id, value })).bind(this),
2074
+ };
2075
+ }
2076
+ unsubscribe(id) {
2077
+ const listener = this.listeners.get(id);
2078
+ if (!listener)
2079
+ return;
2080
+ this.channel.removeEventListener('message', listener);
2081
+ this.listeners.delete(id);
2082
+ }
2083
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.19", ngImport: i0, type: MessageBus, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
2084
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "19.2.19", ngImport: i0, type: MessageBus, providedIn: 'root' });
2085
+ }
2086
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.19", ngImport: i0, type: MessageBus, decorators: [{
2087
+ type: Injectable,
2088
+ args: [{
2089
+ providedIn: 'root',
2090
+ }]
2091
+ }] });
2092
+ function generateDeterministicID() {
2093
+ const stack = new Error().stack;
2094
+ if (stack) {
2095
+ // Look for the actual caller (first non-internal frame)
2096
+ const lines = stack.split('\n');
2097
+ for (let i = 2; i < lines.length; i++) {
2098
+ const line = lines[i];
2099
+ if (line && !line.includes('tabSync') && !line.includes('MessageBus')) {
2100
+ let hash = 0;
2101
+ for (let j = 0; j < line.length; j++) {
2102
+ const char = line.charCodeAt(j);
2103
+ hash = (hash << 5) - hash + char;
2104
+ hash = hash & hash;
2105
+ }
2106
+ return `auto-${Math.abs(hash)}`;
2107
+ }
2108
+ }
2109
+ }
2110
+ throw new Error('Could not generate deterministic ID, please provide one manually.');
2111
+ }
1165
2112
  /**
1166
- * Creates a Promise that resolves when a signal's value satisfies a given predicate.
2113
+ * Synchronizes a WritableSignal across browser tabs using BroadcastChannel API.
1167
2114
  *
1168
- * This is useful for imperatively waiting for a reactive state to change,
1169
- * for example, in tests or to orchestrate complex asynchronous operations.
2115
+ * Creates a shared signal that automatically syncs its value between all tabs
2116
+ * of the same application. When the signal is updated in one tab, all other
2117
+ * tabs will receive the new value automatically.
1170
2118
  *
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.
2119
+ * @template T - The type of the WritableSignal
2120
+ * @param sig - The WritableSignal to synchronize across tabs
2121
+ * @param opt - Optional configuration object
2122
+ * @param opt.id - Explicit channel ID for synchronization. If not provided,
2123
+ * a deterministic ID is generated based on the call site.
2124
+ * Use explicit IDs in production for reliability.
2125
+ *
2126
+ * @returns The same WritableSignal instance, now synchronized across tabs
2127
+ *
2128
+ * @throws {Error} When deterministic ID generation fails and no explicit ID is provided
1177
2129
  *
1178
2130
  * @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
- * }
2131
+ * ```typescript
2132
+ * // Basic usage - auto-generates channel ID from call site
2133
+ * const theme = tabSync(signal('dark'));
1191
2134
  *
1192
- * // Simulate updates
1193
- * setTimeout(() => count.set(1), 500);
1194
- * setTimeout(() => count.set(2), 1000);
1195
- * setTimeout(() => count.set(3), 1500);
2135
+ * // With explicit ID (recommended for production)
2136
+ * const userPrefs = tabSync(signal({ lang: 'en' }), { id: 'user-preferences' });
1196
2137
  *
1197
- * waitForCount();
2138
+ * // Changes in one tab will sync to all other tabs
2139
+ * theme.set('light'); // All tabs will update to 'light'
1198
2140
  * ```
2141
+ *
2142
+ * @remarks
2143
+ * - Only works in browser environments (returns original signal on server)
2144
+ * - Uses a single BroadcastChannel for all synchronized signals
2145
+ * - Automatically cleans up listeners when the injection context is destroyed
2146
+ * - Initial signal value after sync setup is not broadcasted to prevent loops
2147
+ *
1199
2148
  */
2149
+ function tabSync(sig, opt) {
2150
+ if (isPlatformServer(inject(PLATFORM_ID)))
2151
+ return sig;
2152
+ const id = opt?.id || generateDeterministicID();
2153
+ const bus = inject(MessageBus);
2154
+ const { unsub, post } = bus.subscribe(id, (next) => sig.set(next));
2155
+ let first = false;
2156
+ const effectRef = effect(() => {
2157
+ const val = sig();
2158
+ if (!first) {
2159
+ first = true;
2160
+ return;
2161
+ }
2162
+ post(val);
2163
+ });
2164
+ inject(DestroyRef).onDestroy(() => {
2165
+ effectRef.destroy();
2166
+ unsub();
2167
+ });
2168
+ return sig;
2169
+ }
2170
+
1200
2171
  function until(sourceSignal, predicate, options = {}) {
1201
2172
  const injector = options.injector ?? inject(Injector);
1202
2173
  return new Promise((resolve, reject) => {
@@ -1352,6 +2323,8 @@ function withHistory(source, opt) {
1352
2323
  return;
1353
2324
  const valueForRedo = untracked(source);
1354
2325
  const valueToRestore = historyStack.at(-1);
2326
+ if (valueToRestore === undefined)
2327
+ return;
1355
2328
  originalSet.call(source, valueToRestore);
1356
2329
  history.inline((h) => h.pop());
1357
2330
  redoArray.inline((r) => r.push(valueForRedo));
@@ -1362,6 +2335,8 @@ function withHistory(source, opt) {
1362
2335
  return;
1363
2336
  const valueForUndo = untracked(source);
1364
2337
  const valueToRestore = redoStack.at(-1);
2338
+ if (valueToRestore === undefined)
2339
+ return;
1365
2340
  originalSet.call(source, valueToRestore);
1366
2341
  redoArray.inline((r) => r.pop());
1367
2342
  history.mutate((h) => {
@@ -1391,5 +2366,5 @@ function withHistory(source, opt) {
1391
2366
  * Generated bundle index. Do not edit.
1392
2367
  */
1393
2368
 
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 };
2369
+ 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
2370
  //# sourceMappingURL=mmstack-primitives.mjs.map