@mmstack/primitives 20.5.3 → 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`).
@@ -94,7 +95,6 @@ import { JsonPipe } from '@angular/common';
94
95
 
95
96
  @Component({
96
97
  selector: 'app-throttle-demo',
97
- standalone: true,
98
98
  imports: [JsonPipe],
99
99
  template: `
100
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>
@@ -196,7 +196,6 @@ import { stored } from '@mmstack/primitives';
196
196
 
197
197
  @Component({
198
198
  selector: 'app-theme-selector',
199
- standalone: true,
200
199
  // imports: [FormsModule], // Import if using ngModel
201
200
  template: `
202
201
  Theme:
@@ -274,7 +273,6 @@ import { JsonPipe } from '@angular/common';
274
273
 
275
274
  @Component({
276
275
  selector: 'app-store-demo',
277
- standalone: true,
278
276
  imports: [FormsModule, JsonPipe],
279
277
  template: `
280
278
  <h3>User Profile</h3>
@@ -658,6 +656,75 @@ export class HeavyListComponent {
658
656
  }
659
657
  ```
660
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
+
661
728
  ### tabSync
662
729
 
663
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.
@@ -681,7 +748,7 @@ import { tabSync } from '@mmstack/primitives';
681
748
  template: `
682
749
  <p>Open this page in two tabs!</p>
683
750
 
684
- <button (click)="counter.update(n => n + 1)">Count: {{ counter() }}</button>
751
+ <button (click)="counter.update((n) => n + 1)">Count: {{ counter() }}</button>
685
752
 
686
753
  <select [ngModel]="theme()" (ngModelChange)="theme.set($event)">
687
754
  <option value="light">Light</option>
@@ -714,7 +781,6 @@ import { Component, signal, effect } from '@angular/core';
714
781
 
715
782
  @Component({
716
783
  selector: 'app-history-demo',
717
- standalone: true,
718
784
  imports: [FormsModule, JsonPipe],
719
785
  template: `
720
786
  <h4>Simple Text Editor</h4>
@@ -781,7 +847,6 @@ import { JsonPipe } from '@angular/common';
781
847
 
782
848
  @Component({
783
849
  selector: 'app-mouse-tracker',
784
- standalone: true,
785
850
  imports: [JsonPipe],
786
851
  template: `
787
852
  <div (mousemove)="onMouseMove($event)" style="width: 300px; height: 200px; border: 1px solid black; padding: 10px; user-select: none;">Move mouse here...</div>
@@ -819,7 +884,6 @@ import { DatePipe } from '@angular/common';
819
884
 
820
885
  @Component({
821
886
  selector: 'app-network-info',
822
- standalone: true,
823
887
  imports: [DatePipe],
824
888
  template: `
825
889
  @if (netStatus()) {
@@ -850,7 +914,6 @@ import { sensor } from '@mmstack/primitives'; // Or import { pageVisibility }
850
914
 
851
915
  @Component({
852
916
  selector: 'app-visibility-logger',
853
- standalone: true,
854
917
  template: `<p>Page is currently: {{ visibility() }}</p>`,
855
918
  })
856
919
  export class VisibilityLoggerComponent {
@@ -877,7 +940,6 @@ import { sensor } from '@mmstack/primitives'; // Or import { windowSize }
877
940
 
878
941
  @Component({
879
942
  selector: 'app-responsive-display',
880
- standalone: true,
881
943
  template: `
882
944
  <p>Current Window Size: {{ winSize().width }}px x {{ winSize().height }}px</p>
883
945
  <p>Unthrottled: W: {{ winSize.unthrottled().width }} H: {{ winSize.unthrottled().height }}</p>
@@ -911,7 +973,6 @@ import { JsonPipe } from '@angular/common';
911
973
 
912
974
  @Component({
913
975
  selector: 'app-scroll-indicator',
914
- standalone: true,
915
976
  imports: [JsonPipe],
916
977
  template: `
917
978
  <div style="height: 100px; border-bottom: 2px solid red; position: fixed; top: 0; left: 0; width: 100%; background: white; z-index: 10;">
@@ -945,7 +1006,6 @@ import { mediaQuery, prefersDarkMode, prefersReducedMotion } from '@mmstack/prim
945
1006
 
946
1007
  @Component({
947
1008
  selector: 'app-layout-checker',
948
- standalone: true,
949
1009
  template: `
950
1010
  @if (isLargeScreen()) {
951
1011
  <p>Using large screen layout.</p>
@@ -1007,7 +1067,6 @@ import { elementVisibility } from '@mmstack/primitives';
1007
1067
 
1008
1068
  @Component({
1009
1069
  selector: 'app-lazy-load-item',
1010
- standalone: true,
1011
1070
  template: `
1012
1071
  <div #itemToObserve style="height: 300px; margin-top: 100vh; border: 2px solid green;">
1013
1072
  @if (intersectionEntry.visible()) {
@@ -844,6 +844,102 @@ function piped(initial, opt) {
844
844
  return pipeable(signal(initial, opt));
845
845
  }
846
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
+
847
943
  function observerSupported$1() {
848
944
  return typeof ResizeObserver !== 'undefined';
849
945
  }
@@ -1943,7 +2039,6 @@ const noopStore = {
1943
2039
  *
1944
2040
  * @Component({
1945
2041
  * selector: 'app-settings',
1946
- * standalone: true,
1947
2042
  * template: `
1948
2043
  * Theme:
1949
2044
  * <select [ngModel]="theme()" (ngModelChange)="theme.set($event)">
@@ -2381,5 +2476,5 @@ function withHistory(source, opt) {
2381
2476
  * Generated bundle index. Do not edit.
2382
2477
  */
2383
2478
 
2384
- 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 };
2385
2480
  //# sourceMappingURL=mmstack-primitives.mjs.map