@mmstack/primitives 21.0.22 → 21.0.23

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
@@ -1,1098 +1,450 @@
1
1
  # @mmstack/primitives
2
2
 
3
- A collection of utility functions and primitives designed to enhance development with Angular Signals, providing helpful patterns and inspired by features from other reactive libraries. All value helpers also use pure derivations (no effects/RxJS).
3
+ **Signal-native utilities for Angular debounce, throttle, two-way derivations, deep stores, undo/redo, sensors, and more.**
4
4
 
5
5
  [![npm version](https://badge.fury.io/js/%40mmstack%2Fprimitives.svg)](https://badge.fury.io/js/%40mmstack%2Fprimitives)
6
6
  [![License](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/mihajm/mmstack/blob/master/packages/primitives/LICENSE)
7
7
 
8
- ## Installation
8
+ `@mmstack/primitives` is a low-level toolbox of Angular Signal primitives. Every value-producing helper is a pure derivation — no `effect()`, no RxJS bridges, no zone churn — so you can compose them freely inside `computed()` graphs without worrying about side-effect lifetimes. Effect-shaped helpers (`tabSync`, `nestedEffect`, sensors) clean up via `DestroyRef`.
9
+
10
+ ## Install
9
11
 
10
12
  ```bash
11
13
  npm install @mmstack/primitives
12
14
  ```
13
15
 
14
- ## Primitives
15
-
16
- This library provides the following primitives:
17
-
18
- - `debounced` - Creates a writable signal whose value updates are debounced after set/update.
19
- - `throttled` - Creates a writable signal whose value updates are rate-limited.
20
- - `mutable` - A signal variant allowing in-place mutations while triggering updates.
21
- - `stored` - Creates a signal synchronized with persistent storage (e.g., localStorage).
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.
24
- - `withHistory` - Enhances a signal with a complete undo/redo history stack.
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.
28
- - `nestedEffect` - Creates an effect with a hierarchical lifetime, enabling fine-grained, conditional side-effects.
29
- - `toWritable` - Converts a read-only signal to writable using custom write logic.
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
- - `pooled` / `pooledArray` / `pooledMap` / `pooledSet` - Double-buffered object pools for `computed` signals; recycle the output container to remove allocation pressure in high-frequency recomputation.
33
- - `tabSync` - Low level primitive to "share" the value of a WritableSignal accross tabs via the BroadcastChannel api.
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).
35
- - `mediaQuery` - A generic primitive that tracks a CSS media query (forms the basis for `prefersDarkMode` and `prefersReducedMotion`).
36
- - `elementVisibility` - Tracks if an element is intersecting the viewport using IntersectionObserver.
37
- - `elementSize` - Tracks the size of the DOM element
38
- - `mediaQuery` - Creates a signal that reacts to changes based on the provided media queries "truthyness". Additional helpers such as `prefersDarkMode` and `prefersReducedMotion` available
39
- - `mousePosition` - Throttled signal that reacts to the mouses position within a given element
40
- - `networkStatus` - A signal of the current network status, used my @mmstack/resource
41
- - `pageVisibility` - A signal useful when reacting to the user switching tabs
42
- - `scrollPosition` - A throttled signal of the current scroll position within a given element
43
- - `windowSize` - A throttled signal useful to reacting to window resize events
44
- - `until` - Creates a Promise that resolves when a signal's value meets a specific condition.
45
-
46
- ---
47
-
48
- ### debounced
49
-
50
- Creates a WritableSignal where the propagation of its value (after calls to .set() or .update()) is delayed. The publicly readable signal value updates only after a specified time (ms) has passed without further set/update calls. It also includes an .original property, which is a Signal reflecting the value immediately after set/update is called.
51
-
52
- ```typescript
53
- import { Component, signal, effect } from '@angular/core';
54
- import { debounced, debounce } from '@mmstack/primitives';
55
- import { FormsModule } from '@angular/forms';
56
-
57
- @Component({
58
- selector: 'app-debounced',
59
- template: `<input [(ngModel)]="searchTerm" />`,
60
- })
61
- export class SearchComponent {
62
- searchTerm = debounced('', { ms: 300 }); // Debounce for 300ms
63
- example2 = debounce(signal(''), { ms: 300 }); // pattern for adding debounce to an existing signal
64
-
65
- constructor() {
66
- effect(() => {
67
- // Runs 300ms after the user stops typing
68
- console.log('Perform search for:', this.searchTerm());
69
- });
70
- effect(() => {
71
- // Runs immediately on input change
72
- console.log('Input value:', this.searchTerm.original());
73
- });
74
- }
75
- }
76
- ```
77
-
78
- You can also debounce an existing signal:
16
+ ## Contents
79
17
 
80
- ```typescript
81
- import { debounce } from '@mmstack/primitives';
18
+ - [Writable signal variants](#writable-signal-variants) — `mutable`, `derived`, `store` / `mutableStore`, `toWritable`
19
+ - [Timing & propagation](#timing--propagation) `debounced`, `throttled`, `until`
20
+ - [Reactive collections](#reactive-collections) — `indexArray`, `keyArray`, `mapObject`
21
+ - [Effects](#effects) — `nestedEffect`
22
+ - [History & persistence](#history--persistence) — `withHistory`, `stored`, `tabSync`
23
+ - [Performance helpers](#performance-helpers) — `chunked`, `pooled` / `pooledArray` / `pooledMap` / `pooledSet`
24
+ - [Sensors](#sensors) — `sensor()` facade + browser-state signals
25
+ - [Pipelines](#pipelines) — `piped` / `pipeable`, operators (`select`, `map`, `filter`, `filterWith`, `distinct`, `combineWith`, `tap`, `startWith`, `pairwise`, `scan`)
82
26
 
83
- const query = signal('');
84
- const debouncedQuery = debounce(query, { ms: 300 });
85
- ```
27
+ ## Writable signal variants
86
28
 
87
- ### throttled
29
+ ### `mutable`
88
30
 
89
- Creates a WritableSignal whose value is rate-limited. It ensures that the public-facing signal only updates at most once per specified time interval (ms). It uses a trailing-edge strategy, meaning it updates with the most recent value at the end of the interval. This is useful for handling high-frequency events like scrolling or mouse movement without overwhelming your application's reactivity.
31
+ A `WritableSignal` with `.mutate()` and `.inline()` for in-place updates. Cheaper than `update(prev => ({...prev, ...})) ` for large objects or arrays, while still notifying dependents.
90
32
 
91
33
  ```typescript
92
- import { Component, signal, effect } from '@angular/core';
93
- import { throttled } from '@mmstack/primitives';
94
- import { JsonPipe } from '@angular/common';
95
-
96
- @Component({
97
- selector: 'app-throttle-demo',
98
- imports: [JsonPipe],
99
- template: `
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>
101
- <p><b>Original Position:</b> {{ position.original() | json }}</p>
102
- <p><b>Throttled Position:</b> {{ position() | json }}</p>
103
- `,
104
- })
105
- export class ThrottleDemoComponent {
106
- // Throttle updates to at most once every 200ms
107
- position = throttled({ x: 0, y: 0 }, { ms: 200 });
108
-
109
- constructor() {
110
- // This effect runs on every single mouse move event.
111
- effect(() => {
112
- // console.log('Original value updated:', this.position.original());
113
- });
114
- // This effect will only run at most every 200ms.
115
- effect(() => {
116
- console.log('Throttled value updated:', this.position());
117
- });
118
- }
119
-
120
- onMouseMove(event: MouseEvent) {
121
- this.position.set({ x: event.offsetX, y: event.offsetY });
122
- }
123
- }
124
- ```
125
-
126
- ### mutable
127
-
128
- Creates a MutableSignal, a signal variant designed for scenarios where you want to perform in-place mutations on objects or arrays held within the signal, while still ensuring Angular's change detection is correctly triggered. It provides .mutate() and .inline() methods alongside the standard .set() and .update(). Please note that any computeds, which resolve non-primitive values from a mutable require equals to be set to false.
129
-
130
- ```typescript
131
- import { Component, computed, effect } from '@angular/core';
132
34
  import { mutable } from '@mmstack/primitives';
133
- import { FormsModule } from '@angular/forms';
134
35
 
135
- @Component({
136
- selector: 'app-mutable',
137
- template: ` <button (click)="incrementAge()">inc</button> `,
138
- })
139
- export class SearchComponent {
140
- user = mutable({ name: { first: 'John', last: 'Doe' }, age: 30 });
36
+ const user = mutable({ name: 'John', age: 30 });
141
37
 
142
- constructor() {
143
- effect(() => {
144
- // Runs every time user is mutated
145
- console.log(this.user());
146
- });
38
+ user.mutate((prev) => {
39
+ prev.age++;
40
+ return prev;
41
+ });
147
42
 
148
- const age = computed(() => this.user().age);
43
+ user.inline((prev) => {
44
+ prev.age++;
45
+ }); // void return — same effect
46
+ ```
149
47
 
150
- effect(() => {
151
- // Runs every time age changes
152
- console.log(age());
153
- });
48
+ > **Caveat:** A `computed()` that returns a non-primitive value derived from a mutable signal must declare `equal: false` (or `() => false`) — otherwise the reference-equality default suppresses the change notification. This is documented inline on `mutable` itself.
154
49
 
155
- const name = computed(() => this.user().name);
156
- effect(() => {
157
- // Doesnt run if user changes, unless name is destructured
158
- console.log(name());
159
- });
50
+ ### `derived`
160
51
 
161
- const name2 = computed(() => this.user().name, {
162
- equal: () => false,
163
- });
52
+ A two-way-bound slice of another `WritableSignal`. Writes to the derived signal update the source; changes to the source flow through. Use a key/index shorthand for object/array slices, or pass a `{ from, onChange }` pair for custom mappings.
164
53
 
165
- effect(() => {
166
- // Runs every time user changes (even if name did not change)
167
- console.log(name2());
168
- });
169
- }
54
+ ```typescript
55
+ import { derived } from '@mmstack/primitives';
170
56
 
171
- incrementAge() {
172
- user.mutate((prev) => {
173
- prev.age++;
174
- return prev;
175
- });
176
- }
57
+ const user = signal({ name: 'John', age: 30 });
177
58
 
178
- incrementInline() {
179
- user.inline((prev) => {
180
- prev.age++;
181
- });
182
- }
183
- }
59
+ const name = derived(user, 'name'); // WritableSignal<string>
60
+ const list = signal([1, 2, 3]);
61
+ const second = derived(list, 1); // WritableSignal<number>
62
+
63
+ // Full custom mapping
64
+ const upper = derived(user, {
65
+ from: (u) => u.name.toUpperCase(),
66
+ onChange: (next) => user.update((u) => ({ ...u, name: next.toLowerCase() })),
67
+ });
184
68
  ```
185
69
 
186
- ### stored
70
+ When the source is a `MutableSignal`, the derived signal is also a `MutableSignal` — `derived(state, 'items').mutate(arr => { arr.push(...); return arr })` propagates correctly.
187
71
 
188
- Creates a WritableSignal whose state is automatically synchronized with persistent storage (like localStorage or sessionStorage), providing a fallback value when no data is found or fails to parse.
72
+ ### `store` / `mutableStore`
189
73
 
190
- It handles Server-Side Rendering (SSR) gracefully, allows dynamic storage keys, custom serialization/deserialization, custom storage providers, and optional synchronization across browser tabs via the storage event. It returns a StoredSignal<T> which includes a .clear() method and a reactive .key signal.
74
+ Proxies an object (or signal of an object) into a tree of `WritableSignal`s one per property, lazily created and cached via `WeakRef`. Arrays expose indices as signals plus a `.length` signal and `Symbol.iterator`. Mutability propagates: if the root is a `MutableSignal`, every child is too.
191
75
 
192
76
  ```typescript
193
- import { Component, effect, signal } from '@angular/core';
194
- import { stored } from '@mmstack/primitives';
195
- // import { FormsModule } from '@angular/forms'; // Needed for ngModel
77
+ import { store, mutableStore } from '@mmstack/primitives';
196
78
 
197
- @Component({
198
- selector: 'app-theme-selector',
199
- // imports: [FormsModule], // Import if using ngModel
200
- template: `
201
- Theme:
202
- <select [value]="theme()" (change)="theme.set($event.target.value)">
203
- <option value="light">Light</option>
204
- <option value="dark">Dark</option>
205
- <option value="system">System</option>
206
- </select>
207
- <button (click)="theme.clear()">Reset Theme</button>
208
- <p>Using storage key: {{ theme.key() }}</p>
209
- `,
210
- })
211
- export class ThemeSelectorComponent {
212
- // Persist theme preference in localStorage, default to 'system'
213
- theme = stored<'light' | 'dark' | 'system'>('system', {
214
- key: 'user-theme',
215
- syncTabs: true, // Sync theme choice across tabs
216
- });
217
-
218
- constructor() {
219
- effect(() => {
220
- console.log(`Theme set to: ${this.theme()}`);
221
- // Logic to apply theme (e.g., add class to body)
222
- document.body.className = `theme-${this.theme()}`;
223
- });
224
- }
225
- }
79
+ const state = store({
80
+ user: { name: 'Alice', address: { city: 'NYC', zip: 10001 } },
81
+ tags: ['admin', 'editor'],
82
+ });
83
+
84
+ state.user.address.city(); // Signal read: 'NYC'
85
+ state.user.address.zip.set(90210); // Two-way write into the source
86
+ state.tags[0](); // 'admin'
87
+ state.tags.length(); // 2 (reactive)
88
+
89
+ const settings = mutableStore({ notifications: { email: true } });
90
+ settings.notifications.mutate((n) => {
91
+ n.email = false;
92
+ });
226
93
  ```
227
94
 
228
- ### piped
95
+ Top-level array support isn't exposed yet — use `indexArray` / `keyArray` for those.
229
96
 
230
- Adds two fluent APIs to signals:
97
+ ### `toWritable`
231
98
 
232
- - **`.map(...transforms, [options])`** compose pure, synchronous value→value transforms. Returns a computed signal that remains pipeable.
233
- - **`.pipe(...operators)`** – compose operators (signal→signal), useful for combining signals or reusable projections.
99
+ Turn any read-only `Signal<T>` into a `WritableSignal<T>` by providing custom `set` / `update` implementations. Powers `derived` internally; use it directly when you have a `computed` you want to expose as writable.
234
100
 
235
101
  ```typescript
236
- import { piped, pipeable, select, combineWith } from '@mmstack/primitives';
237
- import { signal } from '@angular/core';
238
-
239
- const count = piped(1);
102
+ import { toWritable } from '@mmstack/primitives';
240
103
 
241
- // Map: value -> value
242
- const label = count.map(
243
- (n) => n * 2,
244
- (n) => (num: n),
245
- { equal: (a, b) => a.num === b.num },
104
+ const user = signal({ name: 'John' });
105
+ const name = toWritable(
106
+ computed(() => user().name),
107
+ (next) => user.update((u) => ({ ...u, name: next })),
246
108
  );
247
-
248
- // Pipe: signal -> signal
249
- const base = pipeable(signal(10));
250
- const total = count.pipe(select((n) => n * 3)).pipe(combineWith(count, (a, b) => a + b));
251
-
252
- label(); // e.g., "#2"
253
- total(); // reactive sum
254
109
  ```
255
110
 
256
- ### store / mutableStore / toStore
111
+ ## Timing & propagation
257
112
 
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.
113
+ ### `debounced`
261
114
 
262
- #### Features:
115
+ A `WritableSignal` that holds its read value `ms` milliseconds after the last write. The underlying source is exposed as `.original` for callers that want the immediate value.
263
116
 
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])
117
+ ```typescript
118
+ import { debounce, debounced } from '@mmstack/primitives';
267
119
 
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';
120
+ const query = debounced('', { ms: 300 }); // create + debounce
121
+ const wrapped = debounce(signal(''), { ms: 300 }); // debounce an existing signal
273
122
 
274
- @Component({
275
- selector: 'app-store-demo',
276
- imports: [FormsModule, JsonPipe],
277
- template: `
278
- <h3>User Profile</h3>
279
- <p>Name: {{ state.user.name() }}</p>
123
+ effect(() => fetch(query())); // fires 300ms after typing stops
124
+ effect(() => preview(query.original())); // fires immediately
125
+ ```
280
126
 
281
- <input [ngModel]="state.user.name()" (ngModelChange)="state.user.name.set($event)" />
127
+ ### `throttled`
282
128
 
283
- <h3>Settings (Mutable)</h3>
284
- <label>
285
- <input type="checkbox" [checked]="settings.notifications.email()" (change)="toggleEmail()" />
286
- Email Notifications
287
- </label>
129
+ Rate-limits read propagation to at most one value per `ms` window. Defaults to **trailing-edge only** (the latest write within the window lands at the end). Pass `leading: true` to emit the first write immediately, `trailing: false` to suppress the trailing fire.
288
130
 
289
- <pre>{{ state() | json }}</pre>
290
- `,
291
- })
292
- export class StoreDemoComponent {
293
- // 1. Standard Store
294
- state = store({
295
- user: {
296
- name: 'Alice',
297
- address: { city: 'New York', zip: 10001 },
298
- },
299
- tags: ['admin', 'editor'],
300
- });
301
-
302
- // 2. Mutable Store (allows .mutate/.inline)
303
- settings = mutableStore({
304
- theme: 'dark',
305
- notifications: { email: true, sms: false },
306
- });
307
-
308
- constructor() {
309
- // Effect tracks only the specific slice accessed
310
- effect(() => {
311
- console.log('City changed to:', this.state.user.address.city());
312
- });
131
+ ```typescript
132
+ import { throttled } from '@mmstack/primitives';
313
133
 
314
- // Array access returns a signal for that index
315
- const firstTag = this.state.tags[0];
316
- console.log('First tag:', firstTag()); // 'admin'
317
- }
134
+ // Trailing edge only first write held until window closes (default)
135
+ const t = throttled(0, { ms: 200 });
318
136
 
319
- updateZip() {
320
- // You can set deep properties directly
321
- this.state.user.address.zip.set(90210);
322
- }
137
+ // Lodash-style leading + trailing
138
+ const both = throttled(0, { ms: 200, leading: true, trailing: true });
323
139
 
324
- toggleEmail() {
325
- // With mutableStore, you can use .mutate on the root or sub-signals
326
- this.settings.notifications.mutate((n) => {
327
- n.email = !n.email;
328
- });
329
- }
330
- }
140
+ // Leading edge only — fires immediately, ignores writes during cooldown
141
+ const lead = throttled(0, { ms: 200, leading: true, trailing: false });
331
142
  ```
332
143
 
333
- ### Array Stores
144
+ Same `.original` escape hatch as `debounced`.
334
145
 
335
- 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.
336
- 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`
146
+ ### `until`
337
147
 
338
- ```ts
339
- const state = store({
340
- todos: [
341
- { id: 1, text: 'Buy Milk', done: false },
342
- { id: 2, text: 'Walk Dog', done: true },
343
- ],
344
- });
148
+ Resolves a Promise when a signal value satisfies a predicate. Supports type-narrowing predicates, optional timeout, and auto-cancellation when the consuming context is destroyed.
345
149
 
346
- const firstTodo = state.todos[0]; // Signal<{ text: string, ... }>
347
- const firstTodoText = state.todos[0].text; // Signal<string>
150
+ ```typescript
151
+ import { until } from '@mmstack/primitives';
348
152
 
349
- // Update specific item property without replacing the whole array
350
- state.todos[0].done.set(true);
153
+ const event = signal<Event | null>(null);
351
154
 
352
- const len = state.todos.length(); // reacts to length changes
155
+ // Narrowing predicate promise resolves with MouseEvent
156
+ const click = await until(
157
+ event,
158
+ (e): e is MouseEvent => e instanceof MouseEvent,
159
+ );
353
160
 
354
- for (const todo of state.todos) {
355
- const t = todo(); // iteration returns proxied children
356
- const id = todo.id();
357
- }
161
+ // With a timeout
162
+ await until(progress, (p) => p === 100, { timeout: 5_000 });
358
163
  ```
359
164
 
360
- ### indexArray/keyArray
165
+ ## Reactive collections
361
166
 
362
- 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.
167
+ ### `indexArray` / `keyArray`
363
168
 
364
- `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
169
+ Map a source array signal into a stable array of derived values. `indexArray` stabilizes by **position** each index gets a writable signal whose value is the item at that index. `keyArray` stabilizes by **identity** (via an optional `key` selector) moving an item preserves its mapped output and just updates the item's index signal.
365
170
 
366
- Both utilize memory pooling to "ease" GC pressure.
171
+ Both pool their internal buffers, so reordering a 10k-item list is much cheaper than `.map()` of a `computed`.
367
172
 
368
173
  ```typescript
369
- import { Component, signal } from '@angular/core';
370
174
  import { indexArray, keyArray, mutable } from '@mmstack/primitives';
371
175
 
372
- @Component({
373
- selector: 'app-map-demo',
374
- template: `
375
- <ul>
376
- @for (item of displayItems(); track item) {
377
- <li>{{ item() }}</li>
378
- @if ($first) {
379
- <button (click)="updateFirst(item)">Update First</button>
380
- }
381
- }
382
- </ul>
383
- <button (click)="addItem()">Add</button>
384
- `,
385
- })
386
- export class ListComponent {
387
- readonly sourceItems = signal([
388
- { id: 1, name: 'A' },
389
- { id: 2, name: 'B' },
390
- ]);
391
-
392
- readonly displayItems = indexArray(this.sourceItems, (child, index) => computed(() => `Item ${index}: ${child().name}`));
393
-
394
- // keyArray is similar, but the index becomes dynamic & the child object is static
395
- readonly keyed = keyArray(this.sourceItems, (child, index) => computed(() => `Item ${index()}: ${child.name}}`), {
396
- key: (item) => item.id
397
- });
398
-
399
-
400
- addItem() {
401
- this.sourceItems.update((items) => [...items, { id: Date.now(), name: String.fromCharCode(67 + items.length - 2) }]);
402
- }
403
-
404
- updateFirst() {
405
- this.sourceItems.update((items) => {
406
- items[0] = { ...items[0], name: items[0].name + '+' };
407
- return [...items]; // New array, but indexArray keeps stable signals
408
- });
409
- }
410
-
411
- // since the underlying source is a signal we can also create updaters in the mapper
412
- readonly updatableItems = indexArray(this.sourceItems, (child, index) => {
413
-
414
- return {
415
- value: computed(() => `Item ${index}: ${child().name}`))
416
- updateName: () => child.update((cur) => ({...cur, name: cur.name + '+'}))
417
- };
418
- });
419
-
420
-
421
- // since the underlying source is a WritableSignal we can also create updaters in the mapper
422
- readonly writableItems = indexArray(this.sourceItems, (child, index) => {
176
+ const items = mutable([
177
+ { id: 1, name: 'A' },
178
+ { id: 2, name: 'B' },
179
+ ]);
423
180
 
424
- return {
425
- value: computed(() => `Item ${index}: ${child().name}`))
426
- updateName: () => child.update((cur) => ({...cur, name: cur.name + '+'}))
427
- };
428
- });
429
-
430
- // if the source is a mutable signal we can even update them inline
431
- readonly sourceItems = mutable([
432
- { id: 1, name: 'A' },
433
- { id: 2, name: 'B' },
434
- ]);
435
-
436
- readonly mutableItems = indexArray(this.sourceItems, (child, index) => {
181
+ // Position-stable: `child` is a MutableSignal<{ id, name }> for the current index.
182
+ const labels = indexArray(items, (child, index) =>
183
+ computed(() => `Item ${index}: ${child().name}`),
184
+ );
437
185
 
438
- return {
439
- value: computed(() => `Item ${index}: ${child().name}`))
440
- updateName: () => child.inline((cur) => {
441
- cur.name += '+';
442
- })
443
- };
444
- });
445
- }
186
+ // Identity-stable: `child` is the item value, `index` is a Signal<number>.
187
+ const keyed = keyArray(
188
+ items,
189
+ (child, index) => computed(() => `${index()}: ${child.name}`),
190
+ { key: (item) => item.id },
191
+ );
446
192
  ```
447
193
 
448
- ### mapObject
194
+ `indexArray` is the cheaper default. Reach for `keyArray` only when DOM/instance reuse across reorders matters — `<for>` blocks rendering heavy components, charts, drag-and-drop reordering, etc.
449
195
 
450
- 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.
196
+ ### `mapObject`
451
197
 
452
- 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.
198
+ The object equivalent of `keyArray`: map `Record<K, V>` into `Record<K, U>` with referential stability for unchanged keys. The mapping function receives the key and a writable signal slice (if the source is writable).
453
199
 
454
- ```ts
455
- import { Component, signal, computed } from '@angular/core';
200
+ ```typescript
456
201
  import { mapObject } from '@mmstack/primitives';
457
202
 
458
- @Component({
459
- selector: 'app-settings',
460
- template: `
461
- @for (key of objectKeys(controls()); track key) {
462
- <div class="setting">
463
- <span>{{ controls()[key].label }}</span>
464
- <button (click)="controls()[key].toggle()">
465
- {{ controls()[key].isActive() ? 'ON' : 'OFF' }}
466
- </button>
467
- </div>
468
- }
469
- `,
470
- })
471
- export class SettingsComponent {
472
- objectKeys = Object.keys;
473
-
474
- // Source state
475
- readonly settings = signal<Record<string, boolean>>({
476
- wifi: true,
477
- bluetooth: false,
478
- });
479
-
480
- // Mapped object: { [key]: { label, isActive, toggle } }
481
- readonly controls = mapObject(
482
- this.settings,
483
- (key, value) => {
484
- // 'value' is a WritableSignal linked to this specific property
485
- return {
486
- label: key.toUpperCase(),
487
- isActive: value, // Expose as ReadOnly for template
488
- toggle: () => value.update((v) => !v),
489
- destroy: () => console.log(`Cleanup logic for ${key}`),
490
- };
491
- },
492
- {
493
- // Optional cleanup hook when a key is removed from the source
494
- onDestroy: (mappedItem) => mappedItem.destroy(),
495
- },
496
- );
497
-
498
- addSetting() {
499
- this.settings.update((s) => ({ ...s, airdrop: false }));
500
- }
501
- }
502
- ```
203
+ const settings = signal<Record<string, boolean>>({
204
+ wifi: true,
205
+ bluetooth: false,
206
+ });
503
207
 
