@mmstack/primitives 22.4.0 → 22.5.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, isWritableSignal as isWritableSignal$2, 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, isWritableSignal as isWritableSignal$2, 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
  /**
@@ -446,6 +446,81 @@ function chunked(source, options) {
446
446
  return internal.asReadonly();
447
447
  }
448
448
 
449
+ /**
450
+ * `useDeferredValue` for signals: returns a signal that HOLDS its previous value when
451
+ * `source` changes and catches up at lower priority (after paint / on idle), so an
452
+ * expensive subtree keyed off the deferred value never blocks the urgent update that
453
+ * caused the change — type into a filter, the input echoes instantly, the big list
454
+ * re-renders a beat later.
455
+ *
456
+ * ```ts
457
+ * const query = signal('');
458
+ * const deferredQuery = deferredValue(query);
459
+ * const results = computed(() => expensiveFilter(items(), deferredQuery()));
460
+ * // template: <input [(ngModel)]="query" /> stays responsive; results lag one paint
461
+ * // deferredQuery.pending() → dim the stale list while it catches up
462
+ * ```
463
+ *
464
+ * Rapid changes coalesce: each change reschedules the catch-up, so only the LATEST
465
+ * source value is ever applied (no intermediate churn in the expensive subtree).
466
+ * On the server this is a synchronous pass-through — SSR renders once, so deferral
467
+ * would just mean rendering stale content.
468
+ *
469
+ * This is a scheduling tool, not an async one — for async work compose `latest()`;
470
+ * for coordinated multi-resource reveals use a transition scope.
471
+ */
472
+ function deferredValue(source, opt) {
473
+ const injector = opt?.injector ?? inject(Injector);
474
+ const equal = opt?.equal ?? Object.is;
475
+ if (injector.get(PLATFORM_ID) === 'server') {
476
+ const passthrough = computed(() => source());
477
+ passthrough.pending = computed(() => false, /* @ts-ignore */
478
+ ...(ngDevMode ? [{ debugName: "pending" }] : /* istanbul ignore next */ []));
479
+ return passthrough;
480
+ }
481
+ const schedule = resolveScheduler(opt?.strategy ?? 'afterRender', injector);
482
+ const out = signal(untracked(source), { ...(ngDevMode ? { debugName: "out" } : /* istanbul ignore next */ {}), equal });
483
+ let cancel = null;
484
+ const watch = effect(() => {
485
+ const v = source();
486
+ cancel?.(); // latest wins: rapid changes coalesce into one catch-up
487
+ cancel = schedule(() => {
488
+ cancel = null;
489
+ out.set(v);
490
+ });
491
+ }, { ...(ngDevMode ? { debugName: "watch" } : /* istanbul ignore next */ {}), injector });
492
+ injector.get(DestroyRef).onDestroy(() => {
493
+ watch.destroy();
494
+ cancel?.();
495
+ cancel = null;
496
+ });
497
+ const result = computed(() => out());
498
+ // "behind" is a value comparison, not a schedule flag: an equal-valued catch-up
499
+ // (e.g. type a char, delete it before the deferred view caught up) is not pending
500
+ result.pending = computed(() => !equal(out(), source()), /* @ts-ignore */
501
+ ...(ngDevMode ? [{ debugName: "pending" }] : /* istanbul ignore next */ []));
502
+ return result;
503
+ }
504
+ function resolveScheduler(strategy, injector) {
505
+ if (typeof strategy === 'function')
506
+ return strategy;
507
+ if (strategy === 'idle') {
508
+ return (cb) => {
509
+ const ric = globalThis.requestIdleCallback;
510
+ if (ric) {
511
+ const id = ric(() => cb());
512
+ return () => globalThis.cancelIdleCallback?.(id);
513
+ }
514
+ const id = setTimeout(cb, 0);
515
+ return () => clearTimeout(id);
516
+ };
517
+ }
518
+ return (cb) => {
519
+ const ref = afterNextRender({ read: cb }, { injector });
520
+ return () => ref.destroy();
521
+ };
522
+ }
523
+
449
524
  /**
450
525
  * Structural hold-and-swap as a signal. Given a `target` (the desired value — e.g. the
451
526
  * subtree/def/key you want to show) and a `ready` predicate, returns a signal that keeps
@@ -545,6 +620,17 @@ function createTransitionScope() {
545
620
  source: () => ({ v: value(), settled: !pending() }),
546
621
  computation: (curr, prev) => curr.settled || prev === undefined ? curr.v : prev.value,
547
622
  }),
623
+ abortPending: () => untracked(() => {
624
+ let aborted = 0;
625
+ for (const { ref } of list()) {
626
+ const s = ref.status();
627
+ if ((s === 'loading' || s === 'reloading') && ref.abort) {
628
+ ref.abort();
629
+ aborted++;
630
+ }
631
+ }
632
+ return aborted;
633
+ }),
548
634
  holding,
549
635
  beginHold: () => untracked(() => holdCount.update((c) => c + 1)),
550
636
  endHold: () => untracked(() => holdCount.update((c) => (c > 0 ? c - 1 : 0))),
@@ -566,6 +652,7 @@ function createNoopScope() {
566
652
  // noop
567
653
  },
568
654
  commit: (value) => value,
655
+ abortPending: () => 0,
569
656
  holding: computed(() => false),
570
657
  beginHold: () => {
571
658
  // noop
@@ -577,9 +664,49 @@ function createNoopScope() {
577
664
  };
578
665
  }
579
666
  const TRANSITION_SCOPE = new InjectionToken('@mmstack/primitives:transition-scope');
667
+ /**
668
+ * The scope→`PendingTasks` bridge: while `scope.pending()` is true, hold an Angular
669
+ * pending task so SSR serialization waits for the scope's in-flight loads — HTTP loads
670
+ * already do this via HttpClient, but CUSTOM loaders (a `latest()` over a hand-rolled
671
+ * promise, a non-HTTP resource) would otherwise let the server render a boundary
672
+ * mid-load. Wired automatically by `provideTransitionScope` /
673
+ * `provideForwardingTransitionScope`; call it yourself only for scopes you construct
674
+ * directly with `createTransitionScope()`.
675
+ *
676
+ * Server-only by design: on the browser, tying `ApplicationRef.isStable` to every load
677
+ * would stall stability-gated machinery (testability, hydration timing) for no benefit.
678
+ */
679
+ function bridgeScopeToPendingTasks(scope, injector) {
680
+ const run = (fn) => injector ? runInInjectionContext(injector, fn) : fn();
681
+ run(() => {
682
+ if (inject(PLATFORM_ID) !== 'server')
683
+ return;
684
+ const tasks = inject(PendingTasks);
685
+ let done = null;
686
+ effect(() => {
687
+ if (scope.pending())
688
+ done ??= tasks.add();
689
+ else {
690
+ done?.();
691
+ done = null;
692
+ }
693
+ });
694
+ inject(DestroyRef).onDestroy(() => {
695
+ done?.();
696
+ done = null;
697
+ });
698
+ });
699
+ }
580
700
  /** Provide a fresh transition scope at a boundary so its subtree's resources are tracked independently. */
