@mmstack/primitives 19.6.0 → 19.7.0

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
@@ -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.
@@ -461,6 +465,15 @@ const t = startTransaction(() => applyBulkEdit()); // live state updates; the di
461
465
  await t.done; // committed, display revealed in one frame
462
466
  ```
463
467
 
468
+ Every exit settles: a throwing body rolls back, and if the calling context is **destroyed
469
+ mid-flight** the hold is released (writes kept) and `done` resolves — a transaction can never
470
+ leave a surviving ancestor scope frozen.
471
+
472
+ Attribution is **per transaction**: a load already in flight when it starts is not adopted —
473
+ it can neither commit the transaction early nor block its settle. (The same applies to
474
+ `startTransition`.) A pre-existing flight re-triggered by the transaction's own writes counts
475
+ once it restarts.
476
+
464
477
  ### `holdUntilReady`
465
478
 
466
479
  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 +690,11 @@ outer one on the same tree (e.g. a nested sortable). Reads are throttled
677
690
  (`throttle`, default 16ms); `drag.unthrottled()` exposes the un-throttled view
678
691
  for logic that needs the exact release position.
679
692
 
693
+ The idle state carries the **end reason**: `cancelled` is `true` when the gesture
694
+ was aborted (Escape, `pointercancel`, `.cancel()`) rather than released, and stays
695
+ set until the next `pointerdown` — so a drag consumer can tell "drop here" from
696
+ "abort" (`@mmstack/dnd` uses this to cancel instead of committing).
697
+
680
698
  ```typescript
681
699
  import { sensor } from '@mmstack/primitives';
682
700
 
