@mmstack/primitives 22.3.0 → 22.4.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
 
@@ -633,6 +633,38 @@ function provideForwardingTransitionScope() {
633
633
  function getTransitionScope(injector) {
634
634
  return injector.get(TRANSITION_SCOPE, null);
635
635
  }
636
+ /**
637
+ * @internal Transaction-attributed pending for `startTransition`/`startTransaction`: like
638
+ * `scope.pending`, but loads already in flight when the tracker is created are NOT attributed —
639
+ * a pre-existing background load can neither settle the transaction early nor block its settle
640
+ * forever. A pre-existing flight is excluded only until it first settles; a later re-trigger of
641
+ * the same resource (e.g. the transaction's write changed its request) counts as the
642
+ * transaction's own work.
643
+ */
644
+ function createAttributedPending(scope) {
645
+ const isInFlight = (ref) => {
646
+ const s = untracked(ref.status);
647
+ return s === 'loading' || s === 'reloading';
648
+ };
649
+ const preexisting = new Set(untracked(scope.resources).filter(isInFlight));
650
+ return computed(() => {
651
+ let pending = false;
652
+ for (const ref of scope.resources()) {
653
+ const s = ref.status();
654
+ const loading = s === 'loading' || s === 'reloading';
655
+ if (preexisting.has(ref)) {
656
+ // deletes are monotonic, so this stays sound under re-computation
657
+ if (loading)
658
+ continue;
659
+ preexisting.delete(ref);
660
+ continue;
661
+ }
662
+ if (loading)
663
+ pending = true;
664
+ }
665
+ return pending;
666
+ });
667
+ }
636
668
  /**
637
669
  * Returns a register function bound to the nearest transition scope: it adds a resource
638
670
  * to the scope and removes it when the caller's injection context is destroyed. Pass any
@@ -670,38 +702,43 @@ function registerResource(res, opt) {
670
702
  function injectStartTransition() {
671
703
  const scope = injectTransitionScope();
672
704
  const injector = inject(Injector);
705
+ const destroyRef = inject(DestroyRef);
673
706
  const onServer = isPlatformServer(inject(PLATFORM_ID, { optional: true }) ?? 'browser');
674
707
  return (fn) => {
708
+ // attributed: loads already in flight when the transition starts are not ours —
709
+ // they can neither settle this transition early nor block it forever
710
+ const pending = createAttributedPending(scope);
675
711
  untracked(fn);
676
712
  let sawPending = false;
677
713
  const done = new Promise((resolve) => {
714
+ const settle = () => {
715
+ releaseDestroy();
716
+ watcher.destroy();
717
+ resolve();
718
+ };
678
719
  const watcher = effect(() => {
679
- const p = scope.pending();
720
+ const p = pending();
680
721
  if (p)
681
722
  sawPending = true;
682
723
  // settle: requests went in flight and then drained
683
- if (sawPending && !p) {
684
- watcher.destroy();
685
- resolve();
686
- }
724
+ if (sawPending && !p)
725
+ settle();
687
726
  }, { ...(ngDevMode ? { debugName: "watcher" } : /* istanbul ignore next */ {}), injector });
727
+ // a destroy mid-flight kills the watcher — resolve so awaiters never hang
728
+ const releaseDestroy = destroyRef.onDestroy(settle);
688
729
  if (onServer) {
689
- if (!untracked(scope.pending)) {
690
- watcher.destroy();
691
- resolve();
692
- }
730
+ if (!untracked(pending))
731
+ settle();
693
732
  return;
694
733
  }
695
734
  // no-async fallback: once the reactive system has processed the writes (afterNextRender),
696
735
  // if nothing ever went in flight, the transition is already complete.
697
736
  afterNextRender(() => {
698
- if (!sawPending && !untracked(scope.pending)) {
699
- watcher.destroy();
700
- resolve();
701
- }
737
+ if (!sawPending && !untracked(pending))
738
+ settle();
702
739
  }, { injector });
703
740
  });
704
- return { pending: scope.pending, done };
741
+ return { pending, done };
705
742
  };
706
743
  }
707
744
 
@@ -774,8 +811,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.2", ngImpor
774
811
  }] });
775
812
  /**
776
813
  * Unscoped suspense boundary — **reads the ambient scope** instead of providing one. For cases where
777
- * the resources to coordinate are registered *above* the boundary (e.g. an app-builder page whose
778
- * manifests/connectors register at a higher injector), so the boundary observes that outer scope
814
+ * the resources to coordinate are registered *above* the boundary so the boundary observes that outer scope
779
815
  * rather than opening a fresh one. Pair with a `provideTransitionScope()` (or another boundary) in an
780
816
  * ancestor.
781
817
  */