581
701
  function provideTransitionScope() {
582
- return { provide: TRANSITION_SCOPE, useFactory: createTransitionScope };
702
+ return {
703
+ provide: TRANSITION_SCOPE,
704
+ useFactory: () => {
705
+ const scope = createTransitionScope();
706
+ bridgeScopeToPendingTasks(scope);
707
+ return scope;
708
+ },
709
+ };
583
710
  }
584
711
  function injectTransitionScope() {
585
712
  const scope = inject(TRANSITION_SCOPE, { optional: true });
@@ -616,6 +743,7 @@ function createForwardingScope() {
616
743
  source: () => ({ v: value(), settled: !eff().pending() }),
617
744
  computation: (curr, prev) => curr.settled || prev === undefined ? curr.v : prev.value,
618
745
  }),
746
+ abortPending: () => (untracked(target) ?? own).abortPending(),
619
747
  holding: computed(() => eff().holding()),
620
748
  beginHold: () => (untracked(target) ?? own).beginHold(),
621
749
  endHold: () => (untracked(target) ?? own).endHold(),
@@ -627,7 +755,14 @@ function createForwardingScope() {
627
755
  }
628
756
  /** Provide a forwarding transition scope at a boundary (used by the transition outlet). */
629
757
  function provideForwardingTransitionScope() {
630
- return { provide: TRANSITION_SCOPE, useFactory: createForwardingScope };
758
+ return {
759
+ provide: TRANSITION_SCOPE,
760
+ useFactory: () => {
761
+ const scope = createForwardingScope();
762
+ bridgeScopeToPendingTasks(scope);
763
+ return scope;
764
+ },
765
+ };
631
766
  }
632
767
  /** Read the transition scope reachable from `injector`, or null if none is provided there. */
