@mmstack/primitives 20.9.0 → 20.9.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).
@@ -951,6 +951,170 @@ function injectStartTransaction() {
951
951
  };
952
952
  }
953
953
 
954
+ /**
955
+ * Generic hold-and-swap: the non-router `TransitionRouterOutlet`. When the bound value changes,
956
+ * the OLD view stays mounted and visible (it keeps its old context value — that's the hold) while
957
+ * the NEW view mounts hidden with its **own transition scope**; resources created in the incoming
958
+ * subtree register into that scope just by existing, and once they've gone in flight and settled
959
+ * the views swap in one frame. Tabs, wizard steps, master-detail — any branch change that would
960
+ * otherwise flash a loading state.
961
+ *
962
+ * ```html
963
+ * <div *mmTransition="selectedTab(); let tab">
964
+ * @switch (tab) { ... }
965
+ * </div>
966
+ * ```
967
+ *
968
+ * Distinct from `<mm-suspense>` (the readiness gate): suspense decides placeholder-vs-content
969
+ * *within* one branch, but can't stop an `@switch` from unmounting the old branch the instant the
970
+ * value flips. This directive is the swap itself — the old branch survives until the new one is
971
+ * ready. Compose them freely: suspense inside a transitioned branch handles its first load.
972
+ *
973
+ * Semantics mirror the outlet: the first render is immediate (nothing to hold); an interrupting
974
+ * value change mid-hold destroys the half-ready hidden view and re-targets; a branch that loads
975
+ * nothing swaps right after its first render. Per-view scopes mean the outgoing branch's
976
+ * background work can never delay the swap. Set `mmTransitionImmediate` to skip holding, and
977
+ * `mmTransitionViewTransition` to wrap the swap in `document.startViewTransition` (feature
978
+ * detected). On the server every change swaps immediately.
979
+ */
980
+ class MmTransition {
981
+ tpl = inject(TemplateRef);
982
+ vcr = inject(ViewContainerRef);
983
+ parent = inject(Injector);
984
+ onServer = isPlatformServer(inject(PLATFORM_ID, { optional: true }) ?? 'browser');
985
+ /** The value whose changes are transitioned. Each view keeps the value it was created with. */
986
+ value = input.required(...(ngDevMode ? [{ debugName: "value", alias: 'mmTransition' }] : [{ alias: 'mmTransition' }]));
987
+ /** Skip holding entirely — every change swaps at once (the plain re-render behavior). */
988
+ immediate = input(false, ...(ngDevMode ? [{ debugName: "immediate", alias: 'mmTransitionImmediate' }] : [{ alias: 'mmTransitionImmediate' }]));
989
+ /** Wrap the swap in the View Transitions API for an animated cross-fade (feature detected). */
990
+ viewTransition = input(false, ...(ngDevMode ? [{ debugName: "viewTransition", alias: 'mmTransitionViewTransition' }] : [{
991
+ alias: 'mmTransitionViewTransition',
992
+ }]));
993
+ current = null;
994
+ incoming = null;
995
+ /** Bumped on every re-target/teardown so a superseded (possibly deferred) swap can't commit. */
996
+ swapEpoch = 0;
997
+ holding = signal(false, ...(ngDevMode ? [{ debugName: "holding" }] : []));
998
+ /** True while an incoming view is mounted hidden, waiting to settle. */
999
+ pending = this.holding.asReadonly();
1000
+ static ngTemplateContextGuard(dir,
1001
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
1002
+ ctx) {
1003
+ return true;
1004
+ }
1005
+ constructor() {
1006
+ effect(() => {
1007
+ const v = this.value();
1008
+ untracked(() => this.onValue(v));
1009
+ });
1010
+ inject(DestroyRef).onDestroy(() => {
1011
+ this.swapEpoch++; // a deferred view-transition callback must not touch destroyed state
1012
+ this.dropIncoming();
1013
+ // `current` is destroyed with the container
1014
+ });
1015
+ }
1016
+ onValue(v) {
1017
+ if (!this.current) {
1018
+ // first render: nothing to hold yet — show immediately (also what SSR serializes)
1019
+ this.current = this.createView(v).view;
1020
+ return;
1021
+ }
1022
+ this.dropIncoming(); // an interrupting change supersedes the previous hold
1023
+ this.swapEpoch++;
1024
+ const epoch = this.swapEpoch;
1025
+ if (this.onServer || this.immediate()) {
1026
+ this.finishSwap(epoch, this.createView(v).view);
1027
+ return;
1028
+ }
1029
+ const { view, scope } = this.createView(v);
1030
+ this.setHidden(view, true);
1031
+ this.holding.set(true);
1032
+ // Registration happens synchronously during view creation, so a resource already in
1033
+ // flight counts from the start; later kickoffs are caught by the watcher.
1034
+ let sawPending = untracked(scope.pending);
1035
+ const watcher = effect(() => {
1036
+ const pending = scope.pending();
1037
+ untracked(() => {
1038
+ if (epoch !== this.swapEpoch)
1039
+ return;
1040
+ if (pending)
1041
+ sawPending = true;
1042
+ if (sawPending && !pending)
1043
+ this.commitSwap(epoch, view);
1044
+ });
1045
+ }, ...(ngDevMode ? [{ debugName: "watcher", injector: this.parent }] : [{ injector: this.parent }]));
1046
+ this.incoming = { view, watcher };
1047
+ // Fallback for a branch that loads nothing.
1048
+ afterNextRender(() => {
1049
+ if (epoch === this.swapEpoch &&
1050
+ !sawPending &&
1051
+ !untracked(scope.pending)) {
1052
+ this.commitSwap(epoch, view);
1053
+ }
1054
+ }, { injector: this.parent });
1055
+ }
1056
+ commitSwap(epoch, view) {
1057
+ if (epoch !== this.swapEpoch)
1058
+ return;
1059
+ if (this.viewTransition() &&
1060
+ typeof document !== 'undefined' &&
1061
+ document.startViewTransition) {
1062
+ // the browser snapshots the old frame first; the epoch guard covers the deferral
1063
+ document.startViewTransition(() => this.finishSwap(epoch, view));
1064
+ }
1065
+ else {
1066
+ this.finishSwap(epoch, view);
1067
+ }
1068
+ }
1069
+ /** The actual swap: destroy the old view, reveal the new one. Always instant. */
1070
+ finishSwap(epoch, view) {
1071
+ if (epoch !== this.swapEpoch)
1072
+ return; // superseded while deferred — not ours to commit
1073
+ this.swapEpoch++; // consume: the watcher and the render fallback can both fire, one commits
1074
+ this.current?.destroy();
1075
+ this.setHidden(view, false);
1076
+ this.current = view;
1077
+ this.incoming?.watcher.destroy();
1078
+ this.incoming = null;
1079
+ this.holding.set(false);
1080
+ }
1081
+ dropIncoming() {
1082
+ if (!this.incoming)
1083
+ return;
1084
+ this.incoming.watcher.destroy();
1085
+ this.incoming.view.destroy();
1086
+ this.incoming = null;
1087
+ this.holding.set(false);
1088
+ }
1089
+ createView(v) {
1090
+ // Each view gets its own scope, so its subtree's resources register here by existing —
1091
+ // and the outgoing view's background work can't block the swap (per-view isolation).
1092
+ const injector = Injector.create({
1093
+ parent: this.parent,
1094
+ providers: [provideTransitionScope()],
1095
+ });
1096
+ const scope = getTransitionScope(injector);
1097
+ const view = this.vcr.createEmbeddedView(this.tpl, { $implicit: v, mmTransition: v }, { injector });
1098
+ return { view, scope };
1099
+ }
1100
+ setHidden(view, hidden) {
1101
+ for (const node of view.rootNodes) {
1102
+ // covers HTML and SVG roots; text/comment roots can't be styled — prefer an element root
1103
+ if (node instanceof HTMLElement || node instanceof SVGElement)
1104
+ node.style.display = hidden ? 'none' : '';
1105
+ }
1106
+ }
1107
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.17", ngImport: i0, type: MmTransition, deps: [], target: i0.ɵɵFactoryTarget.Directive });
1108
+ static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "20.3.17", 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 });
1109
+ }
1110
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.17", ngImport: i0, type: MmTransition, decorators: [{
1111
+ type: Directive,
1112
+ args: [{
1113
+ selector: '[mmTransition]',
1114
+ exportAs: 'mmTransition',
1115
+ }]
1116
+ }], 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
+
954
1118
  /**
955
1119
  * @internal
956
1120
  */
