@mmstack/primitives 21.4.1 → 21.5.1

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
  /**
@@ -445,6 +445,79 @@ function chunked(source, options) {
445
445
  return internal.asReadonly();
446
446
  }
447
447
 
448
+ /**
449
+ * `useDeferredValue` for signals: returns a signal that HOLDS its previous value when
450
+ * `source` changes and catches up at lower priority (after paint / on idle), so an
451
+ * expensive subtree keyed off the deferred value never blocks the urgent update that
452
+ * caused the change — type into a filter, the input echoes instantly, the big list
453
+ * re-renders a beat later.
454
+ *
455
+ * ```ts
456
+ * const query = signal('');
457
+ * const deferredQuery = deferredValue(query);
458
+ * const results = computed(() => expensiveFilter(items(), deferredQuery()));
459
+ * // template: <input [(ngModel)]="query" /> stays responsive; results lag one paint
460
+ * // deferredQuery.pending() → dim the stale list while it catches up
461
+ * ```
462
+ *
463
+ * Rapid changes coalesce: each change reschedules the catch-up, so only the LATEST
464
+ * source value is ever applied (no intermediate churn in the expensive subtree).
465
+ * On the server this is a synchronous pass-through — SSR renders once, so deferral
466
+ * would just mean rendering stale content.
467
+ *
468
+ * This is a scheduling tool, not an async one — for async work compose `latest()`;
469
+ * for coordinated multi-resource reveals use a transition scope.
470
+ */
471
+ function deferredValue(source, opt) {
472
+ const injector = opt?.injector ?? inject(Injector);
473
+ const equal = opt?.equal ?? Object.is;
474
+ if (injector.get(PLATFORM_ID) === 'server') {
475
+ const passthrough = computed(() => source());
476
+ passthrough.pending = computed(() => false, ...(ngDevMode ? [{ debugName: "pending" }] : /* istanbul ignore next */ []));
477
+ return passthrough;
478
+ }
479
+ const schedule = resolveScheduler(opt?.strategy ?? 'afterRender', injector);
480
+ const out = signal(untracked(source), { ...(ngDevMode ? { debugName: "out" } : /* istanbul ignore next */ {}), equal });
481
+ let cancel = null;
482
+ const watch = effect(() => {
483
+ const v = source();
484
+ cancel?.(); // latest wins: rapid changes coalesce into one catch-up
485
+ cancel = schedule(() => {
486
+ cancel = null;
487
+ out.set(v);
488
+ });
489
+ }, { ...(ngDevMode ? { debugName: "watch" } : /* istanbul ignore next */ {}), injector });
490
+ injector.get(DestroyRef).onDestroy(() => {
491
+ watch.destroy();
492
+ cancel?.();
493
+ cancel = null;
494
+ });
495
+ const result = computed(() => out());
496
+ // "behind" is a value comparison, not a schedule flag: an equal-valued catch-up
497
+ // (e.g. type a char, delete it before the deferred view caught up) is not pending
498
+ result.pending = computed(() => !equal(out(), source()), ...(ngDevMode ? [{ debugName: "pending" }] : /* istanbul ignore next */ []));
499
+ return result;
500
+ }
501
+ function resolveScheduler(strategy, injector) {
502
+ if (typeof strategy === 'function')
503
+ return strategy;
504
+ if (strategy === 'idle') {
505
+ return (cb) => {
506
+ const ric = globalThis.requestIdleCallback;
507
+ if (ric) {
508
+ const id = ric(() => cb());
509
+ return () => globalThis.cancelIdleCallback?.(id);
510
+ }
511
+ const id = setTimeout(cb, 0);
512
+ return () => clearTimeout(id);
513
+ };
514
+ }
515
+ return (cb) => {
516
+ const ref = afterNextRender({ read: cb }, { injector });
517
+ return () => ref.destroy();
518
+ };
519
+ }
520
+
448
521
  /**
449
522
  * Structural hold-and-swap as a signal. Given a `target` (the desired value — e.g. the
450
523
  * subtree/def/key you want to show) and a `ready` predicate, returns a signal that keeps
@@ -541,6 +614,17 @@ function createTransitionScope() {
541
614
  source: () => ({ v: value(), settled: !pending() }),
542
615
  computation: (curr, prev) => curr.settled || prev === undefined ? curr.v : prev.value,
543
616
  }),
617
+ abortPending: () => untracked(() => {
618
+ let aborted = 0;
619
+ for (const { ref } of list()) {
620
+ const s = ref.status();
621
+ if ((s === 'loading' || s === 'reloading') && ref.abort) {
622
+ ref.abort();
623
+ aborted++;
624
+ }
625
+ }
626
+ return aborted;
627
+ }),
544
628
  holding,
545
629
  beginHold: () => untracked(() => holdCount.update((c) => c + 1)),
546
630
  endHold: () => untracked(() => holdCount.update((c) => (c > 0 ? c - 1 : 0))),
@@ -562,6 +646,7 @@ function createNoopScope() {
562
646
  // noop
563
647
  },
564
648
  commit: (value) => value,
649
+ abortPending: () => 0,
565
650
  holding: computed(() => false),
566
651
  beginHold: () => {
567
652
  // noop
@@ -573,9 +658,49 @@ function createNoopScope() {
573
658
  };
574
659
  }
575
660
  const TRANSITION_SCOPE = new InjectionToken('@mmstack/primitives:transition-scope');
661
+ /**
662
+ * The scope→`PendingTasks` bridge: while `scope.pending()` is true, hold an Angular
663
+ * pending task so SSR serialization waits for the scope's in-flight loads — HTTP loads
664
+ * already do this via HttpClient, but CUSTOM loaders (a `latest()` over a hand-rolled
665
+ * promise, a non-HTTP resource) would otherwise let the server render a boundary
666
+ * mid-load. Wired automatically by `provideTransitionScope` /
667
+ * `provideForwardingTransitionScope`; call it yourself only for scopes you construct
668
+ * directly with `createTransitionScope()`.
669
+ *
670
+ * Server-only by design: on the browser, tying `ApplicationRef.isStable` to every load
671
+ * would stall stability-gated machinery (testability, hydration timing) for no benefit.
672
+ */
673
+ function bridgeScopeToPendingTasks(scope, injector) {
674
+ const run = (fn) => injector ? runInInjectionContext(injector, fn) : fn();
675
+ run(() => {
676
+ if (inject(PLATFORM_ID) !== 'server')
677
+ return;
678
+ const tasks = inject(PendingTasks);
679
+ let done = null;
680
+ effect(() => {
681
+ if (scope.pending())
682
+ done ??= tasks.add();
683
+ else {
684
+ done?.();
685
+ done = null;
686
+ }
687
+ });
688
+ inject(DestroyRef).onDestroy(() => {
689
+ done?.();
690
+ done = null;
691
+ });
692
+ });
693
+ }
576
694
  /** Provide a fresh transition scope at a boundary so its subtree's resources are tracked independently. */
