@mmstack/primitives 22.2.1 → 22.3.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
@@ -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*`, Suspense (`mm-suspense`), `startTransition` / `startTransaction`, `holdUntilReady`
22
+ - [Concurrency & transitions](#concurrency--transitions) — `keepPrevious`, keep-alive (`MmActivity`), `pausable*` / `providePausableOptions`, Suspense (`mm-suspense`), `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
@@ -117,14 +117,18 @@ Each level's shape is resolved from what's known: a value that is currently an o
117
117
 
118
118
  Top-level array support isn't exposed yet — use `indexArray` / `keyArray` for those.
119
119
 
120
- ### `extend` (scoped overlay)
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
- `store.extend(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.
122
+ ### `extendStore` (scoped overlay)
123
+
124
+ `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.
123
125
 
124
126
  ```typescript
127
+ import { extendStore, store } from '@mmstack/primitives';
128
+
125
129
  const app = store({ user: { name: 'Alice' }, theme: 'dark' });
126
130
 
127
- const scope = app.extend({ draft: '' }); // inherits user + theme, adds a local draft
131
+ const scope = extendStore(app, { draft: '' }); // inherits user + theme, adds a local draft
128
132
 
129
133
  scope.user === app.user; // true — the same signal (shared, two-way)
130
134
  scope.user.name.set('Bob'); // writes through to the parent
@@ -132,19 +136,19 @@ scope.draft.set('hello'); // local only — `app` never gains `draft`
132
136
  scope(); // { user: { name: 'Bob' }, theme: 'dark', draft: 'hello' }
133
137
  ```
134
138
 
135
- Resolution per key is **local → parent → local**: a seed key (or one set on the scope before it exists on the parent) is local and _shadows_ the parent — and keeps shadowing even if the parent later grows that key; a key that exists only on the parent writes through to it; a brand-new key lands locally. `scope()` is the merged view (local shadowing), and `Object.keys(scope)` / `key in scope` are the union of both layers. `extend` composes — `a.extend(x).extend(y)` chains parents.
139
+ Resolution per key is **local → parent → local**: a seed key (or one set on the scope before it exists on the parent) is local and _shadows_ the parent — and keeps shadowing even if the parent later grows that key; a key that exists only on the parent writes through to it; a brand-new key lands locally. `scope()` is the merged view (local shadowing), and `Object.keys(scope)` / `key in scope` are the union of both layers. It composes — `extendStore(extendStore(app, x), y)` chains parents.
136
140
 
137
141
  The seed may also be a **signal** of the matching kind, so an existing (externally-owned, reactive) signal becomes the local layer:
138
142
 
139
143
  ```typescript
140
144
  const draft = signal({ title: '' });
141
- const scope = app.extend(draft); // writes to scope.title flow out to `draft`, and back in
145
+ const scope = extendStore(app, draft); // writes to scope.title flow out to `draft`, and back in
142
146
  ```
143
147
 
144
148
  A few release notes:
145
149
 
146
- - The local layer is a plain store (vivify off). Inherited paths vivify when the _parent_ was created with `vivify`; to autovivify local keys, seed with a vivify-enabled store `app.extend(store(seed, { vivify: 'auto' }))`.
147
- - Reserved names — `extend`, `asReadonlyStore`, and the signal methods (`set` / `update` / `mutate` / `inline` / `asReadonly`) — shadow same-named data keys, as on any store.
150
+ - The scope inherits the parent's config (`vivify` / `noUnionLeaves`) and its injector-scoped proxy cache, so **both** inherited and local paths vivify when the parent was created with `vivify`. `extendStore` doesn't accept `vivify` / `noUnionLeaves` they always come from the parent.
151
+ - Reserved names — `asReadonlyStore` and the signal methods (`set` / `update` / `mutate` / `inline` / `asReadonly`) — shadow same-named data keys, as on any store.
148
152
  - `scope.asReadonlyStore()` returns a read-only **snapshot view** of the merge (reactive reads, no writes); it does not share sub-store identity.
149
153
 
150
154
  ### `forkStore`
@@ -165,13 +169,13 @@ base.user.name(); // 'Bob'
165
169
  // draft.discard(); // …or throw the edits away