@@ -634,6 +634,38 @@ function provideForwardingTransitionScope() {
634
634
  function getTransitionScope(injector) {
635
635
  return injector.get(TRANSITION_SCOPE, null);
636
636
  }
637
+ /**
638
+ * @internal Transaction-attributed pending for `startTransition`/`startTransaction`: like
639
+ * `scope.pending`, but loads already in flight when the tracker is created are NOT attributed —
640
+ * a pre-existing background load can neither settle the transaction early nor block its settle
641
+ * forever. A pre-existing flight is excluded only until it first settles; a later re-trigger of
642
+ * the same resource (e.g. the transaction's write changed its request) counts as the
643
+ * transaction's own work.
644
+ */
645
+ function createAttributedPending(scope) {
646
+ const isInFlight = (ref) => {
647
+ const s = untracked(ref.status);
648
+ return s === ResourceStatus.Loading || s === ResourceStatus.Reloading;
649
+ };
650
+ const preexisting = new Set(untracked(scope.resources).filter(isInFlight));
651
+ return computed(() => {
652
+ let pending = false;
653
+ for (const ref of scope.resources()) {
654
+ const s = ref.status();
655
+ const loading = s === ResourceStatus.Loading || s === ResourceStatus.Reloading;
656
+ if (preexisting.has(ref)) {
657
+ // deletes are monotonic, so this stays sound under re-computation
658
+ if (loading)
659
+ continue;
660
+ preexisting.delete(ref);
661
+ continue;
662
+ }
663
+ if (loading)
664
+ pending = true;
665
+ }
666
+ return pending;
667
+ });
668
+ }
637
669
  /**
638
670
  * Returns a register function bound to the nearest transition scope: it adds a resource
639
671
  * to the scope and removes it when the caller's injection context is destroyed. Pass any
@@ -671,38 +703,43 @@ function registerResource(res, opt) {
671
703
  function injectStartTransition() {
672
704
  const scope = injectTransitionScope();
673
705
  const injector = inject(Injector);
706
+ const destroyRef = inject(DestroyRef);
674
707
  const onServer = isPlatformServer(inject(PLATFORM_ID, { optional: true }) ?? 'browser');
675
708
  return (fn) => {
709
+ // attributed: loads already in flight when the transition starts are not ours —
710
+ // they can neither settle this transition early nor block it forever
711
+ const pending = createAttributedPending(scope);
676
712
  untracked(fn);
677
713
  let sawPending = false;
678
714
  const done = new Promise((resolve) => {
715
+ const settle = () => {
716
+ releaseDestroy();
717
+ watcher.destroy();
718
+ resolve();
719
+ };
679
720
  const watcher = effect(() => {
680
- const p = scope.pending();
721
+ const p = pending();
681
722
  if (p)
682
723
  sawPending = true;
683
724
  // settle: requests went in flight and then drained
684
- if (sawPending && !p) {
685
- watcher.destroy();
686
- resolve();
687
- }
725
+ if (sawPending && !p)
726
+ settle();
688
727
  }, { injector });
728
+ // a destroy mid-flight kills the watcher — resolve so awaiters never hang
729
+ const releaseDestroy = destroyRef.onDestroy(settle);
689
730
  if (onServer) {
690
- if (!untracked(scope.pending)) {
691
- watcher.destroy();
692
- resolve();
693
- }
731
+ if (!untracked(pending))
732
+ settle();
694
733
  return;
695
734
  }
696
735
  // no-async fallback: once the reactive system has processed the writes (afterNextRender),
697
736
  // if nothing ever went in flight, the transition is already complete.
698
737
  afterNextRender(() => {
699
- if (!sawPending && !untracked(scope.pending)) {
700
- watcher.destroy();
701
- resolve();
702
- }
738
+ if (!sawPending && !untracked(pending))
739
+ settle();
703
740
  }, { injector });
704
741
  });
705
- return { pending: scope.pending, done };
742
+ return { pending, done };
706
743
  };
707
744
  }
708
745
 
@@ -773,8 +810,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.19", ngImpo
773
810
  }] });
774
811
  /**
775
812
  * Unscoped suspense boundary — **reads the ambient scope** instead of providing one. For cases where
776
- * the resources to coordinate are registered *above* the boundary (e.g. an app-builder page whose
777
- * manifests/connectors register at a higher injector), so the boundary observes that outer scope
813
+ * the resources to coordinate are registered *above* the boundary so the boundary observes that outer scope
778
814
  * rather than opening a fresh one. Pair with a `provideTransitionScope()` (or another boundary) in an
779
815
  * ancestor.
780
816
  */
@@ -839,9 +875,13 @@ function runInTransaction(txn, fn) {
839
875
  function injectStartTransaction() {
840
876
  const scope = injectTransitionScope();
841
877
  const injector = inject(Injector);
878
+ const destroyRef = inject(DestroyRef);
842
879
  const onServer = isPlatformServer(inject(PLATFORM_ID, { optional: true }) ?? 'browser');
843
880
  return (fn) => {
844
881
  const txn = createTransaction();
882
+ // attributed: loads already in flight when the transaction starts are not ours —
883
+ // they can neither commit this transaction early nor block its settle forever
884
+ const pending = createAttributedPending(scope);
845
885
  // Hold BEFORE the writes, so the display freezes at pre-transaction values.
846
886
  scope.beginHold();
847
887
  let finished = false;
@@ -858,6 +898,7 @@ function injectStartTransaction() {
858
898
  if (finished)
859
899
  return;
860
900
  finished = true;
901
+ releaseDestroy();
861
902
  watcher?.destroy();
862
903
  if (restore)
863
904
  txn.restore();
@@ -866,6 +907,10 @@ function injectStartTransaction() {
866
907
  scope.endHold();
867
908
  resolveDone();
868
909
  };
910
+ // The scope may outlive the calling context (a component transacting on an ancestor
911
+ // boundary): a destroy mid-flight kills the settle watcher, so without this the hold
912
+ // would leak and freeze the surviving scope forever. Keep the writes — they landed live.
913
+ const releaseDestroy = destroyRef.onDestroy(() => finish(false));
869
914
  try {
870
915
  runInTransaction(txn, fn);
871
916
  }
@@ -875,25 +920,25 @@ function injectStartTransaction() {
875
920
  }
876
921
  let sawPending = false;
877
922
  watcher = effect(() => {
878
- const p = scope.pending();
923
+ const p = pending();
879
924
  if (p)
880
925
  sawPending = true;
881
926
  if (sawPending && !p)
882
927
  finish(false);
883
928
  }, { injector });
884
929
  if (onServer) {
885
- if (!untracked(scope.pending))
930
+ if (!untracked(pending))
886
931
  finish(false);
887
932
  }
888
933
  else {
889
934
  // no-async fallback: if nothing ever went in flight, settle once the writes are processed.
890
935
  afterNextRender(() => {
891
- if (!sawPending && !untracked(scope.pending))
936
+ if (!sawPending && !untracked(pending))
892
937
  finish(false);
893
938
  }, { injector });
894
939
  }
895
940
  return {
896
- pending: scope.pending,
941
+ pending,
897
942
  done,
898
943
  abort: () => finish(true),
899
944
  };
@@ -2907,9 +2952,13 @@ const IDLE = {
2907
2952
  button: -1,
2908
2953
  pointerType: '',
2909
2954
  origin: null,
2955
+ cancelled: false,
2910
2956
  };
2957
+ /** Terminal state of an aborted gesture — same idle shape, `cancelled: true`. */
2958
+ const CANCELLED = { ...IDLE, cancelled: true };
2911
2959
  function stateEqual(a, b) {
2912
2960
  return (a.active === b.active &&
2961
+ a.cancelled === b.cancelled &&
2913
2962
  a.pointerId === b.pointerId &&
2914
2963
  a.current.x === b.current.x &&
2915
2964
  a.current.y === b.current.y &&
@@ -2987,7 +3036,7 @@ function createPointerDrag(opt) {
2987
3036
  ctrl: e.ctrlKey,
2988
3037
  meta: e.metaKey,
2989
3038
  });
2990
- const end = () => {
3039
+ const end = (cancelled = false) => {
2991
3040
  gesture?.abort();
2992
3041
  gesture = null;
2993
3042
  activePointerId = null;
@@ -2995,8 +3044,8 @@ function createPointerDrag(opt) {
2995
3044
  activePointerType = '';
2996
3045
  activeOrigin = null;
2997
3046
  activated = false;
2998
- state.set(IDLE);
2999
- state.flush(); // terminal transition: reflect IDLE now, not on the trailing edge
3047
+ state.set(cancelled ? CANCELLED : IDLE);
3048
+ state.flush(); // terminal transition: reflect idle now, not on the trailing edge
3000
3049
  };
3001
3050
  const onMove = (e) => {
3002
3051
  if (e.pointerId !== activePointerId)
@@ -3016,6 +3065,7 @@ function createPointerDrag(opt) {
3016
3065
  button: activeButton, // pointermove button is -1; keep the down-button
3017
3066
  pointerType: activePointerType,
3018
3067
  origin: activeOrigin,
3068
+ cancelled: false,
3019
3069
  });
3020
3070
  };
3021
3071
  const onUp = (e) => {
@@ -3024,11 +3074,11 @@ function createPointerDrag(opt) {
3024
3074
  };
3025
3075
  const onCancel = (e) => {
3026
3076
  if (e.pointerId === activePointerId)
3027
- end();
3077
+ end(true);
3028
3078
  };
3029
3079
  const onKey = (e) => {
3030
3080
  if (e.key === 'Escape' && activePointerId !== null)
3031
- end();
3081
+ end(true);
3032
3082
  };
3033
3083
  const onDown = (el) => (e) => {
3034
3084
  if (activePointerId !== null)
@@ -3073,6 +3123,7 @@ function createPointerDrag(opt) {
3073
3123
  button: e.button,
3074
3124
  pointerType: activePointerType,
3075
3125
  origin: activeOrigin,
3126
+ cancelled: false,
3076
3127
  });
3077
3128
  };
3078
3129
  const attach = (el) => {
@@ -3082,7 +3133,7 @@ function createPointerDrag(opt) {
3082
3133
  });
3083
3134
  return () => {
3084
3135
  controller.abort();
3085
- end();
3136
+ end(true); // teardown mid-gesture is an abort, not a drop
3086
3137
  };
3087
3138
  };
3088
3139
  if (isSignal(target)) {
@@ -3100,7 +3151,7 @@ function createPointerDrag(opt) {
3100
3151
  }
3101
3152
  const base = state.asReadonly();
3102
3153
  base.unthrottled = state.original;
3103
- base.cancel = end;
3154
+ base.cancel = () => end(true);
3104
3155
  return base;
3105
3156
  }
3106
3157
 
@@ -3693,30 +3744,35 @@ function getCachedChild(target, prop, build, cache, cleanupRegistry) {
3693
3744
  cleanupRegistry.register(proxy, { target, prop }, ref);
3694
3745
  return proxy;
3695
3746
  }
3747
+ /**
3748
+ * @internal Whether a mutable parent's child value must always re-notify: in-place mutation
3749
+ * keeps an object child's reference stable, so `Object.is` would swallow the change. Decided
3750
+ * per-VALUE (not snapshotted at build) so a union child that becomes an object later still
3751
+ * propagates parent-level mutations.
3752
+ */
3753
+ function mutableChildEqual(a, b) {
3754
+ if (typeof a === 'object' && a !== null)
3755
+ return false;
3756
+ return Object.is(a, b);
3757
+ }
3696
3758
  /**
3697
3759
  * @internal Builds the derived child signal for `prop` and wraps it as an array/object substore.
3698
- * A record parent reads the key directly; any other container goes through the fallback `from`/
3699
- * `onChange` path. Shared verbatim by the array and object proxies the only place a child node
3700
- * is constructed.
3760
+ * Both the read (`v?.[prop]`) and the write (`createFallbackOnChange` copies by the container's
3761
+ * LIVE shape) are shape-adaptive, so a child cached before an array↔record↔null union flip stays
3762
+ * correct after it. The only place a child node is constructed — shared by every container kind.
3701
3763
  */
3702
3764
  function buildChildNode(target, prop, isMutableSource, options) {
3703
3765
  const value = untracked(target);
3704
- const valueIsRecord = isRecord(value);
3705
- const valueIsArray = Array.isArray(value);
3706
3766
  const nodeVivify = resolveVivify(value, options.vivify);
3707
3767
  const vivifyFn = createVivify(nodeVivify);
3708
- const equalFn = (valueIsRecord || valueIsArray) &&
3709
- isMutableSource &&
3710
- typeof value[prop] === 'object'
3711
- ? () => false
3768
+ const equalFn = isMutableSource && (isRecord(value) || Array.isArray(value))
3769
+ ? mutableChildEqual
3712
3770
  : undefined;
3713
- const computation = valueIsRecord
3714
- ? derived(target, prop, { equal: equalFn, vivify: nodeVivify })
3715
- : derived(target, {
3716
- from: (v) => v?.[prop],
3717
- onChange: createFallbackOnChange(target, prop, vivifyFn, isMutableSource),
3718
- equal: equalFn,
3719
- });
3771
+ const computation = derived(target, {
3772
+ from: (v) => v?.[prop],
3773
+ onChange: createFallbackOnChange(target, prop, vivifyFn, isMutableSource),
3774
+ equal: equalFn,
3775
+ });
3720
3776
  const childSample = untracked(computation);
3721
3777
  const childVivify = resolveVivify(childSample, options.vivify);
3722
3778
  const proxy = toStore(computation, options);
@@ -4605,5 +4661,5 @@ function withHistory(sourceOrValue, opt) {
4605
4661
  * Generated bundle index. Do not edit.
4606
4662
  */
4607
4663
 
4608
- 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 };
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 };
4609
4665
  //# sourceMappingURL=mmstack-primitives.mjs.map