@mmstack/primitives 21.0.15 → 21.0.17

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.
package/README.md CHANGED
@@ -20,15 +20,27 @@ This library provides the following primitives:
20
20
  - `mutable` - A signal variant allowing in-place mutations while triggering updates.
21
21
  - `stored` - Creates a signal synchronized with persistent storage (e.g., localStorage).
22
22
  - `piped` – Creates a signal with a chainable & typesafe `.pipe(...)` method, which returns a pipable computed.
23
+ - `store` - A deep-reactivity proxy, with Array & Record support.
23
24
  - `withHistory` - Enhances a signal with a complete undo/redo history stack.
24
- - `mapArray` - Maps a reactive array efficently into an array of stable derivations.
25
+ - `indexArray` - Maps a reactive array by index into an array of stable derivations.
26
+ - `keyArray` - Maps a reactive array by key (track by) into an array of stable derivations.
27
+ - `mapObject` - Maps a reactive object by key (track by) into an object of stable derivations.
25
28
  - `nestedEffect` - Creates an effect with a hierarchical lifetime, enabling fine-grained, conditional side-effects.
26
29
  - `toWritable` - Converts a read-only signal to writable using custom write logic.
27
30
  - `derived` - Creates a signal with two-way binding to a source signal.
31
+ - `chunked` - Creates a signal that time-slices an array into chunked values & emits thats array based on the provided options.
32
+ - `tabSync` - Low level primitive to "share" the value of a WritableSignal accross tabs via the BroadcastChannel api.
28
33
  - `sensor` - A facade function to create various reactive sensor signals (e.g., mouse position, network status, page visibility, dark mode preference)." (This was the suggestion from before; it just reads a little smoother and more accurately reflects what the facade creates directly).
34
+ - `mediaQuery` - A generic primitive that tracks a CSS media query (forms the basis for `prefersDarkMode` and `prefersReducedMotion`).
35
+ - `elementVisibility` - Tracks if an element is intersecting the viewport using IntersectionObserver.
36
+ - `elementSize` - Tracks the size of the DOM element
37
+ - `mediaQuery` - Creates a signal that reacts to changes based on the provided media queries "truthyness". Additional helpers such as `prefersDarkMode` and `prefersReducedMotion` available
38
+ - `mousePosition` - Throttled signal that reacts to the mouses position within a given element
39
+ - `networkStatus` - A signal of the current network status, used my @mmstack/resource
40
+ - `pageVisibility` - A signal useful when reacting to the user switching tabs
41
+ - `scrollPosition` - A throttled signal of the current scroll position within a given element
42
+ - `windowSize` - A throttled signal useful to reacting to window resize events
29
43
  - `until` - Creates a Promise that resolves when a signal's value meets a specific condition.
30
- - `mediaQuery` - A generic primitive that tracks a CSS media query (forms the basis for `prefersDarkMode` and `prefersReducedMotion`).
31
- - `elementVisibility` - Tracks if an element is intersecting the viewport using IntersectionObserver.
32
44
 
33
45
  ---
34
46
 
@@ -241,13 +253,122 @@ label(); // e.g., "#2"
241
253
  total(); // reactive sum
