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