@mmstack/primitives 21.2.1 → 21.2.2
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 +49 -10
- package/fesm2022/mmstack-primitives.mjs +590 -351
- package/fesm2022/mmstack-primitives.mjs.map +1 -1
- package/package.json +1 -1
- package/types/mmstack-primitives.d.ts +264 -117
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
|
|
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,16 @@ 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
|
-
### `
|
|
120
|
+
### `extendStore` (scoped overlay)
|
|
121
121
|
|
|
122
|
-
`store
|
|
122
|
+
`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
123
|
|
|
124
124
|
```typescript
|
|
125
|
+
import { extendStore, store } from '@mmstack/primitives';
|
|
126
|
+
|
|
125
127
|
const app = store({ user: { name: 'Alice' }, theme: 'dark' });
|
|
126
128
|
|
|
127
|
-
const scope = app
|
|
129
|
+
const scope = extendStore(app, { draft: '' }); // inherits user + theme, adds a local draft
|
|
128
130
|
|
|
129
131
|
scope.user === app.user; // true — the same signal (shared, two-way)
|
|
130
132
|
scope.user.name.set('Bob'); // writes through to the parent
|
|
@@ -132,19 +134,19 @@ scope.draft.set('hello'); // local only — `app` never gains `draft`
|
|
|
132
134
|
scope(); // { user: { name: 'Bob' }, theme: 'dark', draft: 'hello' }
|
|
133
135
|
```
|
|
134
136
|
|
|
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.
|
|
137
|
+
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
138
|
|
|
137
139
|
The seed may also be a **signal** of the matching kind, so an existing (externally-owned, reactive) signal becomes the local layer:
|
|
138
140
|
|
|
139
141
|
```typescript
|
|
140
142
|
const draft = signal({ title: '' });
|
|
141
|
-
const scope = app
|
|
143
|
+
const scope = extendStore(app, draft); // writes to scope.title flow out to `draft`, and back in
|
|
142
144
|
```
|
|
143
145
|
|
|
144
146
|
A few release notes:
|
|
145
147
|
|
|
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
|
|
147
|
-
- Reserved names — `
|
|
148
|
+
- 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 — `extendStore(app, store(seed, { vivify: 'auto' }))`.
|
|
149
|
+
- Reserved names — `asReadonlyStore` and the signal methods (`set` / `update` / `mutate` / `inline` / `asReadonly`) — shadow same-named data keys, as on any store.
|
|
148
150
|
- `scope.asReadonlyStore()` returns a read-only **snapshot view** of the merge (reactive reads, no writes); it does not share sub-store identity.
|
|
149
151
|
|
|
150
152
|
### `forkStore`
|
|
@@ -165,7 +167,7 @@ base.user.name(); // 'Bob'
|
|
|
165
167
|
// draft.discard(); // …or throw the edits away
|
|
166
168
|
```
|
|
167
169
|
|
|
168
|
-
The fork is a full store (`draft.store.user.name(...)`, `
|
|
170
|
+
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
171
|
|
|
170
172
|
- **`'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
173
|
- **`'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.
|
|
@@ -365,6 +367,19 @@ pausableEffect(() => poll(url())); // body skipped while paused; deps collapse s
|
|
|
365
367
|
|
|
366
368
|
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
369
|
|
|
370
|
+
#### `providePausableOptions`
|
|
371
|
+
|
|
372
|
+
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:
|
|
373
|
+
|
|
374
|
+
```typescript
|
|
375
|
+
import { providePausableOptions } from '@mmstack/primitives';
|
|
376
|
+
|
|
377
|
+
// e.g. in app.config.ts
|
|
378
|
+
providers: [providePausableOptions({ pause: true })];
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
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.
|
|
382
|
+
|
|
368
383
|
### Suspense — `<mm-suspense>` and the transition scope
|
|
369
384
|
|
|
370
385
|
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 +646,7 @@ const mouse = sensor('mousePosition', {
|
|
|
631
646
|
| `windowSize` | `windowSize()` | `Signal<{ width, height }>` + `.unthrottled` | Throttled to 100ms by default. |
|
|
632
647
|
| `scrollPosition` | `scrollPosition()` | `Signal<{ x, y }>` + `.unthrottled` | Window or element scroll, throttled 100ms. |
|
|
633
648
|
| `mousePosition` | `mousePosition()` | `Signal<{ x, y }>` + `.unthrottled` | Throttled 100ms. `coordinateSpace: 'client' \| 'page'`, optional `touch`. |
|
|
649
|
+
| `pointerDrag` | `pointerDrag()` | `Signal<PointerDragState>` + `.unthrottled` + `.cancel()` | Pointer gesture (down→move→up) with `activationThreshold`, `delta`, modifiers, pointer capture, Escape-cancel. |
|
|
634
650
|
| `elementVisibility` | `elementVisibility(target?)` | `Signal<IntersectionObserverEntry?>` + `.visible` | IntersectionObserver-based, `.visible` is a boolean shorthand. |
|
|
635
651
|
| `elementSize` | `elementSize(target?)` | `Signal<{ width, height }?>` | ResizeObserver-based. Defaults to `border-box`. |
|
|
636
652
|
| `geolocation` | `geolocation(opt?)` | `Signal<GeolocationPosition?>` + `.error` + `.loading` | One-shot by default; pass `watch: true` for `watchPosition`. |
|
|
@@ -640,7 +656,30 @@ const mouse = sensor('mousePosition', {
|
|
|
640
656
|
| `idle` | `idle({ ms })` | `Signal<boolean>` + `.since` | Flips to `true` after `ms` of inactivity. Configurable activity events. |
|
|
641
657
|
| `focusWithin` | `focusWithin(target?)` | `Signal<boolean>` | Mirrors the `:focus-within` CSS pseudo-class. |
|
|
642
658
|
|
|
643
|
-
Element-targeting sensors (`elementSize`, `elementVisibility`, `focusWithin`) default `target` to `inject(ElementRef)` so they're drop-in inside a component.
|
|
659
|
+
Element-targeting sensors (`elementSize`, `elementVisibility`, `focusWithin`, `pointerDrag`) default `target` to `inject(ElementRef)` so they're drop-in inside a component.
|
|
660
|
+
|
|
661
|
+
### `pointerDrag`
|
|
662
|
+
|
|
663
|
+
Tracks a pointer **gesture** (pointerdown → capture → move → up) as a signal — the
|
|
664
|
+
foundation for pointer-based move/resize/marquee on a canvas. Unlike native HTML5
|
|
665
|
+
drag, pointer events fire continuously and coordinates stay reliable; `delta` is
|
|
666
|
+
computed on the same update as `current` (never torn). `active` only flips true
|
|
667
|
+
once the pointer travels past `activationThreshold`, so the same element stays
|
|
668
|
+
clickable. Uses `setPointerCapture`, supports a delegated `handleSelector`, and
|
|
669
|
+
cancels on Escape or via `.cancel()`.
|
|
670
|
+
|
|
671
|
+
```typescript
|
|
672
|
+
import { sensor } from '@mmstack/primitives';
|
|
673
|
+
|
|
674
|
+
const drag = sensor('pointerDrag', { activationThreshold: 4 });
|
|
675
|
+
|
|
676
|
+
// derive position from the gesture — no effects
|
|
677
|
+
const position = computed(() => {
|
|
678
|
+
const d = drag();
|
|
679
|
+
return d.active ? { x: base.x + d.delta.x, y: base.y + d.delta.y } : base;
|
|
680
|
+
});
|
|
681
|
+
// drag().modifiers.shift → e.g. constrain axis · drag.cancel() → revert
|
|
682
|
+
```
|
|
644
683
|
|
|
645
684
|
### `signalFromEvent`
|
|
646
685
|
|