504
- ### nestedEffect
208
+ const controls = mapObject(
209
+ settings,
210
+ (key, value) => ({
211
+ label: key.toUpperCase(),
212
+ isActive: value, // WritableSignal<boolean>
213
+ toggle: () => value.update((v) => !v),
214
+ }),
215
+ { onDestroy: (entry) => console.log(`Removed ${entry.label}`) },
216
+ );
217
+ ```
505
218
 
506
- Creates an effect that can be nested, similar to SolidJS's `createEffect`.
219
+ ## Effects
507
220
 
508
- This primitive enables true hierarchical reactivity. A `nestedEffect` created within another `nestedEffect` is **automatically destroyed and recreated** when the parent re-runs.
221
+ ### `nestedEffect`
509
222
 
510
- It automatically handles injector propagation and lifetime management, allowing you to create fine-grained, conditional side-effects that only track dependencies when they are "live". This is a powerful optimization for scenarios where a "hot" signal (which changes often) should only be tracked when a "cold" signal (a condition that changes rarely) is true.
223
+ A SolidJS-style hierarchical effect: a `nestedEffect` created inside another `nestedEffect` is automatically destroyed and recreated when the parent re-runs. The outer effect only tracks the dependencies you read in _its_ body; the inner effect's deps are tracked only while it's alive.
511
224
 
512
- ```ts
513
- import { Component, signal } from '@angular/core';
225
+ ```typescript
514
226
  import { nestedEffect } from '@mmstack/primitives';
515
227
 
516
- @Component({ selector: 'app-nested-demo' })
517
- export class NestedDemoComponent {
518
- // `coldGuard` changes rarely
519
- readonly coldGuard = signal(false);
520
- // `hotSignal` changes very often
521
- readonly hotSignal = signal(0);
522
-
523
- constructor() {
524
- // A standard effect would track *both* signals and run
525
- // every time `hotSignal` changes, even if `coldGuard` is false.
526
- // effect(() => {
527
- // if (this.coldGuard()) {
528
- // console.log('Hot signal is:', this.hotSignal());
529
- // }
530
- // });
531
-
532
- // `nestedEffect` solves this:
228
+ // `coldGuard` changes rarely, `hotSignal` fires often.
229
+ nestedEffect(() => {
230
+ if (coldGuard()) {
533
231
  nestedEffect(() => {
534
- // This outer effect ONLY tracks `coldGuard`.
535
- // It does not track `hotSignal`.
536
- if (this.coldGuard()) {
537
- // This inner effect is CREATED when coldGuard is true
538
- // and DESTROYED when it becomes false.
539
- nestedEffect(() => {
540
- // It only tracks `hotSignal` while it exists.
541
- console.log('Hot signal is:', this.hotSignal());
542
- });
543
- }
232
+ // Only tracks `hotSignal` while coldGuard is true.
233
+ console.log(hotSignal());
544
234
  });
545
235
  }
546
- }
547
- ```
548
-
549
- #### Advanced Example: Fine-grained Lists
550
-
551
- `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.
552
-
553
- ```ts
554
- import { Component, signal, computed } from '@angular/core';
555
- import { indexArray, nestedEffect } from '@mmstack/primitives';
556
-
557
- @Component({ selector: 'app-list-demo' })
558
- export class ListDemoComponent {
559
- readonly users = signal([
560
- { id: 1, name: 'Alice' },
561
- { id: 2, name: 'Bob' },
562
- ]);
563
-
564
- // indexArray creates stable signals for each item
565
- readonly mappedUsers = indexArray(
566
- this.users,
567
- (userSignal, index) => {
568
- // Create a side-effect tied to THIS item's lifetime
569
- const effectRef = nestedEffect(() => {
570
- // This only runs if `userSignal` (this specific user) changes.
571
- console.log(`User ${index} updated:`, userSignal().name);
572
-
573
- // e.g., updateAGGridRow(index, userSignal());
574
- });
575
-
576
- // Return the data and the cleanup logic
577
- return {
578
- label: computed(() => `User: ${userSignal().name}`),
579
- // This function will be called by `onDestroy`
580
- _destroy: () => effectRef.destroy(),
581
- };
582
- },
583
- {
584
- // When indexArray removes an item, it calls `onDestroy`
585
- onDestroy: (mappedItem) => {
586
- mappedItem._destroy(); // Manually destroy the nested effect
587
- },
588
- },
589
- );
590
- }
236
+ });
591
237
  ```
592
238
 
593
- ### toWritable
594
-
595
- A utility function that converts a read-only Signal into a WritableSignal by allowing you to provide custom implementations for the .set() and .update() methods. This is useful for creating controlled write access to signals that are naturally read-only (like those created by computed). This is used under the hood in derived.
239
+ Composes with `indexArray` to give each mapped item its own effect that's automatically torn down when the item is removed — see the doc comments on `nestedEffect` for the pattern.
596
240
 
597
- ```typescript
598
- import { Component, signal, effect } from '@angular/core';
599
- import { toWritable } from '@mmstack/primitives';
241
+ ## History & persistence
600
242
 
