@mmstack/primitives 20.9.1 → 20.10.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.
- package/README.md +105 -3
- package/fesm2022/mmstack-primitives.mjs +709 -14
- package/fesm2022/mmstack-primitives.mjs.map +1 -1
- package/index.d.ts +424 -10
- package/package.json +1 -1
|
@@ -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,
|
|
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 {
|
|
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 {
|
|
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
|
*/
|
|
@@ -3971,7 +4290,11 @@ function buildChildNode(target, prop, isMutableSource, options) {
|
|
|
3971
4290
|
function toStore(source, { injector, vivify = false, noUnionLeaves = false, ...rest } = {}) {
|
|
3972
4291
|
if (isStore(source))
|
|
3973
4292
|
return source;
|
|
3974
|
-
if
|
|
4293
|
+
// injector is needed ONLY to resolve the two proxy-globals tokens; if a caller supplies the
|
|
4294
|
+
// globals directly (createStoreContext — the worker-side seam with no DI), skip inject entirely
|
|
4295
|
+
const sharedGlobals = rest[STORE_SHARED_GLOBALS];
|
|
4296
|
+
const hasSharedGlobals = !!(sharedGlobals?.cache && sharedGlobals?.registry);
|
|
4297
|
+
if (!injector && !hasSharedGlobals)
|
|
3975
4298
|
injector = inject(Injector);
|
|
3976
4299
|
const writableSource = isWritableSignal(source)
|
|
3977
4300
|
? source
|
|
@@ -3989,13 +4312,18 @@ function toStore(source, { injector, vivify = false, noUnionLeaves = false, ...r
|
|
|
3989
4312
|
return 'primitive';
|
|
3990
4313
|
}, ...(ngDevMode ? [{ debugName: "kind" }] : []));
|
|
3991
4314
|
const STORE_OPTIONS = {
|
|
3992
|
-
|
|
4315
|
+
// may be undefined in worker/DI-less mode; unused downstream once globals are resolved
|
|
4316
|
+
// (children thread the resolved globals via STORE_SHARED_OPTIONS, derived needs no injector)
|
|
4317
|
+
injector: injector,
|
|
3993
4318
|
vivify,
|
|
3994
4319
|
noUnionLeaves,
|
|
3995
4320
|
[STORE_SHARED_GLOBALS]: {
|
|
3996
|
-
|
|
3997
|
-
|
|
3998
|
-
|
|
4321
|
+
// the `injector!` reads run only when a global is absent, which (per hasSharedGlobals) means
|
|
4322
|
+
// an injector was resolved above
|
|
4323
|
+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
4324
|
+
cache: sharedGlobals?.cache ?? injector.get(PROXY_CACHE_TOKEN),
|
|
4325
|
+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
4326
|
+
registry: sharedGlobals?.registry ?? injector.get(PROXY_CLEANUP_TOKEN),
|
|
3999
4327
|
},
|
|
4000
4328
|
};
|
|
4001
4329
|
// built lazily so non-array nodes never allocate it
|
|
@@ -4067,7 +4395,14 @@ function toStore(source, { injector, vivify = false, noUnionLeaves = false, ...r
|
|
|
4067
4395
|
return () => {
|
|
4068
4396
|
if (!isWritableSource)
|
|
4069
4397
|
return s;
|
|
4070
|
-
return untracked(() => toStore(source.asReadonly(), {
|
|
4398
|
+
return untracked(() => toStore(source.asReadonly(), {
|
|
4399
|
+
injector,
|
|
4400
|
+
vivify,
|
|
4401
|
+
noUnionLeaves,
|
|
4402
|
+
// forward the resolved globals — re-resolving from the injector both re-injects
|
|
4403
|
+
// needlessly and breaks in DI-less (worker) mode where injector is undefined
|
|
4404
|
+
[STORE_SHARED_GLOBALS]: STORE_OPTIONS[STORE_SHARED_GLOBALS],
|
|
4405
|
+
}));
|
|
4071
4406
|
};
|
|
4072
4407
|
const k = untracked(kind);
|
|
4073
4408
|
if (prop === 'extend' && k !== 'array')
|
|
@@ -4232,6 +4567,40 @@ function mutableStore(value, opt) {
|
|
|
4232
4567
|
...opt,
|
|
4233
4568
|
});
|
|
4234
4569
|
}
|
|
4570
|
+
/**
|
|
4571
|
+
* Builds a DI-less store context — the shared proxy-cache and cleanup registry that {@link toStore}
|
|
4572
|
+
* normally resolves from the injector — so a `store`/`toStore`/`opLog` graph can run with NO Angular
|
|
4573
|
+
* injection context. Spread the result into the options:
|
|
4574
|
+
*
|
|
4575
|
+
* ```ts
|
|
4576
|
+
* import { microtaskOpLogDriver } from '@mmstack/worker/host';
|
|
4577
|
+
* const ctx = createStoreContext();
|
|
4578
|
+
* const s = store({ todos: [] }, ctx);
|
|
4579
|
+
* const log = opLog(s, { driver: microtaskOpLogDriver(), origin: 'worker' }); // no injector anywhere
|
|
4580
|
+
* ```
|
|
4581
|
+
*
|
|
4582
|
+
* **This is a worker-only fallback — do NOT use it on the main thread.** DI is the default and
|
|
4583
|
+
* correct path in an app: the injector scopes the proxy-cache/cleanup singletons per app instance,
|
|
4584
|
+
* which on the SERVER keeps one request's store identity from bleeding into another's (the exact
|
|
4585
|
+
* hazard a module-scope singleton would reintroduce). A Web Worker is safe because it is a single
|
|
4586
|
+
* store graph per thread and never runs during SSR (spawn is a `PLATFORM_ID === 'server'` no-op),
|
|
4587
|
+
* so there is no cross-request scope to contaminate. Never hoist a `createStoreContext()` to module
|
|
4588
|
+
* scope on a shared/main thread.
|
|
4589
|
+
*
|
|
4590
|
+
* **Share ONE context across every store in a worker** — the same way `providedIn: 'root'` shares
|
|
4591
|
+
* one cache across all of an app's stores. `@mmstack/worker/host` memoizes this per worker
|
|
4592
|
+
* (`workerStoreContext()`); reach for `createStoreContext()` directly only in a bare
|
|
4593
|
+
* (non-worker-host) DI-less setup, and hold the single instance yourself.
|
|
4594
|
+
*/
|
|
4595
|
+
function createStoreContext() {
|
|
4596
|
+
const cache = new WeakMap();
|
|
4597
|
+
const registry = new FinalizationRegistry(({ target, prop }) => {
|
|
4598
|
+
const entry = cache.get(target);
|
|
4599
|
+
if (entry)
|
|
4600
|
+
entry.delete(prop);
|
|
4601
|
+
});
|
|
4602
|
+
return { [STORE_SHARED_GLOBALS]: { cache, registry } };
|
|
4603
|
+
}
|
|
4235
4604
|
|
|
4236
4605
|
function isPlainRecord(value) {
|
|
4237
4606
|
if (value === null || typeof value !== 'object')
|
|
@@ -4309,6 +4678,332 @@ function forkStore(base, opt) {
|
|
|
4309
4678
|
};
|
|
4310
4679
|
}
|
|
4311
4680
|
|
|
4681
|
+
function generateOrigin() {
|
|
4682
|
+
if (globalThis.crypto?.randomUUID)
|
|
4683
|
+
return globalThis.crypto.randomUUID();
|
|
4684
|
+
return Math.random().toString(36).substring(2);
|
|
4685
|
+
}
|
|
4686
|
+
const isPlainArray$1 = (v) => Array.isArray(v) && !isOpaque(v);
|
|
4687
|
+
/**
|
|
4688
|
+
* Reference-identity-pruned structural diff — the same short-circuit discipline as `merge3`:
|
|
4689
|
+
* an untouched subtree kept its reference (the store's copy-on-write contract), so the walk
|
|
4690
|
+
* descends only where refs differ. O(changed paths), not O(tree).
|
|
4691
|
+
*/
|
|
4692
|
+
function diffNode(prev, next, path, ops) {
|
|
4693
|
+
if (Object.is(prev, next))
|
|
4694
|
+
return;
|
|
4695
|
+
if (isRecord(prev) && isRecord(next)) {
|
|
4696
|
+
for (const key of Object.keys(prev)) {
|
|
4697
|
+
if (!Object.hasOwn(next, key))
|
|
4698
|
+
ops.push({ kind: 'delete', path: [...path, key], prev: prev[key] });
|
|
4699
|
+
}
|
|
4700
|
+
for (const key of Object.keys(next)) {
|
|
4701
|
+
if (!Object.hasOwn(prev, key)) {
|
|
4702
|
+
// added key: deliberately NO `prev` property (absent ≠ undefined)
|
|
4703
|
+
ops.push({ kind: 'set', path: [...path, key], next: next[key] });
|
|
4704
|
+
}
|
|
4705
|
+
else {
|
|
4706
|
+
diffNode(prev[key], next[key], [...path, key], ops);
|
|
4707
|
+
}
|
|
4708
|
+
}
|
|
4709
|
+
return;
|
|
4710
|
+
}
|
|
4711
|
+
if (isPlainArray$1(prev) && isPlainArray$1(next)) {
|
|
4712
|
+
// same length → per-index descent (matches `arr[i].x.set(...)` writes); a length
|
|
4713
|
+
// change is a whole unit — index attribution lies under insert/remove/reorder
|
|
4714
|
+
if (prev.length === next.length) {
|
|
4715
|
+
for (let i = 0; i < next.length; i++)
|
|
4716
|
+
diffNode(prev[i], next[i], [...path, i], ops);
|
|
4717
|
+
return;
|
|
4718
|
+
}
|
|
4719
|
+
ops.push({ kind: 'set', path, prev, next });
|
|
4720
|
+
return;
|
|
4721
|
+
}
|
|
4722
|
+
// leaf / type change / opaque — one unit, prev present (the slot existed)
|
|
4723
|
+
ops.push({ kind: 'set', path, prev, next });
|
|
4724
|
+
}
|
|
4725
|
+
/** Immutably applies one op along its path, vivifying missing containers `'auto'`-style. */
|
|
4726
|
+
function applyAt(container, path, idx, op) {
|
|
4727
|
+
const seg = path[idx];
|
|
4728
|
+
const base = isPlainArray$1(container)
|
|
4729
|
+
? container.slice()
|
|
4730
|
+
: isRecord(container)
|
|
4731
|
+
? { ...container }
|
|
4732
|
+
: typeof seg === 'number'
|
|
4733
|
+
? []
|
|
4734
|
+
: {};
|
|
4735
|
+
if (idx === path.length - 1) {
|
|
4736
|
+
if (op.kind === 'delete') {
|
|
4737
|
+
// arrays never receive deletes (length changes travel as whole-array sets)
|
|
4738
|
+
delete base[seg];
|
|
4739
|
+
}
|
|
4740
|
+
else {
|
|
4741
|
+
base[seg] = op.next;
|
|
4742
|
+
}
|
|
4743
|
+
return base;
|
|
4744
|
+
}
|
|
4745
|
+
base[seg] = applyAt(base[seg], path, idx + 1, op);
|
|
4746
|
+
return base;
|
|
4747
|
+
}
|
|
4748
|
+
/**
|
|
4749
|
+
* Pure, store-free application of ops onto a plain root value, returning the next immutable root
|
|
4750
|
+
* (structural-sharing along op paths, missing containers vivified `'auto'`-style). This is the
|
|
4751
|
+
* same transform {@link OpLog.apply} runs, extracted so a replica can fold a received batch into
|
|
4752
|
+
* a value WITHOUT owning a diffing {@link opLog} — e.g. the worker-graph read-replica seam.
|
|
4753
|
+
* Accepts a batch or a bare op list.
|
|
4754
|
+
*/
|
|
4755
|
+
function applyOps(root, ops) {
|
|
4756
|
+
const list = Array.isArray(ops) ? ops : ops.ops;
|
|
4757
|
+
let next = root;
|
|
4758
|
+
for (const op of list) {
|
|
4759
|
+
if (op.path.length === 0) {
|
|
4760
|
+
if (op.kind === 'set')
|
|
4761
|
+
next = op.next;
|
|
4762
|
+
continue; // a root delete is meaningless — ignore (mirrors OpLog.apply)
|
|
4763
|
+
}
|
|
4764
|
+
next = applyAt(next, op.path, 0, op);
|
|
4765
|
+
}
|
|
4766
|
+
return next;
|
|
4767
|
+
}
|
|
4768
|
+
/**
|
|
4769
|
+
* Pure reference-pruned structural diff of two roots into minimal ops (the emission core of
|
|
4770
|
+
* {@link opLog}, exported so code outside a log can produce a batch — e.g. diffing a scratch
|
|
4771
|
+
* draft against a replica's current value to route a write to its owner). Trusts the
|
|
4772
|
+
* copy-on-write contract: an untouched subtree that kept its reference is skipped.
|
|
4773
|
+
*/
|
|
4774
|
+
function diffOps(prev, next) {
|
|
4775
|
+
const ops = [];
|
|
4776
|
+
diffNode(prev, next, [], ops);
|
|
4777
|
+
return ops;
|
|
4778
|
+
}
|
|
4779
|
+
/**
|
|
4780
|
+
* Inverts a batch for undo: reversed order, `set`↔its own inverse (an add — a `set` with no
|
|
4781
|
+
* `prev` — inverts to a `delete`; a `delete` inverts to a `set` restoring `prev`). Feed the
|
|
4782
|
+
* result to {@link OpLog.apply}. Requires the ops' `prev`s, which in-memory batches always
|
|
4783
|
+
* carry — a wire-serialized batch that stripped them is not invertible.
|
|
4784
|
+
*/
|
|
4785
|
+
function invertBatch(batch) {
|
|
4786
|
+
const ops = Array.isArray(batch) ? batch : batch.ops;
|
|
4787
|
+
const inverted = [];
|
|
4788
|
+
for (let i = ops.length - 1; i >= 0; i--) {
|
|
4789
|
+
const op = ops[i];
|
|
4790
|
+
if (op.kind === 'delete') {
|
|
4791
|
+
inverted.push({
|
|
4792
|
+
kind: 'set',
|
|
4793
|
+
path: op.path,
|
|
4794
|
+
next: op.prev,
|
|
4795
|
+
prev: undefined,
|
|
4796
|
+
});
|
|
4797
|
+
continue;
|
|
4798
|
+
}
|
|
4799
|
+
if (!Object.hasOwn(op, 'prev')) {
|
|
4800
|
+
inverted.push({ kind: 'delete', path: op.path, prev: op.next });
|
|
4801
|
+
}
|
|
4802
|
+
else {
|
|
4803
|
+
inverted.push({
|
|
4804
|
+
kind: 'set',
|
|
4805
|
+
path: op.path,
|
|
4806
|
+
next: op.prev,
|
|
4807
|
+
prev: op.next,
|
|
4808
|
+
});
|
|
4809
|
+
}
|
|
4810
|
+
}
|
|
4811
|
+
return inverted;
|
|
4812
|
+
}
|
|
4813
|
+
/**
|
|
4814
|
+
* Observes a copy-on-write signal (a `store`'s root, or any `WritableSignal` holding
|
|
4815
|
+
* immutably-updated objects) and emits its changes as minimal structural op batches — the
|
|
4816
|
+
* shared substrate for sync (ship batches, `apply` remote ones), persistence (journal
|
|
4817
|
+
* batches, replay on boot), undo ({@link invertBatch}), and devtools (`latest`).
|
|
4818
|
+
*
|
|
4819
|
+
* Zero store-core involvement and zero cost when unused: emission is a reference-pruned diff
|
|
4820
|
+
* of the root value per tick (structural sharing makes it O(changed paths)), driven by one
|
|
4821
|
+
* effect. A batch therefore coalesces everything written in one tick — for coarser,
|
|
4822
|
+
* intentional units, stage writes on a `forkStore` and `commit()` (one set → one batch).
|
|
4823
|
+
*
|
|
4824
|
+
* NOT supported on mutable stores/signals: in-place mutation keeps reference identity, which
|
|
4825
|
+
* defeats the diff (same reason `forkStore`'s `'fine'` strategy refuses them) — a dev-mode
|
|
4826
|
+
* warning fires and nothing emits.
|
|
4827
|
+
*
|
|
4828
|
+
* ```ts
|
|
4829
|
+
* const s = store({ todos: [{ done: false }] });
|
|
4830
|
+
* const log = opLog(s, { origin: 'tab-a' });
|
|
4831
|
+
* log.subscribe((b) => channel.postMessage(encode(b))); // ship
|
|
4832
|
+
* channel.onmessage = (m) => log.apply(decode(m.data)); // apply — echo-free
|
|
4833
|
+
* s.todos[0].done.set(true); // → { kind: 'set', path: ['todos', 0, 'done'], … }
|
|
4834
|
+
* ```
|
|
4835
|
+
*/
|
|
4836
|
+
function opLog(source, opt) {
|
|
4837
|
+
const origin = opt?.origin ?? generateOrigin();
|
|
4838
|
+
// a store proxy's `has` trap answers for the VALUE's keys, so `isMutable`'s `'mutate' in`
|
|
4839
|
+
// probe can't see the brand — ask the store's own kind symbol first
|
|
4840
|
+
const storeKind = source[STORE_KIND];
|
|
4841
|
+
const mutableSource = storeKind ? storeKind === 'mutable' : isMutable(source);
|
|
4842
|
+
if (isDevMode() && mutableSource) {
|
|
4843
|
+
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.');
|
|
4844
|
+
}
|
|
4845
|
+
let prevRoot = untracked(source);
|
|
4846
|
+
let version = 0;
|
|
4847
|
+
let destroyed = false;
|
|
4848
|
+
const subscribers = new Set();
|
|
4849
|
+
const latest = signal(null, ...(ngDevMode ? [{ debugName: "latest" }] : []));
|
|
4850
|
+
/** Diff now, emit if there's a delta, advance the baseline. */
|
|
4851
|
+
const flush = () => {
|
|
4852
|
+
if (destroyed)
|
|
4853
|
+
return;
|
|
4854
|
+
const next = untracked(source);
|
|
4855
|
+
if (Object.is(prevRoot, next))
|
|
4856
|
+
return;
|
|
4857
|
+
const ops = [];
|
|
4858
|
+
diffNode(prevRoot, next, [], ops);
|
|
4859
|
+
prevRoot = next;
|
|
4860
|
+
if (!ops.length)
|
|
4861
|
+
return; // fresh refs, equal values — spurious-write tolerance
|
|
4862
|
+
const batch = { origin, version: ++version, ops };
|
|
4863
|
+
latest.set(batch);
|
|
4864
|
+
for (const cb of [...subscribers])
|
|
4865
|
+
cb(batch);
|
|
4866
|
+
};
|
|
4867
|
+
const run = () => {
|
|
4868
|
+
source(); // track every commit…
|
|
4869
|
+
untracked(flush); // …and emit the delta since the last flush
|
|
4870
|
+
};
|
|
4871
|
+
// default driver is an Angular effect (needs an injector); a supplied driver runs injector-free
|
|
4872
|
+
// (the worker-side seam, e.g. microtaskOpLogDriver from @mmstack/worker/host)
|
|
4873
|
+
const ref = opt?.driver
|
|
4874
|
+
? opt.driver(run)
|
|
4875
|
+
: effect(run, { injector: opt?.injector ?? inject(Injector) });
|
|
4876
|
+
return {
|
|
4877
|
+
latest: latest.asReadonly(),
|
|
4878
|
+
subscribe: (cb) => {
|
|
4879
|
+
subscribers.add(cb);
|
|
4880
|
+
return () => subscribers.delete(cb);
|
|
4881
|
+
},
|
|
4882
|
+
// the emission core, callable on demand — reads the source untracked, so it never disturbs the
|
|
4883
|
+
// driver's subscription; a subsequent scheduled run just finds the baseline already advanced
|
|
4884
|
+
flush: () => flush(),
|
|
4885
|
+
apply: (batchOrOps) => {
|
|
4886
|
+
const ops = Array.isArray(batchOrOps)
|
|
4887
|
+
? batchOrOps
|
|
4888
|
+
: batchOrOps.ops;
|
|
4889
|
+
if (!ops.length)
|
|
4890
|
+
return;
|
|
4891
|
+
// pending local writes must emit BEFORE the baseline advances past them
|
|
4892
|
+
flush();
|
|
4893
|
+
const root = applyOps(untracked(source), ops); // one atomic root, structural-shared
|
|
4894
|
+
source.set(root);
|
|
4895
|
+
prevRoot = root; // baseline advance: an applied batch never echoes
|
|
4896
|
+
},
|
|
4897
|
+
destroy: () => {
|
|
4898
|
+
destroyed = true;
|
|
4899
|
+
subscribers.clear();
|
|
4900
|
+
ref.destroy();
|
|
4901
|
+
},
|
|
4902
|
+
};
|
|
4903
|
+
}
|
|
4904
|
+
|
|
4905
|
+
const isPlainArray = (v) => Array.isArray(v) && !isOpaque(v);
|
|
4906
|
+
function keyOf(item, key) {
|
|
4907
|
+
if (typeof key === 'function')
|
|
4908
|
+
return key(item);
|
|
4909
|
+
return isRecord(item) ? item[key] : item;
|
|
4910
|
+
}
|
|
4911
|
+
/**
|
|
4912
|
+
* Produces a value equal to `next` but sharing as much of `prev`'s reference structure as possible:
|
|
4913
|
+
* an object subtree that did not change keeps its `prev` reference, and array items are matched by
|
|
4914
|
+
* `key` so a surviving item keeps its identity across a reorder/insert/remove (only added items are
|
|
4915
|
+
* new, only removed items are dropped). This is what lets a derived store recompute without tearing
|
|
4916
|
+
* down every downstream `computed` that reads an unchanged part of it.
|
|
4917
|
+
*/
|
|
4918
|
+
function reconcile(prev, next, key = 'id') {
|
|
4919
|
+
return reconcileValue(prev, next, key);
|
|
4920
|
+
}
|
|
4921
|
+
function reconcileValue(prev, next, key) {
|
|
4922
|
+
if (Object.is(prev, next))
|
|
4923
|
+
return prev;
|
|
4924
|
+
if (isPlainArray(prev) && isPlainArray(next)) {
|
|
4925
|
+
const byKey = new Map();
|
|
4926
|
+
for (const item of prev)
|
|
4927
|
+
byKey.set(keyOf(item, key), item);
|
|
4928
|
+
let changed = prev.length !== next.length;
|
|
4929
|
+
const out = next.map((item, i) => {
|
|
4930
|
+
const match = byKey.get(keyOf(item, key));
|
|
4931
|
+
const rv = match !== undefined ? reconcileValue(match, item, key) : item;
|
|
4932
|
+
if (rv !== prev[i])
|
|
4933
|
+
changed = true;
|
|
4934
|
+
return rv;
|
|
4935
|
+
});
|
|
4936
|
+
return changed ? out : prev;
|
|
4937
|
+
}
|
|
4938
|
+
if (isRecord(prev) && isRecord(next)) {
|
|
4939
|
+
const nextKeys = Object.keys(next);
|
|
4940
|
+
let changed = Object.keys(prev).length !== nextKeys.length;
|
|
4941
|
+
const out = {};
|
|
4942
|
+
for (const k of nextKeys) {
|
|
4943
|
+
const rv = Object.hasOwn(prev, k)
|
|
4944
|
+
? reconcileValue(prev[k], next[k], key)
|
|
4945
|
+
: next[k];
|
|
4946
|
+
out[k] = rv;
|
|
4947
|
+
if (rv !== prev[k])
|
|
4948
|
+
changed = true;
|
|
4949
|
+
}
|
|
4950
|
+
return changed ? out : prev;
|
|
4951
|
+
}
|
|
4952
|
+
return next;
|
|
4953
|
+
}
|
|
4954
|
+
/**
|
|
4955
|
+
* A derived STORE, the store-shaped counterpart to `computed`. `fn` receives a mutable draft seeded
|
|
4956
|
+
* with the current value and either mutates it in place or returns a new value; whichever it does,
|
|
4957
|
+
* the result is reconciled against the previous value (see {@link reconcile}) so unchanged subtrees
|
|
4958
|
+
* keep reference identity and keyed array items keep their proxy identity. Reading through the
|
|
4959
|
+
* returned store is fine-grained: a `computed` over one field only recomputes when that field
|
|
4960
|
+
* actually changes, even though the whole projection re-ran.
|
|
4961
|
+
*
|
|
4962
|
+
* Recompute is pull-based, exactly like `computed`: the projection is memoized and re-runs on the
|
|
4963
|
+
* first read after a signal `fn` depends on changes, so reads are always coherent (no waiting on an
|
|
4964
|
+
* effect flush) and nothing recomputes while nobody reads. `fn` must be pure, it runs inside the
|
|
4965
|
+
* reactive computation. Prefer `computed` for a plain value; reach for `projection` when you want
|
|
4966
|
+
* the per-property tracking of a store on top of a derivation.
|
|
4967
|
+
*
|
|
4968
|
+
* ```ts
|
|
4969
|
+
* const active = projection<User[]>(() => users().filter((u) => u.active), [], { key: 'id' });
|
|
4970
|
+
* // active[0].name(); — surviving users keep identity across recomputes
|
|
4971
|
+
* ```
|
|
4972
|
+
*
|
|
4973
|
+
* Needs an injection context (or an explicit `injector`) for the store layer's cleanup on the main
|
|
4974
|
+
* thread; with an explicit store context (`createStoreContext()`) it is injector-free, so it also
|
|
4975
|
+
* runs on a worker host.
|
|
4976
|
+
*
|
|
4977
|
+
* @param fn receives the current draft; mutate it, or return new data.
|
|
4978
|
+
* @param seed the initial value, held before the first run.
|
|
4979
|
+
*/
|
|
4980
|
+
function projection(fn, seed, opt) {
|
|
4981
|
+
const { key = 'id', ...storeOpt } = opt ?? {};
|
|
4982
|
+
// linkedSignal rather than an effect-driven signal: the computation runs in the tracked
|
|
4983
|
+
// context (fn's reads are dependencies) and `previous` hands back the last emitted value for
|
|
4984
|
+
// the reconcile, so the projection is glitch-free, lazy, and needs no effect scheduler.
|
|
4985
|
+
const root = linkedSignal(...(ngDevMode ? [{ debugName: "root", source: () => undefined,
|
|
4986
|
+
computation: (_, previous) => {
|
|
4987
|
+
const base = previous ? previous.value : seed;
|
|
4988
|
+
// a plain mutable scratch seeded with the current value; fn mutates it or returns new data
|
|
4989
|
+
const draft = structuredClone(base);
|
|
4990
|
+
const returned = fn(draft);
|
|
4991
|
+
const next = (returned === undefined ? draft : returned);
|
|
4992
|
+
return reconcile(base, next, key);
|
|
4993
|
+
} }] : [{
|
|
4994
|
+
source: () => undefined,
|
|
4995
|
+
computation: (_, previous) => {
|
|
4996
|
+
const base = previous ? previous.value : seed;
|
|
4997
|
+
// a plain mutable scratch seeded with the current value; fn mutates it or returns new data
|
|
4998
|
+
const draft = structuredClone(base);
|
|
4999
|
+
const returned = fn(draft);
|
|
5000
|
+
const next = (returned === undefined ? draft : returned);
|
|
5001
|
+
return reconcile(base, next, key);
|
|
5002
|
+
},
|
|
5003
|
+
}]));
|
|
5004
|
+
return toStore(root, storeOpt).asReadonlyStore();
|
|
5005
|
+
}
|
|
5006
|
+
|
|
4312
5007
|
/**
|
|
4313
5008
|
* @internal The plain-`effect` sibling of the public {@link pausableEffect} (which is built on
|
|
4314
5009
|
* `nestedEffect`). For infra utilities that own a single top-level effect/subscription and don't
|
|
@@ -4843,5 +5538,5 @@ function withHistory(sourceOrValue, opt) {
|
|
|
4843
5538
|
* Generated bundle index. Do not edit.
|
|
4844
5539
|
*/
|
|
4845
5540
|
|
|
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 };
|
|
5541
|
+
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 };
|
|
4847
5542
|
//# sourceMappingURL=mmstack-primitives.mjs.map
|