577
695
  function provideTransitionScope() {
578
- return { provide: TRANSITION_SCOPE, useFactory: createTransitionScope };
696
+ return {
697
+ provide: TRANSITION_SCOPE,
698
+ useFactory: () => {
699
+ const scope = createTransitionScope();
700
+ bridgeScopeToPendingTasks(scope);
701
+ return scope;
702
+ },
703
+ };
579
704
  }
580
705
  function injectTransitionScope() {
581
706
  const scope = inject(TRANSITION_SCOPE, { optional: true });
@@ -611,6 +736,7 @@ function createForwardingScope() {
611
736
  source: () => ({ v: value(), settled: !eff().pending() }),
612
737
  computation: (curr, prev) => curr.settled || prev === undefined ? curr.v : prev.value,
613
738
  }),
739
+ abortPending: () => (untracked(target) ?? own).abortPending(),
614
740
  holding: computed(() => eff().holding()),
615
741
  beginHold: () => (untracked(target) ?? own).beginHold(),
616
742
  endHold: () => (untracked(target) ?? own).endHold(),
@@ -622,7 +748,14 @@ function createForwardingScope() {
622
748
  }
623
749
  /** Provide a forwarding transition scope at a boundary (used by the transition outlet). */
624
750
  function provideForwardingTransitionScope() {
625
- return { provide: TRANSITION_SCOPE, useFactory: createForwardingScope };
751
+ return {
752
+ provide: TRANSITION_SCOPE,
753
+ useFactory: () => {
754
+ const scope = createForwardingScope();
755
+ bridgeScopeToPendingTasks(scope);
756
+ return scope;
757
+ },
758
+ };
626
759
  }
627
760
  /** Read the transition scope reachable from `injector`, or null if none is provided there. */
