@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
|