633
768
  function getTransitionScope(injector) {
@@ -684,6 +819,138 @@ function registerResource(res, opt) {
684
819
  return injectRegisterResource()(res, opt);
685
820
  }
686
821
 
822
+ const frameStack = [];
823
+ /**
824
+ * Thrown by `use()` to short-circuit a computation whose input has no value yet; caught
825
+ * by the owning `latest()`. Identity-compared, so user code must not swallow it — avoid
826
+ * broad `try/catch` around `use()` calls.
827
+ */
828
+ const BLOCKED = new Error('[mmstack/primitives] latest() blocked — internal sentinel, do not catch');
829
+ /**
830
+ * Reads a resource inside a `latest()` computation: returns its value and reports it to
831
+ * the enclosing collector, so the derivation's aggregate `pending`/`status`/`error`
832
+ * include it. When the resource has no value yet (first load) or is in an error state,
833
+ * the computation short-circuits — code after this call simply doesn't run this round —
834
+ * which is what lets you write the happy path with no `undefined` checks:
835
+ *
836
+ * ```ts
837
+ * const fullName = latest(() => {
838
+ * const u = use(user); // waterfalls compose:
839
+ * const org = use(orgFor(u)); // orgFor(u) is only read once `user` has a value
840
+ * return `${u.name} @ ${org.name}`;
841
+ * });
842
+ * ```
843
+ *
844
+ * Must be called synchronously within `latest()` — like `inject()`, it throws elsewhere.
845
+ */
846
+ function use(res) {
847
+ const frame = frameStack.at(-1);
848
+ if (!frame) {
849
+ throw new Error('[mmstack/primitives] use() must be called synchronously within a latest() computation');
850
+ }
851
+ if (!frame.seen.has(res)) {
852
+ frame.seen.add(res);
853
+ frame.deps.push(res);
854
+ }
855
+ // status() is read tracked even on the short-circuit paths, so the owning computed
856
+ // re-evaluates when the load settles / the error clears.
857
+ if (res.status() === 'error') {
858
+ frame.errors.push(res.error?.());
859
+ throw BLOCKED;
860
+ }
861
+ if (!res.hasValue())
862
+ throw BLOCKED;
863
+ return res.value();
864
+ }
865
+ /**
866
+ * An async derivation over resources: evaluates `fn` inside a collector frame so that
867
+ * every `use()` read registers as a member, and exposes the result with resource
868
+ * semantics — the value holds its previous state while anything it read is in flight
869
+ * (never flashing empty), `pending` aggregates the members' in-flight state, and the
870
+ * whole thing is itself a `UseSource`, so `latest`s nest and propagate.
871
+ *
872
+ * ```ts
873
+ * const fullName = latest(() => `${use(user).name} @ ${use(org).name}`);
874
+ * fullName(); // held value — undefined only before the first successful run
875
+ * fullName.pending(); // true while user OR org (re)loads
876
+ * ```
877
+ *
878
+ * Evaluation is a plain `computed` under the hood: lazy, pure, no effects, usable
879
+ * outside any injection context (`register` is the only DI-touching option).
880
+ */
881
+ function latest(fn, opt) {
882
+ const evaluation = computed(() => {
883
+ const frame = { deps: [], seen: new Set(), errors: [] };
884
+ frameStack.push(frame);
885
+ try {
886
+ const value = fn();
887
+ return { kind: 'value', value, deps: frame.deps, errors: frame.errors };
888
+ }
889
+ catch (e) {
890
+ if (e === BLOCKED)
891
+ return { kind: 'blocked', deps: frame.deps, errors: frame.errors };
892
+ return {
893
+ kind: 'thrown',
894
+ thrown: e,
895
+ deps: frame.deps,
896
+ errors: frame.errors,
897
+ };
898
+ }
899
+ finally {
900
+ frameStack.pop();
901
+ }
902
+ }, opt?.debugName ? { debugName: `${opt.debugName}:evaluation` } : undefined);
903
+ const equal = opt?.equal ?? Object.is;
904
+ // The stale-while-revalidate atom: holds the last successful result through blocked /
905
+ // errored rounds. `equal` gates notification, so an in-flight cycle that lands on an
906
+ // equal value never ripples to consumers — while `pending` (independent) still cycles.
907
+ const held = linkedSignal({ ...(ngDevMode ? { debugName: "held" } : /* istanbul ignore next */ {}), source: evaluation,
908
+ computation: (ev, prev) => ev.kind === 'value'
909
+ ? { has: true, v: ev.value }
910
+ : (prev?.value ?? { has: false, v: undefined }),
911
+ equal: (a, b) => a.has === b.has && (!a.has || equal(a.v, b.v)) });
912
+ const value = computed(() => held().v, opt?.debugName ? { debugName: opt.debugName } : undefined);
913
+ const pending = computed(() => evaluation().deps.some((d) => {
914
+ const s = d.status();
915
+ return s === 'loading' || s === 'reloading';
916
+ }), /* @ts-ignore */
917
+ ...(ngDevMode ? [{ debugName: "pending" }] : /* istanbul ignore next */ []));
918
+ const status = computed(() => {
919
+ const ev = evaluation();
920
+ if (ev.kind === 'thrown' || ev.errors.length > 0)
921
+ return 'error';
922
+ if (pending())
923
+ return held().has ? 'reloading' : 'loading';
924
+ return ev.kind === 'value' ? 'resolved' : 'idle';
925
+ }, /* @ts-ignore */
926
+ ...(ngDevMode ? [{ debugName: "status" }] : /* istanbul ignore next */ []));
927
+ const error = computed(() => {
928
+ const ev = evaluation();
929
+ return ev.kind === 'thrown' ? ev.thrown : ev.errors.at(0);
930
+ }, /* @ts-ignore */
931
+ ...(ngDevMode ? [{ debugName: "error" }] : /* istanbul ignore next */ []));
932
+ const result = Object.assign(value, {
933
+ value,
934
+ status,
935
+ pending,
936
+ isLoading: pending,
937
+ error,
938
+ hasValue: () => held().has,
939
+ });
940
+ if (opt?.register) {
941
+ const register = () => {
942
+ const scope = injectTransitionScope();
943
+ scope.add(result, { suspends: opt.register === 'suspend' });
944
+ inject(DestroyRef).onDestroy(() => scope.remove(result));
945
+ };
946
+ if (opt.injector)
947
+ runInInjectionContext(opt.injector, register);
948
+ else
949
+ register();
950
+ }
951
+ return result;
952
+ }
953
+
687
954
  /**
688
955
  * Returns a `startTransition(fn)` bound to the nearest transition scope. `fn` runs its state
689
956
  * mutations (which commit immediately); any resource that reloads as a result holds its value
@@ -946,6 +1213,221 @@ function injectStartTransaction() {
946
1213
  };
947
1214
  }
948
1215
 
1216
+ /**
1217
+ * Generic hold-and-swap: the non-router `TransitionRouterOutlet`. When the bound value changes,
1218
+ * the OLD view stays mounted and visible (it keeps its old context value — that's the hold) while
1219
+ * the NEW view mounts hidden with its **own transition scope**; resources created in the incoming
1220
+ * subtree register into that scope just by existing, and once they've gone in flight and settled
1221
+ * the views swap in one frame. Tabs, wizard steps, master-detail — any branch change that would
1222
+ * otherwise flash a loading state.
1223
+ *
1224
+ * ```html
1225
+ * <div *mmTransition="selectedTab(); let tab">
1226
+ * @switch (tab) { ... }
1227
+ * </div>
1228
+ * ```
1229
+ *
1230
+ * Distinct from `<mm-suspense>` (the readiness gate): suspense decides placeholder-vs-content
1231
+ * *within* one branch, but can't stop an `@switch` from unmounting the old branch the instant the
1232
+ * value flips. This directive is the swap itself — the old branch survives until the new one is
1233
+ * ready. Compose them freely: suspense inside a transitioned branch handles its first load.
1234
+ *
1235
+ * Semantics mirror the outlet: the first render is immediate (nothing to hold); an interrupting
1236
+ * value change mid-hold destroys the half-ready hidden view and re-targets; a branch that loads
1237
+ * nothing swaps right after its first render. Per-view scopes mean the outgoing branch's
1238
+ * background work can never delay the swap. Set `mmTransitionImmediate` to skip holding, and
1239
+ * `mmTransitionViewTransition` to wrap the swap in `document.startViewTransition` (feature
1240
+ * detected). On the server every change swaps immediately.
1241
+ */
1242
+ class MmTransition {
1243
+ tpl = inject(TemplateRef);
1244
+ vcr = inject(ViewContainerRef);
1245
+ parent = inject(Injector);
1246
+ onServer = isPlatformServer(inject(PLATFORM_ID, { optional: true }) ?? 'browser');
1247
+ /** The value whose changes are transitioned. Each view keeps the value it was created with. */
1248
+ value = input.required({ ...(ngDevMode ? { debugName: "value" } : /* istanbul ignore next */ {}), alias: 'mmTransition' });
1249
+ /** Skip holding entirely — every change swaps at once (the plain re-render behavior). */
1250
+ immediate = input(false, { ...(ngDevMode ? { debugName: "immediate" } : /* istanbul ignore next */ {}), alias: 'mmTransitionImmediate' });
1251
+ /** Wrap the swap in the View Transitions API for an animated cross-fade (feature detected). */
1252
+ viewTransition = input(false, { ...(ngDevMode ? { debugName: "viewTransition" } : /* istanbul ignore next */ {}), alias: 'mmTransitionViewTransition' });
1253
+ current = null;
1254
+ incoming = null;
1255
+ /** Bumped on every re-target/teardown so a superseded (possibly deferred) swap can't commit. */
1256
+ swapEpoch = 0;
1257
+ holding = signal(false, /* @ts-ignore */
1258
+ ...(ngDevMode ? [{ debugName: "holding" }] : /* istanbul ignore next */ []));
1259
+ /** True while an incoming view is mounted hidden, waiting to settle. */
1260
+ pending = this.holding.asReadonly();
1261
+ static ngTemplateContextGuard(dir,
1262
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
1263
+ ctx) {
1264
+ return true;
1265
+ }
1266
+ constructor() {
1267
+ effect(() => {
1268
+ const v = this.value();
1269
+ untracked(() => this.onValue(v));
1270
+ });
1271
+ inject(DestroyRef).onDestroy(() => {
1272
+ this.swapEpoch++; // a deferred view-transition callback must not touch destroyed state
1273
+ this.dropIncoming();
1274
+ // `current` is destroyed with the container
1275
+ });
1276
+ }
1277
+ onValue(v) {
1278
+ if (!this.current) {
1279
+ // first render: nothing to hold yet — show immediately (also what SSR serializes)
1280
+ this.current = this.createView(v).view;
1281
+ return;
1282
+ }
1283
+ this.dropIncoming(); // an interrupting change supersedes the previous hold
1284
+ this.swapEpoch++;
1285
+ const epoch = this.swapEpoch;
1286
+ if (this.onServer || this.immediate()) {
1287
+ this.finishSwap(epoch, this.createView(v).view);
1288
+ return;
1289
+ }
1290
+ const { view, scope } = this.createView(v);
1291
+ this.setHidden(view, true);
1292
+ this.holding.set(true);
1293
+ // Registration happens synchronously during view creation, so a resource already in
1294
+ // flight counts from the start; later kickoffs are caught by the watcher.
1295
+ let sawPending = untracked(scope.pending);
1296
+ const watcher = effect(() => {
1297
+ const pending = scope.pending();
1298
+ untracked(() => {
1299
+ if (epoch !== this.swapEpoch)
1300
+ return;
1301
+ if (pending)
1302
+ sawPending = true;
1303
+ if (sawPending && !pending)
1304
+ this.commitSwap(epoch, view);
1305
+ });
1306
+ }, { ...(ngDevMode ? { debugName: "watcher" } : /* istanbul ignore next */ {}), injector: this.parent });
1307
+ this.incoming = { view, watcher };
1308
+ // Fallback for a branch that loads nothing.
1309
+ afterNextRender(() => {
1310
+ if (epoch === this.swapEpoch &&
1311
+ !sawPending &&
1312
+ !untracked(scope.pending)) {
1313
+ this.commitSwap(epoch, view);
1314
+ }
1315
+ }, { injector: this.parent });
1316
+ }
1317
+ commitSwap(epoch, view) {
1318
+ if (epoch !== this.swapEpoch)
1319
+ return;
1320
+ if (this.viewTransition() &&
1321
+ typeof document !== 'undefined' &&
1322
+ document.startViewTransition) {
1323
+ // the browser snapshots the old frame first; the epoch guard covers the deferral
1324
+ document.startViewTransition(() => this.finishSwap(epoch, view));
1325
+ }
1326
+ else {
1327
+ this.finishSwap(epoch, view);
1328
+ }
1329
+ }
1330
+ /** The actual swap: destroy the old view, reveal the new one. Always instant. */
1331
+ finishSwap(epoch, view) {
1332
+ if (epoch !== this.swapEpoch)
1333
+ return; // superseded while deferred — not ours to commit
1334
+ this.swapEpoch++; // consume: the watcher and the render fallback can both fire, one commits
1335
+ this.current?.destroy();
1336
+ this.setHidden(view, false);
1337
+ this.current = view;
1338
+ this.incoming?.watcher.destroy();
1339
+ this.incoming = null;
1340
+ this.holding.set(false);
1341
+ }
1342
+ dropIncoming() {
1343
+ if (!this.incoming)
1344
+ return;
1345
+ this.incoming.watcher.destroy();
1346
+ this.incoming.view.destroy();
1347
+ this.incoming = null;
1348
+ this.holding.set(false);
1349
+ }
1350
+ createView(v) {
1351
+ // Each view gets its own scope, so its subtree's resources register here by existing —
1352
+ // and the outgoing view's background work can't block the swap (per-view isolation).
1353
+ const injector = Injector.create({
1354
+ parent: this.parent,
1355
+ providers: [provideTransitionScope()],
1356
+ });
1357
+ const scope = getTransitionScope(injector);
1358
+ const view = this.vcr.createEmbeddedView(this.tpl, { $implicit: v, mmTransition: v }, { injector });
1359
+ return { view, scope };
1360
+ }
1361
+ setHidden(view, hidden) {
1362
+ for (const node of view.rootNodes) {
1363
+ // covers HTML and SVG roots; text/comment roots can't be styled — prefer an element root
1364
+ if (node instanceof HTMLElement || node instanceof SVGElement)
1365
+ node.style.display = hidden ? 'none' : '';
1366
+ }
1367
+ }
1368
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.2", ngImport: i0, type: MmTransition, deps: [], target: i0.ɵɵFactoryTarget.Directive });
1369
+ static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "22.0.2", type: MmTransition, isStandalone: true, selector: "[mmTransition]", inputs: { value: { classPropertyName: "value", publicName: "mmTransition", isSignal: true, isRequired: true, transformFunction: null }, immediate: { classPropertyName: "immediate", publicName: "mmTransitionImmediate", isSignal: true, isRequired: false, transformFunction: null }, viewTransition: { classPropertyName: "viewTransition", publicName: "mmTransitionViewTransition", isSignal: true, isRequired: false, transformFunction: null } }, exportAs: ["mmTransition"], ngImport: i0 });
1370
+ }
1371
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.2", ngImport: i0, type: MmTransition, decorators: [{
1372
+ type: Directive,
1373
+ args: [{
1374
+ selector: '[mmTransition]',
1375
+ exportAs: 'mmTransition',
1376
+ }]
1377
+ }], 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 }] }] } });
1378
+
1379
+ /**
1380
+ * Per-element morphs on held swaps: assigns `view-transition-name` reactively, so when
1381
+ * a swap wrapped in `document.startViewTransition` flips views (`*mmTransition`'s
1382
+ * `mmTransitionViewTransition`, or the transition outlet's view-transition option), the
1383
+ * browser pairs same-named elements across the outgoing and incoming views and MORPHS
1384
+ * them instead of cross-fading the whole boundary.
1385
+ *
1386
+ * ```html
1387
+ * <!-- outgoing view (list) and incoming view (detail) both name the hero image: -->
1388
+ * <img [mmViewTransitionName]="'hero-' + item().id" [src]="item().img" />
1389
+ * ```
1390
+ *
1391
+ * Why this works with holds: both views coexist in the DOM during a hold, but the
1392
+ * incoming one is `display: none` — elements without boxes aren't captured, so the
1393
+ * same name on both sides is legal at each capture point (old visible at snapshot,
1394
+ * new visible after the swap). No arming/cleanup dance needed.
1395
+ *
1396
+ * The name is normalized to a valid CSS custom-ident (invalid characters → `-`, a
1397
+ * leading digit gets a `_` prefix). An empty string / `'none'` clears the name — use
1398
+ * that to opt an element out conditionally. One rule remains YOURS to keep: a name
1399
+ * must be unique among elements VISIBLE at capture time (two rendered instances of the
1400
+ * same named element make the browser skip the whole transition) — derive names from
1401
+ * ids for anything that can repeat.
1402
+ */
1403
+ class MmViewTransitionName {
1404
+ mmViewTransitionName = input.required(/* @ts-ignore */
1405
+ ...(ngDevMode ? [{ debugName: "mmViewTransitionName" }] : /* istanbul ignore next */ []));
1406
+ constructor() {
1407
+ const el = inject(ElementRef).nativeElement;
1408
+ effect(() => {
1409
+ const name = normalizeIdent(this.mmViewTransitionName());
1410
+ if (name)
1411
+ el.style.setProperty('view-transition-name', name);
1412
+ else
1413
+ el.style.removeProperty('view-transition-name');
1414
+ });
1415
+ }
1416
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.2", ngImport: i0, type: MmViewTransitionName, deps: [], target: i0.ɵɵFactoryTarget.Directive });
1417
+ static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "22.0.2", type: MmViewTransitionName, isStandalone: true, selector: "[mmViewTransitionName]", inputs: { mmViewTransitionName: { classPropertyName: "mmViewTransitionName", publicName: "mmViewTransitionName", isSignal: true, isRequired: true, transformFunction: null } }, ngImport: i0 });
1418
+ }
1419
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.2", ngImport: i0, type: MmViewTransitionName, decorators: [{
1420
+ type: Directive,
1421
+ args: [{ selector: '[mmViewTransitionName]' }]
1422
+ }], ctorParameters: () => [], propDecorators: { mmViewTransitionName: [{ type: i0.Input, args: [{ isSignal: true, alias: "mmViewTransitionName", required: true }] }] } });
1423
+ /** @internal `''`/`'none'` clear; otherwise coerce into a valid custom-ident. */
1424
+ function normalizeIdent(raw) {
1425
+ if (!raw || raw === 'none')
1426
+ return null;
1427
+ const cleaned = raw.replace(/[^a-zA-Z0-9_-]/g, '-');
1428
+ return /^\d/.test(cleaned) ? `_${cleaned}` : cleaned;
1429
+ }
1430
+
949
1431
  /**
950
1432
  * @internal
951
1433
  */