242
254
  ```
243
255
 
244
- ### mapArray
256
+ ### store / mutableStore / toStore
257
+
258
+ Provides "Deep Reactivity" by creating a proxy around a source object or array. Instead of reading raw values, accessing a property on a store returns a Signal representing that specific property.
259
+ This allows you to pass specific slices of a large state object to child components as Input() signals, or bind directly to nested properties without manually creating computed or derived selectors.
260
+ Propagates mutablity/writability down the chain so if the source is a WritableSignal all children are WritableSignal derivations.
261
+
262
+ #### Features:
263
+
264
+ - Lazy Generation: Sub-signals are created only when accessed.
265
+ - Caching: Accessed signals are cached (via WeakRef), so accessing state.user.name multiple times returns the exact same signal instance.
266
+ - Array Support: Array signals provide reactive access to indices (e.g., state.users[0])
267
+
268
+ ```ts
269
+ import { Component, effect } from '@angular/core';
270
+ import { store, mutableStore } from '@mmstack/primitives';
271
+ import { FormsModule } from '@angular/forms';
272
+ import { JsonPipe } from '@angular/common';
273
+
274
+ @Component({
275
+ selector: 'app-store-demo',
276
+ standalone: true,
277
+ imports: [FormsModule, JsonPipe],
278
+ template: `
279
+ <h3>User Profile</h3>
280
+ <p>Name: {{ state.user.name() }}</p>
281
+
282
+ <input [ngModel]="state.user.name()" (ngModelChange)="state.user.name.set($event)" />
283
+
284
+ <h3>Settings (Mutable)</h3>
285
+ <label>
286
+ <input type="checkbox" [checked]="settings.notifications.email()" (change)="toggleEmail()" />
287
+ Email Notifications
288
+ </label>
289
+
290
+ <pre>{{ state() | json }}</pre>
291
+ `,
292
+ })
293
+ export class StoreDemoComponent {
294
+ // 1. Standard Store
295
+ state = store({
296
+ user: {
297
+ name: 'Alice',
298
+ address: { city: 'New York', zip: 10001 },
299
+ },
300
+ tags: ['admin', 'editor'],
301
+ });
302
+
303
+ // 2. Mutable Store (allows .mutate/.inline)
304
+ settings = mutableStore({
305
+ theme: 'dark',
306
+ notifications: { email: true, sms: false },
307
+ });
308
+
309
+ constructor() {
310
+ // Effect tracks only the specific slice accessed
311
+ effect(() => {
312
+ console.log('City changed to:', this.state.user.address.city());
313
+ });
314
+
315
+ // Array access returns a signal for that index
316
+ const firstTag = this.state.tags[0];
317
+ console.log('First tag:', firstTag()); // 'admin'
318
+ }
319
+
320
+ updateZip() {
321
+ // You can set deep properties directly
322
+ this.state.user.address.zip.set(90210);
323
+ }
324
+
325
+ toggleEmail() {
326
+ // With mutableStore, you can use .mutate on the root or sub-signals
327
+ this.settings.notifications.mutate((n) => {
328
+ n.email = !n.email;
329
+ });
330
+ }
331
+ }
332
+ ```
333
+
334
+ ### Array Stores
335
+
336
+ When a store holds an array, the array itself is a signal, but you can also access indices as signals. Additionally Array stores also expose a `.length` signal & support Symbol.Iterator.
337
+ Currently the array store function isn't exposed, but they are automatically created when a given property within the store is an array. Hit me up, if you need top-level array support, though in those cases you're probably looking for `indexArray` / `keyArray`
338
+
339
+ ```ts
340
+ const state = store({
341
+ todos: [
342
+ { id: 1, text: 'Buy Milk', done: false },
343
+ { id: 2, text: 'Walk Dog', done: true },
344
+ ],
345
+ });
346
+
347
+ const firstTodo = state.todos[0]; // Signal<{ text: string, ... }>
348
+ const firstTodoText = state.todos[0].text; // Signal<string>
349
+
350
+ // Update specific item property without replacing the whole array
351
+ state.todos[0].done.set(true);
352
+
353
+ const len = state.todos.length(); // reacts to length changes
354
+
355
+ for (const todo of state.todos) {
356
+ const t = todo(); // iteration returns proxied children
357
+ const id = todo.id();
358
+ }
359
+ ```
360
+
361
+ ### indexArray/keyArray
245
362
 
246
363
  Reactive map helper that stabilizes a source array Signal by length. It provides stability by giving the mapping function a stable Signal<T> for each item based on its index. Sub signals are not re-created, rather they propagate value updates through. This is particularly useful for rendering lists (@for) as it minimizes DOM changes when array items change identity but represent the same conceptual entity.
247
364
 
365
+ `keyArray` is similar, but stabilizes/reconciles via a provided track by function instead of the index. This is computationally more expensive, so use it when "identity" stability is more important than simply data pass-through
366
+
367
+ Both utilize memory pooling to "ease" GC pressure.
368
+
248
369
  ```typescript
249
370
  import { Component, signal } from '@angular/core';