601
- const user = signal({ name: 'John' });
243
+ ### `withHistory`
602
244
 
603
- const name = toWritable(
604
- computed(() => user().name),
605
- (name) => user.update((prev) => ({ ...prev, name })),
606
- ); // WritableSignal<string> bound to user signal
607
- ```
608
-
609
- ### derived
610
-
611
- Creates a WritableSignal that represents a part of another source WritableSignal (e.g., an object property or an array element), enabling two-way data binding. Changes to the source update the derived signal, and changes to the derived signal (via .set() or .update()) update the source signal accordingly.
245
+ Wrap any `WritableSignal` (or pass an initial value) into one with `.undo()`, `.redo()`, `.clear()`, `.canUndo`, `.canRedo`, `.canClear`, and a reactive `.history` stack. `maxSize` bounds both the undo and redo stacks, with `cleanupStrategy: 'shift' | 'halve'`.
612
246
 
613
247
  ```typescript
614
- const user = signal({ name: 'John' });
615
-
616
- const name = derived(user, 'name'); // WritableSignal<string>, which updates user signal & reacts to changes in the name property
617
-
618
- // Full syntax example
619
- const name2 = derived(user, {
620
- from: (u) => u.name,
621
- onChange: (name) => user.update((prev) => ({ ...prev, name })),
622
- });
623
- ```
624
-
625
- ### chunked
626
-
627
- 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.
628
-
629
- 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.
248
+ import { withHistory } from '@mmstack/primitives';
630
249
 
631
- ```ts
632
- import { Component, signal } from '@angular/core';
633
- import { chunked } from '@mmstack/primitives';
250
+ const text = withHistory('Hello', { maxSize: 10, cleanupStrategy: 'halve' });
634
251
 
