@mmstack/primitives 20.9.1 → 20.10.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.
@@ -1,11 +1,11 @@
1
1
  import * as i0 from '@angular/core';
2
- import { isDevMode, inject, Injector, untracked, effect, DestroyRef, InjectionToken, TemplateRef, ViewContainerRef, PLATFORM_ID, input, computed, Directive, signal, runInInjectionContext, linkedSignal, afterNextRender, Component, isSignal, ElementRef, Injectable } from '@angular/core';
2
+ import { isDevMode, inject, Injector, untracked, effect, DestroyRef, InjectionToken, TemplateRef, ViewContainerRef, PLATFORM_ID, input, computed, Directive, signal, runInInjectionContext, linkedSignal, afterNextRender, PendingTasks, Component, ElementRef, isSignal, Injectable } from '@angular/core';
3
3
  import { isPlatformServer } from '@angular/common';
4
4
  import { SIGNAL } from '@angular/core/primitives/signals';
5
5
 
6
- const frameStack = [];
6
+ const frameStack$1 = [];
7
7
  function currentFrame() {
8
- return frameStack.at(-1) ?? null;
8
+ return frameStack$1.at(-1) ?? null;
9
9
  }
10
10
  function clearFrame(frame, userCleanups) {
11
11
  frame.parent = null;
@@ -31,10 +31,10 @@ function clearFrame(frame, userCleanups) {
31
31
  frame.children.clear();
32
32
  }
33
33
  function pushFrame(frame) {
34
- return frameStack.push(frame);
34
+ return frameStack$1.push(frame);
35
35
  }
36
36
  function popFrame() {
37
- return frameStack.pop();
37
+ return frameStack$1.pop();
38
38
  }
39
39
 
40
40
  /**
@@ -457,6 +457,79 @@ function chunked(source, options) {
457
457
  return internal.asReadonly();
458
458
  }
459
459
 
460
+ /**
461
+ * `useDeferredValue` for signals: returns a signal that HOLDS its previous value when
462
+ * `source` changes and catches up at lower priority (after paint / on idle), so an
463
+ * expensive subtree keyed off the deferred value never blocks the urgent update that
464
+ * caused the change — type into a filter, the input echoes instantly, the big list
465
+ * re-renders a beat later.
466
+ *
467
+ * ```ts
468
+ * const query = signal('');
469
+ * const deferredQuery = deferredValue(query);
470
+ * const results = computed(() => expensiveFilter(items(), deferredQuery()));
471
+ * // template: <input [(ngModel)]="query" /> stays responsive; results lag one paint
472
+ * // deferredQuery.pending() → dim the stale list while it catches up
473
+ * ```
474
+ *
475
+ * Rapid changes coalesce: each change reschedules the catch-up, so only the LATEST
476
+ * source value is ever applied (no intermediate churn in the expensive subtree).
477
+ * On the server this is a synchronous pass-through — SSR renders once, so deferral
478
+ * would just mean rendering stale content.
479
+ *
480
+ * This is a scheduling tool, not an async one — for async work compose `latest()`;
481
+ * for coordinated multi-resource reveals use a transition scope.
482
+ */
483
+ function deferredValue(source, opt) {
484
+ const injector = opt?.injector ?? inject(Injector);
485
+ const equal = opt?.equal ?? Object.is;
486
+ if (injector.get(PLATFORM_ID) === 'server') {
487
+ const passthrough = computed(() => source());
488
+ passthrough.pending = computed(() => false, ...(ngDevMode ? [{ debugName: "pending" }] : []));
489
+ return passthrough;
490
+ }
491
+ const schedule = resolveScheduler(opt?.strategy ?? 'afterRender', injector);
492
+ const out = signal(untracked(source), ...(ngDevMode ? [{ debugName: "out", equal }] : [{ equal }]));
493
+ let cancel = null;
494
+ const watch = effect(() => {
495
+ const v = source();
496
+ cancel?.(); // latest wins: rapid changes coalesce into one catch-up
497
+ cancel = schedule(() => {
498
+ cancel = null;
499
+ out.set(v);
500
+ });
501
+ }, ...(ngDevMode ? [{ debugName: "watch", injector }] : [{ injector }]));
502
+ injector.get(DestroyRef).onDestroy(() => {
503
+ watch.destroy();
504
+ cancel?.();
505
+ cancel = null;
506
+ });
507
+ const result = computed(() => out());
508
+ // "behind" is a value comparison, not a schedule flag: an equal-valued catch-up
509
+ // (e.g. type a char, delete it before the deferred view caught up) is not pending
510
+ result.pending = computed(() => !equal(out(), source()), ...(ngDevMode ? [{ debugName: "pending" }] : []));
511
+ return result;
512
+ }
513
+ function resolveScheduler(strategy, injector) {
514
+ if (typeof strategy === 'function')
515
+ return strategy;
516
+ if (strategy === 'idle') {
517
+ return (cb) => {
518
+ const ric = globalThis.requestIdleCallback;
519
+ if (ric) {
520
+ const id = ric(() => cb());
521
+ return () => globalThis.cancelIdleCallback?.(id);
522
+ }
523
+ const id = setTimeout(cb, 0);
524
+ return () => clearTimeout(id);
525
+ };
526
+ }
527
+ return (cb) => {
528
+ const ref = afterNextRender({ read: cb }, { injector });
529
+ return () => ref.destroy();
530
+ };
531
+ }
532
+
460
533
  /**
461
534
  * Structural hold-and-swap as a signal. Given a `target` (the desired value — e.g. the
462
535
  * subtree/def/key you want to show) and a `ready` predicate, returns a signal that keeps
@@ -553,6 +626,17 @@ function createTransitionScope() {
553
626
  source: () => ({ v: value(), settled: !pending() }),
554
627
  computation: (curr, prev) => curr.settled || prev === undefined ? curr.v : prev.value,
555
628
  }),
629
+ abortPending: () => untracked(() => {
630
+ let aborted = 0;
631
+ for (const { ref } of list()) {
632
+ const s = ref.status();
633
+ if ((s === 'loading' || s === 'reloading') && ref.abort) {
634
+ ref.abort();
635
+ aborted++;
636
+ }
637
+ }
638
+ return aborted;
639
+ }),
556
640
  holding,
557
641
  beginHold: () => untracked(() => holdCount.update((c) => c + 1)),
558
642
  endHold: () => untracked(() => holdCount.update((c) => (c > 0 ? c - 1 : 0))),
@@ -574,6 +658,7 @@ function createNoopScope() {
574
658
  // noop
575
659
  },
576
660
  commit: (value) => value,
661
+ abortPending: () => 0,
577
662
  holding: computed(() => false),
578
663
  beginHold: () => {
579
664
  // noop
@@ -585,9 +670,49 @@ function createNoopScope() {
585
670
  };
586
671
  }
587
672
  const TRANSITION_SCOPE = new InjectionToken('@mmstack/primitives:transition-scope');
673
+ /**
674
+ * The scope→`PendingTasks` bridge: while `scope.pending()` is true, hold an Angular
675
+ * pending task so SSR serialization waits for the scope's in-flight loads — HTTP loads
676
+ * already do this via HttpClient, but CUSTOM loaders (a `latest()` over a hand-rolled
677
+ * promise, a non-HTTP resource) would otherwise let the server render a boundary
678
+ * mid-load. Wired automatically by `provideTransitionScope` /
679
+ * `provideForwardingTransitionScope`; call it yourself only for scopes you construct
680
+ * directly with `createTransitionScope()`.
681
+ *
682
+ * Server-only by design: on the browser, tying `ApplicationRef.isStable` to every load
683
+ * would stall stability-gated machinery (testability, hydration timing) for no benefit.
684
+ */
685
+ function bridgeScopeToPendingTasks(scope, injector) {
686
+ const run = (fn) => injector ? runInInjectionContext(injector, fn) : fn();
687
+ run(() => {
688
+ if (inject(PLATFORM_ID) !== 'server')
689
+ return;
690
+ const tasks = inject(PendingTasks);
691
+ let done = null;
692
+ effect(() => {
693
+ if (scope.pending())
694
+ done ??= tasks.add();
695
+ else {
696
+ done?.();
697
+ done = null;
698
+ }
699
+ });
700
+ inject(DestroyRef).onDestroy(() => {
701
+ done?.();
702
+ done = null;
703
+ });
704
+ });
705
+ }
588
706
  /** Provide a fresh transition scope at a boundary so its subtree's resources are tracked independently. */
589
707
  function provideTransitionScope() {
590
- return { provide: TRANSITION_SCOPE, useFactory: createTransitionScope };
708
+ return {
709
+ provide: TRANSITION_SCOPE,
710
+ useFactory: () => {
711
+ const scope = createTransitionScope();
712
+ bridgeScopeToPendingTasks(scope);
713
+ return scope;
714
+ },
715
+ };
591
716
  }
592
717
  function injectTransitionScope() {
593
718
  const scope = inject(TRANSITION_SCOPE, { optional: true });
@@ -623,6 +748,7 @@ function createForwardingScope() {
623
748
  source: () => ({ v: value(), settled: !eff().pending() }),
624
749
  computation: (curr, prev) => curr.settled || prev === undefined ? curr.v : prev.value,
625
750
  }),
751
+ abortPending: () => (untracked(target) ?? own).abortPending(),
626
752
  holding: computed(() => eff().holding()),
627
753
  beginHold: () => (untracked(target) ?? own).beginHold(),
628
754
  endHold: () => (untracked(target) ?? own).endHold(),
@@ -634,7 +760,14 @@ function createForwardingScope() {
634
760
  }
635
761
  /** Provide a forwarding transition scope at a boundary (used by the transition outlet). */
636
762
  function provideForwardingTransitionScope() {
637
- return { provide: TRANSITION_SCOPE, useFactory: createForwardingScope };
763
+ return {
764
+ provide: TRANSITION_SCOPE,
765
+ useFactory: () => {
766
+ const scope = createForwardingScope();
767
+ bridgeScopeToPendingTasks(scope);
768
+ return scope;
769
+ },
770
+ };
638
771
  }
639
772
  /** Read the transition scope reachable from `injector`, or null if none is provided there. */
640
773
  function getTransitionScope(injector) {
@@ -691,6 +824,141 @@ function registerResource(res, opt) {
691
824
  return injectRegisterResource()(res, opt);
692
825
  }
693
826
 
827
+ const frameStack = [];
828
+ /**
829
+ * Thrown by `use()` to short-circuit a computation whose input has no value yet; caught
830
+ * by the owning `latest()`. Identity-compared, so user code must not swallow it — avoid
831
+ * broad `try/catch` around `use()` calls.
832
+ */
833
+ const BLOCKED = new Error('[mmstack/primitives] latest() blocked — internal sentinel, do not catch');
834
+ /**
835
+ * Reads a resource inside a `latest()` computation: returns its value and reports it to
836
+ * the enclosing collector, so the derivation's aggregate `pending`/`status`/`error`
837
+ * include it. When the resource has no value yet (first load) or is in an error state,
838
+ * the computation short-circuits — code after this call simply doesn't run this round —
839
+ * which is what lets you write the happy path with no `undefined` checks:
840
+ *
841
+ * ```ts
842
+ * const fullName = latest(() => {
843
+ * const u = use(user); // waterfalls compose:
844
+ * const org = use(orgFor(u)); // orgFor(u) is only read once `user` has a value
845
+ * return `${u.name} @ ${org.name}`;
846
+ * });
847
+ * ```
848
+ *
849
+ * Must be called synchronously within `latest()` — like `inject()`, it throws elsewhere.
850
+ */
851
+ function use(res) {
852
+ const frame = frameStack.at(-1);
853
+ if (!frame) {
854
+ throw new Error('[mmstack/primitives] use() must be called synchronously within a latest() computation');
855
+ }
856
+ if (!frame.seen.has(res)) {
857
+ frame.seen.add(res);
858
+ frame.deps.push(res);
859
+ }
860
+ // status() is read tracked even on the short-circuit paths, so the owning computed
861
+ // re-evaluates when the load settles / the error clears.
862
+ if (res.status() === 'error') {
863
+ frame.errors.push(res.error?.());
864
+ throw BLOCKED;
865
+ }
866
+ if (!res.hasValue())
867
+ throw BLOCKED;
868
+ return res.value();
869
+ }
870
+ /**
871
+ * An async derivation over resources: evaluates `fn` inside a collector frame so that
872
+ * every `use()` read registers as a member, and exposes the result with resource
873
+ * semantics — the value holds its previous state while anything it read is in flight
874
+ * (never flashing empty), `pending` aggregates the members' in-flight state, and the
875
+ * whole thing is itself a `UseSource`, so `latest`s nest and propagate.
876
+ *
877
+ * ```ts
878
+ * const fullName = latest(() => `${use(user).name} @ ${use(org).name}`);
879
+ * fullName(); // held value — undefined only before the first successful run
880
+ * fullName.pending(); // true while user OR org (re)loads
881
+ * ```
882
+ *
883
+ * Evaluation is a plain `computed` under the hood: lazy, pure, no effects, usable
884
+ * outside any injection context (`register` is the only DI-touching option).
885
+ */
886
+ function latest(fn, opt) {
887
+ const evaluation = computed(() => {
888
+ const frame = { deps: [], seen: new Set(), errors: [] };
889
+ frameStack.push(frame);
890
+ try {
891
+ const value = fn();
892
+ return { kind: 'value', value, deps: frame.deps, errors: frame.errors };
893
+ }
894
+ catch (e) {
895
+ if (e === BLOCKED)
896
+ return { kind: 'blocked', deps: frame.deps, errors: frame.errors };
897
+ return {
898
+ kind: 'thrown',
899
+ thrown: e,
900
+ deps: frame.deps,
901
+ errors: frame.errors,
902
+ };
903
+ }
904
+ finally {
905
+ frameStack.pop();
906
+ }
907
+ }, opt?.debugName ? { debugName: `${opt.debugName}:evaluation` } : undefined);
908
+ const equal = opt?.equal ?? Object.is;
909
+ // The stale-while-revalidate atom: holds the last successful result through blocked /
910
+ // errored rounds. `equal` gates notification, so an in-flight cycle that lands on an
911
+ // equal value never ripples to consumers — while `pending` (independent) still cycles.
912
+ const held = linkedSignal(...(ngDevMode ? [{ debugName: "held", source: evaluation,
913
+ computation: (ev, prev) => ev.kind === 'value'
914
+ ? { has: true, v: ev.value }
915
+ : (prev?.value ?? { has: false, v: undefined }),
916
+ equal: (a, b) => a.has === b.has && (!a.has || equal(a.v, b.v)) }] : [{
917
+ source: evaluation,
918
+ computation: (ev, prev) => ev.kind === 'value'
919
+ ? { has: true, v: ev.value }
920
+ : (prev?.value ?? { has: false, v: undefined }),
921
+ equal: (a, b) => a.has === b.has && (!a.has || equal(a.v, b.v)),
922
+ }]));
923
+ const value = computed(() => held().v, opt?.debugName ? { debugName: opt.debugName } : undefined);
924
+ const pending = computed(() => evaluation().deps.some((d) => {
925
+ const s = d.status();
926
+ return s === 'loading' || s === 'reloading';
927
+ }), ...(ngDevMode ? [{ debugName: "pending" }] : []));
928
+ const status = computed(() => {
929
+ const ev = evaluation();
930
+ if (ev.kind === 'thrown' || ev.errors.length > 0)
931
+ return 'error';
932
+ if (pending())
933
+ return held().has ? 'reloading' : 'loading';
934
+ return ev.kind === 'value' ? 'resolved' : 'idle';
935
+ }, ...(ngDevMode ? [{ debugName: "status" }] : []));
936
+ const error = computed(() => {
937
+ const ev = evaluation();
938
+ return ev.kind === 'thrown' ? ev.thrown : ev.errors.at(0);
939
+ }, ...(ngDevMode ? [{ debugName: "error" }] : []));
940
+ const result = Object.assign(value, {
941
+ value,
942
+ status,
943
+ pending,
944
+ isLoading: pending,
945
+ error,
946
+ hasValue: () => held().has,
947
+ });
948
+ if (opt?.register) {
949
+ const register = () => {
950
+ const scope = injectTransitionScope();
951
+ scope.add(result, { suspends: opt.register === 'suspend' });
952
+ inject(DestroyRef).onDestroy(() => scope.remove(result));
953
+ };
954
+ if (opt.injector)
955
+ runInInjectionContext(opt.injector, register);
956
+ else
957
+ register();
958
+ }
959
+ return result;
960
+ }
961
+
694
962
  /**
695
963
  * Returns a `startTransition(fn)` bound to the nearest transition scope. `fn` runs its state
696
964
  * mutations (which commit immediately); any resource that reloads as a result holds its value
@@ -1115,6 +1383,57 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.17", ngImpo
1115
1383
  }]
1116
1384
  }], ctorParameters: () => [], propDecorators: { value: [{ type: i0.Input, args: [{ isSignal: true, alias: "mmTransition", required: true }] }], immediate: [{ type: i0.Input, args: [{ isSignal: true, alias: "mmTransitionImmediate", required: false }] }], viewTransition: [{ type: i0.Input, args: [{ isSignal: true, alias: "mmTransitionViewTransition", required: false }] }] } });
1117
1385
 
1386
+ /**
1387
+ * Per-element morphs on held swaps: assigns `view-transition-name` reactively, so when
1388
+ * a swap wrapped in `document.startViewTransition` flips views (`*mmTransition`'s
1389
+ * `mmTransitionViewTransition`, or the transition outlet's view-transition option), the
1390
+ * browser pairs same-named elements across the outgoing and incoming views and MORPHS
1391
+ * them instead of cross-fading the whole boundary.
1392
+ *
1393
+ * ```html
1394
+ * <!-- outgoing view (list) and incoming view (detail) both name the hero image: -->
1395
+ * <img [mmViewTransitionName]="'hero-' + item().id" [src]="item().img" />
1396
+ * ```
1397
+ *
1398
+ * Why this works with holds: both views coexist in the DOM during a hold, but the
1399
+ * incoming one is `display: none` — elements without boxes aren't captured, so the
1400
+ * same name on both sides is legal at each capture point (old visible at snapshot,
1401
+ * new visible after the swap). No arming/cleanup dance needed.
1402
+ *
1403
+ * The name is normalized to a valid CSS custom-ident (invalid characters → `-`, a
1404
+ * leading digit gets a `_` prefix). An empty string / `'none'` clears the name — use
1405
+ * that to opt an element out conditionally. One rule remains YOURS to keep: a name
1406
+ * must be unique among elements VISIBLE at capture time (two rendered instances of the
1407
+ * same named element make the browser skip the whole transition) — derive names from
1408
+ * ids for anything that can repeat.
1409
+ */
1410
+ class MmViewTransitionName {
1411
+ mmViewTransitionName = input.required(...(ngDevMode ? [{ debugName: "mmViewTransitionName" }] : []));
1412
+ constructor() {
1413
+ const el = inject(ElementRef).nativeElement;
1414
+ effect(() => {
1415
+ const name = normalizeIdent(this.mmViewTransitionName());
1416
+ if (name)
1417
+ el.style.setProperty('view-transition-name', name);
1418
+ else
1419
+ el.style.removeProperty('view-transition-name');
1420
+ });
1421
+ }
1422
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.17", ngImport: i0, type: MmViewTransitionName, deps: [], target: i0.ɵɵFactoryTarget.Directive });
1423
+ static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "20.3.17", type: MmViewTransitionName, isStandalone: true, selector: "[mmViewTransitionName]", inputs: { mmViewTransitionName: { classPropertyName: "mmViewTransitionName", publicName: "mmViewTransitionName", isSignal: true, isRequired: true, transformFunction: null } }, ngImport: i0 });
1424
+ }
1425
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.17", ngImport: i0, type: MmViewTransitionName, decorators: [{
1426
+ type: Directive,
1427
+ args: [{ selector: '[mmViewTransitionName]' }]
1428
+ }], ctorParameters: () => [], propDecorators: { mmViewTransitionName: [{ type: i0.Input, args: [{ isSignal: true, alias: "mmViewTransitionName", required: true }] }] } });
1429
+ /** @internal `''`/`'none'` clear; otherwise coerce into a valid custom-ident. */
1430
+ function normalizeIdent(raw) {
1431
+ if (!raw || raw === 'none')
1432
+ return null;
1433
+ const cleaned = raw.replace(/[^a-zA-Z0-9_-]/g, '-');
1434
+ return /^\d/.test(cleaned) ? `_${cleaned}` : cleaned;
1435
+ }
1436
+
1118
1437
  /**
1119
1438
  * @internal
1120
1439
  */
