@mmstack/primitives 20.5.2 → 20.5.4

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
@@ -29,6 +29,7 @@ This library provides the following primitives:
29
29
  - `toWritable` - Converts a read-only signal to writable using custom write logic.
30
30
  - `derived` - Creates a signal with two-way binding to a source signal.
31
31
  - `chunked` - Creates a signal that time-slices an array into chunked values & emits thats array based on the provided options.
32
+ - `pooled` / `pooledArray` / `pooledMap` / `pooledSet` - Double-buffered object pools for `computed` signals; recycle the output container to remove allocation pressure in high-frequency recomputation.
32
33
  - `tabSync` - Low level primitive to "share" the value of a WritableSignal accross tabs via the BroadcastChannel api.
33
34
  - `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
35
  - `mediaQuery` - A generic primitive that tracks a CSS media query (forms the basis for `prefersDarkMode` and `prefersReducedMotion`).
@@ -59,6 +60,7 @@ import { FormsModule } from '@angular/forms';
59
60
  })
60
61
  export class SearchComponent {
61
62
  searchTerm = debounced('', { ms: 300 }); // Debounce for 300ms
63
+ example2 = debounce(signal(''), { ms: 300 }); // pattern for adding debounce to an existing signal
62
64
 
63
65
  constructor() {
64
66
  effect(() => {
@@ -93,7 +95,6 @@ import { JsonPipe } from '@angular/common';
93
95
 
94
96
  @Component({
95
97
  selector: 'app-throttle-demo',
96
- standalone: true,
97
98
  imports: [JsonPipe],
98
99
  template: `
99
100
  <div (mousemove)="onMouseMove($event)" style="width: 300px; height: 200px; border: 1px solid black; padding: 10px; user-select: none;">Move mouse here to see updates...</div>
@@ -195,7 +196,6 @@ import { stored } from '@mmstack/primitives';
195
196
 