635
- @Component({
636
- selector: 'app-heavy-list',
637
- template: `
638
- <div class="status-bar">Loaded: {{ visibleItems().length }} / {{ allItems().length }}</div>
639
-
640
- <ul>
641
- @for (item of visibleItems(); track item.id) {
642
- <li>{{ item.label }}</li>
643
- }
644
- </ul>
645
- `,
646
- })
647
- export class HeavyListComponent {
648
- // A heavy source with 10,000 items
649
- readonly allItems = signal(Array.from({ length: 10000 }, (_, i) => ({ id: i, label: `Item #${i}` })));
650
-
651
- // Process 100 items per animation frame to prevent UI blocking
652
- readonly visibleItems = chunked(this.allItems, {
653
- chunkSize: 100,
654
- delay: 'frame', // 'frame' | 'microtask' | number (ms)
655
- });
656
- }
252
+ text.set('Hello world');
253
+ text.undo(); // back to 'Hello'
254
+ text.redo(); // forward to 'Hello world'
255
+ text.canUndo(); // Signal<boolean>
657
256
  ```
658
257
 
659
- ### pooled / pooledArray / pooledMap / pooledSet
258
+ ### `stored`
660
259
 
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.
260
+ A `WritableSignal` whose value is synchronized with `localStorage` (or any compatible adapter). SSR-safe, supports dynamic keys, custom serialization, cross-tab sync via the `storage` event, and per-key cleanup strategies. The returned signal carries a `.clear()` method and a reactive `.key` signal.
666
261
 
667
262
  ```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:
