@mmstack/primitives 20.5.5 → 20.5.7
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 +276 -924
- package/fesm2022/mmstack-primitives.mjs +633 -73
- package/fesm2022/mmstack-primitives.mjs.map +1 -1
- package/index.d.ts +518 -46
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,1098 +1,450 @@
|
|
|
1
1
|
# @mmstack/primitives
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**Signal-native utilities for Angular — debounce, throttle, two-way derivations, deep stores, undo/redo, sensors, and more.**
|
|
4
4
|
|
|
5
5
|
[](https://badge.fury.io/js/%40mmstack%2Fprimitives)
|
|
6
6
|
[](https://github.com/mihajm/mmstack/blob/master/packages/primitives/LICENSE)
|
|
7
7
|
|
|
8
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
81
|
-
|
|
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
|
-
|
|
84
|
-
const debouncedQuery = debounce(query, { ms: 300 });
|
|
85
|
-
```
|
|
27
|
+
## Writable signal variants
|
|
86
28
|
|
|
87
|
-
###
|
|
29
|
+
### `mutable`
|
|
88
30
|
|
|
89
|
-
|
|
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
|
-
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
});
|
|
38
|
+
user.mutate((prev) => {
|
|
39
|
+
prev.age++;
|
|
40
|
+
return prev;
|
|
41
|
+
});
|
|
147
42
|
|
|
148
|
-
|
|
43
|
+
user.inline((prev) => {
|
|
44
|
+
prev.age++;
|
|
45
|
+
}); // void return — same effect
|
|
46
|
+
```
|
|
149
47
|
|
|
150
|
-
|
|
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
|
-
|
|
156
|
-
effect(() => {
|
|
157
|
-
// Doesnt run if user changes, unless name is destructured
|
|
158
|
-
console.log(name());
|
|
159
|
-
});
|
|
50
|
+
### `derived`
|
|
160
51
|
|
|
161
|
-
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
console.log(name2());
|
|
168
|
-
});
|
|
169
|
-
}
|
|
54
|
+
```typescript
|
|
55
|
+
import { derived } from '@mmstack/primitives';
|
|
170
56
|
|
|
171
|
-
|
|
172
|
-
user.mutate((prev) => {
|
|
173
|
-
prev.age++;
|
|
174
|
-
return prev;
|
|
175
|
-
});
|
|
176
|
-
}
|
|
57
|
+
const user = signal({ name: 'John', age: 30 });
|
|
177
58
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
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
|
-
|
|
72
|
+
### `store` / `mutableStore`
|
|
189
73
|
|
|
190
|
-
|
|
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 {
|
|
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
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
|
|
95
|
+
Top-level array support isn't exposed yet — use `indexArray` / `keyArray` for those.
|
|
229
96
|
|
|
230
|
-
|
|
97
|
+
### `toWritable`
|
|
231
98
|
|
|
232
|
-
-
|
|
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 {
|
|
237
|
-
import { signal } from '@angular/core';
|
|
238
|
-
|
|
239
|
-
const count = piped(1);
|
|
102
|
+
import { toWritable } from '@mmstack/primitives';
|
|
240
103
|
|
|
241
|
-
|
|
242
|
-
const
|
|
243
|
-
(
|
|
244
|
-
(
|
|
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
|
-
|
|
111
|
+
## Timing & propagation
|
|
257
112
|
|
|
258
|
-
|
|
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
|
-
|
|
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
|
-
|
|
265
|
-
|
|
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
|
-
|
|
269
|
-
|
|
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
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
-
|
|
127
|
+
### `throttled`
|
|
282
128
|
|
|
283
|
-
|
|
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
|
-
|
|
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
|
-
|
|
315
|
-
|
|
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
|
-
|
|
320
|
-
|
|
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
|
-
|
|
325
|
-
|
|
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
|
-
|
|
144
|
+
Same `.original` escape hatch as `debounced`.
|
|
334
145
|
|
|
335
|
-
|
|
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
|
-
|
|
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
|
-
|
|
347
|
-
|
|
150
|
+
```typescript
|
|
151
|
+
import { until } from '@mmstack/primitives';
|
|
348
152
|
|
|
349
|
-
|
|
350
|
-
state.todos[0].done.set(true);
|
|
153
|
+
const event = signal<Event | null>(null);
|
|
351
154
|
|
|
352
|
-
|
|
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
|
-
|
|
355
|
-
|
|
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
|
-
|
|
165
|
+
## Reactive collections
|
|
361
166
|
|
|
362
|
-
|
|
167
|
+
### `indexArray` / `keyArray`
|
|
363
168
|
|
|
364
|
-
`
|
|
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
|
|
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
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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
|
-
|
|
425
|
-
|
|
426
|
-
|
|
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
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
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
|
-
|
|
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
|
-
|
|
196
|
+
### `mapObject`
|
|
451
197
|
|
|
452
|
-
The
|
|
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
|
-
```
|
|
455
|
-
import { Component, signal, computed } from '@angular/core';
|
|
200
|
+
```typescript
|
|
456
201
|
import { mapObject } from '@mmstack/primitives';
|
|
457
202
|
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
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
|
-
|
|
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
|
-
|
|
219
|
+
## Effects
|
|
507
220
|
|
|
508
|
-
|
|
221
|
+
### `nestedEffect`
|
|
509
222
|
|
|
510
|
-
|
|
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
|
-
```
|
|
513
|
-
import { Component, signal } from '@angular/core';
|
|
225
|
+
```typescript
|
|
514
226
|
import { nestedEffect } from '@mmstack/primitives';
|
|
515
227
|
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
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
|
-
//
|
|
535
|
-
|
|
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
|
-
|
|
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
|
-
|
|
598
|
-
import { Component, signal, effect } from '@angular/core';
|
|
599
|
-
import { toWritable } from '@mmstack/primitives';
|
|
241
|
+
## History & persistence
|
|
600
242
|
|
|
601
|
-
|
|
243
|
+
### `withHistory`
|
|
602
244
|
|
|
603
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
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
|
-
###
|
|
258
|
+
### `stored`
|
|
660
259
|
|
|
661
|
-
A
|
|
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 {
|
|
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
|
-
|
|
703
|
-
|
|
704
|
-
|
|
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
|
-
|
|
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
|
-
###
|
|
274
|
+
### `tabSync`
|
|
773
275
|
|
|
774
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
811
|
-
|
|
812
|
-
### sensor
|
|
284
|
+
## Performance helpers
|
|
813
285
|
|
|
814
|
-
|
|
286
|
+
### `chunked`
|
|
815
287
|
|
|
816
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
836
|
-
|
|
837
|
-
#### mousePosition
|
|
296
|
+
### `pooled` / `pooledArray` / `pooledMap` / `pooledSet`
|
|
838
297
|
|
|
839
|
-
|
|
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 {
|
|
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
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
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
|
-
|
|
870
|
-
|
|
871
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
319
|
+
## Sensors
|
|
908
320
|
|
|
909
|
-
|
|
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 {
|
|
913
|
-
import { sensor } from '@mmstack/primitives'; // Or import { pageVisibility }
|
|
324
|
+
import { sensor } from '@mmstack/primitives';
|
|
914
325
|
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
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
|
-
|
|
335
|
+
`sensors(['networkStatus', 'windowSize'])` returns a record of all requested sensors in one call.
|
|
934
336
|
|
|
935
|
-
|
|
337
|
+
### Available sensors
|
|
936
338
|
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
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
|
-
|
|
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
|
-
|
|
360
|
+
### `signalFromEvent`
|
|
958
361
|
|
|
959
|
-
|
|
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 {
|
|
971
|
-
import { sensor } from '@mmstack/primitives'; // Or import { scrollPosition }
|
|
972
|
-
import { JsonPipe } from '@angular/common';
|
|
365
|
+
import { signalFromEvent } from '@mmstack/primitives';
|
|
973
366
|
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1001
|
-
Reacts to changes in preferences & exposes a `Signal<boolean>`.
|
|
381
|
+
### Sensor example
|
|
1002
382
|
|
|
1003
383
|
```typescript
|
|
1004
|
-
import { Component
|
|
1005
|
-
import {
|
|
384
|
+
import { Component } from '@angular/core';
|
|
385
|
+
import { sensor } from '@mmstack/primitives';
|
|
1006
386
|
|
|
1007
387
|
@Component({
|
|
1008
|
-
selector: 'app-
|
|
388
|
+
selector: 'app-network-badge',
|
|
1009
389
|
template: `
|
|
1010
|
-
@if (
|
|
1011
|
-
<
|
|
390
|
+
@if (network()) {
|
|
391
|
+
<span class="online"
|
|
392
|
+
>Online since {{ network.since() | date: 'short' }}</span
|
|
393
|
+
>
|
|
1012
394
|
} @else {
|
|
1013
|
-
<
|
|
395
|
+
<span class="offline"
|
|
396
|
+
>Offline since {{ network.since() | date: 'short' }}</span
|
|
397
|
+
>
|
|
1014
398
|
}
|
|
1015
399
|
`,
|
|
1016
400
|
})
|
|
1017
|
-
export class
|
|
1018
|
-
readonly
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
415
|
+
const count = piped(1);
|
|
1050
416
|
|
|
1051
|
-
|
|
1052
|
-
|
|
417
|
+
// .map composes value -> value transforms inline
|
|
418
|
+
const label = count.map(
|
|
419
|
+
(n) => n * 2,
|
|
420
|
+
(n) => `#${n}`,
|
|
421
|
+
);
|
|
1053
422
|
|
|
1054
|
-
|
|
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
|
-
###
|
|
431
|
+
### Operators
|
|
1059
432
|
|
|
1060
|
-
|
|
433
|
+
All operators are `(src: Signal<I>) => Signal<O>` and compose via `.pipe(...)`.
|
|
1061
434
|
|
|
1062
|
-
|
|
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
|
-
|
|
1065
|
-
import { Component, effect, ElementRef, viewChild, computed } from '@angular/core';
|
|
1066
|
-
import { elementVisibility } from '@mmstack/primitives';
|
|
448
|
+
## License
|
|
1067
449
|
|
|
1068
|
-
|
|
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)
|