@mmstack/primitives 21.4.0 → 21.4.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).
@@ -939,6 +939,168 @@ function injectStartTransaction() {
939
939
  };
940
940
  }
941
941
 
942
+ /**
943
+ * Generic hold-and-swap: the non-router `TransitionRouterOutlet`. When the bound value changes,
944
+ * the OLD view stays mounted and visible (it keeps its old context value — that's the hold) while
945
+ * the NEW view mounts hidden with its **own transition scope**; resources created in the incoming
946
+ * subtree register into that scope just by existing, and once they've gone in flight and settled
947
+ * the views swap in one frame. Tabs, wizard steps, master-detail — any branch change that would
948
+ * otherwise flash a loading state.
949
+ *
950
+ * ```html
951
+ * <div *mmTransition="selectedTab(); let tab">
952
+ * @switch (tab) { ... }
953
+ * </div>
954
+ * ```
955
+ *
956
+ * Distinct from `<mm-suspense>` (the readiness gate): suspense decides placeholder-vs-content
957
+ * *within* one branch, but can't stop an `@switch` from unmounting the old branch the instant the
958
+ * value flips. This directive is the swap itself — the old branch survives until the new one is
959
+ * ready. Compose them freely: suspense inside a transitioned branch handles its first load.
960
+ *
961
+ * Semantics mirror the outlet: the first render is immediate (nothing to hold); an interrupting
962
+ * value change mid-hold destroys the half-ready hidden view and re-targets; a branch that loads
963
+ * nothing swaps right after its first render. Per-view scopes mean the outgoing branch's
964
+ * background work can never delay the swap. Set `mmTransitionImmediate` to skip holding, and
965
+ * `mmTransitionViewTransition` to wrap the swap in `document.startViewTransition` (feature
966
+ * detected). On the server every change swaps immediately.
967
+ */
968
+ class MmTransition {
969
+ tpl = inject(TemplateRef);
970
+ vcr = inject(ViewContainerRef);
971
+ parent = inject(Injector);
972
+ onServer = isPlatformServer(inject(PLATFORM_ID, { optional: true }) ?? 'browser');
973
+ /** The value whose changes are transitioned. Each view keeps the value it was created with. */
974
+ value = input.required({ ...(ngDevMode ? { debugName: "value" } : /* istanbul ignore next */ {}), alias: 'mmTransition' });
975
+ /** Skip holding entirely — every change swaps at once (the plain re-render behavior). */
976
+ immediate = input(false, { ...(ngDevMode ? { debugName: "immediate" } : /* istanbul ignore next */ {}), alias: 'mmTransitionImmediate' });
977
+ /** Wrap the swap in the View Transitions API for an animated cross-fade (feature detected). */
978
+ viewTransition = input(false, { ...(ngDevMode ? { debugName: "viewTransition" } : /* istanbul ignore next */ {}), alias: 'mmTransitionViewTransition' });
979
+ current = null;
980
+ incoming = null;
981
+ /** Bumped on every re-target/teardown so a superseded (possibly deferred) swap can't commit. */
982
+ swapEpoch = 0;
983
+ holding = signal(false, ...(ngDevMode ? [{ debugName: "holding" }] : /* istanbul ignore next */ []));
984
+ /** True while an incoming view is mounted hidden, waiting to settle. */
985
+ pending = this.holding.asReadonly();
986
+ static ngTemplateContextGuard(dir,
987
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
988
+ ctx) {
989
+ return true;
990
+ }
991
+ constructor() {
992
+ effect(() => {
993
+ const v = this.value();
994
+ untracked(() => this.onValue(v));
995
+ });
996
+ inject(DestroyRef).onDestroy(() => {
997
+ this.swapEpoch++; // a deferred view-transition callback must not touch destroyed state
998
+ this.dropIncoming();
999
+ // `current` is destroyed with the container
1000
+ });
1001
+ }
1002
+ onValue(v) {
1003
+ if (!this.current) {
1004
+ // first render: nothing to hold yet — show immediately (also what SSR serializes)
1005
+ this.current = this.createView(v).view;
1006
+ return;
1007
+ }
1008
+ this.dropIncoming(); // an interrupting change supersedes the previous hold
1009
+ this.swapEpoch++;
1010
+ const epoch = this.swapEpoch;
1011
+ if (this.onServer || this.immediate()) {
1012
+ this.finishSwap(epoch, this.createView(v).view);
1013
+ return;
1014
+ }
1015
+ const { view, scope } = this.createView(v);
1016
+ this.setHidden(view, true);
1017
+ this.holding.set(true);
1018
+ // Registration happens synchronously during view creation, so a resource already in
1019
+ // flight counts from the start; later kickoffs are caught by the watcher.
1020
+ let sawPending = untracked(scope.pending);
1021
+ const watcher = effect(() => {
1022
+ const pending = scope.pending();
1023
+ untracked(() => {
1024
+ if (epoch !== this.swapEpoch)
1025
+ return;
1026
+ if (pending)
1027
+ sawPending = true;
1028
+ if (sawPending && !pending)
1029
+ this.commitSwap(epoch, view);
1030
+ });
1031
+ }, { ...(ngDevMode ? { debugName: "watcher" } : /* istanbul ignore next */ {}), injector: this.parent });
1032
+ this.incoming = { view, watcher };
1033
+ // Fallback for a branch that loads nothing.
1034
+ afterNextRender(() => {
1035
+ if (epoch === this.swapEpoch &&
1036
+ !sawPending &&
1037
+ !untracked(scope.pending)) {
1038
+ this.commitSwap(epoch, view);
1039
+ }
1040
+ }, { injector: this.parent });
1041
+ }
1042
+ commitSwap(epoch, view) {
1043
+ if (epoch !== this.swapEpoch)
1044
+ return;
1045
+ if (this.viewTransition() &&
1046
+ typeof document !== 'undefined' &&
1047
+ document.startViewTransition) {
1048
+ // the browser snapshots the old frame first; the epoch guard covers the deferral
1049
+ document.startViewTransition(() => this.finishSwap(epoch, view));
1050
+ }
1051
+ else {
1052
+ this.finishSwap(epoch, view);
1053
+ }
1054
+ }
1055
+ /** The actual swap: destroy the old view, reveal the new one. Always instant. */
1056
+ finishSwap(epoch, view) {
1057
+ if (epoch !== this.swapEpoch)
1058
+ return; // superseded while deferred — not ours to commit
1059
+ this.swapEpoch++; // consume: the watcher and the render fallback can both fire, one commits
1060
+ this.current?.destroy();
1061
+ this.setHidden(view, false);
1062
+ this.current = view;
1063
+ this.incoming?.watcher.destroy();
1064
+ this.incoming = null;
1065
+ this.holding.set(false);
1066
+ }
1067
+ dropIncoming() {
1068
+ if (!this.incoming)
1069
+ return;
1070
+ this.incoming.watcher.destroy();
1071
+ this.incoming.view.destroy();
1072
+ this.incoming = null;
1073
+ this.holding.set(false);
1074
+ }
1075
+ createView(v) {
1076
+ // Each view gets its own scope, so its subtree's resources register here by existing —
1077
+ // and the outgoing view's background work can't block the swap (per-view isolation).
1078
+ const injector = Injector.create({
1079
+ parent: this.parent,
1080
+ providers: [provideTransitionScope()],
1081
+ });
1082
+ const scope = getTransitionScope(injector);
1083
+ const view = this.vcr.createEmbeddedView(this.tpl, { $implicit: v, mmTransition: v }, { injector });
1084
+ return { view, scope };
1085
+ }
1086
+ setHidden(view, hidden) {
1087
+ for (const node of view.rootNodes) {
1088
+ // covers HTML and SVG roots; text/comment roots can't be styled — prefer an element root
1089
+ if (node instanceof HTMLElement || node instanceof SVGElement)
1090
+ node.style.display = hidden ? 'none' : '';
1091
+ }
1092
+ }
1093
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.12", ngImport: i0, type: MmTransition, deps: [], target: i0.ɵɵFactoryTarget.Directive });
1094
+ static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.2.12", 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 });
1095
+ }
1096
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImport: i0, type: MmTransition, decorators: [{
1097
+ type: Directive,
1098
+ args: [{
1099
+ selector: '[mmTransition]',
1100
+ exportAs: 'mmTransition',
1101
+ }]
1102
+ }], 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
+
942
1104
  /**
943
1105
  * @internal
944
1106
  */
@@ -4648,5 +4810,5 @@ function withHistory(sourceOrValue, opt) {
4648
4810
  * Generated bundle index. Do not edit.
4649
4811
  */
4650
4812
 
4651
- 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 };
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 };
4652
4814
  //# sourceMappingURL=mmstack-primitives.mjs.map