263
+ import { stored } from '@mmstack/primitives';
701
264
 
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,
265
+ const theme = stored<'light' | 'dark' | 'system'>('system', {
266
+ key: 'app-theme',
267
+ syncTabs: true,
723
268
  });
724
- ```
725
-
726
- Complementary to `linkedSignal` (which carries previous *state* forward, not the *container*) and `chunked` (which time-slices large outputs across frames).
727
-
728
- ### tabSync
729
-
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.
731
269
 
732
- 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.
733
-
734
- #### Key Features:
735
-
736
- - SSR Safe: Gracefully degrades to a standard signal on the server.
737
- - Automatic Cleanup: Handles event listeners and disconnects when the injection context is destroyed.
738
- - Smart ID Generation: Can auto-generate IDs for rapid prototyping, but supports explicit IDs for production stability.
739
-
740
- 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.
741
-
742
- ```ts
743
- import { Component, signal } from '@angular/core';
744
- import { tabSync } from '@mmstack/primitives';
745
-
746
- @Component({
747
- selector: 'app-sync-demo',
748
- template: `
749
- <p>Open this page in two tabs!</p>
750
-
751
- <button (click)="counter.update((n) => n + 1)">Count: {{ counter() }}</button>
752
-
753
- <select [ngModel]="theme()" (ngModelChange)="theme.set($event)">
754
- <option value="light">Light</option>
755
- <option value="dark">Dark</option>
756
- </select>
757
- `,
758
- })
759
- export class SyncDemoComponent {
760
- // 1. Quick usage (Auto-ID)
761
- // Good for dev, but ID might change if code moves lines/files
762
- readonly counter = tabSync(signal(0));
763
-
764
- // 2. Production usage (Explicit ID)
765
- // Recommended: Ensures tabs always find each other regardless of minification
766
- readonly theme = tabSync(signal('light'), {
767
- id: 'global-app-theme',
768
- });
769
- }
270
+ theme.set('dark');
271
+ theme.clear(); // restores fallback
770
272
  ```
771
273
 
772
- ### withHistory
274
+ ### `tabSync`
773
275
 
774
- 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.
276
+ Mirrors a `WritableSignal` across browser tabs via `BroadcastChannel`. Used internally by `@mmstack/resource`'s cache invalidation. Provide an explicit `id` in production the auto-generated stack-frame ID is fine for prototyping but unstable across minified builds.
775
277
 
776
278
  ```typescript
777
- import { FormsModule } from '@angular/forms';
778
- import { JsonPipe } from '@angular/common';
779
- import { withHistory } from '@mmstack/primitives';
780
- import { Component, signal, effect } from '@angular/core';
279
+ import { tabSync } from '@mmstack/primitives';
781
280
 
