@mmstack/primitives 21.3.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
@@ -119,6 +119,10 @@ Top-level array support isn't exposed yet — use `indexArray` / `keyArray` for
119
119
 
120
120
  **Union leaves (perf opt-in).** `noUnionLeaves: true` promises no node ever flips between a leaf and a sub-store, so each node's leaf-ness is resolved once on first access and cached instead of staying reactive. Off by default — leave it off if a value can switch between a primitive and an object/array.
121
121
 
122
+ **Unions are fully supported by default.** A node may flip between array ↔ record ↔ primitive ↔ `null` freely: routing (`keys`/iteration/prototype) follows the live kind, and a child signal you grabbed **before** a flip stays correct after it — reads resolve against the new shape (`undefined` through a `null` parent, no throw) and writes copy by the container's live shape, so writing through a pre-flip child never turns an array into a plain object.
123
+
124
+ > Reserved keys: `set`, `update`, `mutate`, `inline`, `asReadonly` (and `extend`, until its removal next minor) resolve to the signal's own methods, so record keys with those names aren't reachable as child stores — read them off the value (`s().set`) instead.
125
+
122
126
  ### `extendStore` (scoped overlay)
123
127
 
124
128
  `extendStore(store, seed)` (on any store kind) creates a **scoped overlay** — a child store that **shares** the parent's signals for inherited keys (the same `WritableSignal`: writes go through to the parent and parent changes flow down) while keeping the seed and any new keys in a **local layer** that never propagates upward. No diffing, no syncing — local keys simply aren't wired to the parent.
@@ -437,6 +441,25 @@ This is also the pattern for coordinating resources registered _above_ a boundar
437
441
 
438
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.
439
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
+
440
463
  ### `injectStartTransition`
441
464
 
442
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).
@@ -461,6 +484,15 @@ const t = startTransaction(() => applyBulkEdit()); // live state updates; the di
461
484
  await t.done; // committed, display revealed in one frame
462
485
  ```
463
486
 
487
+ Every exit settles: a throwing body rolls back, and if the calling context is **destroyed
488
+ mid-flight** the hold is released (writes kept) and `done` resolves — a transaction can never
489
+ leave a surviving ancestor scope frozen.
490
+
491
+ Attribution is **per transaction**: a load already in flight when it starts is not adopted —
492
+ it can neither commit the transaction early nor block its settle. (The same applies to
493
+ `startTransition`.) A pre-existing flight re-triggered by the transaction's own writes counts
494
+ once it restarts.
495
+
464
496
  ### `holdUntilReady`
465
497
 
466
498
  The **structural** counterpart to `keepPrevious`: where that holds a _value_ through a reload, this holds a _structure_ through a swap. Given a `target` signal and a `ready` predicate, it keeps yielding the previous value until `ready()` is true, then swaps to the current target. Mount the incoming structure off to the side so its resources can settle and flip `ready`, keep showing the held one meanwhile, and let the old one go once `ready` releases the swap. (`@mmstack/router-core`'s `<mm-transition-outlet>` is this pattern applied to routes.)
@@ -677,6 +709,11 @@ outer one on the same tree (e.g. a nested sortable). Reads are throttled
677
709
  (`throttle`, default 16ms); `drag.unthrottled()` exposes the un-throttled view
678
710
  for logic that needs the exact release position.
679
711
 
712
+ The idle state carries the **end reason**: `cancelled` is `true` when the gesture
713
+ was aborted (Escape, `pointercancel`, `.cancel()`) rather than released, and stays
714
+ set until the next `pointerdown` — so a drag consumer can tell "drop here" from
715
+ "abort" (`@mmstack/dnd` uses this to cancel instead of committing).
716
+
680
717
  ```typescript
681
718
  import { sensor } from '@mmstack/primitives';
682
719
 