@@ -4151,6 +4633,191 @@ function forkStore(base, opt) {
4151
4633
  };
4152
4634
  }
4153
4635
 
4636
+ function generateOrigin() {
4637
+ if (globalThis.crypto?.randomUUID)
4638
+ return globalThis.crypto.randomUUID();
4639
+ return Math.random().toString(36).substring(2);
4640
+ }
4641
+ const isPlainArray = (v) => Array.isArray(v) && !isOpaque(v);
4642
+ /**
4643
+ * Reference-identity-pruned structural diff — the same short-circuit discipline as `merge3`:
4644
+ * an untouched subtree kept its reference (the store's copy-on-write contract), so the walk
4645
+ * descends only where refs differ. O(changed paths), not O(tree).
4646
+ */
4647
+ function diffNode(prev, next, path, ops) {
4648
+ if (Object.is(prev, next))
4649
+ return;
4650
+ if (isRecord(prev) && isRecord(next)) {
4651
+ for (const key of Object.keys(prev)) {
4652
+ if (!Object.hasOwn(next, key))
4653
+ ops.push({ kind: 'delete', path: [...path, key], prev: prev[key] });
4654
+ }
4655
+ for (const key of Object.keys(next)) {
4656
+ if (!Object.hasOwn(prev, key)) {
4657
+ // added key: deliberately NO `prev` property (absent ≠ undefined)
4658
+ ops.push({ kind: 'set', path: [...path, key], next: next[key] });
4659
+ }
4660
+ else {
4661
+ diffNode(prev[key], next[key], [...path, key], ops);
4662
+ }
4663
+ }
4664
+ return;
4665
+ }
4666
+ if (isPlainArray(prev) && isPlainArray(next)) {
4667
+ // same length → per-index descent (matches `arr[i].x.set(...)` writes); a length
4668
+ // change is a whole unit — index attribution lies under insert/remove/reorder
4669
+ if (prev.length === next.length) {
4670
+ for (let i = 0; i < next.length; i++)
4671
+ diffNode(prev[i], next[i], [...path, i], ops);
4672
+ return;
4673
+ }
4674
+ ops.push({ kind: 'set', path, prev, next });
4675
+ return;
4676
+ }
4677
+ // leaf / type change / opaque — one unit, prev present (the slot existed)
4678
+ ops.push({ kind: 'set', path, prev, next });
4679
+ }
4680
+ /** Immutably applies one op along its path, vivifying missing containers `'auto'`-style. */
4681
+ function applyAt(container, path, idx, op) {
4682
+ const seg = path[idx];
4683
+ const base = isPlainArray(container)
4684
+ ? container.slice()
4685
+ : isRecord(container)
4686
+ ? { ...container }
4687
+ : typeof seg === 'number'
4688
+ ? []
4689
+ : {};
4690
+ if (idx === path.length - 1) {
4691
+ if (op.kind === 'delete') {
4692
+ // arrays never receive deletes (length changes travel as whole-array sets)
4693
+ delete base[seg];
4694
+ }
4695
+ else {
4696
+ base[seg] = op.next;
4697
+ }
4698
+ return base;
4699
+ }
4700
+ base[seg] = applyAt(base[seg], path, idx + 1, op);
4701
+ return base;
4702
+ }
4703
+ /**
4704
+ * Inverts a batch for undo: reversed order, `set`↔its own inverse (an add — a `set` with no
4705
+ * `prev` — inverts to a `delete`; a `delete` inverts to a `set` restoring `prev`). Feed the
4706
+ * result to {@link OpLog.apply}. Requires the ops' `prev`s, which in-memory batches always
4707
+ * carry — a wire-serialized batch that stripped them is not invertible.
4708
+ */
4709
+ function invertBatch(batch) {
4710
+ const ops = Array.isArray(batch) ? batch : batch.ops;
4711
+ const inverted = [];
4712
+ for (let i = ops.length - 1; i >= 0; i--) {
4713
+ const op = ops[i];
4714
+ if (op.kind === 'delete') {
4715
+ inverted.push({ kind: 'set', path: op.path, next: op.prev, prev: undefined });
4716
+ continue;
4717
+ }
4718
+ if (!Object.hasOwn(op, 'prev')) {
4719
+ inverted.push({ kind: 'delete', path: op.path, prev: op.next });
4720
+ }
4721
+ else {
4722
+ inverted.push({ kind: 'set', path: op.path, next: op.prev, prev: op.next });
4723
+ }
4724
+ }
4725
+ return inverted;
4726
+ }
4727
+ /**
4728
+ * Observes a copy-on-write signal (a `store`'s root, or any `WritableSignal` holding
4729
+ * immutably-updated objects) and emits its changes as minimal structural op batches — the
4730
+ * shared substrate for sync (ship batches, `apply` remote ones), persistence (journal
4731
+ * batches, replay on boot), undo ({@link invertBatch}), and devtools (`latest`).
4732
+ *
4733
+ * Zero store-core involvement and zero cost when unused: emission is a reference-pruned diff
4734
+ * of the root value per tick (structural sharing makes it O(changed paths)), driven by one
4735
+ * effect. A batch therefore coalesces everything written in one tick — for coarser,
4736
+ * intentional units, stage writes on a `forkStore` and `commit()` (one set → one batch).
4737
+ *
4738
+ * NOT supported on mutable stores/signals: in-place mutation keeps reference identity, which
4739
+ * defeats the diff (same reason `forkStore`'s `'fine'` strategy refuses them) — a dev-mode
4740
+ * warning fires and nothing emits.
4741
+ *
4742
+ * ```ts
4743
+ * const s = store({ todos: [{ done: false }] });
4744
+ * const log = opLog(s, { origin: 'tab-a' });
4745
+ * log.subscribe((b) => channel.postMessage(encode(b))); // ship
4746
+ * channel.onmessage = (m) => log.apply(decode(m.data)); // apply — echo-free
4747
+ * s.todos[0].done.set(true); // → { kind: 'set', path: ['todos', 0, 'done'], … }
4748
+ * ```
4749
+ */
4750
+ function opLog(source, opt) {
4751
+ const injector = opt?.injector ?? inject(Injector);
4752
+ const origin = opt?.origin ?? generateOrigin();
4753
+ // a store proxy's `has` trap answers for the VALUE's keys, so `isMutable`'s `'mutate' in`
4754
+ // probe can't see the brand — ask the store's own kind symbol first
4755
+ const storeKind = source[STORE_KIND];
4756
+ const mutableSource = storeKind ? storeKind === 'mutable' : isMutable(source);
4757
+ if (isDevMode() && mutableSource) {
4758
+ 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.');
4759
+ }
4760
+ let prevRoot = untracked(source);
4761
+ let version = 0;
4762
+ let destroyed = false;
4763
+ const subscribers = new Set();
4764
+ const latest = signal(null, /* @ts-ignore */
4765
+ ...(ngDevMode ? [{ debugName: "latest" }] : /* istanbul ignore next */ []));
4766
+ /** Diff now, emit if there's a delta, advance the baseline. */
4767
+ const flush = () => {
4768
+ if (destroyed)
4769
+ return;
4770
+ const next = untracked(source);
4771
+ if (Object.is(prevRoot, next))
4772
+ return;
4773
+ const ops = [];
4774
+ diffNode(prevRoot, next, [], ops);
4775
+ prevRoot = next;
4776
+ if (!ops.length)
4777
+ return; // fresh refs, equal values — spurious-write tolerance
4778
+ const batch = { origin, version: ++version, ops };
4779
+ latest.set(batch);
4780
+ for (const cb of [...subscribers])
4781
+ cb(batch);
4782
+ };
4783
+ const ref = effect(() => {
4784
+ source(); // track every commit…
4785
+ untracked(flush); // …and emit the delta since the last flush
4786
+ }, { ...(ngDevMode ? { debugName: "ref" } : /* istanbul ignore next */ {}), injector: opt?.injector });
4787
+ return {
4788
+ latest: latest.asReadonly(),
4789
+ subscribe: (cb) => {
4790
+ subscribers.add(cb);
4791
+ return () => subscribers.delete(cb);
4792
+ },
4793
+ apply: (batchOrOps) => {
4794
+ const ops = Array.isArray(batchOrOps)
4795
+ ? batchOrOps
4796
+ : batchOrOps.ops;
4797
+ if (!ops.length)
4798
+ return;
4799
+ // pending local writes must emit BEFORE the baseline advances past them
4800
+ flush();
4801
+ let root = untracked(source);
4802
+ for (const op of ops) {
4803
+ if (op.path.length === 0) {
4804
+ if (op.kind === 'set')
4805
+ root = op.next;
4806
+ continue; // a root delete is meaningless — ignore
4807
+ }
4808
+ root = applyAt(root, op.path, 0, op);
4809
+ }
4810
+ source.set(root);
4811
+ prevRoot = root; // baseline advance: an applied batch never echoes
4812
+ },
4813
+ destroy: () => {
4814
+ destroyed = true;
4815
+ subscribers.clear();
4816
+ ref.destroy();
4817
+ },
4818
+ };
4819
+ }
4820
+
4154
4821
  /**
4155
4822
  * @internal The plain-`effect` sibling of the public {@link pausableEffect} (which is built on
4156
4823
  * `nestedEffect`). For infra utilities that own a single top-level effect/subscription and don't
@@ -4679,5 +5346,5 @@ function withHistory(sourceOrValue, opt) {
4679
5346
  * Generated bundle index. Do not edit.
4680
5347
  */
4681
5348
 
4682
- export { MmActivity, 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 };
4683
5350
  //# sourceMappingURL=mmstack-primitives.mjs.map