782
- @Component({
783
- selector: 'app-history-demo',
784
- imports: [FormsModule, JsonPipe],
785
- template: `
786
- <h4>Simple Text Editor</h4>
787
- <textarea [(ngModel)]="text" rows="4" cols="50"></textarea>
788
- <div class="buttons" style="margin-top: 8px; display: flex; gap: 8px;">
789
- <button (click)="text.undo()" [disabled]="!text.canUndo()">Undo</button>
790
- <button (click)="text.redo()" [disabled]="!text.canRedo()">Redo</button>
791
- <button (click)="text.clear()" [disabled]="!text.canClear()">Clear History</button>
792
- </div>
793
- <p>History Stack:</p>
794
- <pre>{{ text.history() | json }}</pre>
795
- `,
796
- })
797
- export class HistoryDemoComponent {
798
- // Create a signal and immediately enhance it with history capabilities.
799
- text = withHistory(signal('Hello, type something!'), { maxSize: 10 });
800
-
801
- constructor() {
802
- // You can react to history changes as well
803
- effect(() => {
804
- console.log('History stack changed:', this.text.history());
805
- });
806
- }
807
- }
281
+ const cart = tabSync(signal([]), { id: 'shopping-cart' });
808
282
  ```
809
283
 
810
- ### sensor
811
-
812
- ### sensor
284
+ ## Performance helpers
813
285
 
814
- The `sensor()` facade provides a unified way to create various reactive sensor signals that track browser events, states, and user preferences. You specify the type of sensor you want (e.g., `'mousePosition'`, `'networkStatus'`, `'windowSize'`, `'dark-mode'`), and it returns the corresponding signal, often with specific properties or methods. These primitives are generally SSR-safe and handle their own event listener cleanup.
286
+ ### `chunked`
815
287
 
816
- You can either use the `sensor('sensorType', options)` facade or import the specific sensor functions directly if you prefer.
817
-
818
- **Facade Usage Example:**
288
+ Time-slices a large array into progressive emissions to keep the main thread responsive. Emits the first `chunkSize` items immediately, then schedules the next batch on the next animation frame, microtask, or after a `ms` delay. Resets when the source array changes.
819
289
 
820
290
  ```typescript
821
- import { sensor } from '@mmstack/primitives';
822
- import { effect } from '@angular/core';
823
-
824
- const network = sensor('networkStatus');
825
- const mouse = sensor('mousePosition', { throttle: 50, coordinateSpace: 'page' });
826
- const winSize = sensor('windowSize', { throttle: 150 });
827
- const isDark = sensor('dark-mode');
291
+ import { chunked } from '@mmstack/primitives';
828
292
 
829
- effect(() => console.log('Online:', network().isOnline));
830
- effect(() => console.log('Mouse X:', mouse().x));
831
- effect(() => console.log('Window Width:', winSize().width));
832
- effect(() => console.log('Dark Mode Preferred:', isDark()));
293
+ const visible = chunked(allItems, { chunkSize: 100, delay: 'frame' });
833
294
  ```
834
295
 
835
- Individual sensors available through the facade or direct import:
836
-
837
- #### mousePosition
296
+ ### `pooled` / `pooledArray` / `pooledMap` / `pooledSet`
838
297
 
839
- Tracks the mouse cursor's position. By default, updates are throttled to 100ms. It provides the main throttled signal and an .unthrottled property to access the raw updates.
840
-
841
- Key Options: target, coordinateSpace ('client' or 'page'), touch (boolean), throttle (ms).
298
+ Double-buffered object pools for high-frequency `computed` outputs. After a brief warmup, recomputation reaches **zero allocations**: two buffers swap on every read, with `reset` called on the dirty one before `computation` writes into it.
842
299
 
843
300
  ```typescript
844
- import { Component, effect } from '@angular/core';
845
- import { sensor } from '@mmstack/primitives'; // Or import { mousePosition }
846
- import { JsonPipe } from '@angular/common';
301
+ import { pooledArray, pooledMap } from '@mmstack/primitives';
847
302
 
848
- @Component({
849
- selector: 'app-mouse-tracker',
850
- imports: [JsonPipe],
851
- template: `
852
- <div (mousemove)="onMouseMove($event)" style="width: 300px; height: 200px; border: 1px solid black; padding: 10px; user-select: none;">Move mouse here...</div>
853
- <p><b>Throttled Position:</b> {{ mousePos() | json }}</p>
854
- <p><b>Unthrottled Position:</b> {{ mousePos.unthrottled() | json }}</p>
855
- `,
856
- })
857
- export class MouseTrackerComponent {
858
- // Using the facade
859
- readonly mousePos = sensor('mousePosition', { coordinateSpace: 'page', throttle: 200 });
860
- // Or direct import:
861
- // readonly mousePos = mousePosition({ coordinateSpace: 'page', throttle: 200 });
862
-
863
- // Note: The (mousemove) event here is just to show the example area works.
864
- // The mousePosition sensor binds its own listeners based on the target option.
865
- onMouseMove(event: MouseEvent) {
866
- // No need to call set, mousePosition handles it.
867
- }
303
+ // Reuses one array across reads — no GC churn even at 60fps.
304
+ const activeIds = pooledArray<number[]>((buf) => {
305
+ for (const item of items()) if (item.active) buf.push(item.id);
306
+ return buf;
307
+ });
868
308
 
869
- constructor() {
870
- effect(() => console.log('Throttled Mouse:', this.mousePos()));
871
- effect(() => console.log('Unthrottled Mouse:', this.mousePos.unthrottled()));
872
- }
873
- }
309
+ const byId = pooledMap<Map<number, Item>>((buf) => {
310
+ for (const item of items()) buf.set(item.id, item);
311
+ return buf;
312
+ });
874
313
  ```
875
314
 
876
- #### networkStatus
877
-
878
- Tracks the browser's online/offline status. The returned signal is a boolean (`true` for online) and has an attached `.since` signal indicating when the status last changed.
879
-
880
- ```typescript
881
- import { Component, effect } from '@angular/core';
882
- import { sensor } from '@mmstack/primitives'; // Or import { networkStatus }
883
- import { DatePipe } from '@angular/common';
884
-
885
- @Component({
886
- selector: 'app-network-info',
887
- imports: [DatePipe],
888
- template: `
889
- @if (netStatus()) {
890
- <p>✅ Online (Since: {{ netStatus.since() | date: 'short' }})</p>
891
- } @else {
892
- <p>❌ Offline (Since: {{ netStatus.since() | date: 'short' }})</p>
893
- }
894
- `,
895
- })
896
- export class NetworkInfoComponent {
897
- readonly netStatus = sensor('networkStatus');
315
+ > **Retention contract:** the returned value is only valid until the next read. Do not store it in component state, async closures, or anywhere outside the current reactive tick — the container is recycled and `reset`, mutating any reference you still hold.
898
316
 
899
- constructor() {
900
- effect(() => {
901
- console.log('Network online:', this.netStatus(), 'Since:', this.netStatus.since());
902
- });
903
- }
904
- }
905
- ```
317
+ For custom buffer types (typed arrays, structs) drop down to `pooled` directly. Complementary to `linkedSignal` (which carries previous _state_ forward) and `chunked` (which time-slices large outputs).
906
318
 
907
- #### pageVisibility
319
+ ## Sensors
908
320
 
909
- Tracks the page's visibility state (e.g., 'visible', 'hidden') using the Page Visibility API.
321
+ The `sensor()` facade creates browser-state signals with consistent SSR fallbacks and `DestroyRef`-driven cleanup. Each sensor is also available as a standalone function if you'd rather skip the facade.
910
322
 
911
323
  ```typescript
912
- import { Component, effect } from '@angular/core';
913
- import { sensor } from '@mmstack/primitives'; // Or import { pageVisibility }
324
+ import { sensor } from '@mmstack/primitives';
914
325
 
