@mmstack/primitives 20.4.5 → 20.4.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 +90 -0
- package/fesm2022/mmstack-primitives.mjs +137 -2
- package/fesm2022/mmstack-primitives.mjs.map +1 -1
- package/index.d.ts +78 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -22,6 +22,7 @@ This library provides the following primitives:
|
|
|
22
22
|
- `piped` – Creates a signal with a chainable & typesafe `.pipe(...)` method, which returns a pipable computed.
|
|
23
23
|
- `withHistory` - Enhances a signal with a complete undo/redo history stack.
|
|
24
24
|
- `mapArray` - Maps a reactive array efficently into an array of stable derivations.
|
|
25
|
+
- `nestedEffect` - Creates an effect with a hierarchical lifetime, enabling fine-grained, conditional side-effects.
|
|
25
26
|
- `toWritable` - Converts a read-only signal to writable using custom write logic.
|
|
26
27
|
- `derived` - Creates a signal with two-way binding to a source signal.
|
|
27
28
|
- `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).
|
|
@@ -318,6 +319,95 @@ export class ListComponent {
|
|
|
318
319
|
}
|
|
319
320
|
```
|
|
320
321
|
|
|
322
|
+
### nestedEffect
|
|
323
|
+
|
|
324
|
+
Creates an effect that can be nested, similar to SolidJS's `createEffect`.
|
|
325
|
+
|
|
326
|
+
This primitive enables true hierarchical reactivity. A `nestedEffect` created within another `nestedEffect` is **automatically destroyed and recreated** when the parent re-runs.
|
|
327
|
+
|
|
328
|
+
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.
|
|
329
|
+
|
|
330
|
+
```ts
|
|
331
|
+
import { Component, signal } from '@angular/core';
|
|
332
|
+
import { nestedEffect } from '@mmstack/primitives';
|
|
333
|
+
|
|
334
|
+
@Component({ selector: 'app-nested-demo' })
|
|
335
|
+
export class NestedDemoComponent {
|
|
336
|
+
// `coldGuard` changes rarely
|
|
337
|
+
readonly coldGuard = signal(false);
|
|
338
|
+
// `hotSignal` changes very often
|
|
339
|
+
readonly hotSignal = signal(0);
|
|
340
|
+
|
|
341
|
+
constructor() {
|
|
342
|
+
// A standard effect would track *both* signals and run
|
|
343
|
+
// every time `hotSignal` changes, even if `coldGuard` is false.
|
|
344
|
+
// effect(() => {
|
|
345
|
+
// if (this.coldGuard()) {
|
|
346
|
+
// console.log('Hot signal is:', this.hotSignal());
|
|
347
|
+
// }
|
|
348
|
+
// });
|
|
349
|
+
|
|
350
|
+
// `nestedEffect` solves this:
|
|
351
|
+
nestedEffect(() => {
|
|
352
|
+
// This outer effect ONLY tracks `coldGuard`.
|
|
353
|
+
// It does not track `hotSignal`.
|
|
354
|
+
if (this.coldGuard()) {
|
|
355
|
+
// This inner effect is CREATED when coldGuard is true
|
|
356
|
+
// and DESTROYED when it becomes false.
|
|
357
|
+
nestedEffect(() => {
|
|
358
|
+
// It only tracks `hotSignal` while it exists.
|
|
359
|
+
console.log('Hot signal is:', this.hotSignal());
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
#### Advanced Example: Fine-grained Lists
|
|
368
|
+
|
|
369
|
+
`nestedEffect` can be composed with `mapArray` 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.
|
|
370
|
+
|
|
371
|
+
```ts
|
|
372
|
+
import { Component, signal, computed } from '@angular/core';
|
|
373
|
+
import { mapArray, nestedEffect } from '@mmstack/primitives';
|
|
374
|
+
|
|
375
|
+
@Component({ selector: 'app-list-demo' })
|
|
376
|
+
export class ListDemoComponent {
|
|
377
|
+
readonly users = signal([
|
|
378
|
+
{ id: 1, name: 'Alice' },
|
|
379
|
+
{ id: 2, name: 'Bob' },
|
|
380
|
+
]);
|
|
381
|
+
|
|
382
|
+
// mapArray creates stable signals for each item
|
|
383
|
+
readonly mappedUsers = mapArray(
|
|
384
|
+
this.users,
|
|
385
|
+
(userSignal, index) => {
|
|
386
|
+
// Create a side-effect tied to THIS item's lifetime
|
|
387
|
+
const effectRef = nestedEffect(() => {
|
|
388
|
+
// This only runs if `userSignal` (this specific user) changes.
|
|
389
|
+
console.log(`User ${index} updated:`, userSignal().name);
|
|
390
|
+
|
|
391
|
+
// e.g., updateAGGridRow(index, userSignal());
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
// Return the data and the cleanup logic
|
|
395
|
+
return {
|
|
396
|
+
label: computed(() => `User: ${userSignal().name}`),
|
|
397
|
+
// This function will be called by `onDestroy`
|
|
398
|
+
_destroy: () => effectRef.destroy(),
|
|
399
|
+
};
|
|
400
|
+
},
|
|
401
|
+
{
|
|
402
|
+
// When mapArray removes an item, it calls `onDestroy`
|
|
403
|
+
onDestroy: (mappedItem) => {
|
|
404
|
+
mappedItem._destroy(); // Manually destroy the nested effect
|
|
405
|
+
},
|
|
406
|
+
},
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
```
|
|
410
|
+
|
|
321
411
|
### toWritable
|
|
322
412
|
|
|
323
413
|
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.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as i0 from '@angular/core';
|
|
2
|
-
import { untracked, signal, inject, DestroyRef, computed, PLATFORM_ID, isSignal, effect, ElementRef, linkedSignal, isDevMode,
|
|
2
|
+
import { untracked, signal, inject, DestroyRef, computed, PLATFORM_ID, isSignal, effect, ElementRef, linkedSignal, isDevMode, Injector, Injectable, runInInjectionContext } from '@angular/core';
|
|
3
3
|
import { isPlatformServer } from '@angular/common';
|
|
4
4
|
import { SIGNAL } from '@angular/core/primitives/signals';
|
|
5
5
|
|
|
@@ -478,6 +478,141 @@ function mapArray(source, map, options) {
|
|
|
478
478
|
});
|
|
479
479
|
}
|
|
480
480
|
|
|
481
|
+
const frameStack = [];
|
|
482
|
+
function current() {
|
|
483
|
+
return frameStack.at(-1) ?? null;
|
|
484
|
+
}
|
|
485
|
+
function clearFrame(frame, userCleanups) {
|
|
486
|
+
for (const child of frame.children) {
|
|
487
|
+
try {
|
|
488
|
+
child.destroy();
|
|
489
|
+
}
|
|
490
|
+
catch (e) {
|
|
491
|
+
if (isDevMode())
|
|
492
|
+
console.error('Error destroying nested effect:', e);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
frame.children.clear();
|
|
496
|
+
for (const fn of userCleanups) {
|
|
497
|
+
try {
|
|
498
|
+
fn();
|
|
499
|
+
}
|
|
500
|
+
catch (e) {
|
|
501
|
+
if (isDevMode())
|
|
502
|
+
console.error('Error destroying nested effect:', e);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
userCleanups.length = 0;
|
|
506
|
+
}
|
|
507
|
+
/**
|
|
508
|
+
* Creates an effect that can be nested, similar to SolidJS's `createEffect`.
|
|
509
|
+
*
|
|
510
|
+
* This primitive enables true hierarchical reactivity. A `nestedEffect` created
|
|
511
|
+
* within another `nestedEffect` is automatically destroyed and recreated when
|
|
512
|
+
* the parent re-runs.
|
|
513
|
+
*
|
|
514
|
+
* It automatically handles injector propagation and lifetime management, allowing
|
|
515
|
+
* you to create fine-grained, conditional side-effects that only track
|
|
516
|
+
* dependencies when they are "live".
|
|
517
|
+
*
|
|
518
|
+
* @param effectFn The side-effect function, which receives a cleanup register function.
|
|
519
|
+
* @param options (Optional) Angular's `CreateEffectOptions`.
|
|
520
|
+
* @returns An `EffectRef` for the created effect.
|
|
521
|
+
*
|
|
522
|
+
* @example
|
|
523
|
+
* ```ts
|
|
524
|
+
* // Assume `coldGuard` changes rarely, but `hotSignal` changes often.
|
|
525
|
+
* const coldGuard = signal(false);
|
|
526
|
+
* const hotSignal = signal(0);
|
|
527
|
+
*
|
|
528
|
+
* nestedEffect(() => {
|
|
529
|
+
* // This outer effect only tracks `coldGuard`.
|
|
530
|
+
* if (coldGuard()) {
|
|
531
|
+
*
|
|
532
|
+
* // This inner effect is CREATED when coldGuard is true
|
|
533
|
+
* // and DESTROYED when it becomes false.
|
|
534
|
+
* nestedEffect(() => {
|
|
535
|
+
* // It only tracks `hotSignal` while it exists.
|
|
536
|
+
* console.log('Hot signal is:', hotSignal());
|
|
537
|
+
* });
|
|
538
|
+
* }
|
|
539
|
+
* // If `coldGuard` is false, this outer effect does not track `hotSignal`.
|
|
540
|
+
* });
|
|
541
|
+
* ```
|
|
542
|
+
* @example
|
|
543
|
+
* ```ts
|
|
544
|
+
* const users = signal([
|
|
545
|
+
{ id: 1, name: 'Alice' },
|
|
546
|
+
{ id: 2, name: 'Bob' }
|
|
547
|
+
]);
|
|
548
|
+
|
|
549
|
+
// The fine-grained mapped list
|
|
550
|
+
const mappedUsers = mapArray(
|
|
551
|
+
users,
|
|
552
|
+
(userSignal, index) => {
|
|
553
|
+
// 1. Create a fine-grained SIDE EFFECT for *this item*
|
|
554
|
+
// This effect's lifetime is now tied to this specific item. created once on init of this index.
|
|
555
|
+
const effectRef = nestedEffect(() => {
|
|
556
|
+
// This only runs if *this* userSignal changes,
|
|
557
|
+
// not if the whole list changes.
|
|
558
|
+
console.log(`User ${index} updated:`, userSignal().name);
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
// 2. Return the data AND the cleanup logic
|
|
562
|
+
return {
|
|
563
|
+
// The mapped data
|
|
564
|
+
label: computed(() => `User: ${userSignal().name}`),
|
|
565
|
+
|
|
566
|
+
// The cleanup function
|
|
567
|
+
destroyEffect: () => effectRef.destroy()
|
|
568
|
+
};
|
|
569
|
+
},
|
|
570
|
+
{
|
|
571
|
+
// 3. Tell mapArray HOW to clean up when an item is removed, this needs to be manual as it's not a nestedEffect itself
|
|
572
|
+
onDestroy: (mappedItem) => {
|
|
573
|
+
mappedItem.destroyEffect();
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
);
|
|
577
|
+
* ```
|
|
578
|
+
*/
|
|
579
|
+
function nestedEffect(effectFn, options) {
|
|
580
|
+
const parent = current();
|
|
581
|
+
const injector = options?.injector ?? parent?.injector ?? inject(Injector);
|
|
582
|
+
const srcRef = untracked(() => {
|
|
583
|
+
return effect((cleanup) => {
|
|
584
|
+
const frame = {
|
|
585
|
+
injector,
|
|
586
|
+
children: new Set(),
|
|
587
|
+
};
|
|
588
|
+
const userCleanups = [];
|
|
589
|
+
frameStack.push(frame);
|
|
590
|
+
try {
|
|
591
|
+
effectFn((fn) => {
|
|
592
|
+
userCleanups.push(fn);
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
finally {
|
|
596
|
+
frameStack.pop();
|
|
597
|
+
}
|
|
598
|
+
return cleanup(() => clearFrame(frame, userCleanups));
|
|
599
|
+
}, {
|
|
600
|
+
...options,
|
|
601
|
+
injector,
|
|
602
|
+
manualCleanup: !!parent,
|
|
603
|
+
});
|
|
604
|
+
});
|
|
605
|
+
const ref = {
|
|
606
|
+
...srcRef,
|
|
607
|
+
destroy: () => {
|
|
608
|
+
parent?.children.delete(ref);
|
|
609
|
+
srcRef.destroy();
|
|
610
|
+
},
|
|
611
|
+
};
|
|
612
|
+
parent?.children.add(ref);
|
|
613
|
+
return ref;
|
|
614
|
+
}
|
|
615
|
+
|
|
481
616
|
/** Project with optional equality. Pure & sync. */
|
|
482
617
|
const select = (projector, opt) => (src) => computed(() => projector(src()), opt);
|
|
483
618
|
/** Combine with another signal using a projector. */
|
|
@@ -1608,5 +1743,5 @@ function withHistory(source, opt) {
|
|
|
1608
1743
|
* Generated bundle index. Do not edit.
|
|
1609
1744
|
*/
|
|
1610
1745
|
|
|
1611
|
-
export { combineWith, debounce, debounced, derived, distinct, elementVisibility, filter, isDerivation, isMutable, map, mapArray, mediaQuery, mousePosition, mutable, networkStatus, pageVisibility, pipeable, piped, prefersDarkMode, prefersReducedMotion, scrollPosition, select, sensor, stored, tabSync, tap, throttle, throttled, toFakeDerivation, toFakeSignalDerivation, toWritable, until, windowSize, withHistory };
|
|
1746
|
+
export { combineWith, debounce, debounced, derived, distinct, elementVisibility, filter, isDerivation, isMutable, map, mapArray, mediaQuery, mousePosition, mutable, nestedEffect, networkStatus, pageVisibility, pipeable, piped, prefersDarkMode, prefersReducedMotion, scrollPosition, select, sensor, stored, tabSync, tap, throttle, throttled, toFakeDerivation, toFakeSignalDerivation, toWritable, until, windowSize, withHistory };
|
|
1612
1747
|
//# sourceMappingURL=mmstack-primitives.mjs.map
|