@mmstack/primitives 20.4.7 → 20.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,8 +1,218 @@
1
1
  import * as i0 from '@angular/core';
2
- import { untracked, signal, inject, DestroyRef, computed, PLATFORM_ID, isSignal, effect, ElementRef, linkedSignal, isDevMode, Injector, Injectable, runInInjectionContext } from '@angular/core';
2
+ import { isDevMode, inject, Injector, untracked, effect, DestroyRef, linkedSignal, computed, signal, isSignal, ElementRef, PLATFORM_ID, Injectable, runInInjectionContext } from '@angular/core';
3
3
  import { isPlatformServer } from '@angular/common';
4
4
  import { SIGNAL } from '@angular/core/primitives/signals';
5
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(...(ngDevMode ? [{ debugName: "internal", source,
198
+ computation: (items) => items.slice(0, chunkSize),
199
+ equal }] : [{
200
+ source,
201
+ computation: (items) => items.slice(0, chunkSize),
202
+ equal,
203
+ }]));
204
+ nestedEffect((cleanup) => {
205
+ const fullList = source();
206
+ const current = internal();
207
+ if (current.length >= fullList.length)
208
+ return;
209
+ return cleanup(delayFn(() => untracked(() => internal.set(fullList.slice(0, current.length + chunkSize)))));
210
+ }, {
211
+ injector: injector,
212
+ });
213
+ return internal.asReadonly();
214
+ }
215
+
6
216
  /**
7
217
  * Converts a read-only `Signal` into a `WritableSignal` by providing custom `set` and, optionally, `update` functions.
8
218
  * This can be useful for creating controlled write access to a signal that is otherwise read-only.
@@ -31,9 +241,9 @@ import { SIGNAL } from '@angular/core/primitives/signals';
31
241
  *
32
242
  * writableSignal.set(5); // sets value of originalValue.a to 5 & triggers all signals
33
243
  */
