@mmstack/primitives 19.0.4 → 19.1.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025-present Miha J. Mulec
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,230 @@
1
- # mmstack/primitives
1
+ # @mmstack/primitives
2
2
 
3
- A library for signal primitives, still working on the docs ;)
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).
4
+
5
+ [![npm version](https://badge.fury.io/js/%40mmstack%2Fprimitives.svg)](https://badge.fury.io/js/%40mmstack%2Fprimitives)
6
+ [![License](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/mihajm/mmstack/blob/master/packages/primitives/LICENSE)
7
+
8
+ ## Installation
9
+
10
+ ```bash
11
+ npm install @mmstack/primitives
12
+ ```
13
+
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
+ - `mutable` - A signal variant allowing in-place mutations while triggering updates.
20
+ - `stored` - Creates a signal synchronized with persistent storage (e.g., localStorage).
21
+ - `mapArray` - Maps a reactive array efficently into an array of stable derivations.
22
+ - `toWritable` - Converts a read-only signal to writable using custom write logic.
23
+ - `derived` - Creates a signal with two-way binding to a source signal.
24
+
25
+ ---
26
+
27
+ ### debounced
28
+
29
+ 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.
30
+
31
+ ```typescript
32
+ import { Component, signal, effect } from '@angular/core';
33
+ import { debounced } from '@mmstack/primitives';
34
+ import { FormsModule } from '@angular/forms';
35
+
36
+ @Component({
37
+ selector: 'app-debounced',
38
+ template: `<input [(ngModel)]="searchTerm" />`,
39
+ })
40
+ export class SearchComponent {
41
+ searchTerm = debounced('', { ms: 300 }); // Debounce for 300ms
42
+
43
+ constructor() {
44
+ effect(() => {
45
+ // Runs 300ms after the user stops typing
46
+ console.log('Perform search for:', this.searchTerm());
47
+ });
48
+ effect(() => {
49
+ // Runs immediately on input change
50
+ console.log('Input value:', this.searchTerm.original());
51
+ });
52
+ }
53
+ }
54
+ ```
55
+
56
+ ### mutable
57
+
58
+ 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.
59
+
60
+ ```typescript
61
+ import { Component, computed, effect } from '@angular/core';
62
+ import { mutable } from '@mmstack/primitives';
63
+ import { FormsModule } from '@angular/forms';
64
+
65
+ @Component({
66
+ selector: 'app-mutable',
67
+ template: ` <button (click)="incrementAge()">inc</button> `,
68
+ })
69
+ export class SearchComponent {
70
+ user = mutable({ name: { first: 'John', last: 'Doe' }, age: 30 });
71
+
72
+ constructor() {
73
+ effect(() => {
74
+ // Runs every time user is mutated
75
+ console.log(this.user());
76
+ });
77
+
78
+ const age = computed(() => this.user().age);
79
+
80
+ effect(() => {
81
+ // Runs every time age changes
82
+ console.log(age());
83
+ });
84
+
85
+ const name = computed(() => this.user().name);
86
+ effect(() => {
87
+ // Doesnt run if user changes, unless name is destructured
88
+ console.log(name());
89
+ });
90
+
91
+ const name2 = computed(() => this.user().name, {
92
+ equal: () => false,
93
+ });
94
+
95
+ effect(() => {
96
+ // Runs every time user changes (even if name did not change)
97
+ console.log(name2());
98
+ });
99
+ }
100
+
101
+ incrementAge() {
102
+ user.mutate((prev) => {
103
+ prev.age++;
104
+ return prev;
105
+ });
106
+ }
107
+
108
+ incrementInline() {
109
+ user.inline((prev) => {
110
+ prev.age++;
111
+ });
112
+ }
113
+ }
114
+ ```
115
+
116
+ ### stored
117
+
118
+ 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.
119
+
120
+ 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.
121
+
122
+ ```typescript
123
+ import { Component, effect, signal } from '@angular/core';
124
+ import { stored } from '@mmstack/primitives';
125
+ // import { FormsModule } from '@angular/forms'; // Needed for ngModel
126
+
127
+ @Component({
128
+ selector: 'app-theme-selector',
129
+ standalone: true,
130
+ // imports: [FormsModule], // Import if using ngModel
131
+ template: `
132
+ Theme:
133
+ <select [value]="theme()" (change)="theme.set($event.target.value)">
134
+ <option value="light">Light</option>
135
+ <option value="dark">Dark</option>
136
+ <option value="system">System</option>
137
+ </select>
138
+ <button (click)="theme.clear()">Reset Theme</button>
139
+ <p>Using storage key: {{ theme.key() }}</p>
140
+ `,
141
+ })
142
+ export class ThemeSelectorComponent {
143
+ // Persist theme preference in localStorage, default to 'system'
144
+ theme = stored<'light' | 'dark' | 'system'>('system', {
145
+ key: 'user-theme',
146
+ syncTabs: true, // Sync theme choice across tabs
147
+ });
148
+
149
+ constructor() {
150
+ effect(() => {
151
+ console.log(`Theme set to: ${this.theme()}`);
152
+ // Logic to apply theme (e.g., add class to body)
153
+ document.body.className = `theme-${this.theme()}`;
154
+ });
155
+ }
156
+ }
157
+ ```
158
+
159
+ ### mapArray
160
+
161
+ 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.
162
+
163
+ ```typescript
164
+ import { Component, signal } from '@angular/core';
165
+ import { mapArray } from '@mmstack/primitives';
166
+
167
+ @Component({
168
+ selector: 'app-map-demo',
169
+ template: `
170
+ <ul>
171
+ @for (item of displayItems(); track item) {
172
+ <li>{{ item() }}</li>
173
+ }
174
+ </ul>
175
+ <button (click)="addItem()">Add</button>
176
+ <button (click)="updateFirst()">Update First</button>
177
+ `,
178
+ })
179
+ export class ListComponent {
180
+ sourceItems = signal([
181
+ { id: 1, name: 'A' },
182
+ { id: 2, name: 'B' },
183
+ ]);
184
+
185
+ readonly displayItems = mapArray(this.sourceItems, (child, index) => computed(() => `Item ${index}: ${child().name}`));
186
+
187
+ addItem() {
188
+ this.sourceItems.update((items) => [...items, { id: Date.now(), name: String.fromCharCode(67 + items.length - 2) }]);
189
+ }
190
+
191
+ updateFirst() {
192
+ this.sourceItems.update((items) => {
193
+ items[0] = { ...items[0], name: items[0].name + '+' };
194
+ return [...items]; // New array, but mapArray keeps stable signals
195
+ });
196
+ }
197
+ }
198
+ ```
199
+
200
+ ### toWritable
201
+
202
+ 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.
203
+
204
+ ```typescript
205
+ import { Component, signal, effect } from '@angular/core';
206
+ import { toWritable } from '@mmstack/primitives';
207
+
208
+ const user = signal({ name: 'John' });
209
+
210
+ const name = toWritable(
211
+ computed(() => user().name),
212
+ (name) => user.update((prev) => ({ ...prev, name })),
213
+ ); // WritableSignal<string> bound to user signal
214
+ ```
215
+
216
+ ### derived
217
+
218
+ 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.
219
+
220
+ ```typescript
221
+ const user = signal({ name: 'John' });
222
+
223
+ const name = derived(user, 'name'); // WritableSignal<string>, which updates user signal & reacts to changes in the name property
224
+
225
+ // Full syntax example
226
+ const name2 = derived(user, {
227
+ from: (u) => u.name,
228
+ onChange: (name) => user.update((prev) => ({ ...prev, name })),
229
+ });
230
+ ```
@@ -1,4 +1,6 @@
1
- import { untracked, signal, computed, isSignal, linkedSignal } from '@angular/core';
1
+ import { untracked, signal, computed, isSignal, linkedSignal, inject, PLATFORM_ID, isDevMode, effect, DestroyRef } from '@angular/core';
2
+ import { isPlatformServer } from '@angular/common';
3
+ import { SIGNAL } from '@angular/core/primitives/signals';
2
4
 
3
5
  /**
4
6
  * Converts a read-only `Signal` into a `WritableSignal` by providing custom `set` and, optionally, `update` functions.
@@ -36,38 +38,88 @@ function toWritable(signal, set, update) {
36
38
  return internal;
37
39
  }
38
40
 
39
- function debounced(value, opt) {
40
- const sig = signal(value, opt);
41
- const ms = opt?.ms ?? 300;
42
- let timeout;
43
- const originalSet = sig.set;
44
- const originalUpdate = sig.update;
41
+ /**
42
+ * Creates a `WritableSignal` whose publicly readable value is updated only after
43
+ * a specified debounce period (`ms`) has passed since the last call to its
44
+ * `.set()` or `.update()` method.
45
+ *
46
+ * This implementation avoids using `effect` by leveraging intermediate `computed`
47
+ * signals and a custom `equal` function to delay value propagation based on a timer.
48
+ *
49
+ * @template T The type of value the signal holds.
50
+ * @param initial The initial value of the signal.
51
+ * @param opt Options for signal creation, including:
52
+ * - `ms`: The debounce time in milliseconds. Defaults to 0 if omitted (no debounce).
53
+ * - Other `CreateSignalOptions` (like `equal`) are passed to underlying signals.
54
+ * @returns A `DebouncedSignal<T>` instance. Its readable value updates are debounced,
55
+ * and it includes an `.original` property providing immediate access to the latest set value.
56
+ *
57
+ * @example
58
+ * ```ts
59
+ * import { effect } from '@angular/core';
60
+ *
61
+ * // Create a debounced signal with a 500ms delay
62
+ * const query = debounced('', { ms: 500 });
63
+ *
64
+ * effect(() => {
65
+ * // This effect runs 500ms after the last change to 'query'
66
+ * console.log('Debounced Query:', query());
67
+ * });
68
+ *
69
+ * effect(() => {
70
+ * // This effect runs immediately when 'query.original' changes
71
+ * console.log('Original Query:', query.original());
72
+ * });
73
+ *
74
+ * console.log('Setting query to "a"');
75
+ * query.set('a');
76
+ * // Output: Original Query: a
77
+ *
78
+ * setTimeout(() => {
79
+ * console.log('Setting query to "ab"');
80
+ * query.set('ab');
81
+ * // Output: Original Query: ab
82
+ * }, 200); // Before debounce timeout
83
+ *
84
+ * setTimeout(() => {
85
+ * console.log('Setting query to "abc"');
86
+ * query.set('abc');
87
+ * // Output: Original Query: abc
88
+ * }, 400); // Before debounce timeout
89
+ *
90
+ * // ~500ms after the *last* set (at 400ms), the debounced effect runs:
91
+ * // Output (at ~900ms): Debounced Query: abc
92
+ * ```
93
+ */
94
+ function debounced(initial, opt) {
95
+ const internal = signal(initial, opt);
96
+ const ms = opt.ms ?? 0;
45
97
  const trigger = signal(false);
46
- // Set on the original signal, then trigger the debounced update
98
+ let timeout;
47
99
  const set = (value) => {
48
- originalSet(value); // Update the *original* signal immediately
49
100
  if (timeout)
50
101
  clearTimeout(timeout);
102
+ internal.set(value);
51
103
  timeout = setTimeout(() => {
52
- trigger.update((cur) => !cur); // Trigger the computed signal
104
+ trigger.update((c) => !c);
53
105
  }, ms);
54
106
  };
55
- // Update on the original signal, then trigger the debounced update
56
107
  const update = (fn) => {
57
- originalUpdate(fn); // Update the *original* signal immediately
58
108
  if (timeout)
59
109
  clearTimeout(timeout);
110
+ internal.update(fn);
60
111
  timeout = setTimeout(() => {
61
- trigger.update((cur) => !cur); // Trigger the computed signal
112
+ trigger.update((c) => !c);
62
113
  }, ms);
63
114
  };
64
- // Create a computed signal that depends on the trigger.
65
- // This computed signal is what provides the debounced behavior.
66
- const writable = toWritable(computed(() => {
67
- trigger();
68
- return untracked(sig);
69
- }), set, update);
70
- writable.original = sig;
115
+ const stable = computed(() => ({
116
+ trigger: trigger(),
117
+ value: internal(),
118
+ }), {
119
+ equal: (a, b) => a.trigger === b.trigger,
120
+ });
121
+ const writable = toWritable(computed(() => stable().value, opt), set, update);
122
+ writable.original = internal;
71
123
  return writable;
72
124
  }
73
125
 
@@ -132,32 +184,84 @@ function isDerivation(sig) {
132
184
  return 'from' in sig;
133
185
  }
134
186
 
135
- function createReconciler(source, opt) {
136
- const map = opt?.map ?? ((source, index) => source[index]);
137
- return (length, prev) => {
138
- if (!prev)
139
- return Array.from({ length }, (_, i) => computed(() => map(source(), i), opt));
140
- if (length === prev.source)
141
- return prev.value;
142
- if (length < prev.source) {
143
- return prev.value.slice(0, length);
144
- }
145
- else {
146
- const next = [...prev.value];
147
- for (let i = prev.source; i < length; i++) {
148
- next.push(computed(() => map(source(), i), opt));
149
- }
150
- return next;
151
- }
152
- };
153
- }
154
- function mapArray(source, opt) {
187
+ /**
188
+ * Reactively maps items from a source array (or signal of an array) using a provided mapping function.
189
+ *
190
+ * This function serves a similar purpose to SolidJS's `mapArray` by providing stability
191
+ * for mapped items. It receives a source function returning an array (or a Signal<T[]>)
192
+ * and a mapping function.
193
+ *
194
+ * For each item in the source array, it creates a stable `computed` signal representing
195
+ * that item's value at its current index. This stable signal (`Signal<T>`) is passed
196
+ * to the mapping function. This ensures that downstream computations or components
197
+ * depending on the mapped result only re-render or re-calculate for the specific items
198
+ * that have changed, or when items are added/removed, rather than re-evaluating everything
199
+ * when the source array reference changes but items remain the same.
200
+ *
201
+ * It efficiently handles changes in the source array's length by reusing existing mapped
202
+ * results when possible, slicing when the array shrinks, and appending new mapped items
203
+ * when it grows.
204
+ *
205
+ * @template T The type of items in the source array.
206
+ * @template U The type of items in the resulting mapped array.
207
+ *
208
+ * @param source A function returning the source array `T[]`, or a `Signal<T[]>` itself.
209
+ * The `mapArray` function will reactively update based on changes to this source.
210
+ * @param map The mapping function. It is called for each item in the source array.
211
+ * It receives:
212
+ * - `value`: A stable `Signal<T>` representing the item at the current index.
213
+ * Use this signal within your mapping logic if you need reactivity
214
+ * tied to the specific item's value changes.
215
+ * - `index`: The number index of the item in the array.
216
+ * It should return the mapped value `U`.
217
+ * @param [opt] Optional `CreateSignalOptions<T>`. These options are passed directly
218
+ * to the `computed` signal created for each individual item (`Signal<T>`).
219
+ * This allows specifying options like a custom `equal` function for item comparison.
220
+ *
221
+ * @returns A `Signal<U[]>` containing the mapped array. This signal updates whenever
222
+ * the source array changes (either length or the values of its items).
223
+ *
224
+ * @example
225
+ * ```ts
226
+ * const sourceItems = signal([
227
+ * { id: 1, name: 'Apple' },
228
+ * { id: 2, name: 'Banana' }
229
+ * ]);
230
+ *
231
+ * const mappedItems = mapArray(
232
+ * sourceItems,
233
+ * (itemSignal, index) => {
234
+ * // itemSignal is stable for a given item based on its index.
235
+ * // We create a computed here to react to changes in the item's name.
236
+ * return computed(() => `${index}: ${itemSignal().name.toUpperCase()}`);
237
+ * },
238
+ * // Example optional options (e.g., custom equality for item signals)
239
+ * { equal: (a, b) => a.id === b.id && a.name === b.name }
240
+ * );
241
+ * ```
242
+ */
243
+ function mapArray(source, map, opt) {
155
244
  const data = isSignal(source) ? source : computed(source);
156
- const length = computed(() => data().length);
157
- const reconciler = createReconciler(data, opt);
245
+ const len = computed(() => data().length);
158
246
  return linkedSignal({
159
- source: () => length(),
160
- computation: (len, prev) => reconciler(len, prev),
247
+ source: () => len(),
248
+ computation: (len, prev) => {
249
+ if (!prev)
250
+ return Array.from({ length: len }, (_, i) => map(computed(() => source()[i], opt), i));
251
+ if (len === prev.value.length)
252
+ return prev.value;
253
+ if (len < prev.value.length) {
254
+ return prev.value.slice(0, len);
255
+ }
256
+ else {
257
+ const next = [...prev.value];
258
+ for (let i = prev.value.length; i < len; i++) {
259
+ next[i] = map(computed(() => source()[i], opt), i);
260
+ }
261
+ return next;
262
+ }
263
+ },
264
+ equal: (a, b) => a.length === b.length,
161
265
  });
162
266
  }
163
267
 
@@ -212,9 +316,195 @@ function isMutable(value) {
212
316
  return 'mutate' in value && typeof value.mutate === 'function';
213
317
  }
214
318
 
319
+ // Internal dummy store for server-side rendering
320
+ const noopStore = {
321
+ getItem: () => null,
322
+ setItem: () => {
323
+ /* noop */
324
+ },
325
+ removeItem: () => {
326
+ /* noop */
327
+ },
328
+ };
329
+ /**
330
+ * Creates a `WritableSignal` whose state is automatically synchronized with persistent storage
331
+ * (like `localStorage` or `sessionStorage`).
332
+ *
333
+ * It handles Server-Side Rendering (SSR) gracefully, allows dynamic storage keys,
334
+ * custom serialization/deserialization, custom storage providers, and optional
335
+ * synchronization across browser tabs.
336
+ *
337
+ * @template T The type of value held by the signal and stored (after serialization).
338
+ * @param fallback The default value of type `T` to use when no value is found in storage
339
+ * or when deserialization fails. The signal's value will never be `null` or `undefined`
340
+ * publicly, it will always revert to this fallback.
341
+ * @param options Configuration options (`CreateStoredOptions<T>`). Requires at least the `key`.
342
+ * @returns A `StoredSignal<T>` instance. This signal behaves like a standard `WritableSignal<T>`,
343
+ * but its value is persisted. It includes a `.clear()` method to remove the item from storage
344
+ * and a `.key` signal providing the current storage key.
345
+ *
346
+ * @remarks
347
+ * - **Persistence:** The signal automatically saves its value to storage whenever the signal's
348
+ * value or its configured `key` changes. This is managed internally using `effect`.
349
+ * - **SSR Safety:** Detects server environments and uses a no-op storage, preventing errors.
350
+ * - **Error Handling:** Catches and logs errors during serialization/deserialization in dev mode.
351
+ * - **Tab Sync:** If `syncTabs` is true, listens to `storage` events to keep the signal value
352
+ * consistent across browser tabs using the same key. Cleanup is handled automatically
353
+ * using `DestroyRef`.
354
+ * - **Removal:** Use the `.clear()` method on the returned signal to remove the item from storage.
355
+ * Setting the signal to the fallback value will store the fallback value, not remove the item.
356
+ *
357
+ * @example
358
+ * ```ts
359
+ * import { Component, effect, signal } from '@angular/core';
360
+ * import { stored } from '@mmstack/primitives'; // Adjust import path
361
+ *
362
+ * @Component({
363
+ * selector: 'app-settings',
364
+ * standalone: true,
365
+ * template: `
366
+ * Theme:
367
+ * <select [ngModel]="theme()" (ngModelChange)="theme.set($event)">
368
+ * <option value="light">Light</option>
369
+ * <option value="dark">Dark</option>
370
+ * </select>
371
+ * <button (click)="theme.clear()">Clear Theme Setting</button>
372
+ * <p>Storage Key Used: {{ theme.key() }}</p>
373
+ * ` // Requires FormsModule for ngModel
374
+ * })
375
+ * export class SettingsComponent {
376
+ * theme = stored<'light' | 'dark'>('light', { key: 'app-theme', syncTabs: true });
377
+ * }
378
+ * ```
379
+ */
380
+ function stored(fallback, { key, store: providedStore, serialize = JSON.stringify, deserialize = JSON.parse, syncTabs = false, equal = Object.is, ...rest }) {
381
+ const isServer = isPlatformServer(inject(PLATFORM_ID));
382
+ const fallbackStore = isServer ? noopStore : localStorage;
383
+ const store = providedStore ?? fallbackStore;
384
+ const keySig = typeof key === 'string'
385
+ ? computed(() => key)
386
+ : isSignal(key)
387
+ ? key
388
+ : computed(key);
389
+ const getValue = (key) => {
390
+ const found = store.getItem(key);
391
+ if (found === null)
392
+ return null;
393
+ try {
394
+ return deserialize(found);
395
+ }
396
+ catch (err) {
397
+ if (isDevMode())
398
+ console.error(`Failed to parse stored value for key "${key}":`, err);
399
+ return null;
400
+ }
401
+ };
402
+ const storeValue = (key, value) => {
403
+ try {
404
+ if (value === null)
405
+ return store.removeItem(key);
406
+ const serialized = serialize(value);
407
+ store.setItem(key, serialized);
408
+ }
409
+ catch (err) {
410
+ if (isDevMode())
411
+ console.error(`Failed to store value for key "${key}":`, err);
412
+ }
413
+ };
414
+ const opt = {
415
+ ...rest,
416
+ equal,
417
+ };
418
+ const internal = signal(getValue(untracked(keySig)), {
419
+ ...opt,
420
+ equal: (a, b) => {
421
+ if (a === null && b === null)
422
+ return true;
423
+ if (a === null || b === null)
424
+ return false;
425
+ return equal(a, b);
426
+ },
427
+ });
428
+ effect(() => storeValue(keySig(), internal()));
429
+ if (syncTabs && !isServer) {
430
+ const destroyRef = inject(DestroyRef);
431
+ const sync = (e) => {
432
+ if (e.key !== untracked(keySig))
433
+ return;
434
+ if (e.newValue === null)
435
+ internal.set(null);
436
+ else
437
+ internal.set(getValue(e.key));
438
+ };
439
+ window.addEventListener('storage', sync);
440
+ destroyRef.onDestroy(() => window.removeEventListener('storage', sync));
441
+ }
442
+ const writable = toWritable(computed(() => internal() ?? fallback, opt), internal.set);
443
+ writable.clear = () => {
444
+ internal.set(null);
445
+ };
446
+ writable.key = keySig;
447
+ return writable;
448
+ }
449
+
450
+ function withHistory(source, opt) {
451
+ const { equal = Object.is, debugName } = source[SIGNAL];
452
+ const maxSize = opt?.maxSize ?? Infinity;
453
+ const history = mutable([], opt);
454
+ const redoArray = mutable([]);
455
+ const set = (value) => {
456
+ const current = untracked(source);
457
+ if (equal(value, current))
458
+ return;
459
+ source.set(value);
460
+ history.mutate((c) => {
461
+ if (c.length >= maxSize) {
462
+ c = c.slice(Math.floor(maxSize / 2));
463
+ }
464
+ c.push(current);
465
+ return c;
466
+ });
467
+ redoArray.set([]);
468
+ };
469
+ const update = (updater) => {
470
+ set(updater(untracked(source)));
471
+ };
472
+ const internal = toWritable(computed(() => source(), {
473
+ equal,
474
+ debugName,
475
+ }), set, update);
476
+ internal.history = history;
477
+ internal.undo = () => {
478
+ const last = untracked(history);
479
+ if (last.length === 0)
480
+ return;
481
+ const prev = last.at(-1);
482
+ const cur = untracked(source);
483
+ history.inline((c) => c.pop());
484
+ redoArray.inline((c) => c.push(cur));
485
+ source.set(prev);
486
+ };
487
+ internal.redo = () => {
488
+ const last = untracked(redoArray);
489
+ if (last.length === 0)
490
+ return;
491
+ const prev = last.at(-1);
492
+ redoArray.inline((c) => c.pop());
493
+ set(prev);
494
+ };
495
+ internal.clear = () => {
496
+ history.set([]);
497
+ redoArray.set([]);
498
+ };
499
+ internal.canUndo = computed(() => history().length > 0);
500
+ internal.canRedo = computed(() => redoArray().length > 0);
501
+ internal.canClear = computed(() => internal.canUndo() || internal.canRedo());
502
+ return internal;
503
+ }
504
+
215
505
  /**
216
506
  * Generated bundle index. Do not edit.
217
507
  */
218
508
 
219
- export { debounced, derived, isDerivation, isMutable, mapArray, mutable, toFakeDerivation, toFakeSignalDerivation, toWritable };
509
+ export { debounced, derived, isDerivation, isMutable, mapArray, mutable, stored, toFakeDerivation, toFakeSignalDerivation, toWritable, withHistory };
220
510
  //# sourceMappingURL=mmstack-primitives.mjs.map