@@ -4309,6 +4628,190 @@ function forkStore(base, opt) {
4309
4628
  };
4310
4629
  }
4311
4630
 
4631
+ function generateOrigin() {
4632
+ if (globalThis.crypto?.randomUUID)
4633
+ return globalThis.crypto.randomUUID();
4634
+ return Math.random().toString(36).substring(2);
4635
+ }
4636
+ const isPlainArray = (v) => Array.isArray(v) && !isOpaque(v);
4637
+ /**
4638
+ * Reference-identity-pruned structural diff — the same short-circuit discipline as `merge3`:
4639
+ * an untouched subtree kept its reference (the store's copy-on-write contract), so the walk
4640
+ * descends only where refs differ. O(changed paths), not O(tree).
4641
+ */
4642
+ function diffNode(prev, next, path, ops) {
4643
+ if (Object.is(prev, next))
4644
+ return;
4645
+ if (isRecord(prev) && isRecord(next)) {
4646
+ for (const key of Object.keys(prev)) {
4647
+ if (!Object.hasOwn(next, key))
4648
+ ops.push({ kind: 'delete', path: [...path, key], prev: prev[key] });
4649
+ }
4650
+ for (const key of Object.keys(next)) {
4651
+ if (!Object.hasOwn(prev, key)) {
4652
+ // added key: deliberately NO `prev` property (absent ≠ undefined)
4653
+ ops.push({ kind: 'set', path: [...path, key], next: next[key] });
4654
+ }
4655
+ else {
4656
+ diffNode(prev[key], next[key], [...path, key], ops);
4657
+ }
4658
+ }
4659
+ return;
4660
+ }
4661
+ if (isPlainArray(prev) && isPlainArray(next)) {
4662
+ // same length → per-index descent (matches `arr[i].x.set(...)` writes); a length
4663
+ // change is a whole unit — index attribution lies under insert/remove/reorder
4664
+ if (prev.length === next.length) {
4665
+ for (let i = 0; i < next.length; i++)
4666
+ diffNode(prev[i], next[i], [...path, i], ops);
4667
+ return;
4668
+ }
4669
+ ops.push({ kind: 'set', path, prev, next });
4670
+ return;
4671
+ }
4672
+ // leaf / type change / opaque — one unit, prev present (the slot existed)
4673
+ ops.push({ kind: 'set', path, prev, next });
4674
+ }
4675
+ /** Immutably applies one op along its path, vivifying missing containers `'auto'`-style. */
4676
+ function applyAt(container, path, idx, op) {
4677
+ const seg = path[idx];
4678
+ const base = isPlainArray(container)
4679
+ ? container.slice()
4680
+ : isRecord(container)
4681
+ ? { ...container }
4682
+ : typeof seg === 'number'
4683
+ ? []
4684
+ : {};
4685
+ if (idx === path.length - 1) {
4686
+ if (op.kind === 'delete') {
4687
+ // arrays never receive deletes (length changes travel as whole-array sets)
4688
+ delete base[seg];
4689
+ }
4690
+ else {
4691
+ base[seg] = op.next;
4692
+ }
4693
+ return base;
4694
+ }
4695
+ base[seg] = applyAt(base[seg], path, idx + 1, op);
4696
+ return base;
4697
+ }
4698
+ /**
4699
+ * Inverts a batch for undo: reversed order, `set`↔its own inverse (an add — a `set` with no
4700
+ * `prev` — inverts to a `delete`; a `delete` inverts to a `set` restoring `prev`). Feed the
4701
+ * result to {@link OpLog.apply}. Requires the ops' `prev`s, which in-memory batches always
4702
+ * carry — a wire-serialized batch that stripped them is not invertible.
4703
+ */
4704
+ function invertBatch(batch) {
4705
+ const ops = Array.isArray(batch) ? batch : batch.ops;
4706
+ const inverted = [];
4707
+ for (let i = ops.length - 1; i >= 0; i--) {
4708
+ const op = ops[i];
4709
+ if (op.kind === 'delete') {
4710
+ inverted.push({ kind: 'set', path: op.path, next: op.prev, prev: undefined });
4711
+ continue;
4712
+ }
4713
+ if (!Object.hasOwn(op, 'prev')) {
4714
+ inverted.push({ kind: 'delete', path: op.path, prev: op.next });
4715
+ }
4716
+ else {
4717
+ inverted.push({ kind: 'set', path: op.path, next: op.prev, prev: op.next });
4718
+ }
4719
+ }
4720
+ return inverted;
4721
+ }
4722
+ /**
4723
+ * Observes a copy-on-write signal (a `store`'s root, or any `WritableSignal` holding
4724
+ * immutably-updated objects) and emits its changes as minimal structural op batches — the
4725
+ * shared substrate for sync (ship batches, `apply` remote ones), persistence (journal
4726
+ * batches, replay on boot), undo ({@link invertBatch}), and devtools (`latest`).
4727
+ *
4728
+ * Zero store-core involvement and zero cost when unused: emission is a reference-pruned diff
4729
+ * of the root value per tick (structural sharing makes it O(changed paths)), driven by one
4730
+ * effect. A batch therefore coalesces everything written in one tick — for coarser,
4731
+ * intentional units, stage writes on a `forkStore` and `commit()` (one set → one batch).
4732
+ *
4733
+ * NOT supported on mutable stores/signals: in-place mutation keeps reference identity, which
4734
+ * defeats the diff (same reason `forkStore`'s `'fine'` strategy refuses them) — a dev-mode
4735
+ * warning fires and nothing emits.
4736
+ *
4737
+ * ```ts
4738
+ * const s = store({ todos: [{ done: false }] });
4739
+ * const log = opLog(s, { origin: 'tab-a' });
4740
+ * log.subscribe((b) => channel.postMessage(encode(b))); // ship
4741
+ * channel.onmessage = (m) => log.apply(decode(m.data)); // apply — echo-free
4742
+ * s.todos[0].done.set(true); // → { kind: 'set', path: ['todos', 0, 'done'], … }
4743
+ * ```
4744
+ */
4745
+ function opLog(source, opt) {
4746
+ const injector = opt?.injector ?? inject(Injector);
4747
+ const origin = opt?.origin ?? generateOrigin();
4748
+ // a store proxy's `has` trap answers for the VALUE's keys, so `isMutable`'s `'mutate' in`
4749
+ // probe can't see the brand — ask the store's own kind symbol first
4750
+ const storeKind = source[STORE_KIND];
4751
+ const mutableSource = storeKind ? storeKind === 'mutable' : isMutable(source);
4752
+ if (isDevMode() && mutableSource) {
4753
+ console.warn('[@mmstack/primitives] opLog observes copy-on-write updates via reference identity — a MUTABLE store/signal mutates in place, so changes are invisible to it. Use an immutable store, or set whole values.');
4754
+ }
4755
+ let prevRoot = untracked(source);
4756
+ let version = 0;
4757
+ let destroyed = false;
4758
+ const subscribers = new Set();
4759
+ const latest = signal(null, ...(ngDevMode ? [{ debugName: "latest" }] : []));
4760
+ /** Diff now, emit if there's a delta, advance the baseline. */
4761
+ const flush = () => {
4762
+ if (destroyed)
4763
+ return;
4764
+ const next = untracked(source);
4765
+ if (Object.is(prevRoot, next))
4766
+ return;
4767
+ const ops = [];
4768
+ diffNode(prevRoot, next, [], ops);
4769
+ prevRoot = next;
4770
+ if (!ops.length)
4771
+ return; // fresh refs, equal values — spurious-write tolerance
4772
+ const batch = { origin, version: ++version, ops };
4773
+ latest.set(batch);
4774
+ for (const cb of [...subscribers])
4775
+ cb(batch);
4776
+ };
4777
+ const ref = effect(() => {
4778
+ source(); // track every commit…
4779
+ untracked(flush); // …and emit the delta since the last flush
4780
+ }, ...(ngDevMode ? [{ debugName: "ref", injector: opt?.injector }] : [{ injector: opt?.injector }]));
4781
+ return {
4782
+ latest: latest.asReadonly(),
4783
+ subscribe: (cb) => {
4784
+ subscribers.add(cb);
4785
+ return () => subscribers.delete(cb);
4786
+ },
4787
+ apply: (batchOrOps) => {
4788
+ const ops = Array.isArray(batchOrOps)
4789
+ ? batchOrOps
4790
+ : batchOrOps.ops;
4791
+ if (!ops.length)
4792
+ return;
4793
+ // pending local writes must emit BEFORE the baseline advances past them
4794
+ flush();
4795
+ let root = untracked(source);
4796
+ for (const op of ops) {
4797
+ if (op.path.length === 0) {
4798
+ if (op.kind === 'set')
4799
+ root = op.next;
4800
+ continue; // a root delete is meaningless — ignore
4801
+ }
4802
+ root = applyAt(root, op.path, 0, op);
4803
+ }
4804
+ source.set(root);
4805
+ prevRoot = root; // baseline advance: an applied batch never echoes
4806
+ },
4807
+ destroy: () => {
4808
+ destroyed = true;
4809
+ subscribers.clear();
4810
+ ref.destroy();
4811
+ },
4812
+ };
4813
+ }
4814
+
4312
4815
  /**
4313
4816
  * @internal The plain-`effect` sibling of the public {@link pausableEffect} (which is built on
4314
4817
  * `nestedEffect`). For infra utilities that own a single top-level effect/subscription and don't
@@ -4843,5 +5346,5 @@ function withHistory(sourceOrValue, opt) {
4843
5346
  * Generated bundle index. Do not edit.
4844
5347
  */