@@ -628,6 +628,38 @@ function provideForwardingTransitionScope() {
628
628
  function getTransitionScope(injector) {
629
629
  return injector.get(TRANSITION_SCOPE, null);
630
630
  }
631
+ /**
632
+ * @internal Transaction-attributed pending for `startTransition`/`startTransaction`: like
633
+ * `scope.pending`, but loads already in flight when the tracker is created are NOT attributed —
634
+ * a pre-existing background load can neither settle the transaction early nor block its settle
635
+ * forever. A pre-existing flight is excluded only until it first settles; a later re-trigger of
636
+ * the same resource (e.g. the transaction's write changed its request) counts as the
637
+ * transaction's own work.
638
+ */
639
+ function createAttributedPending(scope) {
640
+ const isInFlight = (ref) => {
641
+ const s = untracked(ref.status);
642
+ return s === 'loading' || s === 'reloading';
643
+ };
644
+ const preexisting = new Set(untracked(scope.resources).filter(isInFlight));
645
+ return computed(() => {
646
+ let pending = false;
647
+ for (const ref of scope.resources()) {
648
+ const s = ref.status();
649
+ const loading = s === 'loading' || s === 'reloading';
650
+ if (preexisting.has(ref)) {
651
+ // deletes are monotonic, so this stays sound under re-computation
652
+ if (loading)
653
+ continue;
654
+ preexisting.delete(ref);
655
+ continue;
656
+ }
657
+ if (loading)
658
+ pending = true;
659
+ }
660
+ return pending;
661
+ });
662
+ }
631
663
  /**
632
664
  * Returns a register function bound to the nearest transition scope: it adds a resource
633
665
  * to the scope and removes it when the caller's injection context is destroyed. Pass any
@@ -665,38 +697,43 @@ function registerResource(res, opt) {
665
697
  function injectStartTransition() {
666
698
  const scope = injectTransitionScope();
667
699
  const injector = inject(Injector);
700
+ const destroyRef = inject(DestroyRef);
668
701
  const onServer = isPlatformServer(inject(PLATFORM_ID, { optional: true }) ?? 'browser');
669
702
  return (fn) => {
703
+ // attributed: loads already in flight when the transition starts are not ours —
704
+ // they can neither settle this transition early nor block it forever
705
+ const pending = createAttributedPending(scope);
670
706
  untracked(fn);
671
707
  let sawPending = false;
672
708
  const done = new Promise((resolve) => {
709
+ const settle = () => {
710
+ releaseDestroy();
711
+ watcher.destroy();
712
+ resolve();
713
+ };
673
714
  const watcher = effect(() => {
674
- const p = scope.pending();
715
+ const p = pending();
675
716
  if (p)
676
717
  sawPending = true;
677
718
  // settle: requests went in flight and then drained
678
- if (sawPending && !p) {
679
- watcher.destroy();
680
- resolve();
681
- }
719
+ if (sawPending && !p)
720
+ settle();
682
721
  }, { ...(ngDevMode ? { debugName: "watcher" } : /* istanbul ignore next */ {}), injector });
722
+ // a destroy mid-flight kills the watcher — resolve so awaiters never hang
723
+ const releaseDestroy = destroyRef.onDestroy(settle);
683
724
  if (onServer) {
684
- if (!untracked(scope.pending)) {
685
- watcher.destroy();
686
- resolve();
687
- }
725
+ if (!untracked(pending))
726
+ settle();
688
727
  return;
689
728
  }
690
729
  // no-async fallback: once the reactive system has processed the writes (afterNextRender),
691
730
  // if nothing ever went in flight, the transition is already complete.
692
731
  afterNextRender(() => {
693
- if (!sawPending && !untracked(scope.pending)) {
694
- watcher.destroy();
695
- resolve();
696
- }
732
+ if (!sawPending && !untracked(pending))
733
+ settle();
697
734
  }, { injector });
698
735
  });
699
- return { pending: scope.pending, done };
736
+ return { pending, done };
700
737
  };
701
738
  }
702
739
 
@@ -767,8 +804,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
767
804
  }] });
768
805
  /**
769
806
  * Unscoped suspense boundary — **reads the ambient scope** instead of providing one. For cases where
770
- * the resources to coordinate are registered *above* the boundary (e.g. an app-builder page whose
771
- * manifests/connectors register at a higher injector), so the boundary observes that outer scope
807
+ * the resources to coordinate are registered *above* the boundary so the boundary observes that outer scope
772
808
  * rather than opening a fresh one. Pair with a `provideTransitionScope()` (or another boundary) in an
773
809
  * ancestor.
774
810
  */