166
170
  ```
167
171
 
168
- The fork is a full store (`draft.store.user.name(...)`, `extend`, deep reads/writes — everything `store` gives you). It's built on `linkedSignal`: it holds local writes until the **base changes underneath it**, then runs a `strategy` to reconcile:
172
+ The fork is a full store (`draft.store.user.name(...)`, `extendStore`, deep reads/writes — everything `store` gives you). It's built on `linkedSignal`: it holds local writes until the **base changes underneath it**, then runs a `strategy` to reconcile:
169
173
 
170
174
  - **`'fine'`** (default for immutable stores) — per-path 3-way merge: keep the paths the fork edited, take the base's live values for the paths it didn't. Survives concurrent base changes. Relies on copy-on-write reference identity, so it's **unsupported on a mutable base** (in-place mutation defeats it — `fork` warns and falls back to `'coarse'`).
171
175
  - **`'coarse'`** — any base change resets the whole fork. Cheapest; correct when the base is held for the fork's lifetime (e.g. a transition). The default for a mutable base.
172
176
  - **a `ReconcileFn<T>`** — `(ancestor, mine, theirs) => merged`, for bring-your-own merge (array-by-id, Immer patches, CRDT-ish).
173
177
 
174
- > Pass the same `vivify` / `noUnionLeaves` the base was created with fork config isn't inherited (it's closed over inside the base), so mismatched config gives the fork different write semantics.
178
+ > The fork inherits the base's `vivify` / `noUnionLeaves` and its injector-scoped proxy cache automatically, so its write semantics match the base. Pass them explicitly only to override (advanced).
175
179
 
176
180
  ### `toWritable`
177
181
 
@@ -365,6 +369,19 @@ pausableEffect(() => poll(url())); // body skipped while paused; deps collapse s
365
369
 
366
370
  While paused each one **collapses its dependency set to just the pause predicate**, so an upstream change can't trigger work; on resume it re-tracks and re-runs / recomputes with the latest values. SSR never pauses.
367
371
 
372
+ #### `providePausableOptions`
373
+
374
+ Sets an app-wide default pause source for every pausable-aware primitive — the `pausable*` family above plus the opt-in integrations (`stored`, `chunked`). A call-site `pause` always wins; this only fills in when the call didn't specify one. Use it to make everything honour the ambient `*mmActivity` boundary from one place:
375
+
376
+ ```typescript
377
+ import { providePausableOptions } from '@mmstack/primitives';
378
+
379
+ // e.g. in app.config.ts
380
+ providers: [providePausableOptions({ pause: true })];
381
+ ```
382
+
383
+ With this provided, `stored(...)` / `chunked(...)` (off by default) start reading the ambient paused context; pass `pause: false` at an individual call site to opt that one back out.
384
+
368
385
  ### Suspense — `<mm-suspense>` and the transition scope
369
386
 
370
387
  A **transition scope** is a per-boundary registry of resources whose async state a boundary coordinates. `<mm-suspense>` provides its own scope, so resources created in its subtree register into it automatically (via `@mmstack/resource`'s `register` option, or `registerResource(ref)` for a hand-rolled `ResourceRef`):
@@ -631,6 +648,7 @@ const mouse = sensor('mousePosition', {
631
648
  | `windowSize` | `windowSize()` | `Signal<{ width, height }>` + `.unthrottled` | Throttled to 100ms by default. |
632
649
  | `scrollPosition` | `scrollPosition()` | `Signal<{ x, y }>` + `.unthrottled` | Window or element scroll, throttled 100ms. |
633
650
  | `mousePosition` | `mousePosition()` | `Signal<{ x, y }>` + `.unthrottled` | Throttled 100ms. `coordinateSpace: 'client' \| 'page'`, optional `touch`. |
651
+ | `pointerDrag` | `pointerDrag()` | `Signal<PointerDragState>` + `.unthrottled` + `.cancel()` | Pointer gesture (down→move→up) with `activationThreshold`, `delta`, modifiers, pointer capture, Escape-cancel. |
634
652
  | `elementVisibility` | `elementVisibility(target?)` | `Signal<IntersectionObserverEntry?>` + `.visible` | IntersectionObserver-based, `.visible` is a boolean shorthand. |
635
653
  | `elementSize` | `elementSize(target?)` | `Signal<{ width, height }?>` | ResizeObserver-based. Defaults to `border-box`. |
636
654
  | `geolocation` | `geolocation(opt?)` | `Signal<GeolocationPosition?>` + `.error` + `.loading` | One-shot by default; pass `watch: true` for `watchPosition`. |
@@ -640,7 +658,37 @@ const mouse = sensor('mousePosition', {
640
658
  | `idle` | `idle({ ms })` | `Signal<boolean>` + `.since` | Flips to `true` after `ms` of inactivity. Configurable activity events. |
641
659
  | `focusWithin` | `focusWithin(target?)` | `Signal<boolean>` | Mirrors the `:focus-within` CSS pseudo-class. |
642
660
 
643
- Element-targeting sensors (`elementSize`, `elementVisibility`, `focusWithin`) default `target` to `inject(ElementRef)` so they're drop-in inside a component.
661
+ Element-targeting sensors (`elementSize`, `elementVisibility`, `focusWithin`, `pointerDrag`) default `target` to `inject(ElementRef)` so they're drop-in inside a component.
662
+
663
+ ### `pointerDrag`
664
+
665
+ Tracks a pointer **gesture** (pointerdown → capture → move → up) as a signal — the
666
+ foundation for pointer-based move/resize/marquee on a canvas. Unlike native HTML5
667
+ drag, pointer events fire continuously and coordinates stay reliable; `delta` is
668
+ computed on the same update as `current` (never torn). `active` only flips true
669
+ once the pointer travels past `activationThreshold`, so the same element stays
670
+ clickable. Uses `setPointerCapture`, supports a delegated `handleSelector`, and
671
+ cancels on Escape or via `.cancel()`.
672
+
673
+ A delegated `handleSelector` reports which child actually started the drag via
674
+ `drag().origin` (so one listener on a container can serve many handles), and
675
+ `stopPropagation: true` lets an inner sensor claim the `pointerdown` over an
676
+ outer one on the same tree (e.g. a nested sortable). Reads are throttled
677
+ (`throttle`, default 16ms); `drag.unthrottled()` exposes the un-throttled view
678
+ for logic that needs the exact release position.
679
+
680
+ ```typescript
681
+ import { sensor } from '@mmstack/primitives';
682
+
683
+ const drag = sensor('pointerDrag', { activationThreshold: 4 });
684
+
685
+ // derive position from the gesture — no effects
686
+ const position = computed(() => {
687
+ const d = drag();
688
+ return d.active ? { x: base.x + d.delta.x, y: base.y + d.delta.y } : base;
689
+ });
690
+ // drag().modifiers.shift → e.g. constrain axis · drag().origin → the handle · drag.cancel() → revert
691
+ ```
644
692
 
645
693
  ### `signalFromEvent`
646
694