628
761
  function getTransitionScope(injector) {
@@ -679,6 +812,135 @@ function registerResource(res, opt) {
679
812
  return injectRegisterResource()(res, opt);
680
813
  }
681
814
 
815
+ const frameStack = [];
816
+ /**
817
+ * Thrown by `use()` to short-circuit a computation whose input has no value yet; caught
818
+ * by the owning `latest()`. Identity-compared, so user code must not swallow it — avoid
819
+ * broad `try/catch` around `use()` calls.
820
+ */
821
+ const BLOCKED = new Error('[mmstack/primitives] latest() blocked — internal sentinel, do not catch');
822
+ /**
823
+ * Reads a resource inside a `latest()` computation: returns its value and reports it to
824
+ * the enclosing collector, so the derivation's aggregate `pending`/`status`/`error`
825
+ * include it. When the resource has no value yet (first load) or is in an error state,
826
+ * the computation short-circuits — code after this call simply doesn't run this round —
827
+ * which is what lets you write the happy path with no `undefined` checks:
828
+ *
829
+ * ```ts
830
+ * const fullName = latest(() => {
831
+ * const u = use(user); // waterfalls compose:
832
+ * const org = use(orgFor(u)); // orgFor(u) is only read once `user` has a value
833
+ * return `${u.name} @ ${org.name}`;
834
+ * });
835
+ * ```
836
+ *
837
+ * Must be called synchronously within `latest()` — like `inject()`, it throws elsewhere.
838
+ */
839
+ function use(res) {
840
+ const frame = frameStack.at(-1);
841
+ if (!frame) {
842
+ throw new Error('[mmstack/primitives] use() must be called synchronously within a latest() computation');
843
+ }
844
+ if (!frame.seen.has(res)) {
845
+ frame.seen.add(res);
846
+ frame.deps.push(res);
847
+ }
848
+ // status() is read tracked even on the short-circuit paths, so the owning computed
849
+ // re-evaluates when the load settles / the error clears.
850
+ if (res.status() === 'error') {
851
+ frame.errors.push(res.error?.());
852
+ throw BLOCKED;
853
+ }
854
+ if (!res.hasValue())
855
+ throw BLOCKED;
856
+ return res.value();
857
+ }
858
+ /**
859
+ * An async derivation over resources: evaluates `fn` inside a collector frame so that
860
+ * every `use()` read registers as a member, and exposes the result with resource
861
+ * semantics — the value holds its previous state while anything it read is in flight
862
+ * (never flashing empty), `pending` aggregates the members' in-flight state, and the
863
+ * whole thing is itself a `UseSource`, so `latest`s nest and propagate.
864
+ *
865
+ * ```ts
866
+ * const fullName = latest(() => `${use(user).name} @ ${use(org).name}`);
867
+ * fullName(); // held value — undefined only before the first successful run
868
+ * fullName.pending(); // true while user OR org (re)loads
869
+ * ```
870
+ *
871
+ * Evaluation is a plain `computed` under the hood: lazy, pure, no effects, usable
872
+ * outside any injection context (`register` is the only DI-touching option).
873
+ */
874
+ function latest(fn, opt) {
875
+ const evaluation = computed(() => {
876
+ const frame = { deps: [], seen: new Set(), errors: [] };
877
+ frameStack.push(frame);
878
+ try {
879
+ const value = fn();
880
+ return { kind: 'value', value, deps: frame.deps, errors: frame.errors };
881
+ }
882
+ catch (e) {
883
+ if (e === BLOCKED)
884
+ return { kind: 'blocked', deps: frame.deps, errors: frame.errors };
885
+ return {
886
+ kind: 'thrown',
887
+ thrown: e,
888
+ deps: frame.deps,
889
+ errors: frame.errors,
890
+ };
891
+ }
892
+ finally {
893
+ frameStack.pop();
894
+ }
895
+ }, opt?.debugName ? { debugName: `${opt.debugName}:evaluation` } : undefined);
896
+ const equal = opt?.equal ?? Object.is;
897
+ // The stale-while-revalidate atom: holds the last successful result through blocked /
898
+ // errored rounds. `equal` gates notification, so an in-flight cycle that lands on an
899
+ // equal value never ripples to consumers — while `pending` (independent) still cycles.
900
+ const held = linkedSignal({ ...(ngDevMode ? { debugName: "held" } : /* istanbul ignore next */ {}), source: evaluation,
901
+ computation: (ev, prev) => ev.kind === 'value'
902
+ ? { has: true, v: ev.value }
903
+ : (prev?.value ?? { has: false, v: undefined }),
904
+ equal: (a, b) => a.has === b.has && (!a.has || equal(a.v, b.v)) });
905
+ const value = computed(() => held().v, opt?.debugName ? { debugName: opt.debugName } : undefined);
906
+ const pending = computed(() => evaluation().deps.some((d) => {
907
+ const s = d.status();
908
+ return s === 'loading' || s === 'reloading';
909
+ }), ...(ngDevMode ? [{ debugName: "pending" }] : /* istanbul ignore next */ []));
910
+ const status = computed(() => {
911
+ const ev = evaluation();
912
+ if (ev.kind === 'thrown' || ev.errors.length > 0)
913
+ return 'error';
914
+ if (pending())
915
+ return held().has ? 'reloading' : 'loading';
916
+ return ev.kind === 'value' ? 'resolved' : 'idle';
917
+ }, ...(ngDevMode ? [{ debugName: "status" }] : /* istanbul ignore next */ []));
918
+ const error = computed(() => {
919
+ const ev = evaluation();
920
+ return ev.kind === 'thrown' ? ev.thrown : ev.errors.at(0);
921
+ }, ...(ngDevMode ? [{ debugName: "error" }] : /* istanbul ignore next */ []));
922
+ const result = Object.assign(value, {
923
+ value,
924
+ status,
925
+ pending,
926
+ isLoading: pending,
927
+ error,
928
+ hasValue: () => held().has,
929
+ });
930
+ if (opt?.register) {
931
+ const register = () => {
932
+ const scope = injectTransitionScope();
933
+ scope.add(result, { suspends: opt.register === 'suspend' });
934
+ inject(DestroyRef).onDestroy(() => scope.remove(result));
935
+ };
936
+ if (opt.injector)
937
+ runInInjectionContext(opt.injector, register);
938
+ else
939
+ register();
940
+ }
941
+ return result;
942
+ }
943
+
682
944
  /**
683
945
  * Returns a `startTransition(fn)` bound to the nearest transition scope. `fn` runs its state
684
946
  * mutations (which commit immediately); any resource that reloads as a result holds its value
@@ -1101,6 +1363,57 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
1101
1363
  }]
1102
1364
  }], 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 }] }] } });
1103
1365
 
1366
+ /**
1367
+ * Per-element morphs on held swaps: assigns `view-transition-name` reactively, so when
1368
+ * a swap wrapped in `document.startViewTransition` flips views (`*mmTransition`'s
1369
+ * `mmTransitionViewTransition`, or the transition outlet's view-transition option), the
1370
+ * browser pairs same-named elements across the outgoing and incoming views and MORPHS
1371
+ * them instead of cross-fading the whole boundary.
1372
+ *
1373
+ * ```html
1374
+ * <!-- outgoing view (list) and incoming view (detail) both name the hero image: -->
1375
+ * <img [mmViewTransitionName]="'hero-' + item().id" [src]="item().img" />
1376
+ * ```
1377
+ *
1378
+ * Why this works with holds: both views coexist in the DOM during a hold, but the
1379
+ * incoming one is `display: none` — elements without boxes aren't captured, so the
1380
+ * same name on both sides is legal at each capture point (old visible at snapshot,
1381
+ * new visible after the swap). No arming/cleanup dance needed.
1382
+ *
1383
+ * The name is normalized to a valid CSS custom-ident (invalid characters → `-`, a
1384
+ * leading digit gets a `_` prefix). An empty string / `'none'` clears the name — use
1385
+ * that to opt an element out conditionally. One rule remains YOURS to keep: a name
1386
+ * must be unique among elements VISIBLE at capture time (two rendered instances of the
1387
+ * same named element make the browser skip the whole transition) — derive names from
1388
+ * ids for anything that can repeat.
1389
+ */
1390
+ class MmViewTransitionName {
1391
+ mmViewTransitionName = input.required(...(ngDevMode ? [{ debugName: "mmViewTransitionName" }] : /* istanbul ignore next */ []));
1392
+ constructor() {
1393
+ const el = inject(ElementRef).nativeElement;
1394
+ effect(() => {
1395
+ const name = normalizeIdent(this.mmViewTransitionName());
1396
+ if (name)
1397
+ el.style.setProperty('view-transition-name', name);
1398
+ else
1399
+ el.style.removeProperty('view-transition-name');
1400
+ });
1401
+ }
1402
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.12", ngImport: i0, type: MmViewTransitionName, deps: [], target: i0.ɵɵFactoryTarget.Directive });
1403
+ static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.2.12", type: MmViewTransitionName, isStandalone: true, selector: "[mmViewTransitionName]", inputs: { mmViewTransitionName: { classPropertyName: "mmViewTransitionName", publicName: "mmViewTransitionName", isSignal: true, isRequired: true, transformFunction: null } }, ngImport: i0 });
1404
+ }
1405
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImport: i0, type: MmViewTransitionName, decorators: [{
1406
+ type: Directive,
1407
+ args: [{ selector: '[mmViewTransitionName]' }]
1408
+ }], ctorParameters: () => [], propDecorators: { mmViewTransitionName: [{ type: i0.Input, args: [{ isSignal: true, alias: "mmViewTransitionName", required: true }] }] } });
1409
+ /** @internal `''`/`'none'` clear; otherwise coerce into a valid custom-ident. */
1410
+ function normalizeIdent(raw) {
1411
+ if (!raw || raw === 'none')
1412
+ return null;
1413
+ const cleaned = raw.replace(/[^a-zA-Z0-9_-]/g, '-');
1414
+ return /^\d/.test(cleaned) ? `_${cleaned}` : cleaned;
1415
+ }
1416
+
1104
1417
  /**
1105
1418
  * @internal
1106
1419
  */
