@mmstack/primitives 20.8.0 → 20.9.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
 
@@ -640,6 +640,38 @@ function provideForwardingTransitionScope() {
640
640
  function getTransitionScope(injector) {
641
641
  return injector.get(TRANSITION_SCOPE, null);
642
642
  }
643
+ /**
644
+ * @internal Transaction-attributed pending for `startTransition`/`startTransaction`: like
645
+ * `scope.pending`, but loads already in flight when the tracker is created are NOT attributed —
646
+ * a pre-existing background load can neither settle the transaction early nor block its settle
647
+ * forever. A pre-existing flight is excluded only until it first settles; a later re-trigger of
648
+ * the same resource (e.g. the transaction's write changed its request) counts as the
649
+ * transaction's own work.
650
+ */
651
+ function createAttributedPending(scope) {
652
+ const isInFlight = (ref) => {
653
+ const s = untracked(ref.status);
654
+ return s === 'loading' || s === 'reloading';
655
+ };
656
+ const preexisting = new Set(untracked(scope.resources).filter(isInFlight));
657
+ return computed(() => {
658
+ let pending = false;
659
+ for (const ref of scope.resources()) {
660
+ const s = ref.status();
661
+ const loading = s === 'loading' || s === 'reloading';
662
+ if (preexisting.has(ref)) {
663
+ // deletes are monotonic, so this stays sound under re-computation
664
+ if (loading)
665
+ continue;
666
+ preexisting.delete(ref);
667
+ continue;
668
+ }
669
+ if (loading)
670
+ pending = true;
671
+ }
672
+ return pending;
673
+ });
674
+ }
643
675
  /**
644
676
  * Returns a register function bound to the nearest transition scope: it adds a resource
645
677
  * to the scope and removes it when the caller's injection context is destroyed. Pass any
@@ -677,38 +709,43 @@ function registerResource(res, opt) {
677
709
  function injectStartTransition() {
678
710
  const scope = injectTransitionScope();
679
711
  const injector = inject(Injector);
712
+ const destroyRef = inject(DestroyRef);
680
713
  const onServer = isPlatformServer(inject(PLATFORM_ID, { optional: true }) ?? 'browser');
681
714
  return (fn) => {
715
+ // attributed: loads already in flight when the transition starts are not ours —
716
+ // they can neither settle this transition early nor block it forever
717
+ const pending = createAttributedPending(scope);
682
718
  untracked(fn);
683
719
  let sawPending = false;
684
720
  const done = new Promise((resolve) => {
721
+ const settle = () => {
722
+ releaseDestroy();
723
+ watcher.destroy();
724
+ resolve();
725
+ };
685
726
  const watcher = effect(() => {
686
- const p = scope.pending();
727
+ const p = pending();
687
728
  if (p)
688
729
  sawPending = true;
689
730
  // settle: requests went in flight and then drained
690
- if (sawPending && !p) {
691
- watcher.destroy();
692
- resolve();
693
- }
731
+ if (sawPending && !p)
732
+ settle();
694
733
  }, ...(ngDevMode ? [{ debugName: "watcher", injector }] : [{ injector }]));
734
+ // a destroy mid-flight kills the watcher — resolve so awaiters never hang
735
+ const releaseDestroy = destroyRef.onDestroy(settle);
695
736
  if (onServer) {
696
- if (!untracked(scope.pending)) {
697
- watcher.destroy();
698
- resolve();
699
- }
737
+ if (!untracked(pending))
738
+ settle();
700
739
  return;
701
740
  }
702
741
  // no-async fallback: once the reactive system has processed the writes (afterNextRender),
703
742
  // if nothing ever went in flight, the transition is already complete.
704
743
  afterNextRender(() => {
705
- if (!sawPending && !untracked(scope.pending)) {
706
- watcher.destroy();
707
- resolve();
708
- }
744
+ if (!sawPending && !untracked(pending))
745
+ settle();
709
746
  }, { injector });
710
747
  });
711
- return { pending: scope.pending, done };
748
+ return { pending, done };
712
749
  };
713
750
  }
714
751
 
@@ -779,8 +816,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.17", ngImpo
779
816
  }] });
780
817
  /**
781
818
  * Unscoped suspense boundary — **reads the ambient scope** instead of providing one. For cases where
782
- * the resources to coordinate are registered *above* the boundary (e.g. an app-builder page whose
783
- * manifests/connectors register at a higher injector), so the boundary observes that outer scope
819
+ * the resources to coordinate are registered *above* the boundary so the boundary observes that outer scope
784
820
  * rather than opening a fresh one. Pair with a `provideTransitionScope()` (or another boundary) in an
785
821
  * ancestor.
786
822
  */
@@ -845,9 +881,13 @@ function runInTransaction(txn, fn) {
845
881
  function injectStartTransaction() {
846
882
  const scope = injectTransitionScope();
847
883
  const injector = inject(Injector);
884
+ const destroyRef = inject(DestroyRef);
848
885
  const onServer = isPlatformServer(inject(PLATFORM_ID, { optional: true }) ?? 'browser');
849
886
  return (fn) => {
850
887
  const txn = createTransaction();
888
+ // attributed: loads already in flight when the transaction starts are not ours —
889
+ // they can neither commit this transaction early nor block its settle forever
890
+ const pending = createAttributedPending(scope);
851
891
  // Hold BEFORE the writes, so the display freezes at pre-transaction values.
852
892
  scope.beginHold();
853
893
  let finished = false;
@@ -864,6 +904,7 @@ function injectStartTransaction() {
864
904
  if (finished)
865
905
  return;
866
906
  finished = true;
907
+ releaseDestroy();
867
908
  watcher?.destroy();
868
909
  if (restore)
869
910
  txn.restore();
@@ -872,6 +913,10 @@ function injectStartTransaction() {
872
913
  scope.endHold();
873
914
  resolveDone();
874
915
  };
916
+ // The scope may outlive the calling context (a component transacting on an ancestor
917
+ // boundary): a destroy mid-flight kills the settle watcher, so without this the hold
918
+ // would leak and freeze the surviving scope forever. Keep the writes — they landed live.
919
+ const releaseDestroy = destroyRef.onDestroy(() => finish(false));
875
920
  try {
876
921
  runInTransaction(txn, fn);
877
922
  }
@@ -881,25 +926,25 @@ function injectStartTransaction() {
881
926
  }
882
927
  let sawPending = false;
883
928
  watcher = effect(() => {
884
- const p = scope.pending();
929
+ const p = pending();
885
930
  if (p)
886
931
  sawPending = true;
887
932
  if (sawPending && !p)
888
933
  finish(false);
889
934
  }, { injector });
890
935
  if (onServer) {
891
- if (!untracked(scope.pending))
936
+ if (!untracked(pending))
892
937
  finish(false);
893
938
  }
894
939
  else {
895
940
  // no-async fallback: if nothing ever went in flight, settle once the writes are processed.
896
941
  afterNextRender(() => {
897
- if (!sawPending && !untracked(scope.pending))
942
+ if (!sawPending && !untracked(pending))
898
943
  finish(false);
899
944
  }, { injector });
900
945
  }
901
946
  return {
902
- pending: scope.pending,
947
+ pending,
903
948
  done,
904
949
  abort: () => finish(true),
905
950
  };
@@ -2917,9 +2962,13 @@ const IDLE = {
2917
2962
  button: -1,
2918
2963
  pointerType: '',
2919
2964
  origin: null,
2965
+ cancelled: false,
2920
2966
  };
2967
+ /** Terminal state of an aborted gesture — same idle shape, `cancelled: true`. */
2968
+ const CANCELLED = { ...IDLE, cancelled: true };
2921
2969
  function stateEqual(a, b) {
2922
2970
  return (a.active === b.active &&
2971
+ a.cancelled === b.cancelled &&
2923
2972
  a.pointerId === b.pointerId &&
2924
2973
  a.current.x === b.current.x &&
2925
2974
  a.current.y === b.current.y &&
@@ -2997,7 +3046,7 @@ function createPointerDrag(opt) {
2997
3046
  ctrl: e.ctrlKey,
2998
3047
  meta: e.metaKey,
2999
3048
  });
3000
- const end = () => {
3049
+ const end = (cancelled = false) => {
3001
3050
  gesture?.abort();
3002
3051
  gesture = null;
3003
3052
  activePointerId = null;
@@ -3005,8 +3054,8 @@ function createPointerDrag(opt) {
3005
3054
  activePointerType = '';
3006
3055
  activeOrigin = null;
3007
3056
  activated = false;
3008
- state.set(IDLE);
3009
- state.flush(); // terminal transition: reflect IDLE now, not on the trailing edge
3057
+ state.set(cancelled ? CANCELLED : IDLE);
3058
+ state.flush(); // terminal transition: reflect idle now, not on the trailing edge
3010
3059
  };
3011
3060
  const onMove = (e) => {
3012
3061
  if (e.pointerId !== activePointerId)
@@ -3026,6 +3075,7 @@ function createPointerDrag(opt) {
3026
3075
  button: activeButton, // pointermove button is -1; keep the down-button
3027
3076
  pointerType: activePointerType,
3028
3077
  origin: activeOrigin,
3078
+ cancelled: false,
3029
3079
  });
3030
3080
  };
3031
3081
  const onUp = (e) => {
@@ -3034,11 +3084,11 @@ function createPointerDrag(opt) {
3034
3084
  };
3035
3085
  const onCancel = (e) => {
3036
3086
  if (e.pointerId === activePointerId)
3037
- end();
3087
+ end(true);
3038
3088
  };
3039
3089
  const onKey = (e) => {
3040
3090
  if (e.key === 'Escape' && activePointerId !== null)
3041
- end();
3091
+ end(true);
3042
3092
  };
3043
3093
  const onDown = (el) => (e) => {
3044
3094
  if (activePointerId !== null)
@@ -3083,6 +3133,7 @@ function createPointerDrag(opt) {
3083
3133
  button: e.button,
3084
3134
  pointerType: activePointerType,
3085
3135
  origin: activeOrigin,
3136
+ cancelled: false,
3086
3137
  });
3087
3138
  };
3088
3139
  const attach = (el) => {
@@ -3092,7 +3143,7 @@ function createPointerDrag(opt) {
3092
3143
  });
3093
3144
  return () => {
3094
3145
  controller.abort();
3095
- end();
3146
+ end(true); // teardown mid-gesture is an abort, not a drop
3096
3147
  };
3097
3148
  };
3098
3149
  if (isSignal(target)) {
@@ -3110,7 +3161,7 @@ function createPointerDrag(opt) {
3110
3161
  }
3111
3162
  const base = state.asReadonly();
3112
3163
  base.unthrottled = state.original;
3113
- base.cancel = end;
3164
+ base.cancel = () => end(true);
3114
3165
  return base;
3115
3166
  }
3116
3167
 
@@ -3703,30 +3754,35 @@ function getCachedChild(target, prop, build, cache, cleanupRegistry) {
3703
3754
  cleanupRegistry.register(proxy, { target, prop }, ref);
3704
3755
  return proxy;
3705
3756
  }
3757
+ /**
3758
+ * @internal Whether a mutable parent's child value must always re-notify: in-place mutation
3759
+ * keeps an object child's reference stable, so `Object.is` would swallow the change. Decided
3760
+ * per-VALUE (not snapshotted at build) so a union child that becomes an object later still
3761
+ * propagates parent-level mutations.
3762
+ */
3763
+ function mutableChildEqual(a, b) {
3764
+ if (typeof a === 'object' && a !== null)
3765
+ return false;
3766
+ return Object.is(a, b);
3767
+ }
3706
3768
  /**
3707
3769
  * @internal Builds the derived child signal for `prop` and wraps it as an array/object substore.
3708
- * A record parent reads the key directly; any other container goes through the fallback `from`/
3709
- * `onChange` path. Shared verbatim by the array and object proxies the only place a child node
3710
- * is constructed.
3770
+ * Both the read (`v?.[prop]`) and the write (`createFallbackOnChange` copies by the container's
3771
+ * LIVE shape) are shape-adaptive, so a child cached before an array↔record↔null union flip stays
3772
+ * correct after it. The only place a child node is constructed — shared by every container kind.
3711
3773
  */
3712
3774
  function buildChildNode(target, prop, isMutableSource, options) {
3713
3775
  const value = untracked(target);
3714
- const valueIsRecord = isRecord(value);
3715
- const valueIsArray = Array.isArray(value);
3716
3776
  const nodeVivify = resolveVivify(value, options.vivify);
3717
3777
  const vivifyFn = createVivify(nodeVivify);
3718
- const equalFn = (valueIsRecord || valueIsArray) &&
3719
- isMutableSource &&
3720
- typeof value[prop] === 'object'
3721
- ? () => false
3778
+ const equalFn = isMutableSource && (isRecord(value) || Array.isArray(value))
3779
+ ? mutableChildEqual
3722
3780
  : undefined;
3723
- const computation = valueIsRecord
3724
- ? derived(target, prop, { equal: equalFn, vivify: nodeVivify })
3725
- : derived(target, {
3726
- from: (v) => v?.[prop],
3727
- onChange: createFallbackOnChange(target, prop, vivifyFn, isMutableSource),
3728
- equal: equalFn,
3729
- });
3781
+ const computation = derived(target, {
3782
+ from: (v) => v?.[prop],
3783
+ onChange: createFallbackOnChange(target, prop, vivifyFn, isMutableSource),
3784
+ equal: equalFn,
3785
+ });
3730
3786
  const childSample = untracked(computation);
3731
3787
  const childVivify = resolveVivify(childSample, options.vivify);
3732
3788
  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