@mmstack/primitives 20.4.4 → 20.4.6

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.
@@ -1,4 +1,5 @@
1
- import { untracked, signal, inject, DestroyRef, computed, PLATFORM_ID, isSignal, effect, ElementRef, linkedSignal, isDevMode, Injector, runInInjectionContext } from '@angular/core';
1
+ import * as i0 from '@angular/core';
2
+ import { untracked, signal, inject, DestroyRef, computed, PLATFORM_ID, isSignal, effect, ElementRef, linkedSignal, isDevMode, Injector, Injectable, runInInjectionContext } from '@angular/core';
2
3
  import { isPlatformServer } from '@angular/common';
3
4
  import { SIGNAL } from '@angular/core/primitives/signals';
4
5
 
@@ -477,6 +478,141 @@ function mapArray(source, map, options) {
477
478
  });
478
479
  }
479
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
+
480
616
  /** Project with optional equality. Pure & sync. */
481
617
  const select = (projector, opt) => (src) => computed(() => projector(src()), opt);
482
618
  /** Combine with another signal using a projector. */
@@ -1293,6 +1429,122 @@ function stored(fallback, { key, store: providedStore, serialize = JSON.stringif
1293
1429
  return writable;
1294
1430
  }
1295
1431
 
1432
+ class MessageBus {
1433
+ channel = new BroadcastChannel('mmstack-tab-sync-bus');
1434
+ listeners = new Map();
1435
+ subscribe(id, listener) {
1436
+ this.unsubscribe(id); // Ensure no duplicate listeners
1437
+ const wrapped = (ev) => {
1438
+ try {
1439
+ if (ev.data?.id === id)
1440
+ listener(ev.data?.value);
1441
+ }
1442
+ catch {
1443
+ // noop
1444
+ }
1445
+ };
1446
+ this.channel.addEventListener('message', wrapped);
1447
+ this.listeners.set(id, wrapped);
1448
+ return {
1449
+ unsub: (() => this.unsubscribe(id)).bind(this),
1450
+ post: ((value) => this.channel.postMessage({ id, value })).bind(this),
1451
+ };
1452
+ }
1453
+ unsubscribe(id) {
1454
+ const listener = this.listeners.get(id);
1455
+ if (!listener)
1456
+ return;
1457
+ this.channel.removeEventListener('message', listener);
1458
+ this.listeners.delete(id);
1459
+ }
1460
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.1", ngImport: i0, type: MessageBus, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
1461
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.1", ngImport: i0, type: MessageBus, providedIn: 'root' });
1462
+ }
1463
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.1", ngImport: i0, type: MessageBus, decorators: [{
1464
+ type: Injectable,
1465
+ args: [{
1466
+ providedIn: 'root',
1467
+ }]
1468
+ }] });
1469
+ function generateDeterministicID() {
1470
+ const stack = new Error().stack;
1471
+ if (stack) {
1472
+ // Look for the actual caller (first non-internal frame)
1473
+ const lines = stack.split('\n');
1474
+ for (let i = 2; i < lines.length; i++) {
1475
+ const line = lines[i];
1476
+ if (line && !line.includes('tabSync') && !line.includes('MessageBus')) {
1477
+ let hash = 0;
1478
+ for (let j = 0; j < line.length; j++) {
1479
+ const char = line.charCodeAt(j);
1480
+ hash = (hash << 5) - hash + char;
1481
+ hash = hash & hash;
1482
+ }
1483
+ return `auto-${Math.abs(hash)}`;
1484
+ }
1485
+ }
1486
+ }
1487
+ throw new Error('Could not generate deterministic ID, please provide one manually.');
1488
+ }
1489
+ /**
1490
+ * Synchronizes a WritableSignal across browser tabs using BroadcastChannel API.
1491
+ *
1492
+ * Creates a shared signal that automatically syncs its value between all tabs
1493
+ * of the same application. When the signal is updated in one tab, all other
1494
+ * tabs will receive the new value automatically.
1495
+ *
1496
+ * @template T - The type of the WritableSignal
1497
+ * @param sig - The WritableSignal to synchronize across tabs
1498
+ * @param opt - Optional configuration object
1499
+ * @param opt.id - Explicit channel ID for synchronization. If not provided,
1500
+ * a deterministic ID is generated based on the call site.
1501
+ * Use explicit IDs in production for reliability.
1502
+ *
1503
+ * @returns The same WritableSignal instance, now synchronized across tabs
1504
+ *
1505
+ * @throws {Error} When deterministic ID generation fails and no explicit ID is provided
1506
+ *
1507
+ * @example
1508
+ * ```typescript
1509
+ * // Basic usage - auto-generates channel ID from call site
1510
+ * const theme = tabSync(signal('dark'));
1511
+ *
1512
+ * // With explicit ID (recommended for production)
1513
+ * const userPrefs = tabSync(signal({ lang: 'en' }), { id: 'user-preferences' });
1514
+ *
1515
+ * // Changes in one tab will sync to all other tabs
1516
+ * theme.set('light'); // All tabs will update to 'light'
1517
+ * ```
1518
+ *
1519
+ * @remarks
1520
+ * - Only works in browser environments (returns original signal on server)
1521
+ * - Uses a single BroadcastChannel for all synchronized signals
1522
+ * - Automatically cleans up listeners when the injection context is destroyed
1523
+ * - Initial signal value after sync setup is not broadcasted to prevent loops
1524
+ *
1525
+ */
1526
+ function tabSync(sig, opt) {
1527
+ if (isPlatformServer(inject(PLATFORM_ID)))
1528
+ return sig;
1529
+ const id = opt?.id || generateDeterministicID();
1530
+ const bus = inject(MessageBus);
1531
+ const { unsub, post } = bus.subscribe(id, (next) => sig.set(next));
1532
+ let first = false;
1533
+ const effectRef = effect(() => {
1534
+ const val = sig();
1535
+ if (!first) {
1536
+ first = true;
1537
+ return;
1538
+ }
1539
+ post(val);
1540
+ }, ...(ngDevMode ? [{ debugName: "effectRef" }] : []));
1541
+ inject(DestroyRef).onDestroy(() => {
1542
+ effectRef.destroy();
1543
+ unsub();
1544
+ });
1545
+ return sig;
1546
+ }
1547
+
1296
1548
  function until(sourceSignal, predicate, options = {}) {
1297
1549
  const injector = options.injector ?? inject(Injector);
1298
1550
  return new Promise((resolve, reject) => {
@@ -1491,5 +1743,5 @@ function withHistory(source, opt) {
1491
1743
  * Generated bundle index. Do not edit.
1492
1744
  */
1493
1745
 
1494
- 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, 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 };
1495
1747
  //# sourceMappingURL=mmstack-primitives.mjs.map