250
- import { mapArray, mutable } from '@mmstack/primitives';
371
+ import { indexArray, keyArray, mutable } from '@mmstack/primitives';
251
372
 
252
373
  @Component({
253
374
  selector: 'app-map-demo',
@@ -269,7 +390,13 @@ export class ListComponent {
269
390
  { id: 2, name: 'B' },
270
391
  ]);
271
392
 
272
- readonly displayItems = mapArray(this.sourceItems, (child, index) => computed(() => `Item ${index}: ${child().name}`));
393
+ readonly displayItems = indexArray(this.sourceItems, (child, index) => computed(() => `Item ${index}: ${child().name}`));
394
+
395
+ // keyArray is similar, but the index becomes dynamic & the child object is static
396
+ readonly keyed = keyArray(this.sourceItems, (child, index) => computed(() => `Item ${index()}: ${child.name}}`), {
397
+ key: (item) => item.id
398
+ });
399
+
273
400
 
274
401
  addItem() {
275
402
  this.sourceItems.update((items) => [...items, { id: Date.now(), name: String.fromCharCode(67 + items.length - 2) }]);
@@ -278,12 +405,12 @@ export class ListComponent {
278
405
  updateFirst() {
279
406
  this.sourceItems.update((items) => {
280
407
  items[0] = { ...items[0], name: items[0].name + '+' };
281
- return [...items]; // New array, but mapArray keeps stable signals
408
+ return [...items]; // New array, but indexArray keeps stable signals
282
409
  });
283
410
  }
284
411
 
285
412
  // since the underlying source is a signal we can also create updaters in the mapper
286
- readonly updatableItems = mapArray(this.sourceItems, (child, index) => {
413
+ readonly updatableItems = indexArray(this.sourceItems, (child, index) => {
287
414
 
288
415
  return {
289
416
  value: computed(() => `Item ${index}: ${child().name}`))
@@ -293,7 +420,7 @@ export class ListComponent {
293
420
 
294
421
 
295
422
  // since the underlying source is a WritableSignal we can also create updaters in the mapper
296
- readonly writableItems = mapArray(this.sourceItems, (child, index) => {
423
+ readonly writableItems = indexArray(this.sourceItems, (child, index) => {
297
424
 
298
425
  return {
299
426
  value: computed(() => `Item ${index}: ${child().name}`))
@@ -307,7 +434,7 @@ export class ListComponent {
307
434
  { id: 2, name: 'B' },
308
435
  ]);
309
436
 
310
- readonly mutableItems = mapArray(this.sourceItems, (child, index) => {
437
+ readonly mutableItems = indexArray(this.sourceItems, (child, index) => {
311
438
 
312
439
  return {
313
440
  value: computed(() => `Item ${index}: ${child().name}`))
@@ -319,6 +446,62 @@ export class ListComponent {
319
446
  }
320
447
  ```
321
448
 
449
+ ### mapObject
450
+
451
+ Projects a reactive object (Record<string, T>) into a new object (Record<string, U>), maintaining referential stability for values associated with unchanged keys. This is the Object-equivalent of keyArray.
452
+
453
+ The projection function receives the key and a value Signal. If the source is a WritableSignal or MutableSignal, the provided value signal is also writable (via derived), allowing child components or logic to update the specific property in the source object directly.
454
+
455
+ ```ts
456
+ import { Component, signal, computed } from '@angular/core';
457
+ import { mapObject } from '@mmstack/primitives';
458
+
459
+ @Component({
460
+ selector: 'app-settings',
461
+ template: `
462
+ @for (key of objectKeys(controls()); track key) {
463
+ <div class="setting">
464
+ <span>{{ controls()[key].label }}</span>
465
+ <button (click)="controls()[key].toggle()">
466
+ {{ controls()[key].isActive() ? 'ON' : 'OFF' }}
467
+ </button>
468
+ </div>
469
+ }
470
+ `,
471
+ })
472
+ export class SettingsComponent {
473
+ objectKeys = Object.keys;
474
+
475
+ // Source state
476
+ readonly settings = signal<Record<string, boolean>>({
477
+ wifi: true,
478
+ bluetooth: false,
479
+ });
480
+
481
+ // Mapped object: { [key]: { label, isActive, toggle } }
482
+ readonly controls = mapObject(
483
+ this.settings,
484
+ (key, value) => {
485
+ // 'value' is a WritableSignal linked to this specific property
486
+ return {
487
+ label: key.toUpperCase(),
488
+ isActive: value, // Expose as ReadOnly for template
489
+ toggle: () => value.update((v) => !v),
490
+ destroy: () => console.log(`Cleanup logic for ${key}`),
491
+ };
492
+ },
493
+ {
494
+ // Optional cleanup hook when a key is removed from the source
495
+ onDestroy: (mappedItem) => mappedItem.destroy(),
496
+ },
497
+ );
498
+
499
+ addSetting() {
500
+ this.settings.update((s) => ({ ...s, airdrop: false }));
501
+ }
502
+ }
503
+ ```
504
+
322
505
  ### nestedEffect
323
506
 
324
507
  Creates an effect that can be nested, similar to SolidJS's `createEffect`.
@@ -366,11 +549,11 @@ export class NestedDemoComponent {
366
549
 
367
550
  #### Advanced Example: Fine-grained Lists
368
551
 
369
- `nestedEffect` can be composed with `mapArray` to create truly fine-grained reactive lists, where each item can manage its own side-effects (like external library integrations) that are automatically cleaned up when the item is removed.
552
+ `nestedEffect` can be composed with `indexArray` to create truly fine-grained reactive lists, where each item can manage its own side-effects (like external library integrations) that are automatically cleaned up when the item is removed.
370
553
 
371
554
  ```ts
372
555
  import { Component, signal, computed } from '@angular/core';
373
- import { mapArray, nestedEffect } from '@mmstack/primitives';
556
+ import { indexArray, nestedEffect } from '@mmstack/primitives';
374
557
 
375
558
  @Component({ selector: 'app-list-demo' })
376
559
  export class ListDemoComponent {
@@ -379,8 +562,8 @@ export class ListDemoComponent {
379
562
  { id: 2, name: 'Bob' },
380
563
  ]);
381
564
 
382
- // mapArray creates stable signals for each item
383
- readonly mappedUsers = mapArray(
565
+ // indexArray creates stable signals for each item
566
+ readonly mappedUsers = indexArray(
384
567
  this.users,
385
568
  (userSignal, index) => {
386
569
  // Create a side-effect tied to THIS item's lifetime
@@ -399,7 +582,7 @@ export class ListDemoComponent {
399
582
  };
400
583
  },
401
584
  {
402
- // When mapArray removes an item, it calls `onDestroy`
585
+ // When indexArray removes an item, it calls `onDestroy`
403
586
  onDestroy: (mappedItem) => {
404
587
  mappedItem._destroy(); // Manually destroy the nested effect
405
588
  },
@@ -440,6 +623,84 @@ const name2 = derived(user, {
440
623
  });
441
624
  ```
442
625
 
626
+ ### chunked
627
+
628
+ Creates a Signal that progressively emits segments of a source array, chunk by chunk. This is a time-slicing primitive designed to keep the main thread responsive when rendering large lists or processing heavy data sets.
629
+
630
+ Instead of rendering 10,000 items at once (which would freeze the UI), chunked emits the first batch immediately, then schedules the next batch to be added in the next frame (or after a delay), repeating until the full list is visible. If the source array changes, the process resets and starts over from the first chunk.
631
+
632
+ ```ts
633
+ import { Component, signal } from '@angular/core';
634
+ import { chunked } from '@mmstack/primitives';
635
+
636
+ @Component({
637
+ selector: 'app-heavy-list',
638
+ template: `
639
+ <div class="status-bar">Loaded: {{ visibleItems().length }} / {{ allItems().length }}</div>
640
+
641
+ <ul>
642
+ @for (item of visibleItems(); track item.id) {
643
+ <li>{{ item.label }}</li>
644
+ }
645
+ </ul>
646
+ `,
647
+ })
648
+ export class HeavyListComponent {
649
+ // A heavy source with 10,000 items
650
+ readonly allItems = signal(Array.from({ length: 10000 }, (_, i) => ({ id: i, label: `Item #${i}` })));
651
+
652
+ // Process 100 items per animation frame to prevent UI blocking
653
+ readonly visibleItems = chunked(this.allItems, {
654
+ chunkSize: 100,
655
+ delay: 'frame', // 'frame' | 'microtask' | number (ms)
656
+ });
657
+ }
658
+ ```
659
+
660
+ ### tabSync
661
+
662
+ A low-level primitive that synchronizes a WritableSignal across multiple browser tabs or windows of the same application using the BroadcastChannel API. Used by the cache in @mmstack/resource & the stored signal.
663
+
664
+ When the signal is updated in one tab, the new value is broadcast and automatically set in the corresponding signal in all other open tabs. This is ideal for synchronizing global state like user sessions, theme preferences, or shopping cart data.
665
+
666
+ #### Key Features:
667
+
668
+ - SSR Safe: Gracefully degrades to a standard signal on the server.
669
+ - Automatic Cleanup: Handles event listeners and disconnects when the injection context is destroyed.
670
+ - Smart ID Generation: Can auto-generate IDs for rapid prototyping, but supports explicit IDs for production stability.
671
+
672
+ Note: While tabSync attempts to generate a deterministic ID based on the call site, it is highly recommended to provide a manual id string in production to ensure stability across different builds and minification processes.
673
+
674
+ ```ts
675
+ import { Component, signal } from '@angular/core';
676
+ import { tabSync } from '@mmstack/primitives';
677
+
678
+ @Component({
679
+ selector: 'app-sync-demo',
680
+ template: `
681
+ <p>Open this page in two tabs!</p>
682
+
683
+ <button (click)="counter.update(n => n + 1)">Count: {{ counter() }}</button>
684
+
685
+ <select [ngModel]="theme()" (ngModelChange)="theme.set($event)">
686
+ <option value="light">Light</option>
687
+ <option value="dark">Dark</option>
688
+ </select>
689
+ `,
690
+ })
691
+ export class SyncDemoComponent {
692
+ // 1. Quick usage (Auto-ID)
693
+ // Good for dev, but ID might change if code moves lines/files
694
+ readonly counter = tabSync(signal(0));
695
+
696
+ // 2. Production usage (Explicit ID)
697
+ // Recommended: Ensures tabs always find each other regardless of minification
698
+ readonly theme = tabSync(signal('light'), {
699
+ id: 'global-app-theme',
700
+ });
701
+ }
702
+ ```
703
+
443
704
  ### withHistory
444
705
 
445
706
  Enhances any WritableSignal with a complete undo/redo history stack. This is useful for building user-friendly editors, forms, or any feature where reverting changes is necessary. It provides .undo(), .redo(), and .clear() methods, along with reactive boolean signals like .canUndo and .canRedo to easily enable or disable UI controls.
@@ -1619,12 +1619,6 @@ function sensors(track, opt) {
1619
1619
  }, {});
1620
1620
  }
1621
1621
 
1622
- function isRecord(value) {
1623
- if (value === null || typeof value !== 'object')
1624
- return false;
1625
- const proto = Object.getPrototypeOf(value);
1626
- return proto === Object.prototype || proto === null;
1627
- }
1628
1622
  const IS_STORE = Symbol('MMSTACK::IS_STORE');
1629
1623
  const PROXY_CACHE = new WeakMap();
1630
1624
  const SIGNAL_FN_PROP = new Set([
@@ -1634,7 +1628,13 @@ const SIGNAL_FN_PROP = new Set([
1634
1628
  'inline',
1635
1629
  'asReadonly',
1636
1630
  ]);
1631
+ const PROXY_CLEANUP = new FinalizationRegistry(({ target, prop }) => {
1632
+ const storeCache = PROXY_CACHE.get(target);
1633
+ if (storeCache)
1634
+ storeCache.delete(prop);
1635
+ });
1637
1636
  /**
1637
+ * @internal
1638
1638
  * Validates whether a value is a Signal Store.
1639
1639
  */
1640
1640
  function isStore(value) {
@@ -1642,8 +1642,134 @@ function isStore(value) {
1642
1642
  value !== null &&
1643
1643
  value[IS_STORE] === true);
1644
1644
  }
1645
+ function isIndexProp(prop) {
1646
+ return typeof prop === 'string' && prop.trim() !== '' && !isNaN(+prop);
1647
+ }
1648
+ function isRecord(value) {
1649
+ if (value === null || typeof value !== 'object')
1650
+ return false;
1651
+ const proto = Object.getPrototypeOf(value);
1652
+ return proto === Object.prototype || proto === null;
1653
+ }
1654
+ /**
1655
+ * @internal
1656
+ * Makes an array store
1657
+ */
1658
+ function toArrayStore(source, injector) {
1659
+ if (isStore(source))
1660
+ return source;
1661
+ const isMutableSource = isMutable(source);
1662
+ const lengthSignal = computed(() => {
1663
+ const v = source();
1664
+ if (!Array.isArray(v))
1665
+ return 0;
1666
+ return v.length;
1667
+ }, ...(ngDevMode ? [{ debugName: "lengthSignal" }] : []));
1668
+ return new Proxy(source, {
1669
+ has(_, prop) {
1670
+ if (prop === 'length')
1671
+ return true;
1672
+ if (isIndexProp(prop)) {
1673
+ const idx = +prop;
1674
+ return idx >= 0 && idx < untracked(lengthSignal);
1675
+ }
1676
+ return Reflect.has(untracked(source), prop);
1677
+ },
1678
+ ownKeys() {
1679
+ const v = untracked(source);
1680
+ if (!Array.isArray(v))
1681
+ return [];
1682
+ const len = v.length;
1683
+ const arr = new Array(len + 1);
1684
+ for (let i = 0; i < len; i++) {
1685
+ arr[i] = String(i);
1686
+ }
1687
+ arr[len] = 'length';
1688
+ return arr;
1689
+ },
1690
+ getPrototypeOf() {
1691
+ return Array.prototype;
1692
+ },
1693
+ getOwnPropertyDescriptor(_, prop) {
1694
+ const v = untracked(source);
1695
+ if (!Array.isArray(v))
1696
+ return;
1697
+ if (prop === 'length' ||
1698
+ (typeof prop === 'string' && !isNaN(+prop) && +prop < v.length)) {
1699
+ return {
1700
+ enumerable: true,
1701
+ configurable: true, // Required for proxies to dynamic targets
1702
+ };
1703
+ }
1704
+ return;
1705
+ },
1706
+ get(target, prop, receiver) {
1707
+ if (prop === IS_STORE)
1708
+ return true;
1709
+ if (prop === 'length')
1710
+ return lengthSignal;
1711
+ if (prop === Symbol.iterator) {
1712
+ return function* () {
1713
+ for (let i = 0; i < untracked(lengthSignal); i++) {
1714
+ yield receiver[i];
1715
+ }
1716
+ };
1717
+ }
1718
+ if (typeof prop === 'symbol' || SIGNAL_FN_PROP.has(prop))
1719
+ return target[prop];
1720
+ if (isIndexProp(prop)) {
1721
+ const idx = +prop;
1722
+ let storeCache = PROXY_CACHE.get(target);
1723
+ if (!storeCache) {
1724
+ storeCache = new Map();
1725
+ PROXY_CACHE.set(target, storeCache);
1726
+ }
1727
+ const cachedRef = storeCache.get(idx);
1728
+ if (cachedRef) {
1729
+ const cached = cachedRef.deref();
1730
+ if (cached)
1731
+ return cached;
1732
+ storeCache.delete(idx);
1733
+ PROXY_CLEANUP.unregister(cachedRef);
1734
+ }
1735
+ const value = untracked(target);
1736
+ const valueIsArray = Array.isArray(value);
1737
+ const valueIsRecord = isRecord(value);
1738
+ const equalFn = (valueIsRecord || valueIsArray) &&
1739
+ isMutableSource &&
1740
+ typeof value[idx] === 'object'
1741
+ ? () => false
1742
+ : undefined;
1743
+ const computation = valueIsRecord
1744
+ ? derived(target, idx, { equal: equalFn })
1745
+ : derived(target, {
1746
+ from: (v) => v?.[idx],
1747
+ onChange: (newValue) => target.update((v) => {
1748
+ if (v === null || v === undefined)
1749
+ return v;
1750
+ try {
1751
+ v[idx] = newValue;
1752
+ }
1753
+ catch (e) {
1754
+ if (isDevMode())
1755
+ console.error(`[store] Failed to set property "${String(idx)}"`, e);
1756
+ }
1757
+ return v;
1758
+ }),
1759
+ });
1760
+ const proxy = Array.isArray(untracked(computation))
1761
+ ? toArrayStore(computation, injector)
1762
+ : toStore(computation, injector);
1763
+ const ref = new WeakRef(proxy);
1764
+ storeCache.set(idx, ref);
1765
+ PROXY_CLEANUP.register(proxy, { target, prop: idx }, ref);
1766
+ return proxy;
1767
+ }
1768
+ return Reflect.get(target, prop, receiver);
1769
+ },
1770
+ });
1771
+ }
1645
1772
  /**
1646
- * @experimental This API is experimental and may change or be removed in future releases.
1647
1773
  * Converts a Signal into a deep-observable Store.
1648
1774
  * Accessing nested properties returns a derived Signal of that path.
1649
1775
  * @example
@@ -1667,7 +1793,12 @@ function toStore(source, injector) {
1667
1793
  },
1668
1794
  ownKeys() {
1669
1795
  const v = untracked(source);
1670
- return isRecord(v) ? Reflect.ownKeys(v) : [];
1796
+ if (!isRecord(v))
1797
+ return [];
1798
+ return Reflect.ownKeys(v);
1799
+ },
1800
+ getPrototypeOf() {
1801
+ return Object.getPrototypeOf(untracked(source));
1671
1802
  },
1672
1803
  getOwnPropertyDescriptor(_, prop) {
1673
1804
  const value = untracked(source);
@@ -1700,10 +1831,14 @@ function toStore(source, injector) {
1700
1831
  if (cached)
1701
1832
  return cached;
1702
1833
  storeCache.delete(prop);
1834
+ PROXY_CLEANUP.unregister(cachedRef);
1703
1835
  }
1704
1836
  const value = untracked(target);
1705
1837
  const valueIsRecord = isRecord(value);
1706
- const equalFn = valueIsRecord && isMutableSource && typeof value[prop] === 'object'
1838
+ const valueIsArray = Array.isArray(value);
1839
+ const equalFn = (valueIsRecord || valueIsArray) &&
1840
+ isMutableSource &&
1841
+ typeof value[prop] === 'object'
1707
1842
  ? () => false
1708
1843
  : undefined;
1709
1844
  const computation = valueIsRecord
@@ -1723,18 +1858,30 @@ function toStore(source, injector) {
1723
1858
  return v;
1724
1859
  }),
1725
1860
  });
1726
- const proxy = toStore(computation, injector);
1727
- storeCache.set(prop, new WeakRef(proxy));
1861
+ const proxy = Array.isArray(untracked(computation))
1862
+ ? toArrayStore(computation, injector)
1863
+ : toStore(computation, injector);
1864
+ const ref = new WeakRef(proxy);
1865
+ storeCache.set(prop, ref);
1866
+ PROXY_CLEANUP.register(proxy, { target, prop }, ref);
1728
1867
  return proxy;
1729
1868
  },
1730
1869
  });
1731
1870
  return s;
1732
1871
  }
1872
+ /**
1873
+ * Creates a WritableSignalStore from a value.
1874
+ * @see {@link toStore}
1875
+ */
1733
1876
  function store(value, opt) {
1734
- return toStore(signal(value, opt));
1877
+ return toStore(signal(value, opt), opt?.injector);
1735
1878
  }
1879
+ /**
1880
+ * Creates a MutableSignalStore from a value.
1881
+ * @see {@link toStore}
1882
+ */
1736
1883
  function mutableStore(value, opt) {
1737
- return toStore(mutable(value, opt));
1884
+ return toStore(mutable(value, opt), opt?.injector);
1738
1885
  }
1739
1886
 
1740
1887
  // Internal dummy store for server-side rendering