34
- function toWritable(signal, set, update) {
35
- const internal = signal;
36
- internal.asReadonly = () => signal;
244
+ function toWritable(source, set, update, opt) {
245
+ const internal = (opt?.pure !== false ? computed(source) : source);
246
+ internal.asReadonly = () => source;
37
247
  internal.set = set;
38
248
  internal.update = update ?? ((updater) => set(updater(untracked(internal))));
39
249
  return internal;
@@ -115,19 +325,19 @@ function debounce(source, opt) {
115
325
  catch {
116
326
  // not in injection context & no destroyRef provided opting out of cleanup
117
327
  }
118
- const triggerFn = (afterClean) => {
328
+ const triggerFn = (next) => {
119
329
  if (timeout)
120
330
  clearTimeout(timeout);
121
- afterClean();
331
+ source.set(next);
122
332
  timeout = setTimeout(() => {
123
333
  trigger.update((c) => !c);
124
334
  }, ms);
125
335
  };
126
336
  const set = (value) => {
127
- triggerFn(() => source.set(value));
337
+ triggerFn(value);
128
338
  };
129
339
  const update = (fn) => {
130
- triggerFn(() => source.update(fn));
340
+ triggerFn(fn(untracked(source)));
131
341
  };
132
342
  const writable = toWritable(computed(() => {
133
343
  trigger();
@@ -218,7 +428,7 @@ function derived(source, optOrKey, opt) {
218
428
  : (next) => {
219
429
  source.update((cur) => ({ ...cur, [optOrKey]: next }));
220
430
  };
221
- const rest = typeof optOrKey === 'object' ? optOrKey : opt;
431
+ const rest = typeof optOrKey === 'object' ? { ...optOrKey, ...opt } : opt;
222
432
  const baseEqual = rest?.equal ?? Object.is;
223
433
  let trigger = false;
224
434
  const equal = isMutable(source)
@@ -228,7 +438,7 @@ function derived(source, optOrKey, opt) {
228
438
  return baseEqual(a, b);
229
439
  }
230
440
  : baseEqual;
231
- const sig = toWritable(computed(() => from(source()), { ...rest, equal }), (newVal) => onChange(newVal));
441
+ const sig = toWritable(computed(() => from(source()), { ...rest, equal }), (newVal) => onChange(newVal), undefined, { pure: false });
232
442
  sig.from = from;
233
443
  if (isMutable(source)) {
234
444
  sig.mutate = (updater) => {
@@ -289,116 +499,8 @@ function isDerivation(sig) {
289
499
  return 'from' in sig;
290
500
  }
291
501
 
292
- function observerSupported() {
293
- return typeof IntersectionObserver !== 'undefined';
294
- }
295
- /**
296
- * Creates a read-only signal that tracks the intersection status of a target DOM element
297
- * with the viewport or a specified root element, using the `IntersectionObserver` API.
298
- *
299
- * It can observe a static `ElementRef`/`Element` or a `Signal` that resolves to one,
300
- * allowing for dynamic targets.
301
- *
302
- * @param target The DOM element (or `ElementRef`, or a `Signal` resolving to one) to observe.
303
- * If the signal resolves to `null`, observation stops.
304
- * @param options Optional `IntersectionObserverInit` options (e.g., `root`, `rootMargin`, `threshold`)
305
- * and an optional `debugName`.
306
- * @returns A `Signal<IntersectionObserverEntry | undefined>`. It emits `undefined` initially,
307
- * on the server, or if the target is `null`. Otherwise, it emits the latest
308
- * `IntersectionObserverEntry`. Consumers can derive a boolean `isVisible` from
309
- * this entry's `isIntersecting` property.
310
- *
311
- * @example
312
- * ```ts
313
- * import { Component, effect, ElementRef, viewChild } from '@angular/core';
314
- * import { elementVisibility } from '@mmstack/primitives';
315
- * import { computed } from '@angular/core'; // For derived boolean
316
- *
317
- * @Component({
318
- * selector: 'app-lazy-image',
319
- * template: `
320
- * <div #imageContainer style="height: 200px; border: 1px dashed grey;">
321
- * @if (isVisible()) {
322
- * <img src="your-image-url.jpg" alt="Lazy loaded image" />
323
- * <p>Image is VISIBLE!</p>
324
- * } @else {
325
- * <p>Scroll down to see the image...</p>
326
- * }
327
- * </div>
328
- * `
329
- * })
330
- * export class LazyImageComponent {
331
- * readonly imageContainer = viewChild.required<ElementRef<HTMLDivElement>>('imageContainer');
332
- *
333
- * // Observe the element, get the full IntersectionObserverEntry
334
- * readonly intersectionEntry = elementVisibility(this.imageContainer);
335
- *
336
- * // Derive a simple boolean for visibility
337
- * readonly isVisible = computed(() => this.intersectionEntry()?.isIntersecting ?? false);
338
- *
339
- * constructor() {
340
- * effect(() => {
341
- * console.log('Intersection Entry:', this.intersectionEntry());
342
- * console.log('Is Visible:', this.isVisible());
343
- * });
344
- * }
345
- * }
346
- * ```
347
- */
348
- function elementVisibility(target = inject(ElementRef), opt) {
349
- if (isPlatformServer(inject(PLATFORM_ID)) || !observerSupported()) {
350
- const base = computed(() => undefined, {
351
- debugName: opt?.debugName,
352
- });
353
- base.visible = computed(() => false, ...(ngDevMode ? [{ debugName: "visible" }] : []));
354
- return base;
355
- }
356
- const state = signal(undefined, {
357
- debugName: opt?.debugName,
358
- equal: (a, b) => {
359
- if (!a && !b)
360
- return true;
361
- if (!a || !b)
362
- return false;
363
- return (a.target === b.target &&
364
- a.isIntersecting === b.isIntersecting &&
365
- a.intersectionRatio === b.intersectionRatio &&
366
- a.boundingClientRect.top === b.boundingClientRect.top &&
367
- a.boundingClientRect.left === b.boundingClientRect.left &&
368
- a.boundingClientRect.width === b.boundingClientRect.width &&
369
- a.boundingClientRect.height === b.boundingClientRect.height);
370
- },
371
- });
372
- const targetSignal = isSignal(target) ? target : computed(() => target);
373
- effect((cleanup) => {
374
- const el = targetSignal();
375
- if (!el)
376
- return state.set(undefined);
377
- let observer = null;
378
- observer = new IntersectionObserver(([entry]) => state.set(entry), opt);
379
- observer.observe(el instanceof ElementRef ? el.nativeElement : el);
380
- cleanup(() => {
381
- observer?.disconnect();
382
- });
383
- });
384
- const base = state.asReadonly();
385
- base.visible = computed(() => {
386
- const s = state();
387
- if (!s)
388
- return false;
389
- return s.isIntersecting;
390
- }, ...(ngDevMode ? [{ debugName: "visible" }] : []));
391
- return base;
392
- }
393
-
394
- /**
395
- * @internal
396
- * Checks if a signal is a WritableSignal.
397
- * @param sig The signal to check.
398
- */
399
- function isWritable(sig) {
400
- // We just need to check for the presence of a 'set' method.
401
- return 'set' in sig;
502
+ function isWritableSignal(value) {
503
+ return 'set' in value && typeof value.set === 'function';
402
504
  }
403
505
  /**
404
506
  * @internal
@@ -407,31 +509,45 @@ function isWritable(sig) {
407
509
  * @returns
408
510
  */
409
511
  function createSetter(source) {
410
- if (!isWritable(source))
512
+ if (!isWritableSignal(source))
411
513
  return () => {
412
514
  // noop;
413
515
  };
414
516
  if (isMutable(source))
415
517
  return (value, index) => {
416
- source.inline((arr) => {
518
+ source.mutate((arr) => {
417
519
  arr[index] = value;
520
+ return arr;
418
521
  });
419
522
  };
420
523
  return (value, index) => {
421
524
  source.update((arr) => arr.map((v, i) => (i === index ? value : v)));
422
525
  };
423
526
  }
424
- function mapArray(source, map, options) {
527
+
528
+ /**
529
+ * Helper to create the derived signal for a specific index.
530
+ * Extracts the cast logic to keep the main loop clean.
531
+ */
532
+ function createItemSignal(source, index, setter, opt) {
533
+ return derived(
534
+ // We cast to any/Mutable to satisfy the overload signature,
535
+ // but 'derived' internally checks isMutable() for safety.
536
+ source, {
537
+ from: (src) => src[index],
538
+ onChange: (value) => setter(value, index),
539
+ }, opt);
540
+ }
541
+ function indexArray(source, map, opt = {}) {
425
542
  const data = isSignal(source) ? source : computed(source);
426
543
  const len = computed(() => data().length, ...(ngDevMode ? [{ debugName: "len" }] : []));
427
544
  const setter = createSetter(data);
428
- const opt = { ...options };
429
- const writableData = isWritable(data)
545
+ const writableData = isWritableSignal(data)
430
546
  ? data
431
547
  : toWritable(data, () => {
432
548
  // noop
433
549
  });
434
- if (isWritable(data) && isMutable(data) && !opt.equal) {
550
+ if (isWritableSignal(data) && isMutable(data) && !opt.equal) {
435
551
  opt.equal = (a, b) => {
436
552
  if (a !== b)
437
553
  return false; // actually check primitives and references
@@ -442,175 +558,201 @@ function mapArray(source, map, options) {
442
558
  source: () => len(),
443
559
  computation: (len, prev) => {
444
560
  if (!prev)
445
- return Array.from({ length: len }, (_, i) => {
446
- const derivation = derived(writableData, // typcase to largest type
447
- {
448
- from: (src) => src[i],
449
- onChange: (value) => setter(value, i),
450
- }, opt);
451
- return map(derivation, i);
452
- });
561
+ return Array.from({ length: len }, (_, i) => map(createItemSignal(writableData, i, setter, opt), i));
453
562
  if (len === prev.value.length)
454
563
  return prev.value;
455
564
  if (len < prev.value.length) {
456
- const slice = prev.value.slice(0, len);
457
565
  if (opt.onDestroy) {
458
566
  for (let i = len; i < prev.value.length; i++) {
459
- opt.onDestroy?.(prev.value[i]);
567
+ opt.onDestroy(prev.value[i]);
460
568
  }
461
569
  }
462
- return slice;
463
- }
464
- else {
465
- const next = [...prev.value];
466
- for (let i = prev.value.length; i < len; i++) {
467
- const derivation = derived(writableData, // typcase to largest type
468
- {
469
- from: (src) => src[i],
470
- onChange: (value) => setter(value, i),
471
- }, opt);
472
- next[i] = map(derivation, i);
473
- }
474
- return next;
570
+ return prev.value.slice(0, len);
475
571
  }
476
- },
477
- equal: (a, b) => a.length === b.length,
478
- });
479
- }
480
-
481
- const frameStack = [];
482
- function current() {
483
- return frameStack.at(-1) ?? null;
484
- }
485
- function clearFrame(frame, userCleanups) {
486
- for (const child of frame.children) {
487
- try {
488
- child.destroy();
489
- }
490
- catch (e) {
491
- if (isDevMode())
492
- console.error('Error destroying nested effect:', e);
493
- }
494
- }
495
- frame.children.clear();
496
- for (const fn of userCleanups) {
497
- try {
498
- fn();
499
- }
500
- catch (e) {
501
- if (isDevMode())
502
- console.error('Error destroying nested effect:', e);
503
- }
504
- }
505
- userCleanups.length = 0;
506
- }
507
- /**
508
- * Creates an effect that can be nested, similar to SolidJS's `createEffect`.
509
- *
510
- * This primitive enables true hierarchical reactivity. A `nestedEffect` created
511
- * within another `nestedEffect` is automatically destroyed and recreated when
512
- * the parent re-runs.
513
- *
514
- * It automatically handles injector propagation and lifetime management, allowing
515
- * you to create fine-grained, conditional side-effects that only track
516
- * dependencies when they are "live".
517
- *
518
- * @param effectFn The side-effect function, which receives a cleanup register function.
519
- * @param options (Optional) Angular's `CreateEffectOptions`.
520
- * @returns An `EffectRef` for the created effect.
521
- *
522
- * @example
523
- * ```ts
524
- * // Assume `coldGuard` changes rarely, but `hotSignal` changes often.
525
- * const coldGuard = signal(false);
526
- * const hotSignal = signal(0);
527
- *
528
- * nestedEffect(() => {
529
- * // This outer effect only tracks `coldGuard`.
530
- * if (coldGuard()) {
531
- *
532
- * // This inner effect is CREATED when coldGuard is true
533
- * // and DESTROYED when it becomes false.
534
- * nestedEffect(() => {
535
- * // It only tracks `hotSignal` while it exists.
536
- * console.log('Hot signal is:', hotSignal());
537
- * });
538
- * }
539
- * // If `coldGuard` is false, this outer effect does not track `hotSignal`.
540
- * });
541
- * ```
542
- * @example
543
- * ```ts
544
- * const users = signal([
545
- { id: 1, name: 'Alice' },
546
- { id: 2, name: 'Bob' }
547
- ]);
548
-
549
- // The fine-grained mapped list
550
- const mappedUsers = mapArray(
551
- users,
552
- (userSignal, index) => {
553
- // 1. Create a fine-grained SIDE EFFECT for *this item*
554
- // This effect's lifetime is now tied to this specific item. created once on init of this index.
555
- const effectRef = nestedEffect(() => {
556
- // This only runs if *this* userSignal changes,
557
- // not if the whole list changes.
558
- console.log(`User ${index} updated:`, userSignal().name);
559
- });
560
-
561
- // 2. Return the data AND the cleanup logic
562
- return {
563
- // The mapped data
564
- label: computed(() => `User: ${userSignal().name}`),
565
-
566
- // The cleanup function
567
- destroyEffect: () => effectRef.destroy()
568
- };
569
- },
570
- {
571
- // 3. Tell mapArray HOW to clean up when an item is removed, this needs to be manual as it's not a nestedEffect itself
572
- onDestroy: (mappedItem) => {
573
- mappedItem.destroyEffect();
574
- }
575
- }
576
- );
577
- * ```
578
- */
579
- function nestedEffect(effectFn, options) {
580
- const parent = current();
581
- const injector = options?.injector ?? parent?.injector ?? inject(Injector);
582
- const srcRef = untracked(() => {
583
- return effect((cleanup) => {
584
- const frame = {
585
- injector,
586
- children: new Set(),
587
- };
588
- const userCleanups = [];
589
- frameStack.push(frame);
590
- try {
591
- effectFn((fn) => {
592
- userCleanups.push(fn);
593
- });
572
+ const next = prev.value.slice();
573
+ for (let i = prev.value.length; i < len; i++)
574
+ next[i] = map(createItemSignal(writableData, i, setter, opt), i);
575
+ return next;
576
+ },
577
+ equal: (a, b) => a.length === b.length,
578
+ });
579
+ }
580
+ /**
581
+ * @deprecated use indexArray instead
582
+ */
583
+ const mapArray = indexArray;
584
+
585
+ /**
586
+ * Reactively maps items from a source array to a new array by value (identity).
587
+ *
588
+ * similar to `Array.prototype.map`, but:
589
+ * 1. The `mapFn` receives the `index` as a Signal.
590
+ * 2. If an item in the `source` array moves to a new position, the *result* of the map function is reused and moved.
591
+ * The `index` signal is updated to the new index.
592
+ * 3. The `mapFn` is only run for *new* items.
593
+ *
594
+ * This is useful for building efficient lists where DOM nodes or heavy instances should be reused
595
+ * when the list is reordered.
596
+ *
597
+ * @param source A `Signal<T[]>` or a function returning `T[]`.
598
+ * @param mapFn The mapping function. Receives the item and its index as a Signal.
599
+ * @param options Optional configuration:
600
+ * - `onDestroy`: A callback invoked when a mapped item is removed from the array.
601
+ * @returns A `Signal<U[]>` containing the mapped array.
602
+ */
603
+ function keyArray(source, mapFn, options = {}) {
604
+ const sourceSignal = isSignal(source) ? source : computed(source);
605
+ const items = [];
606
+ let mapped = [];
607
+ const indexes = [];
608
+ const getKey = options.key || ((v) => v);
609
+ const newIndices = new Map();
610
+ const temp = [];
611
+ const tempIndexes = [];
612
+ const newIndicesNext = [];
613
+ const newIndexesCache = new Array();
614
+ return computed(() => {
615
+ const newItems = sourceSignal() || [];
616
+ return untracked(() => {
617
+ let i;
618
+ let j;
619
+ const newLen = newItems.length;
620
+ const len = items.length;
621
+ const newMapped = new Array(newLen);
622
+ const newIndexes = newIndexesCache;
623
+ newIndexes.length = 0;
624
+ newIndexes.length = newLen;
625
+ let start;
626
+ let end;
627
+ let newEnd;
628
+ let item;
629
+ let key;
630
+ if (newLen === 0) {
631
+ if (len !== 0) {
632
+ if (options.onDestroy) {
633
+ for (let k = 0; k < len; k++)
634
+ options.onDestroy(mapped[k]);
635
+ }
636
+ items.length = 0;
637
+ mapped = [];
638
+ indexes.length = 0;
639
+ }
640
+ return mapped;
594
641
  }
595
- finally {
596
- frameStack.pop();
642
+ if (len === 0) {
643
+ for (j = 0; j < newLen; j++) {
644
+ item = newItems[j];
645
+ items[j] = item;
646
+ const indexSignal = signal(j, ...(ngDevMode ? [{ debugName: "indexSignal" }] : []));
647
+ newIndexes[j] = indexSignal;
648
+ newMapped[j] = mapFn(item, indexSignal);
649
+ }
597
650
  }
598
- return cleanup(() => clearFrame(frame, userCleanups));
599
- }, {
600
- ...options,
601
- injector,
602
- manualCleanup: !!parent,
651
+ else {
652
+ newIndices.clear();
653
+ temp.length = 0;
654
+ tempIndexes.length = 0;
655
+ newIndicesNext.length = 0;
656
+ for (start = 0, end = Math.min(len, newLen); start < end && getKey(items[start]) === getKey(newItems[start]); start++) {
657
+ newMapped[start] = mapped[start];
658
+ newIndexes[start] = indexes[start];
659
+ }
660
+ for (end = len - 1, newEnd = newLen - 1; end >= start &&
661
+ newEnd >= start &&
662
+ getKey(items[end]) === getKey(newItems[newEnd]); end--, newEnd--) {
663
+ temp[newEnd] = mapped[end];
664
+ tempIndexes[newEnd] = indexes[end];
665
+ }
666
+ for (j = newEnd; j >= start; j--) {
667
+ item = newItems[j];
668
+ key = getKey(item);
669
+ i = newIndices.get(key);
670
+ newIndicesNext[j] = i === undefined ? -1 : i;
671
+ newIndices.set(key, j);
672
+ }
673
+ for (i = start; i <= end; i++) {
674
+ item = items[i];
675
+ key = getKey(item);
676
+ j = newIndices.get(key);
677
+ if (j !== undefined && j !== -1) {
678
+ temp[j] = mapped[i];
679
+ tempIndexes[j] = indexes[i];
680
+ j = newIndicesNext[j];
681
+ newIndices.set(key, j);
682
+ }
683
+ else {
684
+ if (options.onDestroy)
685
+ options.onDestroy(mapped[i]);
686
+ }
687
+ }
688
+ // 2) Set all new values
689
+ for (j = start; j < newLen; j++) {
690
+ if (temp[j] !== undefined) {
691
+ newMapped[j] = temp[j];
692
+ newIndexes[j] = tempIndexes[j];
693
+ newIndexes[j].set(j);
694
+ }
695
+ else {
696
+ const indexSignal = signal(j, ...(ngDevMode ? [{ debugName: "indexSignal" }] : []));
697
+ newIndexes[j] = indexSignal;
698
+ newMapped[j] = mapFn(newItems[j], indexSignal);
699
+ }
700
+ }
701
+ items.length = newLen;
702
+ for (let k = 0; k < newLen; k++)
703
+ items[k] = newItems[k];
704
+ }
705
+ mapped = newMapped;
706
+ indexes.length = newLen;
707
+ for (let k = 0; k < newLen; k++)
708
+ indexes[k] = newIndexes[k];
709
+ return mapped;
603
710
  });
604
711
  });
605
- const ref = {
606
- ...srcRef,
607
- destroy: () => {
608
- parent?.children.delete(ref);
609
- srcRef.destroy();
712
+ }
713
+
714
+ function pooledKeys(src) {
715
+ const aBuf = new Set();
716
+ const bBuf = new Set();
717
+ let active = aBuf;
718
+ let spare = bBuf;
719
+ return computed(() => {
720
+ const val = src();
721
+ spare.clear();
722
+ for (const k in val)
723
+ if (Object.prototype.hasOwnProperty.call(val, k))
724
+ spare.add(k);
725
+ if (active.size === spare.size && active.isSubsetOf(spare))
726
+ return active;
727
+ const temp = active;
728
+ active = spare;
729
+ spare = temp;
730
+ return active;
731
+ });
732
+ }
733
+ function mapObject(source, mapFn, options = {}) {
734
+ const src = isSignal(source) ? source : computed(source);
735
+ const writable = (isWritableSignal(src)
736
+ ? src
737
+ : toWritable(src, () => {
738
+ // noop
739
+ })); // maximal overload internally
740
+ return linkedSignal({
741
+ source: pooledKeys(src),
742
+ computation: (next, prev) => {
743
+ const nextObj = {};
744
+ for (const k of next)
745
+ nextObj[k] =
746
+ prev && prev.source.has(k)
747
+ ? prev.value[k]
748
+ : mapFn(k, derived(writable, k));
749
+ if (options.onDestroy && prev && prev.source.size)
750
+ for (const k of prev.source)
751
+ if (!next.has(k))
752
+ options.onDestroy(prev.value[k]);
753
+ return nextObj;
610
754
  },
611
- };
612
- parent?.children.add(ref);
613
- return ref;
755
+ }).asReadonly();
614
756
  }
615
757
 
616
758
  /** Project with optional equality. Pure & sync. */
@@ -698,6 +840,206 @@ function piped(initial, opt) {
698
840
  return pipeable(signal(initial, opt));
699
841
  }
700
842
 
843
+ function observerSupported$1() {
844
+ return typeof ResizeObserver !== 'undefined';
845
+ }
846
+ /**
847
+ * Creates a read-only signal that tracks the size of a target DOM element.
848
+ *
849
+ * By default, it observes the `border-box` size to align with `getBoundingClientRect()`,
850
+ * which is used to provide a synchronous initial value if possible.
851
+ *
852
+ * @param target The DOM element (or `ElementRef`, or a `Signal` resolving to one) to observe.
853
+ * @param options Optional configuration including `box` (defaults to 'border-box') and `debugName`.
854
+ * @returns A `Signal<ElementSize | undefined>`.
855
+ *
856
+ * @example
857
+ * ```ts
858
+ * const size = elementSize(elementRef);
859
+ * effect(() => {
860
+ * console.log('Size:', size()?.width, size()?.height);
861
+ * });
862
+ * ```
863
+ */
864
+ function elementSize(target = inject(ElementRef), opt) {
865
+ const getElement = () => {
866
+ if (isSignal(target)) {
867
+ try {
868
+ const val = target();
869
+ return val instanceof ElementRef ? val.nativeElement : val;
870
+ }
871
+ catch {
872
+ return null;
873
+ }
874
+ }
875
+ return target instanceof ElementRef ? target.nativeElement : target;
876
+ };
877
+ const resolveInitialValue = () => {
878
+ if (!observerSupported$1())
879
+ return undefined;
880
+ const el = getElement();
881
+ if (el && el.getBoundingClientRect) {
882
+ const rect = el.getBoundingClientRect();
883
+ return { width: rect.width, height: rect.height };
884
+ }
885
+ return undefined;
886
+ };
887
+ if (isPlatformServer(inject(PLATFORM_ID))) {
888
+ return computed(() => untracked(resolveInitialValue), {
889
+ debugName: opt?.debugName,
890
+ });
891
+ }
892
+ const state = signal(untracked(resolveInitialValue), {
893
+ debugName: opt?.debugName,
894
+ equal: (a, b) => a?.width === b?.width && a?.height === b?.height,
895
+ });
896
+ const targetSignal = isSignal(target) ? target : computed(() => target);
897
+ effect((cleanup) => {
898
+ const el = targetSignal();
899
+ if (el) {
900
+ const nativeEl = el instanceof ElementRef ? el.nativeElement : el;
901
+ const rect = nativeEl.getBoundingClientRect();
902
+ untracked(() => state.set({ width: rect.width, height: rect.height }));
903
+ }
904
+ else {
905
+ untracked(() => state.set(undefined));
906
+ return;
907
+ }
908
+ if (!observerSupported$1())
909
+ return;
910
+ let observer = null;
911
+ observer = new ResizeObserver(([entry]) => {
912
+ let width = 0;
913
+ let height = 0;
914
+ const boxOption = opt?.box ?? 'border-box';
915
+ if (boxOption === 'border-box' && entry.borderBoxSize?.length > 0) {
916
+ const size = entry.borderBoxSize[0];
917
+ width = size.inlineSize;
918
+ height = size.blockSize;
919
+ }
920
+ else if (boxOption === 'content-box' &&
921
+ entry.contentBoxSize?.length > 0) {
922
+ width = entry.contentBoxSize[0].inlineSize;
923
+ height = entry.contentBoxSize[0].blockSize;
924
+ }
925
+ else {
926
+ width = entry.contentRect.width;
927
+ height = entry.contentRect.height;
928
+ }
929
+ state.set({ width, height });
930
+ });
931
+ observer.observe(el instanceof ElementRef ? el.nativeElement : el, {
932
+ box: opt?.box ?? 'border-box',
933
+ });
934
+ cleanup(() => {
935
+ observer?.disconnect();
936
+ });
937
+ });
938
+ return state.asReadonly();
939
+ }
940
+
941
+ function observerSupported() {
942
+ return typeof IntersectionObserver !== 'undefined';
943
+ }
944
+ /**
945
+ * Creates a read-only signal that tracks the intersection status of a target DOM element
946
+ * with the viewport or a specified root element, using the `IntersectionObserver` API.
947
+ *
948
+ * It can observe a static `ElementRef`/`Element` or a `Signal` that resolves to one,
949
+ * allowing for dynamic targets.
950
+ *
951
+ * @param target The DOM element (or `ElementRef`, or a `Signal` resolving to one) to observe.
952
+ * If the signal resolves to `null`, observation stops.
953
+ * @param options Optional `IntersectionObserverInit` options (e.g., `root`, `rootMargin`, `threshold`)
954
+ * and an optional `debugName`.
955
+ * @returns A `Signal<IntersectionObserverEntry | undefined>`. It emits `undefined` initially,
956
+ * on the server, or if the target is `null`. Otherwise, it emits the latest
957
+ * `IntersectionObserverEntry`. Consumers can derive a boolean `isVisible` from
958
+ * this entry's `isIntersecting` property.
959
+ *
960
+ * @example
961
+ * ```ts
962
+ * import { Component, effect, ElementRef, viewChild } from '@angular/core';
963
+ * import { elementVisibility } from '@mmstack/primitives';
964
+ * import { computed } from '@angular/core'; // For derived boolean
965
+ *
966
+ * @Component({
967
+ * selector: 'app-lazy-image',
968
+ * template: `
969
+ * <div #imageContainer style="height: 200px; border: 1px dashed grey;">
970
+ * @if (isVisible()) {
971
+ * <img src="your-image-url.jpg" alt="Lazy loaded image" />
972
+ * <p>Image is VISIBLE!</p>
973
+ * } @else {
974
+ * <p>Scroll down to see the image...</p>
975
+ * }
976
+ * </div>
977
+ * `
978
+ * })
979
+ * export class LazyImageComponent {
980
+ * readonly imageContainer = viewChild.required<ElementRef<HTMLDivElement>>('imageContainer');
981
+ *
982
+ * // Observe the element, get the full IntersectionObserverEntry
983
+ * readonly intersectionEntry = elementVisibility(this.imageContainer);
984
+ *
985
+ * // Derive a simple boolean for visibility
986
+ * readonly isVisible = computed(() => this.intersectionEntry()?.isIntersecting ?? false);
987
+ *
988
+ * constructor() {
989
+ * effect(() => {
990
+ * console.log('Intersection Entry:', this.intersectionEntry());
991
+ * console.log('Is Visible:', this.isVisible());
992
+ * });
993
+ * }
994
+ * }
995
+ * ```
996
+ */
997
+ function elementVisibility(target = inject(ElementRef), opt) {
998
+ if (isPlatformServer(inject(PLATFORM_ID)) || !observerSupported()) {
999
+ const base = computed(() => undefined, {
1000
+ debugName: opt?.debugName,
1001
+ });
1002
+ base.visible = computed(() => false, ...(ngDevMode ? [{ debugName: "visible" }] : []));
1003
+ return base;
1004
+ }
1005
+ const state = signal(undefined, {
1006
+ debugName: opt?.debugName,
1007
+ equal: (a, b) => {
1008
+ if (!a && !b)
1009
+ return true;
1010
+ if (!a || !b)
1011
+ return false;
1012
+ return (a.target === b.target &&
1013
+ a.isIntersecting === b.isIntersecting &&
1014
+ a.intersectionRatio === b.intersectionRatio &&
1015
+ a.boundingClientRect.top === b.boundingClientRect.top &&
1016
+ a.boundingClientRect.left === b.boundingClientRect.left &&
1017
+ a.boundingClientRect.width === b.boundingClientRect.width &&
1018
+ a.boundingClientRect.height === b.boundingClientRect.height);
1019
+ },
1020
+ });
1021
+ const targetSignal = isSignal(target) ? target : computed(() => target);
1022
+ effect((cleanup) => {
1023
+ const el = targetSignal();
1024
+ if (!el)
1025
+ return state.set(undefined);
1026
+ let observer = null;
1027
+ observer = new IntersectionObserver(([entry]) => state.set(entry), opt);
1028
+ observer.observe(el instanceof ElementRef ? el.nativeElement : el);
1029
+ cleanup(() => {
1030
+ observer?.disconnect();
1031
+ });
1032
+ });
1033
+ const base = state.asReadonly();
1034
+ base.visible = computed(() => {
1035
+ const s = state();
1036
+ if (!s)
1037
+ return false;
1038
+ return s.isIntersecting;
1039
+ }, ...(ngDevMode ? [{ debugName: "visible" }] : []));
1040
+ return base;
1041
+ }
1042
+
701
1043
  /**
702
1044
  * Creates a read-only signal that reactively tracks whether a CSS media query
703
1045
  * string currently matches.
@@ -869,8 +1211,7 @@ function throttle(source, opt) {
869
1211
  catch {
870
1212
  // not in injection context & no destroyRef provided opting out of cleanup
871
1213
  }
872
- const triggerFn = (updateSourceAction) => {
873
- updateSourceAction();
1214
+ const tick = () => {
874
1215
  if (timeout)
875
1216
  return;
876
1217
  timeout = setTimeout(() => {
@@ -879,10 +1220,12 @@ function throttle(source, opt) {
879
1220
  }, ms);
880
1221
  };
881
1222
  const set = (value) => {
882
- triggerFn(() => source.set(value));
1223
+ source.set(value);
1224
+ tick();
883
1225
  };
884
1226
  const update = (fn) => {
885
- triggerFn(() => source.update(fn));
1227
+ source.update(fn);
1228
+ tick();
886
1229
  };
887
1230
  const writable = toWritable(computed(() => {
888
1231
  trigger();
@@ -1250,18 +1593,303 @@ function sensor(type, options) {
1250
1593
  return networkStatus(options?.debugName);
1251
1594
  case 'pageVisibility':
1252
1595
  return pageVisibility(options?.debugName);
1596
+ case 'darkMode':
1253
1597
  case 'dark-mode':
1254
1598
  return prefersDarkMode(options?.debugName);
1599
+ case 'reducedMotion':
1255
1600
  case 'reduced-motion':
1256
1601
  return prefersReducedMotion(options?.debugName);
1602
+ case 'mediaQuery': {
1603
+ const opt = options;
1604
+ return mediaQuery(opt.query, opt.debugName);
1605
+ }
1257
1606
  case 'windowSize':
1258
1607
  return windowSize(options);
1259
1608
  case 'scrollPosition':
1260
1609
  return scrollPosition(options);
1610
+ case 'elementVisibility': {
1611
+ const opt = options;
1612
+ return elementVisibility(opt.target, opt);
1613
+ }
1614
+ case 'elementSize': {
1615
+ const opt = options;
1616
+ return elementSize(opt.target, opt);
1617
+ }
1261
1618
  default:
1262
1619
  throw new Error(`Unknown sensor type: ${type}`);
1263
1620
  }
1264
1621
  }
1622
+ function sensors(track, opt) {
1623
+ return track.reduce((result, key) => {
1624
+ result[key] = sensor(key, opt?.[key]);
1625
+ return result;
1626
+ }, {});
1627
+ }
1628
+
1629
+ const IS_STORE = Symbol('MMSTACK::IS_STORE');
1630
+ const PROXY_CACHE = new WeakMap();
1631
+ const SIGNAL_FN_PROP = new Set([
1632
+ 'set',
1633
+ 'update',
1634
+ 'mutate',
1635
+ 'inline',
1636
+ 'asReadonly',
1637
+ ]);
1638
+ const PROXY_CLEANUP = new FinalizationRegistry(({ target, prop }) => {
1639
+ const storeCache = PROXY_CACHE.get(target);
1640
+ if (storeCache)
1641
+ storeCache.delete(prop);
1642
+ });
1643
+ /**
1644
+ * @internal
1645
+ * Validates whether a value is a Signal Store.
1646
+ */
1647
+ function isStore(value) {
1648
+ return (typeof value === 'function' &&
1649
+ value !== null &&
1650
+ value[IS_STORE] === true);
1651
+ }
1652
+ function isIndexProp(prop) {
1653
+ return typeof prop === 'string' && prop.trim() !== '' && !isNaN(+prop);
1654
+ }
1655
+ function isRecord(value) {
1656
+ if (value === null || typeof value !== 'object')
1657
+ return false;
1658
+ const proto = Object.getPrototypeOf(value);
1659
+ return proto === Object.prototype || proto === null;
1660
+ }
1661
+ /**
1662
+ * @internal
1663
+ * Makes an array store
1664
+ */
1665
+ function toArrayStore(source, injector) {
1666
+ if (isStore(source))
1667
+ return source;
1668
+ const isMutableSource = isMutable(source);
1669
+ const lengthSignal = computed(() => {
1670
+ const v = source();
1671
+ if (!Array.isArray(v))
1672
+ return 0;
1673
+ return v.length;
1674
+ }, ...(ngDevMode ? [{ debugName: "lengthSignal" }] : []));
1675
+ return new Proxy(source, {
1676
+ has(_, prop) {
1677
+ if (prop === 'length')
1678
+ return true;
1679
+ if (isIndexProp(prop)) {
1680
+ const idx = +prop;
1681
+ return idx >= 0 && idx < untracked(lengthSignal);
1682
+ }
1683
+ return Reflect.has(untracked(source), prop);
1684
+ },
1685
+ ownKeys() {
1686
+ const v = untracked(source);
1687
+ if (!Array.isArray(v))
1688
+ return [];
1689
+ const len = v.length;
1690
+ const arr = new Array(len + 1);
1691
+ for (let i = 0; i < len; i++) {
1692
+ arr[i] = String(i);
1693
+ }
1694
+ arr[len] = 'length';
1695
+ return arr;
1696
+ },
1697
+ getPrototypeOf() {
1698
+ return Array.prototype;
1699
+ },
1700
+ getOwnPropertyDescriptor(_, prop) {
1701
+ const v = untracked(source);
1702
+ if (!Array.isArray(v))
1703
+ return;
1704
+ if (prop === 'length' ||
1705
+ (typeof prop === 'string' && !isNaN(+prop) && +prop < v.length)) {
1706
+ return {
1707
+ enumerable: true,
1708
+ configurable: true, // Required for proxies to dynamic targets
1709
+ };
1710
+ }
1711
+ return;
1712
+ },
1713
+ get(target, prop, receiver) {
1714
+ if (prop === IS_STORE)
1715
+ return true;
1716
+ if (prop === 'length')
1717
+ return lengthSignal;
1718
+ if (prop === Symbol.iterator) {
1719
+ return function* () {
1720
+ for (let i = 0; i < untracked(lengthSignal); i++) {
1721
+ yield receiver[i];
1722
+ }
1723
+ };
1724
+ }
1725
+ if (typeof prop === 'symbol' || SIGNAL_FN_PROP.has(prop))
1726
+ return target[prop];
1727
+ if (isIndexProp(prop)) {
1728
+ const idx = +prop;
1729
+ let storeCache = PROXY_CACHE.get(target);
1730
+ if (!storeCache) {
1731
+ storeCache = new Map();
1732
+ PROXY_CACHE.set(target, storeCache);
1733
+ }
1734
+ const cachedRef = storeCache.get(idx);
1735
+ if (cachedRef) {
1736
+ const cached = cachedRef.deref();
1737
+ if (cached)
1738
+ return cached;
1739
+ storeCache.delete(idx);
1740
+ PROXY_CLEANUP.unregister(cachedRef);
1741
+ }
1742
+ const value = untracked(target);
1743
+ const valueIsArray = Array.isArray(value);
1744
+ const valueIsRecord = isRecord(value);
1745
+ const equalFn = (valueIsRecord || valueIsArray) &&
1746
+ isMutableSource &&
1747
+ typeof value[idx] === 'object'
1748
+ ? () => false
1749
+ : undefined;
1750
+ const computation = valueIsRecord
1751
+ ? derived(target, idx, { equal: equalFn })
1752
+ : derived(target, {
1753
+ from: (v) => v?.[idx],
1754
+ onChange: (newValue) => target.update((v) => {
1755
+ if (v === null || v === undefined)
1756
+ return v;
1757
+ try {
1758
+ v[idx] = newValue;
1759
+ }
1760
+ catch (e) {
1761
+ if (isDevMode())
1762
+ console.error(`[store] Failed to set property "${String(idx)}"`, e);
1763
+ }
1764
+ return v;
1765
+ }),
1766
+ });
1767
+ const proxy = Array.isArray(untracked(computation))
1768
+ ? toArrayStore(computation, injector)
1769
+ : toStore(computation, injector);
1770
+ const ref = new WeakRef(proxy);
1771
+ storeCache.set(idx, ref);
1772
+ PROXY_CLEANUP.register(proxy, { target, prop: idx }, ref);
1773
+ return proxy;
1774
+ }
1775
+ return Reflect.get(target, prop, receiver);
1776
+ },
1777
+ });
1778
+ }
1779
+ /**
1780
+ * Converts a Signal into a deep-observable Store.
1781
+ * Accessing nested properties returns a derived Signal of that path.
1782
+ * @example
1783
+ * const state = store({ user: { name: 'John' } });
1784
+ * const nameSignal = state.user.name; // WritableSignal<string>
1785
+ */
1786
+ function toStore(source, injector) {
1787
+ if (isStore(source))
1788
+ return source;
1789
+ if (!injector)
1790
+ injector = inject(Injector);
1791
+ const writableSource = isWritableSignal(source)
1792
+ ? source
1793
+ : toWritable(source, () => {
1794
+ // noop
1795
+ });
1796
+ const isMutableSource = isMutable(writableSource);
1797
+ const s = new Proxy(writableSource, {
1798
+ has(_, prop) {
1799
+ return Reflect.has(untracked(source), prop);
1800
+ },
1801
+ ownKeys() {
1802
+ const v = untracked(source);
1803
+ if (!isRecord(v))
1804
+ return [];
1805
+ return Reflect.ownKeys(v);
1806
+ },
1807
+ getPrototypeOf() {
1808
+ return Object.getPrototypeOf(untracked(source));
1809
+ },
1810
+ getOwnPropertyDescriptor(_, prop) {
1811
+ const value = untracked(source);
1812
+ if (!isRecord(value) || !(prop in value))
1813
+ return;
1814
+ return {
1815
+ enumerable: true,
1816
+ configurable: true,
1817
+ };
1818
+ },
1819
+ get(target, prop) {
1820
+ if (prop === IS_STORE)
1821
+ return true;
1822
+ if (prop === 'asReadonlyStore')
1823
+ return () => {
1824
+ if (!isWritableSignal(source))
1825
+ return s;
1826
+ return untracked(() => toStore(source.asReadonly(), injector));
1827
+ };
1828
+ if (typeof prop === 'symbol' || SIGNAL_FN_PROP.has(prop))
1829
+ return target[prop];
1830
+ let storeCache = PROXY_CACHE.get(target);
1831
+ if (!storeCache) {
1832
+ storeCache = new Map();
1833
+ PROXY_CACHE.set(target, storeCache);
1834
+ }
1835
+ const cachedRef = storeCache.get(prop);
1836
+ if (cachedRef) {
1837
+ const cached = cachedRef.deref();
1838
+ if (cached)
1839
+ return cached;
1840
+ storeCache.delete(prop);
1841
+ PROXY_CLEANUP.unregister(cachedRef);
1842
+ }
1843
+ const value = untracked(target);
1844
+ const valueIsRecord = isRecord(value);
1845
+ const valueIsArray = Array.isArray(value);
1846
+ const equalFn = (valueIsRecord || valueIsArray) &&
1847
+ isMutableSource &&
1848
+ typeof value[prop] === 'object'
1849
+ ? () => false
1850
+ : undefined;
1851
+ const computation = valueIsRecord
1852
+ ? derived(target, prop, { equal: equalFn })
1853
+ : derived(target, {
1854
+ from: (v) => v?.[prop],
1855
+ onChange: (newValue) => target.update((v) => {
1856
+ if (v === null || v === undefined)
1857
+ return v;
1858
+ try {
1859
+ v[prop] = newValue;
1860
+ }
1861
+ catch (e) {
1862
+ if (isDevMode())
1863
+ console.error(`[store] Failed to set property "${String(prop)}"`, e);
1864
+ }
1865
+ return v;
1866
+ }),
1867
+ });
1868
+ const proxy = Array.isArray(untracked(computation))
1869
+ ? toArrayStore(computation, injector)
1870
+ : toStore(computation, injector);
1871
+ const ref = new WeakRef(proxy);
1872
+ storeCache.set(prop, ref);
1873
+ PROXY_CLEANUP.register(proxy, { target, prop }, ref);
1874
+ return proxy;
1875
+ },
1876
+ });
1877
+ return s;
1878
+ }
1879
+ /**
1880
+ * Creates a WritableSignalStore from a value.
1881
+ * @see {@link toStore}
1882
+ */
1883
+ function store(value, opt) {
1884
+ return toStore(signal(value, opt), opt?.injector);
1885
+ }
1886
+ /**
1887
+ * Creates a MutableSignalStore from a value.
1888
+ * @see {@link toStore}
1889
+ */
1890
+ function mutableStore(value, opt) {
1891
+ return toStore(mutable(value, opt), opt?.injector);
1892
+ }
1265
1893
 
1266
1894
  // Internal dummy store for server-side rendering
1267
1895
  const noopStore = {
@@ -1324,7 +1952,7 @@ const noopStore = {
1324
1952
  * }
1325
1953
  * ```
1326
1954
  */
1327
- function stored(fallback, { key, store: providedStore, serialize = JSON.stringify, deserialize = JSON.parse, syncTabs = false, equal = Object.is, onKeyChange = 'load', cleanupOldKey = false, ...rest }) {
1955
+ function stored(fallback, { key, store: providedStore, serialize = JSON.stringify, deserialize = JSON.parse, syncTabs = false, equal = Object.is, onKeyChange = 'load', cleanupOldKey = false, validate = () => true, ...rest }) {
1328
1956
  const isServer = isPlatformServer(inject(PLATFORM_ID));
1329
1957
  const fallbackStore = isServer ? noopStore : localStorage;
1330
1958
  const store = providedStore ?? fallbackStore;
@@ -1338,7 +1966,10 @@ function stored(fallback, { key, store: providedStore, serialize = JSON.stringif
1338
1966
  if (found === null)
1339
1967
  return null;
1340
1968
  try {
1341
- return deserialize(found);
1969
+ const deserialized = deserialize(found);
1970
+ if (!validate(deserialized))
1971
+ return null;
1972
+ return deserialized;
1342
1973
  }
1343
1974
  catch (err) {
1344
1975
  if (isDevMode())
@@ -1457,10 +2088,10 @@ class MessageBus {
1457
2088
  this.channel.removeEventListener('message', listener);
1458
2089
  this.listeners.delete(id);
1459
2090
  }
1460
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.1", ngImport: i0, type: MessageBus, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
1461
- static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.1", ngImport: i0, type: MessageBus, providedIn: 'root' });
2091
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.17", ngImport: i0, type: MessageBus, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
2092
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.17", ngImport: i0, type: MessageBus, providedIn: 'root' });
1462
2093
  }
1463
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.1", ngImport: i0, type: MessageBus, decorators: [{
2094
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.17", ngImport: i0, type: MessageBus, decorators: [{
1464
2095
  type: Injectable,
1465
2096
  args: [{
1466
2097
  providedIn: 'root',
@@ -1743,5 +2374,5 @@ function withHistory(source, opt) {
1743
2374
  * Generated bundle index. Do not edit.
1744
2375
  */
1745
2376
 
1746
- export { combineWith, debounce, debounced, derived, distinct, elementVisibility, filter, isDerivation, isMutable, map, mapArray, mediaQuery, mousePosition, mutable, nestedEffect, networkStatus, pageVisibility, pipeable, piped, prefersDarkMode, prefersReducedMotion, scrollPosition, select, sensor, stored, tabSync, tap, throttle, throttled, toFakeDerivation, toFakeSignalDerivation, toWritable, until, windowSize, withHistory };
2377
+ 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 };
1747
2378
  //# sourceMappingURL=mmstack-primitives.mjs.map