196
197
  @Component({
197
198
  selector: 'app-theme-selector',
198
- standalone: true,
199
199
  // imports: [FormsModule], // Import if using ngModel
200
200
  template: `
201
201
  Theme:
@@ -273,7 +273,6 @@ import { JsonPipe } from '@angular/common';
273
273
 
274
274
  @Component({
275
275
  selector: 'app-store-demo',
276
- standalone: true,
277
276
  imports: [FormsModule, JsonPipe],
278
277
  template: `
279
278
  <h3>User Profile</h3>
@@ -657,6 +656,75 @@ export class HeavyListComponent {
657
656
  }
658
657
  ```
659
658
 
659
+ ### pooled / pooledArray / pooledMap / pooledSet
660
+
661
+ A double-buffered object pool for `computed` signal outputs. After a brief warm-up the pool reaches steady state with **zero allocations per recomputation** — two buffers are swapped on every read, with `reset` invoked before each `computation`. Each read returns a different identity from the previous read, so default `Object.is` equality still flags changes correctly. Most users will reach for the preset helpers (`pooledArray`, `pooledMap`, `pooledSet`); drop down to `pooled` only when you need a custom buffer type.
662
+
663
+ > **Retention contract:** the value returned from a pooled signal is only valid until the next read of that signal. The container is reused on the second-next read and will be `reset` first, mutating any reference you still hold. Do not store the result in component state, async closures, or anywhere outside the same reactive tick. Treat it as scratch output consumed synchronously.
664
+
665
+ Use these when a computed is recomputed at high frequency and produces a large allocation (filter/map outputs over big arrays, lookup indices, RAF-driven computeds). For typical UI computeds over small data, just use `computed` — the docs cost and footgun aren't worth saving an allocation that doesn't show up in a profile.
666
+
667
+ ```typescript
668
+ import { Component, signal } from '@angular/core';
669
+ import { pooledArray, pooledMap, pooledSet } from '@mmstack/primitives';
670
+
671
+ @Component({
672
+ selector: 'app-pooled-demo',
673
+ template: `<p>Active: {{ activeIds().length }} / {{ items().length }}</p>`,
674
+ })
675
+ export class PooledDemoComponent {
676
+ readonly items = signal(Array.from({ length: 10_000 }, (_, i) => ({ id: i, active: i % 2 === 0 })));
677
+
678
+ // Recycles a single number[] across recomputations.
679
+ readonly activeIds = pooledArray<number[]>((buf) => {
680
+ for (const item of this.items()) {
681
+ if (item.active) buf.push(item.id);
682
+ }
683
+ return buf;
684
+ });
685
+
686
+ // Recycles a Map for fast id → item lookups.
687
+ readonly byId = pooledMap<Map<number, { id: number; active: boolean }>>((buf) => {
688
+ for (const item of this.items()) buf.set(item.id, item);
689
+ return buf;
690
+ });
691
+
692
+ // Recycles a Set of distinct values.
693
+ readonly distinctFlags = pooledSet<Set<boolean>>((buf) => {
694
+ for (const item of this.items()) buf.add(item.active);
695
+ return buf;
696
+ });
697
+ }
698
+ ```
699
+
700
+ Need a custom buffer type (typed array, your own struct)? Use `pooled` directly:
701
+
702
+ ```typescript
703
+ import { signal } from '@angular/core';
704
+ import { pooled } from '@mmstack/primitives';
705
+
706
+ const source = signal<{ active: boolean }[]>([]);
707
+
708
+ // Pre-allocate both slots at construction (eager) — useful when create() is expensive.
709
+ const counters = pooled<{ total: number; active: number }>({
710
+ create: () => ({ total: 0, active: 0 }),
711
+ reset: (c) => {
712
+ c.total = 0;
713
+ c.active = 0;
714
+ },
715
+ computation: (c) => {
716
+ for (const item of source()) {
717
+ c.total++;
718
+ if (item.active) c.active++;
719
+ }
720
+ return c;
721
+ },
722
+ eager: true,
723
+ });
724
+ ```
725
+
726
+ Complementary to `linkedSignal` (which carries previous *state* forward, not the *container*) and `chunked` (which time-slices large outputs across frames).
727
+
660
728
  ### tabSync
661
729
 
662
730
  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.
@@ -680,7 +748,7 @@ import { tabSync } from '@mmstack/primitives';
680
748
  template: `
681
749
  <p>Open this page in two tabs!</p>
682
750
 
683
- <button (click)="counter.update(n => n + 1)">Count: {{ counter() }}</button>
751
+ <button (click)="counter.update((n) => n + 1)">Count: {{ counter() }}</button>
684
752
 
685
753
  <select [ngModel]="theme()" (ngModelChange)="theme.set($event)">
686
754
  <option value="light">Light</option>
@@ -713,7 +781,6 @@ import { Component, signal, effect } from '@angular/core';
713
781
 
714
782
  @Component({
715
783
  selector: 'app-history-demo',
716
- standalone: true,
717
784
  imports: [FormsModule, JsonPipe],
718
785
  template: `
719
786
  <h4>Simple Text Editor</h4>
@@ -780,7 +847,6 @@ import { JsonPipe } from '@angular/common';
780
847
 
781
848
  @Component({
782
849
  selector: 'app-mouse-tracker',
783
- standalone: true,
784
850
  imports: [JsonPipe],
785
851
  template: `
786
852
  <div (mousemove)="onMouseMove($event)" style="width: 300px; height: 200px; border: 1px solid black; padding: 10px; user-select: none;">Move mouse here...</div>
@@ -818,7 +884,6 @@ import { DatePipe } from '@angular/common';
818
884
 
819
885
  @Component({
820
886
  selector: 'app-network-info',
821
- standalone: true,
822
887
  imports: [DatePipe],
823
888
  template: `
824
889
  @if (netStatus()) {
@@ -849,7 +914,6 @@ import { sensor } from '@mmstack/primitives'; // Or import { pageVisibility }
849
914
 
850
915
  @Component({
851
916
  selector: 'app-visibility-logger',
852
- standalone: true,
853
917
  template: `<p>Page is currently: {{ visibility() }}</p>`,
854
918
  })
855
919
  export class VisibilityLoggerComponent {
@@ -876,7 +940,6 @@ import { sensor } from '@mmstack/primitives'; // Or import { windowSize }
876
940
 
877
941
  @Component({
878
942
  selector: 'app-responsive-display',
879
- standalone: true,
880
943
  template: `
881
944
  <p>Current Window Size: {{ winSize().width }}px x {{ winSize().height }}px</p>
882
945
  <p>Unthrottled: W: {{ winSize.unthrottled().width }} H: {{ winSize.unthrottled().height }}</p>
@@ -910,7 +973,6 @@ import { JsonPipe } from '@angular/common';
910
973
 
911
974
  @Component({
912
975
  selector: 'app-scroll-indicator',
913
- standalone: true,
914
976
  imports: [JsonPipe],
915
977
  template: `
916
978
  <div style="height: 100px; border-bottom: 2px solid red; position: fixed; top: 0; left: 0; width: 100%; background: white; z-index: 10;">
@@ -944,7 +1006,6 @@ import { mediaQuery, prefersDarkMode, prefersReducedMotion } from '@mmstack/prim
944
1006
 
945
1007
  @Component({
946
1008
  selector: 'app-layout-checker',
947
- standalone: true,
948
1009
  template: `
949
1010
  @if (isLargeScreen()) {
950
1011
  <p>Using large screen layout.</p>
@@ -1006,7 +1067,6 @@ import { elementVisibility } from '@mmstack/primitives';
1006
1067
 
1007
1068
  @Component({
1008
1069
  selector: 'app-lazy-load-item',
1009
- standalone: true,
1010
1070
  template: `
1011
1071
  <div #itemToObserve style="height: 300px; margin-top: 100vh; border: 2px solid green;">
1012
1072
  @if (intersectionEntry.visible()) {
@@ -138,6 +138,9 @@ function nestedEffect(effectFn, options) {
138
138
  manualCleanup: options?.manualCleanup ?? !!parent,
139
139
  });
140
140
  });
141
+ let unregisterCleanup;
142
+ if (!parent && !options?.manualCleanup)
143
+ unregisterCleanup = injector.get(DestroyRef).onDestroy(() => ref.destroy());
141
144
  const ref = {
142
145
  destroy: () => {
143
146
  if (isDestroyed)
@@ -145,10 +148,10 @@ function nestedEffect(effectFn, options) {
145
148
  isDestroyed = true;
146
149
  parent?.children.delete(ref);
147
150
  srcRef.destroy();
151
+ unregisterCleanup?.();
148
152
  },
149
153
  };
150
154
  parent?.children.add(ref);
151
- injector.get(DestroyRef).onDestroy(() => ref.destroy());
152
155
  return ref;
153
156
  }
154
157
 
@@ -841,6 +844,102 @@ function piped(initial, opt) {
841
844
  return pipeable(signal(initial, opt));
842
845
  }
843
846
 
847
+ /**
848
+ * A `Signal<U>` backed by a two-slot object pool: `create` is called at most
849
+ * twice over the pool's lifetime, and the two `T` instances are swapped on
850
+ * every recomputation with `reset` invoked on the dirty one before
851
+ * `computation` writes into it. Consecutive reads return different identities,
852
+ * so the default `Object.is` equality still flags changes.
853
+ *
854
+ * **Retention contract:** the returned value is only valid until the next
855
+ * recomputation of this signal. The container is recycled and `reset`,
856
+ * mutating any reference you still hold — do not store the result, pass it to
857
+ * async code, or hand it to consumers that outlive the current reactive tick.
858
+ *
859
+ * For collection buffers prefer the presets: {@link pooledArray},
860
+ * {@link pooledMap}, {@link pooledSet}.
861
+ *
862
+ * @see [Angular `linkedSignal`](https://angular.dev/api/core/linkedSignal) — carries previous *state* forward; complementary, not a substitute.
863
+ *
864
+ * @example
865
+ * ```ts
866
+ * const source = signal<{ active: boolean }[]>([]);
867
+ *
868
+ * const counters = pooled<{ total: number; active: number }>({
869
+ * create: () => ({ total: 0, active: 0 }),
870
+ * reset: (c) => { c.total = 0; c.active = 0; },
871
+ * computation: (c) => {
872
+ * for (const item of source()) { c.total++; if (item.active) c.active++; }
873
+ * return c;
874
+ * },
875
+ * });
876
+ * ```
877
+ */
878
+ function pooled({ create, reset, computation, ...opt }) {
879
+ let other = opt.eager ? create() : undefined;
880
+ let current = opt.eager ? create() : undefined;
881
+ let otherFresh = opt.eager;
882
+ let currentFresh = opt.eager;
883
+ return computed(() => {
884
+ let next;
885
+ let nextFresh;
886
+ if (other !== undefined) {
887
+ next = other;
888
+ nextFresh = !!otherFresh;
889
+ }
890
+ else {
891
+ next = untracked(() => create());
892
+ nextFresh = true;
893
+ }
894
+ if (current !== undefined) {
895
+ other = current;
896
+ otherFresh = currentFresh;
897
+ }
898
+ current = next;
899
+ // the buffer is about to be mutated by `computation`, so it's no longer fresh
900
+ currentFresh = false;
901
+ const clean = nextFresh ? next : (untracked(() => reset(next)) ?? next);
902
+ return computation(clean);
903
+ }, opt);
904
+ }
905
+
906
+ function toPooledOptions(optOrComputation, create, reset, signalOpt) {
907
+ const opt = typeof optOrComputation === 'object' ? optOrComputation : signalOpt;
908
+ const computation = typeof optOrComputation === 'function'
909
+ ? optOrComputation
910
+ : optOrComputation.computation;
911
+ return {
912
+ create,
913
+ reset,
914
+ computation,
915
+ ...opt,
916
+ };
917
+ }
918
+ function createEmptyArray() {
919
+ return [];
920
+ }
921
+ function resetArray(arr) {
922
+ arr.length = 0;
923
+ }
924
+ function pooledArray(optOrComputation, signalOpt) {
925
+ return pooled(toPooledOptions(optOrComputation, createEmptyArray, resetArray, signalOpt));
926
+ }
927
+ function createEmptySet() {
928
+ return new Set();
929
+ }
930
+ function resetClearable(clearable) {
931
+ clearable.clear();
932
+ }
933
+ function pooledSet(optOrComputation, signalOpt) {
934
+ return pooled(toPooledOptions(optOrComputation, createEmptySet, resetClearable, signalOpt));
935
+ }
936
+ function createEmptyMap() {
937
+ return new Map();
938
+ }
939
+ function pooledMap(optOrComputation, signalOpt) {
940
+ return pooled(toPooledOptions(optOrComputation, createEmptyMap, resetClearable, signalOpt));
941
+ }
942
+
844
943
  function observerSupported$1() {
845
944
  return typeof ResizeObserver !== 'undefined';
846
945
  }
@@ -1940,7 +2039,6 @@ const noopStore = {
1940
2039
  *
1941
2040
  * @Component({
1942
2041
  * selector: 'app-settings',
1943
- * standalone: true,
1944
2042
  * template: `
1945
2043
  * Theme:
1946
2044
  * <select [ngModel]="theme()" (ngModelChange)="theme.set($event)">
@@ -2378,5 +2476,5 @@ function withHistory(source, opt) {
2378
2476
  * Generated bundle index. Do not edit.
2379
2477
  */
2380
2478
 
2381
- 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 };
2479
+ 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, pooled, pooledArray, pooledMap, pooledSet, prefersDarkMode, prefersReducedMotion, scrollPosition, select, sensor, sensors, store, stored, tabSync, tap, throttle, throttled, toFakeDerivation, toFakeSignalDerivation, toStore, toWritable, until, windowSize, withHistory };
2382
2480
  //# sourceMappingURL=mmstack-primitives.mjs.map