@@ -833,9 +869,13 @@ function runInTransaction(txn, fn) {
833
869
  function injectStartTransaction() {
834
870
  const scope = injectTransitionScope();
835
871
  const injector = inject(Injector);
872
+ const destroyRef = inject(DestroyRef);
836
873
  const onServer = isPlatformServer(inject(PLATFORM_ID, { optional: true }) ?? 'browser');
837
874
  return (fn) => {
838
875
  const txn = createTransaction();
876
+ // attributed: loads already in flight when the transaction starts are not ours —
877
+ // they can neither commit this transaction early nor block its settle forever
878
+ const pending = createAttributedPending(scope);
839
879
  // Hold BEFORE the writes, so the display freezes at pre-transaction values.
840
880
  scope.beginHold();
841
881
  let finished = false;
@@ -852,6 +892,7 @@ function injectStartTransaction() {
852
892
  if (finished)
853
893
  return;
854
894
  finished = true;
895
+ releaseDestroy();
855
896
  watcher?.destroy();
856
897
  if (restore)
857
898
  txn.restore();
@@ -860,6 +901,10 @@ function injectStartTransaction() {
860
901
  scope.endHold();
861
902
  resolveDone();
862
903
  };
904
+ // The scope may outlive the calling context (a component transacting on an ancestor
905
+ // boundary): a destroy mid-flight kills the settle watcher, so without this the hold
906
+ // would leak and freeze the surviving scope forever. Keep the writes — they landed live.
907
+ const releaseDestroy = destroyRef.onDestroy(() => finish(false));
863
908
  try {
864
909
  runInTransaction(txn, fn);
865
910
  }
@@ -869,31 +914,193 @@ function injectStartTransaction() {
869
914
  }
870
915
  let sawPending = false;
871
916
  watcher = effect(() => {
872
- const p = scope.pending();
917
+ const p = pending();
873
918
  if (p)
874
919
  sawPending = true;
875
920
  if (sawPending && !p)
876
921
  finish(false);
877
922
  }, { injector });
878
923
  if (onServer) {
879
- if (!untracked(scope.pending))
924
+ if (!untracked(pending))
880
925
  finish(false);
881
926
  }
882
927
  else {
883
928
  // no-async fallback: if nothing ever went in flight, settle once the writes are processed.
884
929
  afterNextRender(() => {
885
- if (!sawPending && !untracked(scope.pending))
930
+ if (!sawPending && !untracked(pending))
886
931
  finish(false);
887
932
  }, { injector });
888
933
  }
889
934
  return {
890
- pending: scope.pending,
935
+ pending,
891
936
  done,
892
937
  abort: () => finish(true),
893
938
  };
894
939
  };
895
940
  }
896
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
+
897
1104
  /**
898
1105
  * @internal
899
1106
  */
@@ -2895,9 +3102,13 @@ const IDLE = {
2895
3102
  button: -1,
2896
3103
  pointerType: '',
2897
3104
  origin: null,
3105
+ cancelled: false,
2898
3106
  };
3107
+ /** Terminal state of an aborted gesture — same idle shape, `cancelled: true`. */
3108
+ const CANCELLED = { ...IDLE, cancelled: true };
2899
3109
  function stateEqual(a, b) {
2900
3110
  return (a.active === b.active &&
3111
+ a.cancelled === b.cancelled &&
2901
3112
  a.pointerId === b.pointerId &&
2902
3113
  a.current.x === b.current.x &&
2903
3114
  a.current.y === b.current.y &&
@@ -2975,7 +3186,7 @@ function createPointerDrag(opt) {
2975
3186
  ctrl: e.ctrlKey,
2976
3187
  meta: e.metaKey,
2977
3188
  });
2978
- const end = () => {
3189
+ const end = (cancelled = false) => {
2979
3190
  gesture?.abort();
2980
3191
  gesture = null;
2981
3192
  activePointerId = null;
@@ -2983,8 +3194,8 @@ function createPointerDrag(opt) {
2983
3194
  activePointerType = '';
2984
3195
  activeOrigin = null;
2985
3196
  activated = false;
2986
- state.set(IDLE);
2987
- state.flush(); // terminal transition: reflect IDLE now, not on the trailing edge
3197
+ state.set(cancelled ? CANCELLED : IDLE);
3198
+ state.flush(); // terminal transition: reflect idle now, not on the trailing edge
2988
3199
  };
2989
3200
  const onMove = (e) => {
2990
3201
  if (e.pointerId !== activePointerId)
@@ -3004,6 +3215,7 @@ function createPointerDrag(opt) {
3004
3215
  button: activeButton, // pointermove button is -1; keep the down-button
3005
3216
  pointerType: activePointerType,
3006
3217
  origin: activeOrigin,
3218
+ cancelled: false,
3007
3219
  });
3008
3220
  };
3009
3221
  const onUp = (e) => {
@@ -3012,11 +3224,11 @@ function createPointerDrag(opt) {
3012
3224
  };
3013
3225
  const onCancel = (e) => {
3014
3226
  if (e.pointerId === activePointerId)
3015
- end();
3227
+ end(true);
3016
3228
  };
3017
3229
  const onKey = (e) => {
3018
3230
  if (e.key === 'Escape' && activePointerId !== null)
3019
- end();
3231
+ end(true);
3020
3232
  };
3021
3233
  const onDown = (el) => (e) => {
3022
3234
  if (activePointerId !== null)
@@ -3061,6 +3273,7 @@ function createPointerDrag(opt) {
3061
3273
  button: e.button,
3062
3274
  pointerType: activePointerType,
3063
3275
  origin: activeOrigin,
3276
+ cancelled: false,
3064
3277
  });
3065
3278
  };
3066
3279
  const attach = (el) => {
@@ -3070,7 +3283,7 @@ function createPointerDrag(opt) {
3070
3283
  });
3071
3284
  return () => {
3072
3285
  controller.abort();
3073
- end();
3286
+ end(true); // teardown mid-gesture is an abort, not a drop
3074
3287
  };
3075
3288
  };
3076
3289
  if (isSignal(target)) {
@@ -3088,7 +3301,7 @@ function createPointerDrag(opt) {
3088
3301
  }
3089
3302
  const base = state.asReadonly();
3090
3303
  base.unthrottled = state.original;
3091
- base.cancel = end;
3304
+ base.cancel = () => end(true);
3092
3305
  return base;
3093
3306
  }
3094
3307
 
@@ -3684,30 +3897,35 @@ function getCachedChild(target, prop, build, cache, cleanupRegistry) {
3684
3897
  cleanupRegistry.register(proxy, { target, prop }, ref);
3685
3898
  return proxy;
3686
3899
  }
3900
+ /**
3901
+ * @internal Whether a mutable parent's child value must always re-notify: in-place mutation
3902
+ * keeps an object child's reference stable, so `Object.is` would swallow the change. Decided
3903
+ * per-VALUE (not snapshotted at build) so a union child that becomes an object later still
3904
+ * propagates parent-level mutations.
3905
+ */
3906
+ function mutableChildEqual(a, b) {
3907
+ if (typeof a === 'object' && a !== null)
3908
+ return false;
3909
+ return Object.is(a, b);
3910
+ }
3687
3911
  /**
3688
3912
  * @internal Builds the derived child signal for `prop` and wraps it as an array/object substore.
3689
- * A record parent reads the key directly; any other container goes through the fallback `from`/
3690
- * `onChange` path. Shared verbatim by the array and object proxies the only place a child node
3691
- * is constructed.
3913
+ * Both the read (`v?.[prop]`) and the write (`createFallbackOnChange` copies by the container's
3914
+ * LIVE shape) are shape-adaptive, so a child cached before an array↔record↔null union flip stays
3915
+ * correct after it. The only place a child node is constructed — shared by every container kind.
3692
3916
  */
3693
3917
  function buildChildNode(target, prop, isMutableSource, options) {
3694
3918
  const value = untracked(target);
3695
- const valueIsRecord = isRecord(value);
3696
- const valueIsArray = Array.isArray(value);
3697
3919
  const nodeVivify = resolveVivify(value, options.vivify);
3698
3920
  const vivifyFn = createVivify(nodeVivify);
3699
- const equalFn = (valueIsRecord || valueIsArray) &&
3700
- isMutableSource &&
3701
- typeof value[prop] === 'object'
3702
- ? () => false
3921
+ const equalFn = isMutableSource && (isRecord(value) || Array.isArray(value))
3922
+ ? mutableChildEqual
3703
3923
  : undefined;
3704
- const computation = valueIsRecord
3705
- ? derived(target, prop, { equal: equalFn, vivify: nodeVivify })
3706
- : derived(target, {
3707
- from: (v) => v?.[prop],
3708
- onChange: createFallbackOnChange(target, prop, vivifyFn, isMutableSource),
3709
- equal: equalFn,
3710
- });
3924
+ const computation = derived(target, {
3925
+ from: (v) => v?.[prop],
3926
+ onChange: createFallbackOnChange(target, prop, vivifyFn, isMutableSource),
3927
+ equal: equalFn,
3928
+ });
3711
3929
  const childSample = untracked(computation);
3712
3930
  const childVivify = resolveVivify(childSample, options.vivify);
3713
3931
  const proxy = toStore(computation, options);
@@ -4592,5 +4810,5 @@ function withHistory(sourceOrValue, opt) {
4592
4810
  * Generated bundle index. Do not edit.
4593
4811
  */
4594
4812
 
4595
- export { MmActivity, PAUSABLE_OPTIONS, SuspenseBoundary, SuspenseBoundaryBase, UnscopedSuspenseBoundary, activeTransaction, batteryStatus, chunked, clipboard, combineWith, 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 };
4596
4814
  //# sourceMappingURL=mmstack-primitives.mjs.map