@@ -3950,7 +4263,11 @@ function buildChildNode(target, prop, isMutableSource, options) {
3950
4263
  function toStore(source, { injector, vivify = false, noUnionLeaves = false, ...rest } = {}) {
3951
4264
  if (isStore(source))
3952
4265
  return source;
3953
- if (!injector)
4266
+ // injector is needed ONLY to resolve the two proxy-globals tokens; if a caller supplies the
4267
+ // globals directly (createStoreContext — the worker-side seam with no DI), skip inject entirely
4268
+ const sharedGlobals = rest[STORE_SHARED_GLOBALS];
4269
+ const hasSharedGlobals = !!(sharedGlobals?.cache && sharedGlobals?.registry);
4270
+ if (!injector && !hasSharedGlobals)
3954
4271
  injector = inject(Injector);
3955
4272
  const writableSource = isWritableSignal(source)
3956
4273
  ? source
@@ -3968,13 +4285,18 @@ function toStore(source, { injector, vivify = false, noUnionLeaves = false, ...r
3968
4285
  return 'primitive';
3969
4286
  }, ...(ngDevMode ? [{ debugName: "kind" }] : /* istanbul ignore next */ []));
3970
4287
  const STORE_OPTIONS = {
3971
- injector,
4288
+ // may be undefined in worker/DI-less mode; unused downstream once globals are resolved
4289
+ // (children thread the resolved globals via STORE_SHARED_OPTIONS, derived needs no injector)
4290
+ injector: injector,
3972
4291
  vivify,
3973
4292
  noUnionLeaves,
3974
4293
  [STORE_SHARED_GLOBALS]: {
3975
- cache: rest[STORE_SHARED_GLOBALS]?.cache ?? injector.get(PROXY_CACHE_TOKEN),
3976
- registry: rest[STORE_SHARED_GLOBALS]?.registry ??
3977
- injector.get(PROXY_CLEANUP_TOKEN),
4294
+ // the `injector!` reads run only when a global is absent, which (per hasSharedGlobals) means
4295
+ // an injector was resolved above
4296
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
4297
+ cache: sharedGlobals?.cache ?? injector.get(PROXY_CACHE_TOKEN),
4298
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
4299
+ registry: sharedGlobals?.registry ?? injector.get(PROXY_CLEANUP_TOKEN),
3978
4300
  },
3979
4301
  };
3980
4302
  // built lazily so non-array nodes never allocate it
@@ -4046,7 +4368,14 @@ function toStore(source, { injector, vivify = false, noUnionLeaves = false, ...r
4046
4368
  return () => {
4047
4369
  if (!isWritableSource)
4048
4370
  return s;
4049
- return untracked(() => toStore(source.asReadonly(), { injector, vivify, noUnionLeaves }));
4371
+ return untracked(() => toStore(source.asReadonly(), {
4372
+ injector,
4373
+ vivify,
4374
+ noUnionLeaves,
4375
+ // forward the resolved globals — re-resolving from the injector both re-injects
4376
+ // needlessly and breaks in DI-less (worker) mode where injector is undefined
4377
+ [STORE_SHARED_GLOBALS]: STORE_OPTIONS[STORE_SHARED_GLOBALS],
4378
+ }));
4050
4379
  };
4051
4380
  const k = untracked(kind);
4052
4381
  if (prop === 'extend' && k !== 'array')
@@ -4211,6 +4540,40 @@ function mutableStore(value, opt) {
4211
4540
  ...opt,
4212
4541
  });
4213
4542
  }
4543
+ /**
4544
+ * Builds a DI-less store context — the shared proxy-cache and cleanup registry that {@link toStore}
4545
+ * normally resolves from the injector — so a `store`/`toStore`/`opLog` graph can run with NO Angular
4546
+ * injection context. Spread the result into the options:
4547
+ *
4548
+ * ```ts
4549
+ * import { microtaskOpLogDriver } from '@mmstack/worker/host';
4550
+ * const ctx = createStoreContext();
4551
+ * const s = store({ todos: [] }, ctx);
4552
+ * const log = opLog(s, { driver: microtaskOpLogDriver(), origin: 'worker' }); // no injector anywhere
4553
+ * ```
4554
+ *
4555
+ * **This is a worker-only fallback — do NOT use it on the main thread.** DI is the default and
4556
+ * correct path in an app: the injector scopes the proxy-cache/cleanup singletons per app instance,
4557
+ * which on the SERVER keeps one request's store identity from bleeding into another's (the exact
4558
+ * hazard a module-scope singleton would reintroduce). A Web Worker is safe because it is a single
4559
+ * store graph per thread and never runs during SSR (spawn is a `PLATFORM_ID === 'server'` no-op),
4560
+ * so there is no cross-request scope to contaminate. Never hoist a `createStoreContext()` to module
4561
+ * scope on a shared/main thread.
4562
+ *
4563
+ * **Share ONE context across every store in a worker** — the same way `providedIn: 'root'` shares
4564
+ * one cache across all of an app's stores. `@mmstack/worker/host` memoizes this per worker
4565
+ * (`workerStoreContext()`); reach for `createStoreContext()` directly only in a bare
4566
+ * (non-worker-host) DI-less setup, and hold the single instance yourself.
4567
+ */
4568
+ function createStoreContext() {
4569
+ const cache = new WeakMap();
4570
+ const registry = new FinalizationRegistry(({ target, prop }) => {
4571
+ const entry = cache.get(target);
4572
+ if (entry)
4573
+ entry.delete(prop);
4574
+ });
4575
+ return { [STORE_SHARED_GLOBALS]: { cache, registry } };
4576
+ }
4214
4577
 
4215
4578
  function isPlainRecord(value) {
4216
4579
  if (value === null || typeof value !== 'object')
@@ -4285,6 +4648,322 @@ function forkStore(base, opt) {
4285
4648
  };
4286
4649
  }
4287
4650
 
4651
+ function generateOrigin() {
4652
+ if (globalThis.crypto?.randomUUID)
4653
+ return globalThis.crypto.randomUUID();
4654
+ return Math.random().toString(36).substring(2);
4655
+ }
4656
+ const isPlainArray$1 = (v) => Array.isArray(v) && !isOpaque(v);
4657
+ /**
4658
+ * Reference-identity-pruned structural diff — the same short-circuit discipline as `merge3`:
4659
+ * an untouched subtree kept its reference (the store's copy-on-write contract), so the walk
4660
+ * descends only where refs differ. O(changed paths), not O(tree).
4661
+ */
4662
+ function diffNode(prev, next, path, ops) {
4663
+ if (Object.is(prev, next))
4664
+ return;
4665
+ if (isRecord(prev) && isRecord(next)) {
4666
+ for (const key of Object.keys(prev)) {
4667
+ if (!Object.hasOwn(next, key))
4668
+ ops.push({ kind: 'delete', path: [...path, key], prev: prev[key] });
4669
+ }
4670
+ for (const key of Object.keys(next)) {
4671
+ if (!Object.hasOwn(prev, key)) {
4672
+ // added key: deliberately NO `prev` property (absent ≠ undefined)
4673
+ ops.push({ kind: 'set', path: [...path, key], next: next[key] });
4674
+ }
4675
+ else {
4676
+ diffNode(prev[key], next[key], [...path, key], ops);
4677
+ }
4678
+ }
4679
+ return;
4680
+ }
4681
+ if (isPlainArray$1(prev) && isPlainArray$1(next)) {
4682
+ // same length → per-index descent (matches `arr[i].x.set(...)` writes); a length
4683
+ // change is a whole unit — index attribution lies under insert/remove/reorder
4684
+ if (prev.length === next.length) {
4685
+ for (let i = 0; i < next.length; i++)
4686
+ diffNode(prev[i], next[i], [...path, i], ops);
4687
+ return;
4688
+ }
4689
+ ops.push({ kind: 'set', path, prev, next });
4690
+ return;
4691
+ }
4692
+ // leaf / type change / opaque — one unit, prev present (the slot existed)
4693
+ ops.push({ kind: 'set', path, prev, next });
4694
+ }
4695
+ /** Immutably applies one op along its path, vivifying missing containers `'auto'`-style. */
4696
+ function applyAt(container, path, idx, op) {
4697
+ const seg = path[idx];
4698
+ const base = isPlainArray$1(container)
4699
+ ? container.slice()
4700
+ : isRecord(container)
4701
+ ? { ...container }
4702
+ : typeof seg === 'number'
4703
+ ? []
4704
+ : {};
4705
+ if (idx === path.length - 1) {
4706
+ if (op.kind === 'delete') {
4707
+ // arrays never receive deletes (length changes travel as whole-array sets)
4708
+ delete base[seg];
4709
+ }
4710
+ else {
4711
+ base[seg] = op.next;
4712
+ }
4713
+ return base;
4714
+ }
4715
+ base[seg] = applyAt(base[seg], path, idx + 1, op);
4716
+ return base;
4717
+ }
4718
+ /**
4719
+ * Pure, store-free application of ops onto a plain root value, returning the next immutable root
4720
+ * (structural-sharing along op paths, missing containers vivified `'auto'`-style). This is the
4721
+ * same transform {@link OpLog.apply} runs, extracted so a replica can fold a received batch into
4722
+ * a value WITHOUT owning a diffing {@link opLog} — e.g. the worker-graph read-replica seam.
4723
+ * Accepts a batch or a bare op list.
4724
+ */
4725
+ function applyOps(root, ops) {
4726
+ const list = Array.isArray(ops) ? ops : ops.ops;
4727
+ let next = root;
4728
+ for (const op of list) {
4729
+ if (op.path.length === 0) {
4730
+ if (op.kind === 'set')
4731
+ next = op.next;
4732
+ continue; // a root delete is meaningless — ignore (mirrors OpLog.apply)
4733
+ }
4734
+ next = applyAt(next, op.path, 0, op);
4735
+ }
4736
+ return next;
4737
+ }
4738
+ /**
4739
+ * Pure reference-pruned structural diff of two roots into minimal ops (the emission core of
4740
+ * {@link opLog}, exported so code outside a log can produce a batch — e.g. diffing a scratch
4741
+ * draft against a replica's current value to route a write to its owner). Trusts the
4742
+ * copy-on-write contract: an untouched subtree that kept its reference is skipped.
4743
+ */
4744
+ function diffOps(prev, next) {
4745
+ const ops = [];
4746
+ diffNode(prev, next, [], ops);
4747
+ return ops;
4748
+ }
4749
+ /**
4750
+ * Inverts a batch for undo: reversed order, `set`↔its own inverse (an add — a `set` with no
4751
+ * `prev` — inverts to a `delete`; a `delete` inverts to a `set` restoring `prev`). Feed the
4752
+ * result to {@link OpLog.apply}. Requires the ops' `prev`s, which in-memory batches always
4753
+ * carry — a wire-serialized batch that stripped them is not invertible.
4754
+ */
4755
+ function invertBatch(batch) {
4756
+ const ops = Array.isArray(batch) ? batch : batch.ops;
4757
+ const inverted = [];
4758
+ for (let i = ops.length - 1; i >= 0; i--) {
4759
+ const op = ops[i];
4760
+ if (op.kind === 'delete') {
4761
+ inverted.push({
4762
+ kind: 'set',
4763
+ path: op.path,
4764
+ next: op.prev,
4765
+ prev: undefined,
4766
+ });
4767
+ continue;
4768
+ }
4769
+ if (!Object.hasOwn(op, 'prev')) {
4770
+ inverted.push({ kind: 'delete', path: op.path, prev: op.next });
4771
+ }
4772
+ else {
4773
+ inverted.push({
4774
+ kind: 'set',
4775
+ path: op.path,
4776
+ next: op.prev,
4777
+ prev: op.next,
4778
+ });
4779
+ }
4780
+ }
4781
+ return inverted;
4782
+ }
4783
+ /**
4784
+ * Observes a copy-on-write signal (a `store`'s root, or any `WritableSignal` holding
4785
+ * immutably-updated objects) and emits its changes as minimal structural op batches — the
4786
+ * shared substrate for sync (ship batches, `apply` remote ones), persistence (journal
4787
+ * batches, replay on boot), undo ({@link invertBatch}), and devtools (`latest`).
4788
+ *
4789
+ * Zero store-core involvement and zero cost when unused: emission is a reference-pruned diff
4790
+ * of the root value per tick (structural sharing makes it O(changed paths)), driven by one
4791
+ * effect. A batch therefore coalesces everything written in one tick — for coarser,
4792
+ * intentional units, stage writes on a `forkStore` and `commit()` (one set → one batch).
4793
+ *
4794
+ * NOT supported on mutable stores/signals: in-place mutation keeps reference identity, which
4795
+ * defeats the diff (same reason `forkStore`'s `'fine'` strategy refuses them) — a dev-mode
4796
+ * warning fires and nothing emits.
4797
+ *
4798
+ * ```ts
4799
+ * const s = store({ todos: [{ done: false }] });
4800
+ * const log = opLog(s, { origin: 'tab-a' });
4801
+ * log.subscribe((b) => channel.postMessage(encode(b))); // ship
4802
+ * channel.onmessage = (m) => log.apply(decode(m.data)); // apply — echo-free
4803
+ * s.todos[0].done.set(true); // → { kind: 'set', path: ['todos', 0, 'done'], … }
4804
+ * ```
4805
+ */
4806
+ function opLog(source, opt) {
4807
+ const origin = opt?.origin ?? generateOrigin();
4808
+ // a store proxy's `has` trap answers for the VALUE's keys, so `isMutable`'s `'mutate' in`
4809
+ // probe can't see the brand — ask the store's own kind symbol first
4810
+ const storeKind = source[STORE_KIND];
4811
+ const mutableSource = storeKind ? storeKind === 'mutable' : isMutable(source);
4812
+ if (isDevMode() && mutableSource) {
4813
+ 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.');
4814
+ }
4815
+ let prevRoot = untracked(source);
4816
+ let version = 0;
4817
+ let destroyed = false;
4818
+ const subscribers = new Set();
4819
+ const latest = signal(null, ...(ngDevMode ? [{ debugName: "latest" }] : /* istanbul ignore next */ []));
4820
+ /** Diff now, emit if there's a delta, advance the baseline. */
4821
+ const flush = () => {
4822
+ if (destroyed)
4823
+ return;
4824
+ const next = untracked(source);
4825
+ if (Object.is(prevRoot, next))
4826
+ return;
4827
+ const ops = [];
4828
+ diffNode(prevRoot, next, [], ops);
4829
+ prevRoot = next;
4830
+ if (!ops.length)
4831
+ return; // fresh refs, equal values — spurious-write tolerance
4832
+ const batch = { origin, version: ++version, ops };
4833
+ latest.set(batch);
4834
+ for (const cb of [...subscribers])
4835
+ cb(batch);
4836
+ };
4837
+ const run = () => {
4838
+ source(); // track every commit…
4839
+ untracked(flush); // …and emit the delta since the last flush
4840
+ };
4841
+ // default driver is an Angular effect (needs an injector); a supplied driver runs injector-free
4842
+ // (the worker-side seam, e.g. microtaskOpLogDriver from @mmstack/worker/host)
4843
+ const ref = opt?.driver
4844
+ ? opt.driver(run)
4845
+ : effect(run, { injector: opt?.injector ?? inject(Injector) });
4846
+ return {
4847
+ latest: latest.asReadonly(),
4848
+ subscribe: (cb) => {
4849
+ subscribers.add(cb);
4850
+ return () => subscribers.delete(cb);
4851
+ },
4852
+ // the emission core, callable on demand — reads the source untracked, so it never disturbs the
4853
+ // driver's subscription; a subsequent scheduled run just finds the baseline already advanced
4854
+ flush: () => flush(),
4855
+ apply: (batchOrOps) => {
4856
+ const ops = Array.isArray(batchOrOps)
4857
+ ? batchOrOps
4858
+ : batchOrOps.ops;
4859
+ if (!ops.length)
4860
+ return;
4861
+ // pending local writes must emit BEFORE the baseline advances past them
4862
+ flush();
4863
+ const root = applyOps(untracked(source), ops); // one atomic root, structural-shared
4864
+ source.set(root);
4865
+ prevRoot = root; // baseline advance: an applied batch never echoes
4866
+ },
4867
+ destroy: () => {
4868
+ destroyed = true;
4869
+ subscribers.clear();
4870
+ ref.destroy();
4871
+ },
4872
+ };
4873
+ }
4874
+
4875
+ const isPlainArray = (v) => Array.isArray(v) && !isOpaque(v);
4876
+ function keyOf(item, key) {
4877
+ if (typeof key === 'function')
4878
+ return key(item);
4879
+ return isRecord(item) ? item[key] : item;
4880
+ }
4881
+ /**
4882
+ * Produces a value equal to `next` but sharing as much of `prev`'s reference structure as possible:
4883
+ * an object subtree that did not change keeps its `prev` reference, and array items are matched by
4884
+ * `key` so a surviving item keeps its identity across a reorder/insert/remove (only added items are
4885
+ * new, only removed items are dropped). This is what lets a derived store recompute without tearing
4886
+ * down every downstream `computed` that reads an unchanged part of it.
4887
+ */
4888
+ function reconcile(prev, next, key = 'id') {
4889
+ return reconcileValue(prev, next, key);
4890
+ }
4891
+ function reconcileValue(prev, next, key) {
4892
+ if (Object.is(prev, next))
4893
+ return prev;
4894
+ if (isPlainArray(prev) && isPlainArray(next)) {
4895
+ const byKey = new Map();
4896
+ for (const item of prev)
4897
+ byKey.set(keyOf(item, key), item);
4898
+ let changed = prev.length !== next.length;
4899
+ const out = next.map((item, i) => {
4900
+ const match = byKey.get(keyOf(item, key));
4901
+ const rv = match !== undefined ? reconcileValue(match, item, key) : item;
4902
+ if (rv !== prev[i])
4903
+ changed = true;
4904
+ return rv;
4905
+ });
4906
+ return changed ? out : prev;
4907
+ }
4908
+ if (isRecord(prev) && isRecord(next)) {
4909
+ const nextKeys = Object.keys(next);
4910
+ let changed = Object.keys(prev).length !== nextKeys.length;
4911
+ const out = {};
4912
+ for (const k of nextKeys) {
4913
+ const rv = Object.hasOwn(prev, k)
4914
+ ? reconcileValue(prev[k], next[k], key)
4915
+ : next[k];
4916
+ out[k] = rv;
4917
+ if (rv !== prev[k])
4918
+ changed = true;
4919
+ }
4920
+ return changed ? out : prev;
4921
+ }
4922
+ return next;
4923
+ }
4924
+ /**
4925
+ * A derived STORE, the store-shaped counterpart to `computed`. `fn` receives a mutable draft seeded
4926
+ * with the current value and either mutates it in place or returns a new value; whichever it does,
4927
+ * the result is reconciled against the previous value (see {@link reconcile}) so unchanged subtrees
4928
+ * keep reference identity and keyed array items keep their proxy identity. Reading through the
4929
+ * returned store is fine-grained: a `computed` over one field only recomputes when that field
4930
+ * actually changes, even though the whole projection re-ran.
4931
+ *
4932
+ * Recompute is pull-based, exactly like `computed`: the projection is memoized and re-runs on the
4933
+ * first read after a signal `fn` depends on changes, so reads are always coherent (no waiting on an
4934
+ * effect flush) and nothing recomputes while nobody reads. `fn` must be pure, it runs inside the
4935
+ * reactive computation. Prefer `computed` for a plain value; reach for `projection` when you want
4936
+ * the per-property tracking of a store on top of a derivation.
4937
+ *
4938
+ * ```ts
4939
+ * const active = projection<User[]>(() => users().filter((u) => u.active), [], { key: 'id' });
4940
+ * // active[0].name(); — surviving users keep identity across recomputes
4941
+ * ```
4942
+ *
4943
+ * Needs an injection context (or an explicit `injector`) for the store layer's cleanup on the main
4944
+ * thread; with an explicit store context (`createStoreContext()`) it is injector-free, so it also
4945
+ * runs on a worker host.
4946
+ *
4947
+ * @param fn receives the current draft; mutate it, or return new data.
4948
+ * @param seed the initial value, held before the first run.
4949
+ */
4950
+ function projection(fn, seed, opt) {
4951
+ const { key = 'id', ...storeOpt } = opt ?? {};
4952
+ // linkedSignal rather than an effect-driven signal: the computation runs in the tracked
4953
+ // context (fn's reads are dependencies) and `previous` hands back the last emitted value for
4954
+ // the reconcile, so the projection is glitch-free, lazy, and needs no effect scheduler.
4955
+ const root = linkedSignal({ ...(ngDevMode ? { debugName: "root" } : /* istanbul ignore next */ {}), source: () => undefined,
4956
+ computation: (_, previous) => {
4957
+ const base = previous ? previous.value : seed;
4958
+ // a plain mutable scratch seeded with the current value; fn mutates it or returns new data
4959
+ const draft = structuredClone(base);
4960
+ const returned = fn(draft);
4961
+ const next = (returned === undefined ? draft : returned);
4962
+ return reconcile(base, next, key);
4963
+ } });
4964
+ return toStore(root, storeOpt).asReadonlyStore();
4965
+ }
4966
+
4288
4967
  /**
4289
4968
  * @internal The plain-`effect` sibling of the public {@link pausableEffect} (which is built on
4290
4969
  * `nestedEffect`). For infra utilities that own a single top-level effect/subscription and don't
@@ -4810,5 +5489,5 @@ function withHistory(sourceOrValue, opt) {
4810
5489
  * Generated bundle index. Do not edit.
4811
5490
  */
4812
5491
 
4813
- 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 };
5492
+ export { MmActivity, MmTransition, MmViewTransitionName, PAUSABLE_OPTIONS, SuspenseBoundary, SuspenseBoundaryBase, UnscopedSuspenseBoundary, activeTransaction, applyOps, batteryStatus, bridgeScopeToPendingTasks, chunked, clipboard, combineWith, createAttributedPending, createForwardingScope, createStoreContext, createTransaction, createTransitionScope, debounce, debounced, deferredValue, derived, diffOps, 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, projection, provideForwardingTransitionScope, providePausableOptions, providePaused, provideTransitionScope, reconcile, registerResource, resolvePause, scan, scrollPosition, select, sensor, sensors, signalFromEvent, startWith, store, stored, tabSync, tap, throttle, throttled, toFakeDerivation, toFakeSignalDerivation, toStore, toWritable, until, use, windowSize, withHistory };
4814
5493
  //# sourceMappingURL=mmstack-primitives.mjs.map