4845
5348
 
4846
- export { MmActivity, MmTransition, PAUSABLE_OPTIONS, SuspenseBoundary, SuspenseBoundaryBase, UnscopedSuspenseBoundary, activeTransaction, batteryStatus, chunked, clipboard, combineWith, createAttributedPending, createForwardingScope, createTransaction, createTransitionScope, debounce, debounced, derived, distinct, elementSize, elementVisibility, extendStore, filter, filterWith, focusWithin, forkStore, geolocation, getTransitionScope, holdUntilReady, idle, indexArray, injectPaused, injectRegisterResource, injectStartTransaction, injectStartTransition, injectTransitionScope, isDerivation, isLeaf, isMutable, isOpaque, isStore, keepPrevious, keyArray, map, mapArray, mapObject, mediaQuery, merge3, mousePosition, mutable, mutableStore, nestedEffect, networkStatus, opaque, orientation, pageVisibility, pairwise, pausableComputed, pausableEffect, pausableSignal, pipeable, piped, pointerDrag, pooled, pooledArray, pooledMap, pooledSet, prefersDarkMode, prefersReducedMotion, provideForwardingTransitionScope, providePausableOptions, providePaused, provideTransitionScope, registerResource, resolvePause, scan, scrollPosition, select, sensor, sensors, signalFromEvent, startWith, store, stored, tabSync, tap, throttle, throttled, toFakeDerivation, toFakeSignalDerivation, toStore, toWritable, until, windowSize, withHistory };
5349
+ export { MmActivity, MmTransition, MmViewTransitionName, PAUSABLE_OPTIONS, SuspenseBoundary, SuspenseBoundaryBase, UnscopedSuspenseBoundary, activeTransaction, batteryStatus, bridgeScopeToPendingTasks, chunked, clipboard, combineWith, createAttributedPending, createForwardingScope, createTransaction, createTransitionScope, debounce, debounced, deferredValue, derived, distinct, elementSize, elementVisibility, extendStore, filter, filterWith, focusWithin, forkStore, geolocation, getTransitionScope, holdUntilReady, idle, indexArray, injectPaused, injectRegisterResource, injectStartTransaction, injectStartTransition, injectTransitionScope, invertBatch, isDerivation, isLeaf, isMutable, isOpaque, isStore, keepPrevious, keyArray, latest, map, mapArray, mapObject, mediaQuery, merge3, mousePosition, mutable, mutableStore, nestedEffect, networkStatus, opLog, opaque, orientation, pageVisibility, pairwise, pausableComputed, pausableEffect, pausableSignal, pipeable, piped, pointerDrag, pooled, pooledArray, pooledMap, pooledSet, prefersDarkMode, prefersReducedMotion, provideForwardingTransitionScope, providePausableOptions, providePaused, provideTransitionScope, registerResource, resolvePause, scan, scrollPosition, select, sensor, sensors, signalFromEvent, startWith, store, stored, tabSync, tap, throttle, throttled, toFakeDerivation, toFakeSignalDerivation, toStore, toWritable, until, use, windowSize, withHistory };
4847
5350
  //# sourceMappingURL=mmstack-primitives.mjs.map