@@ -840,9 +876,13 @@ function runInTransaction(txn, fn) {
840
876
  function injectStartTransaction() {
841
877
  const scope = injectTransitionScope();
842
878
  const injector = inject(Injector);
879
+ const destroyRef = inject(DestroyRef);
843
880
  const onServer = isPlatformServer(inject(PLATFORM_ID, { optional: true }) ?? 'browser');
844
881
  return (fn) => {
845
882
  const txn = createTransaction();
883
+ // attributed: loads already in flight when the transaction starts are not ours —
884
+ // they can neither commit this transaction early nor block its settle forever
885
+ const pending = createAttributedPending(scope);
846
886
  // Hold BEFORE the writes, so the display freezes at pre-transaction values.
847
887
  scope.beginHold();
848
888
  let finished = false;
@@ -859,6 +899,7 @@ function injectStartTransaction() {
859
899
  if (finished)
860
900
  return;
861
901
  finished = true;
902
+ releaseDestroy();
862
903
  watcher?.destroy();
863
904
  if (restore)
864
905
  txn.restore();
@@ -867,6 +908,10 @@ function injectStartTransaction() {
867
908
  scope.endHold();
868
909
  resolveDone();
869
910
  };
911
+ // The scope may outlive the calling context (a component transacting on an ancestor
912
+ // boundary): a destroy mid-flight kills the settle watcher, so without this the hold
913
+ // would leak and freeze the surviving scope forever. Keep the writes — they landed live.
914
+ const releaseDestroy = destroyRef.onDestroy(() => finish(false));
870
915
  try {
871
916
  runInTransaction(txn, fn);
872
917
  }
@@ -876,25 +921,25 @@ function injectStartTransaction() {
876
921
  }
877
922
  let sawPending = false;
878
923
  watcher = effect(() => {
879
- const p = scope.pending();
924
+ const p = pending();
880
925
  if (p)
881
926
  sawPending = true;
882
927
  if (sawPending && !p)
883
928
  finish(false);
884
929
  }, { injector });
885
930
  if (onServer) {
886
- if (!untracked(scope.pending))
931
+ if (!untracked(pending))
887
932
  finish(false);
888
933
  }
889
934
  else {
890
935
  // no-async fallback: if nothing ever went in flight, settle once the writes are processed.
891
936
  afterNextRender(() => {
892
- if (!sawPending && !untracked(scope.pending))
937
+ if (!sawPending && !untracked(pending))
893
938
  finish(false);
894
939
  }, { injector });
895
940
  }
896
941
  return {
897
- pending: scope.pending,
942
+ pending,
898
943
  done,
899
944
  abort: () => finish(true),
900
945
  };
@@ -2921,9 +2966,13 @@ const IDLE = {
2921
2966
  button: -1,
2922
2967
  pointerType: '',
2923
2968
  origin: null,
2969
+ cancelled: false,
2924
2970
  };
2971
+ /** Terminal state of an aborted gesture — same idle shape, `cancelled: true`. */
2972
+ const CANCELLED = { ...IDLE, cancelled: true };
2925
2973
  function stateEqual(a, b) {
2926
2974
  return (a.active === b.active &&
2975
+ a.cancelled === b.cancelled &&
2927
2976
  a.pointerId === b.pointerId &&
2928
2977
  a.current.x === b.current.x &&
2929
2978
  a.current.y === b.current.y &&
@@ -3001,7 +3050,7 @@ function createPointerDrag(opt) {
3001
3050
  ctrl: e.ctrlKey,
3002
3051
  meta: e.metaKey,
3003
3052
  });
3004
- const end = () => {
3053
+ const end = (cancelled = false) => {
3005
3054
  gesture?.abort();
3006
3055
  gesture = null;
3007
3056
  activePointerId = null;
@@ -3009,8 +3058,8 @@ function createPointerDrag(opt) {
3009
3058
  activePointerType = '';
3010
3059
  activeOrigin = null;
3011
3060
  activated = false;
3012
- state.set(IDLE);
3013
- state.flush(); // terminal transition: reflect IDLE now, not on the trailing edge
3061
+ state.set(cancelled ? CANCELLED : IDLE);
3062
+ state.flush(); // terminal transition: reflect idle now, not on the trailing edge
3014
3063
  };
3015
3064
  const onMove = (e) => {
3016
3065
  if (e.pointerId !== activePointerId)
@@ -3030,6 +3079,7 @@ function createPointerDrag(opt) {
3030
3079
  button: activeButton, // pointermove button is -1; keep the down-button
3031
3080
  pointerType: activePointerType,
3032
3081
  origin: activeOrigin,
3082
+ cancelled: false,
3033
3083
  });
3034
3084
  };
3035
3085
  const onUp = (e) => {
@@ -3038,11 +3088,11 @@ function createPointerDrag(opt) {
3038
3088
  };
3039
3089
  const onCancel = (e) => {
3040
3090
  if (e.pointerId === activePointerId)
3041
- end();
3091
+ end(true);
3042
3092
  };
3043
3093
  const onKey = (e) => {
3044
3094
  if (e.key === 'Escape' && activePointerId !== null)
3045
- end();
3095
+ end(true);
3046
3096
  };
3047
3097
  const onDown = (el) => (e) => {
3048
3098
  if (activePointerId !== null)
@@ -3087,6 +3137,7 @@ function createPointerDrag(opt) {
3087
3137
  button: e.button,
3088
3138
  pointerType: activePointerType,
3089
3139
  origin: activeOrigin,
3140
+ cancelled: false,
3090
3141
  });
3091
3142
  };
3092
3143
  const attach = (el) => {
@@ -3096,7 +3147,7 @@ function createPointerDrag(opt) {
3096
3147
  });
3097
3148
  return () => {
3098
3149
  controller.abort();
3099
- end();
3150
+ end(true); // teardown mid-gesture is an abort, not a drop
3100
3151
  };
3101
3152
  };
3102
3153
  if (isSignal(target)) {
@@ -3114,7 +3165,7 @@ function createPointerDrag(opt) {
3114
3165
  }
3115
3166
  const base = state.asReadonly();
3116
3167
  base.unthrottled = state.original;
3117
- base.cancel = end;
3168
+ base.cancel = () => end(true);
3118
3169
  return base;
3119
3170
  }
3120
3171
 
@@ -3710,30 +3761,35 @@ function getCachedChild(target, prop, build, cache, cleanupRegistry) {
3710
3761
  cleanupRegistry.register(proxy, { target, prop }, ref);
3711
3762
  return proxy;
3712
3763
  }
3764
+ /**
3765
+ * @internal Whether a mutable parent's child value must always re-notify: in-place mutation
3766
+ * keeps an object child's reference stable, so `Object.is` would swallow the change. Decided
3767
+ * per-VALUE (not snapshotted at build) so a union child that becomes an object later still
3768
+ * propagates parent-level mutations.
3769
+ */
3770
+ function mutableChildEqual(a, b) {
3771
+ if (typeof a === 'object' && a !== null)
3772
+ return false;
3773
+ return Object.is(a, b);
3774
+ }
3713
3775
  /**
3714
3776
  * @internal Builds the derived child signal for `prop` and wraps it as an array/object substore.
3715
- * A record parent reads the key directly; any other container goes through the fallback `from`/
3716
- * `onChange` path. Shared verbatim by the array and object proxies the only place a child node
3717
- * is constructed.
3777
+ * Both the read (`v?.[prop]`) and the write (`createFallbackOnChange` copies by the container's
3778
+ * LIVE shape) are shape-adaptive, so a child cached before an array↔record↔null union flip stays
3779
+ * correct after it. The only place a child node is constructed — shared by every container kind.
3718
3780
  */
3719
3781
  function buildChildNode(target, prop, isMutableSource, options) {
3720
3782
  const value = untracked(target);
3721
- const valueIsRecord = isRecord(value);
3722
- const valueIsArray = Array.isArray(value);
3723
3783
  const nodeVivify = resolveVivify(value, options.vivify);
3724
3784
  const vivifyFn = createVivify(nodeVivify);
3725
- const equalFn = (valueIsRecord || valueIsArray) &&
3726
- isMutableSource &&
3727
- typeof value[prop] === 'object'
3728
- ? () => false
3785
+ const equalFn = isMutableSource && (isRecord(value) || Array.isArray(value))
3786
+ ? mutableChildEqual
3729
3787
  : undefined;
3730
- const computation = valueIsRecord
3731
- ? derived(target, prop, { equal: equalFn, vivify: nodeVivify })
3732
- : derived(target, {
3733
- from: (v) => v?.[prop],
3734
- onChange: createFallbackOnChange(target, prop, vivifyFn, isMutableSource),
3735
- equal: equalFn,
3736
- });
3788
+ const computation = derived(target, {
3789
+ from: (v) => v?.[prop],
3790
+ onChange: createFallbackOnChange(target, prop, vivifyFn, isMutableSource),
3791
+ equal: equalFn,
3792
+ });
3737
3793
  const childSample = untracked(computation);
3738
3794
  const childVivify = resolveVivify(childSample, options.vivify);
3739
3795
  const proxy = toStore(computation, options);
@@ -4623,5 +4679,5 @@ function withHistory(sourceOrValue, opt) {
4623
4679
  * Generated bundle index. Do not edit.
4624
4680
  */
4625
4681
 
4626
- 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 };
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 };
4627
4683
  //# sourceMappingURL=mmstack-primitives.mjs.map