@@ -4679,5 +4843,5 @@ function withHistory(sourceOrValue, opt) {
4679
4843
  * Generated bundle index. Do not edit.
4680
4844
  */
4681
4845
 
4682
- export { MmActivity, PAUSABLE_OPTIONS, SuspenseBoundary, SuspenseBoundaryBase, UnscopedSuspenseBoundary, activeTransaction, batteryStatus, chunked, clipboard, combineWith, createAttributedPending, createForwardingScope, createTransaction, createTransitionScope, debounce, debounced, derived, distinct, elementSize, elementVisibility, extendStore, filter, filterWith, focusWithin, forkStore, geolocation, getTransitionScope, holdUntilReady, idle, indexArray, injectPaused, injectRegisterResource, injectStartTransaction, injectStartTransition, injectTransitionScope, isDerivation, isLeaf, isMutable, isOpaque, isStore, keepPrevious, keyArray, map, mapArray, mapObject, mediaQuery, merge3, mousePosition, mutable, mutableStore, nestedEffect, networkStatus, opaque, orientation, pageVisibility, pairwise, pausableComputed, pausableEffect, pausableSignal, pipeable, piped, pointerDrag, pooled, pooledArray, pooledMap, pooledSet, prefersDarkMode, prefersReducedMotion, provideForwardingTransitionScope, providePausableOptions, providePaused, provideTransitionScope, registerResource, resolvePause, scan, scrollPosition, select, sensor, sensors, signalFromEvent, startWith, store, stored, tabSync, tap, throttle, throttled, toFakeDerivation, toFakeSignalDerivation, toStore, toWritable, until, windowSize, withHistory };
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 };
4683
4847
  //# sourceMappingURL=mmstack-primitives.mjs.map