915
- @Component({
916
- selector: 'app-visibility-logger',
917
- template: `<p>Page is currently: {{ visibility() }}</p>`,
918
- })
919
- export class VisibilityLoggerComponent {
920
- readonly visibility = sensor('pageVisibility');
921
-
922
- constructor() {
923
- effect(() => {
924
- console.log('Page visibility changed to:', this.visibility());
925
- if (this.visibility() === 'hidden') {
926
- // Perform cleanup or pause tasks
927
- }
928
- });
929
- }
930
- }
326
+ const network = sensor('networkStatus'); // Signal<boolean> + .since
327
+ const isDark = sensor('dark-mode'); // Signal<boolean>
328
+ const winSize = sensor('windowSize', { throttle: 150 });
329
+ const mouse = sensor('mousePosition', {
330
+ coordinateSpace: 'page',
331
+ throttle: 50,
332
+ });
931
333
  ```
932
334
 
933
- #### windowSize
335
+ `sensors(['networkStatus', 'windowSize'])` returns a record of all requested sensors in one call.
934
336
 
935
- Tracks the browser window's inner dimensions (width and height). Updates are throttled by default (100ms). It provides the main throttled signal and an .unthrottled property to access raw updates.
337
+ ### Available sensors
936
338
 
937
- ```typescript
938
- import { Component, effect, computed } from '@angular/core';
939
- import { sensor } from '@mmstack/primitives'; // Or import { windowSize }
339
+ | Type | Standalone fn | Returns | Notes |
340
+ | ------------------- | ---------------------------- | ------------------------------------------------------ | -------------------------------------------------------------------------- |
341
+ | `networkStatus` | `networkStatus()` | `Signal<boolean>` + `.since` | Online/offline. `since` is `Signal<Date>` of last transition. |
342
+ | `pageVisibility` | `pageVisibility()` | `Signal<DocumentVisibilityState>` | `'visible' \| 'hidden' \| 'prerender'`. |
343
+ | `mediaQuery` | `mediaQuery(q)` | `Signal<boolean>` | Generic CSS media-query tracker. |
344
+ | `dark-mode` | `prefersDarkMode()` | `Signal<boolean>` | Shorthand for `(prefers-color-scheme: dark)`. |
345
+ | `reduced-motion` | `prefersReducedMotion()` | `Signal<boolean>` | Shorthand for `(prefers-reduced-motion: reduce)`. |
346
+ | `windowSize` | `windowSize()` | `Signal<{ width, height }>` + `.unthrottled` | Throttled to 100ms by default. |
347
+ | `scrollPosition` | `scrollPosition()` | `Signal<{ x, y }>` + `.unthrottled` | Window or element scroll, throttled 100ms. |
348
+ | `mousePosition` | `mousePosition()` | `Signal<{ x, y }>` + `.unthrottled` | Throttled 100ms. `coordinateSpace: 'client' \| 'page'`, optional `touch`. |
349
+ | `elementVisibility` | `elementVisibility(target?)` | `Signal<IntersectionObserverEntry?>` + `.visible` | IntersectionObserver-based, `.visible` is a boolean shorthand. |
350
+ | `elementSize` | `elementSize(target?)` | `Signal<{ width, height }?>` | ResizeObserver-based. Defaults to `border-box`. |
351
+ | `geolocation` | `geolocation(opt?)` | `Signal<GeolocationPosition?>` + `.error` + `.loading` | One-shot by default; pass `watch: true` for `watchPosition`. |
352
+ | `clipboard` | `clipboard()` | `Signal<string>` + `.copy(v)` + `.isSupported` | Mirrors clipboard contents; `.copy` writes through and updates the signal. |
353
+ | `orientation` | `orientation()` | `Signal<{ angle, type }>` | Tracks `screen.orientation`. |
354
+ | `batteryStatus` | `batteryStatus()` | `Signal<BatteryStatus \| null>` | `null` until `navigator.getBattery()` resolves, or forever if unsupported. |
355
+ | `idle` | `idle({ ms })` | `Signal<boolean>` + `.since` | Flips to `true` after `ms` of inactivity. Configurable activity events. |
356
+ | `focusWithin` | `focusWithin(target?)` | `Signal<boolean>` | Mirrors the `:focus-within` CSS pseudo-class. |
940
357
 
941
- @Component({
942
- selector: 'app-responsive-display',
943
- template: `
944
- <p>Current Window Size: {{ winSize().width }}px x {{ winSize().height }}px</p>
945
- <p>Unthrottled: W: {{ winSize.unthrottled().width }} H: {{ winSize.unthrottled().height }}</p>
946
- @if (isMobileDisplay()) {
947
- <p>Displaying mobile layout.</p>
948
- } @else {
949
- <p>Displaying desktop layout.</p>
950
- }
951
- `,
952
- })
953
- export class ResponsiveDisplayComponent {
954
- readonly winSize = sensor('windowSize', { throttle: 150 });
955
- // Or: readonly winSize = windowSize({ throttle: 150 });
358
+ Element-targeting sensors (`elementSize`, `elementVisibility`, `focusWithin`) default `target` to `inject(ElementRef)` so they're drop-in inside a component.
956
359
 
957
- readonly isMobileDisplay = computed(() => this.winSize().width < 768);
360
+ ### `signalFromEvent`
958
361
 
959
- constructor() {
960
- effect(() => console.log('Window Size (Throttled):', this.winSize()));
961
- }
962
- }
963
- ```
964
-
965
- #### scrollPosition
966
-
967
- Tracks the scroll position (x, y) of the window or a specified HTML element. Updates are throttled by default (100ms). It provides the main throttled signal and an .unthrottled property to access raw updates.
362
+ A generic EventTarget → Signal helper. Not surfaced through the `sensor()` facade (it needs positional arguments rather than an options bag), but it's how most of the sensors above are shaped under the hood.
968
363
 
969
364
  ```typescript
970
- import { Component, effect, ElementRef, viewChild } from '@angular/core';
971
- import { sensor } from '@mmstack/primitives'; // Or import { scrollPosition }
972
- import { JsonPipe } from '@angular/common';
365
+ import { signalFromEvent } from '@mmstack/primitives';
973
366
 
974
- @Component({
975
- selector: 'app-scroll-indicator',
976
- imports: [JsonPipe],
977
- template: `
978
- <div style="height: 100px; border-bottom: 2px solid red; position: fixed; top: 0; left: 0; width: 100%; background: white; z-index: 10;">
979
- Page Scroll Y: {{ pageScroll().y }}px
980
- <p>Unthrottled Y: {{ pageScroll.unthrottled().y }}</p>
981
- </div>
982
- <div #scrollableContent style="height: 2000px; padding-top: 120px;">Scroll down...</div>
983
- `,
984
- })
985
- export class ScrollIndicatorComponent {
986
- readonly pageScroll = sensor('scrollPosition', { throttle: 50 });
987
- // Or: readonly pageScroll = scrollPosition({ throttle: 50 });
988
-
989
- constructor() {
990
- effect(() => {
991
- // Example: Change header style based on scroll
992
- console.log('Page scroll Y (Throttled):', this.pageScroll().y);
993
- });
994
- }
995
- }
367
+ // Raw event signal
368
+ const lastClick = signalFromEvent<MouseEvent>(document, 'click', null);
369
+
370
+ // Projecting overload — pluck just the data you want
371
+ const lastPoint = signalFromEvent<MouseEvent, { x: number; y: number }>(
372
+ document,
373
+ 'mousemove',
374
+ { x: 0, y: 0 },
375
+ (e) => ({ x: e.clientX, y: e.clientY }),
376
+ );
996
377
  ```
997
378
 
998
- #### mediaQuery, prefersDarkMode() & prefersReducedMotion()
379
+ The `target` accepts a static `EventTarget`, an `ElementRef`, or a `Signal` resolving to either — when the signal flips, the listener is moved automatically.
999
380
 
1000
- A generic mediaQuery primitive, you can use directly for any CSS media query. Two specific versions have been created for `prefersDarkMode()` & `prefersReducedMotion()`.
1001
- Reacts to changes in preferences & exposes a `Signal<boolean>`.
381
+ ### Sensor example
1002
382
 
1003
383
  ```typescript
