@mmstack/primitives 19.7.0 → 19.7.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 CHANGED
@@ -19,7 +19,7 @@ npm install @mmstack/primitives
19
19
  - [Timing & propagation](#timing--propagation) — `debounced`, `throttled`, `until`
20
20
  - [Reactive collections](#reactive-collections) — `indexArray`, `keyArray`, `mapObject`
21
21
  - [Effects](#effects) — `nestedEffect`
22
- - [Concurrency & transitions](#concurrency--transitions) — `keepPrevious`, keep-alive (`MmActivity`), `pausable*` / `providePausableOptions`, Suspense (`mm-suspense`), `startTransition` / `startTransaction`, `holdUntilReady`
22
+ - [Concurrency & transitions](#concurrency--transitions) — `keepPrevious`, keep-alive (`MmActivity`), `pausable*` / `providePausableOptions`, Suspense (`mm-suspense`), hold-and-swap (`*mmTransition`), `startTransition` / `startTransaction`, `holdUntilReady`
23
23
  - [History & persistence](#history--persistence) — `withHistory`, `stored`, `tabSync`
24
24
  - [Performance helpers](#performance-helpers) — `chunked`, `pooled` / `pooledArray` / `pooledMap` / `pooledSet`
25
25
  - [Sensors](#sensors) — `sensor()` facade + browser-state signals
@@ -441,6 +441,25 @@ This is also the pattern for coordinating resources registered _above_ a boundar
441
441
 
442
442
  **Forwarding scope (advanced).** `provideForwardingTransitionScope()` provides a scope that can be **re-pointed at a different target at runtime** via `setTarget(scope | null)` — reads follow the current target, while `add`/`remove` pin to the target a resource was registered under (so re-pointing never strands a registration). It's the building block for a coordinator that hosts several independent sub-scopes and switches which one it observes — e.g. a router outlet that, per navigation, points at the incoming route's own scope (read it from any injector with `getTransitionScope(injector)`). Most apps reach for `provideTransitionScope()`; this is for that one extra level of control.
443
443
 
444
+ ### Hold-and-swap — `*mmTransition`
445
+
446
+ The transition itself, for any branch change — tabs, wizard steps, master-detail. Suspense decides placeholder-vs-content _within_ a branch, but it can't stop an `@switch` from unmounting the old branch the instant the value flips. `*mmTransition` holds it: when the bound value changes, the **old view stays mounted and visible** (keeping its old value) while the **new view mounts hidden with its own transition scope**; resources created in the incoming subtree register there just by existing, and once they've gone in flight and settled the views swap in one frame.
447
+
448
+ ```html
449
+ <div *mmTransition="selectedTab(); let tab">
450
+ @switch (tab) {
451
+ @case ('overview') {
452
+ <overview-pane />
453
+ }
454
+ @case ('activity') {
455
+ <activity-pane />
456
+ }
457
+ }
458
+ </div>
459
+ ```
460
+
461
+ The first render is immediate (nothing to hold). An interrupting change mid-hold destroys the half-ready hidden view and re-targets — the stable view stays visible until the newest branch settles. A branch that loads nothing swaps right after its first render, and per-view scopes mean the outgoing branch's background work can never delay the swap. `immediate: true` skips holding; `viewTransition: true` wraps the swap in `document.startViewTransition` (feature detected). This is `@mmstack/router-core`'s `<mm-transition-outlet>` without the router — same semantics, any signal as the trigger.
462
+
444
463
  ### `injectStartTransition`
445
464
 
446
465
  The analog of React's `useTransition`. `startTransition(fn)` runs your state mutations (which commit immediately); any resource that reloads as a result **holds its value and reveals together once everything settles** — so a multi-resource update lands as one consistent frame instead of a torn mix of new and stale. The returned handle gives you a unified `pending` signal and a `done` promise for imperative coordination (disable a button, await completion).
@@ -945,6 +945,170 @@ function injectStartTransaction() {
945
945
  };
946
946
  }
947
947
 
948
+ /**
949
+ * Generic hold-and-swap: the non-router `TransitionRouterOutlet`. When the bound value changes,
950
+ * the OLD view stays mounted and visible (it keeps its old context value — that's the hold) while
951
+ * the NEW view mounts hidden with its **own transition scope**; resources created in the incoming
952
+ * subtree register into that scope just by existing, and once they've gone in flight and settled
953
+ * the views swap in one frame. Tabs, wizard steps, master-detail — any branch change that would
954
+ * otherwise flash a loading state.
955
+ *
956
+ * ```html
957
+ * <div *mmTransition="selectedTab(); let tab">
958
+ * @switch (tab) { ... }
959
+ * </div>
960
+ * ```
961
+ *
962
+ * Distinct from `<mm-suspense>` (the readiness gate): suspense decides placeholder-vs-content
963
+ * *within* one branch, but can't stop an `@switch` from unmounting the old branch the instant the
964
+ * value flips. This directive is the swap itself — the old branch survives until the new one is
965
+ * ready. Compose them freely: suspense inside a transitioned branch handles its first load.
966
+ *
967
+ * Semantics mirror the outlet: the first render is immediate (nothing to hold); an interrupting
968
+ * value change mid-hold destroys the half-ready hidden view and re-targets; a branch that loads
969
+ * nothing swaps right after its first render. Per-view scopes mean the outgoing branch's
970
+ * background work can never delay the swap. Set `mmTransitionImmediate` to skip holding, and
971
+ * `mmTransitionViewTransition` to wrap the swap in `document.startViewTransition` (feature
972
+ * detected). On the server every change swaps immediately.
973
+ */
974
+ class MmTransition {
975
+ tpl = inject(TemplateRef);
976
+ vcr = inject(ViewContainerRef);
977
+ parent = inject(Injector);
978
+ onServer = isPlatformServer(inject(PLATFORM_ID, { optional: true }) ?? 'browser');
979
+ /** The value whose changes are transitioned. Each view keeps the value it was created with. */
980
+ value = input.required({ alias: 'mmTransition' });
981
+ /** Skip holding entirely — every change swaps at once (the plain re-render behavior). */
982
+ immediate = input(false, { alias: 'mmTransitionImmediate' });
983
+ /** Wrap the swap in the View Transitions API for an animated cross-fade (feature detected). */
984
+ viewTransition = input(false, {
985
+ alias: 'mmTransitionViewTransition',
986
+ });
987
+ current = null;
988
+ incoming = null;
989
+ /** Bumped on every re-target/teardown so a superseded (possibly deferred) swap can't commit. */
990
+ swapEpoch = 0;
991
+ holding = signal(false);
992
+ /** True while an incoming view is mounted hidden, waiting to settle. */
993
+ pending = this.holding.asReadonly();
994
+ static ngTemplateContextGuard(dir,
995
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
996
+ ctx) {
997
+ return true;
998
+ }
999
+ constructor() {
1000
+ effect(() => {
1001
+ const v = this.value();
1002
+ untracked(() => this.onValue(v));
1003
+ });
1004
+ inject(DestroyRef).onDestroy(() => {
1005
+ this.swapEpoch++; // a deferred view-transition callback must not touch destroyed state
1006
+ this.dropIncoming();
1007
+ // `current` is destroyed with the container
1008
+ });
1009
+ }
1010
+ onValue(v) {
1011
+ if (!this.current) {
1012
+ // first render: nothing to hold yet — show immediately (also what SSR serializes)
1013
+ this.current = this.createView(v).view;
1014
+ return;
1015
+ }
1016
+ this.dropIncoming(); // an interrupting change supersedes the previous hold
1017
+ this.swapEpoch++;
1018
+ const epoch = this.swapEpoch;
1019
+ if (this.onServer || this.immediate()) {
1020
+ this.finishSwap(epoch, this.createView(v).view);
1021
+ return;
1022
+ }
1023
+ const { view, scope } = this.createView(v);
1024
+ this.setHidden(view, true);
1025
+ this.holding.set(true);
1026
+ // Registration happens synchronously during view creation, so a resource already in
1027
+ // flight counts from the start; later kickoffs are caught by the watcher.
1028
+ let sawPending = untracked(scope.pending);
1029
+ const watcher = effect(() => {
1030
+ const pending = scope.pending();
1031
+ untracked(() => {
1032
+ if (epoch !== this.swapEpoch)
1033
+ return;
1034
+ if (pending)
1035
+ sawPending = true;
1036
+ if (sawPending && !pending)
1037
+ this.commitSwap(epoch, view);
1038
+ });
1039
+ }, { injector: this.parent });
1040
+ this.incoming = { view, watcher };
1041
+ // Fallback for a branch that loads nothing.
1042
+ afterNextRender(() => {
1043
+ if (epoch === this.swapEpoch &&
1044
+ !sawPending &&
1045
+ !untracked(scope.pending)) {
1046
+ this.commitSwap(epoch, view);
1047
+ }
1048
+ }, { injector: this.parent });
1049
+ }
1050
+ commitSwap(epoch, view) {
1051
+ if (epoch !== this.swapEpoch)
1052
+ return;
1053
+ if (this.viewTransition() &&
1054
+ typeof document !== 'undefined' &&
1055
+ document.startViewTransition) {
1056
+ // the browser snapshots the old frame first; the epoch guard covers the deferral
1057
+ document.startViewTransition(() => this.finishSwap(epoch, view));
1058
+ }
1059
+ else {
1060
+ this.finishSwap(epoch, view);
1061
+ }
1062
+ }
1063
+ /** The actual swap: destroy the old view, reveal the new one. Always instant. */
1064
+ finishSwap(epoch, view) {
1065
+ if (epoch !== this.swapEpoch)
1066
+ return; // superseded while deferred — not ours to commit
1067
+ this.swapEpoch++; // consume: the watcher and the render fallback can both fire, one commits
1068
+ this.current?.destroy();
1069
+ this.setHidden(view, false);
1070
+ this.current = view;
1071
+ this.incoming?.watcher.destroy();
1072
+ this.incoming = null;
1073
+ this.holding.set(false);
1074
+ }
1075
+ dropIncoming() {
1076
+ if (!this.incoming)
1077
+ return;
1078
+ this.incoming.watcher.destroy();
1079
+ this.incoming.view.destroy();
1080
+ this.incoming = null;
1081
+ this.holding.set(false);
1082
+ }
1083
+ createView(v) {
1084
+ // Each view gets its own scope, so its subtree's resources register here by existing —
1085
+ // and the outgoing view's background work can't block the swap (per-view isolation).
1086
+ const injector = Injector.create({
1087
+ parent: this.parent,
1088
+ providers: [provideTransitionScope()],
1089
+ });
1090
+ const scope = getTransitionScope(injector);
1091
+ const view = this.vcr.createEmbeddedView(this.tpl, { $implicit: v, mmTransition: v }, { injector });
1092
+ return { view, scope };
1093
+ }
1094
+ setHidden(view, hidden) {
1095
+ for (const node of view.rootNodes) {
1096
+ // covers HTML and SVG roots; text/comment roots can't be styled — prefer an element root
1097
+ if (node instanceof HTMLElement || node instanceof SVGElement)
1098
+ node.style.display = hidden ? 'none' : '';
1099
+ }
1100
+ }
1101
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.19", ngImport: i0, type: MmTransition, deps: [], target: i0.ɵɵFactoryTarget.Directive });
1102
+ static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "19.2.19", 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 });
1103
+ }
1104
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.19", ngImport: i0, type: MmTransition, decorators: [{
1105
+ type: Directive,
1106
+ args: [{
1107
+ selector: '[mmTransition]',
1108
+ exportAs: 'mmTransition',
1109
+ }]
1110
+ }], ctorParameters: () => [] });
1111
+
948
1112
  /**
949
1113
  * @internal
950
1114
  */
@@ -4661,5 +4825,5 @@ function withHistory(sourceOrValue, opt) {
4661
4825
  * Generated bundle index. Do not edit.
4662
4826
  */
4663
4827
 
4664
- 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 };
4828
+ 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 };
4665
4829
  //# sourceMappingURL=mmstack-primitives.mjs.map