1004
- import { Component, effect } from '@angular/core';
1005
- import { mediaQuery, prefersDarkMode, prefersReducedMotion } from '@mmstack/primitives'; // Direct import
384
+ import { Component } from '@angular/core';
385
+ import { sensor } from '@mmstack/primitives';
1006
386
 
1007
387
  @Component({
1008
- selector: 'app-layout-checker',
388
+ selector: 'app-network-badge',
1009
389
  template: `
1010
- @if (isLargeScreen()) {
1011
- <p>Using large screen layout.</p>
390
+ @if (network()) {
391
+ <span class="online"
392
+ >Online since {{ network.since() | date: 'short' }}</span
393
+ >
1012
394
  } @else {
1013
- <p>Using small screen layout.</p>
395
+ <span class="offline"
396
+ >Offline since {{ network.since() | date: 'short' }}</span
397
+ >
1014
398
  }
1015
399
  `,
1016
400
  })
1017
- export class LayoutCheckerComponent {
1018
- readonly isLargeScreen = mediaQuery('(min-width: 1280px)');
1019
- readonly prefersDark = prefersDarkMode(); // is just a pre-defined mediaQuery signal
1020
- readonly prefersReducedMotion = prefersReducedMotion(); // is just a pre-defined mediaQuery signal
1021
- constructor() {
1022
- effect(() => {
1023
- console.log('Is large screen:', this.isLargeScreen());
1024
- });
1025
- }
401
+ export class NetworkBadgeComponent {
402
+ protected readonly network = sensor('networkStatus');
1026
403
  }
1027
404
  ```
1028
405
 
1029
- ### until
1030
-
1031
- The `until` primitive provides a powerful way to bridge Angular's reactive signals with imperative, Promise-based asynchronous code. It returns a Promise that resolves when the value of a given signal satisfies a specified predicate function.
1032
-
1033
- This is particularly useful for:
406
+ ## Pipelines
1034
407
 
1035
- - Orchestrating complex sequences of operations (e.g., waiting for data to load or for a user action to complete before proceeding).
1036
- - Writing tests where you need to await a certain state before making assertions.
1037
- - Integrating with other Promise-based APIs.
408
+ ### `piped` and `pipeable`
1038
409
 
1039
- It also supports optional timeouts and automatic cancellation via DestroyRef if the consuming context (like a component) is destroyed before the condition is met.
410
+ Adds a chainable, fully typed `.pipe(...)` and `.map(...)` to any signal. `piped(initial)` creates a writable signal already wrapped; `pipeable(existing)` retrofits the API onto a signal you already have.
1040
411
 
1041
412
  ```typescript
1042
- import { signal } from '@angular/core';
1043
- import { until } from '@mmstack/primitives';
1044
-
1045
- it('should reject on timeout if the condition is not met in time', async () => {
1046
- const count = signal(0);
1047
- const timeoutDuration = 500;
413
+ import { piped, pipeable, map, distinct, scan } from '@mmstack/primitives';
1048
414
 
1049
- const untilPromise = until(count, (value) => value >= 10, { timeout: timeoutDuration });
415
+ const count = piped(1);
1050
416
 
1051
- // Simulate a change that doesn't meet the condition
1052
- setTimeout(() => count.set(1), 10);
417
+ // .map composes value -> value transforms inline
418
+ const label = count.map(
419
+ (n) => n * 2,
420
+ (n) => `#${n}`,
421
+ );
1053
422
 
1054
- await expect(untilPromise).toThrow(`until: Timeout after ${timeoutDuration}ms.`);
1055
- });
423
+ // .pipe composes operators (signal -> signal)
424
+ const total = pipeable(signal(10)).pipe(
425
+ map((n) => n * 3),
426
+ distinct(),
427
+ scan((acc, n) => acc + n, 0),
428
+ );
1056
429
  ```
1057
430
 
1058
- ### elementVisibility
431
+ ### Operators
1059
432
 
1060
- Tracks if a target DOM element is intersecting with the viewport (or a specified root element) using the `IntersectionObserver` API. This is highly performant for use cases like lazy-loading content or triggering animations when elements scroll into view.
433
+ All operators are `(src: Signal<I>) => Signal<O>` and compose via `.pipe(...)`.
1061
434
 
1062
- It can observe a static `ElementRef`/`Element` or a `Signal` that resolves to one, allowing for dynamic targets. The returned signal emits the full `IntersectionObserverEntry` object (or `undefined`) & exposes a sub-signal `.visible` which is just a boolean signal for ease of use
435
+ | Operator | Shape | Notes |
436
+ | -------------------------------- | -------------------------- | ----------------------------------------------------------------------- |
437
+ | `select(fn, opt?)` | `(I) => O` | Projection with optional equality. Identical to `map` + `distinct`. |
438
+ | `map(fn)` | `(I) => O` | Pure transform. |
439
+ | `distinct(equal?)` | `T -> T` | Suppress emissions when `equal(prev, next)` returns `true`. |
440
+ | `combineWith(other, fn)` | `(A, B) => R` | Project two signals together. |
441
+ | `filter(predicate)` | `T -> T \| undefined` | Keeps last passing value; returns `undefined` until the first match. |
442
+ | `filterWith(predicate, initial)` | `T -> T` | Same as `filter` but emits `initial` before the first match. |
443
+ | `tap(fn, injector?)` | `T -> T` | Runs a side effect via `effect()`; pass an `Injector` when out of DI. |
444
+ | `startWith(initial)` | `T -> T \| U` | Emits `initial` first, then mirrors source. |
445
+ | `pairwise()` | `T -> [T \| undefined, T]` | Emits `[prev, curr]` pairs (prev is `undefined` on the first emission). |
446
+ | `scan(reducer, seed)` | `(R, T) => R` | Reduce-like accumulator across emissions. |
1063
447
 
1064
- ```typescript
1065
- import { Component, effect, ElementRef, viewChild, computed } from '@angular/core';
1066
- import { elementVisibility } from '@mmstack/primitives';
448
+ ## License
1067
449
 
1068
- @Component({
1069
- selector: 'app-lazy-load-item',
1070
- template: `
1071
- <div #itemToObserve style="height: 300px; margin-top: 100vh; border: 2px solid green;">
1072
- @if (intersectionEntry.visible()) {
1073
- <p>This content was lazy-loaded because it became visible!</p>
1074
- } @else {
1075
- <p>Item is off-screen. Scroll down to load it.</p>
1076
- }
1077
- </div>
1078
- `,
1079
- })
1080
- export class LazyLoadItemComponent {
1081
- readonly itemRef = viewChild.required<ElementRef<HTMLDivElement>>('itemToObserve', {
1082
- read: ElementRef,
1083
- });
1084
-
1085
- // Observe the element, get the full IntersectionObserverEntry
1086
- readonly intersectionEntry = elementVisibility(this.itemRef);
1087
-
1088
- constructor() {
1089
- effect(() => {
1090
- if (this.intersectionEntry.visible()) {
1091
- console.log('Item is now visible!', this.intersectionEntry());
1092
- } else {
1093
- console.log('Item is no longer visible or not yet visible.');
1094
- }
1095
- });
1096
- }
1097
- }
1098
- ```
450
+ MIT © [Miha Mulec](https://github.com/mihajm)