@mmstack/primitives 21.4.1 → 21.5.1
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 +105 -3
- package/fesm2022/mmstack-primitives.mjs +693 -14
- package/fesm2022/mmstack-primitives.mjs.map +1 -1
- package/package.json +1 -1
- package/types/mmstack-primitives.d.ts +424 -10
package/README.md
CHANGED
|
@@ -17,10 +17,10 @@ npm install @mmstack/primitives
|
|
|
17
17
|
|
|
18
18
|
- [Writable signal variants](#writable-signal-variants) — `mutable`, `derived`, `store` / `mutableStore`, `forkStore`, `toWritable`
|
|
19
19
|
- [Timing & propagation](#timing--propagation) — `debounced`, `throttled`, `until`
|
|
20
|
-
- [Reactive collections](#reactive-collections) — `indexArray`, `keyArray`, `mapObject`
|
|
20
|
+
- [Reactive collections](#reactive-collections) — `indexArray`, `keyArray`, `mapObject`, `projection`
|
|
21
21
|
- [Effects](#effects) — `nestedEffect`
|
|
22
|
-
- [Concurrency & transitions](#concurrency--transitions) — `keepPrevious`, keep-alive (`MmActivity`), `pausable*` / `providePausableOptions`, Suspense (`mm-suspense`), hold-and-swap (`*mmTransition`), `startTransition` / `startTransaction`, `holdUntilReady`
|
|
23
|
-
- [History & persistence](#history--persistence) — `withHistory`, `stored`, `tabSync`
|
|
22
|
+
- [Concurrency & transitions](#concurrency--transitions) — `keepPrevious`, keep-alive (`MmActivity`), `pausable*` / `providePausableOptions`, Suspense (`mm-suspense`), hold-and-swap (`*mmTransition`), per-element morphs (`mmViewTransitionName`), async derivations (`latest` / `use`), `deferredValue`, `startTransition` / `startTransaction`, `holdUntilReady`
|
|
23
|
+
- [History & persistence](#history--persistence) — `withHistory`, `stored`, `tabSync`, `opLog`
|
|
24
24
|
- [Performance helpers](#performance-helpers) — `chunked`, `pooled` / `pooledArray` / `pooledMap` / `pooledSet`
|
|
25
25
|
- [Sensors](#sensors) — `sensor()` facade + browser-state signals
|
|
26
26
|
- [Pipelines](#pipelines) — `piped` / `pipeable`, operators (`select`, `map`, `filter`, `filterWith`, `distinct`, `combineWith`, `tap`, `startWith`, `pairwise`, `scan`)
|
|
@@ -303,6 +303,32 @@ const controls = mapObject(
|
|
|
303
303
|
);
|
|
304
304
|
```
|
|
305
305
|
|
|
306
|
+
### `projection`
|
|
307
|
+
|
|
308
|
+
A derived **store**, the store-shaped counterpart to `computed`. Where `derived` slices one value out of a source and `indexArray` / `keyArray` map a list, `projection` derives a whole store subtree from a computation. `fn` receives a mutable draft seeded with the current value and either mutates it or returns new data; the result is reconciled against the previous value so unchanged object subtrees keep their reference and keyed array items keep their identity across recomputes. Reading through the returned store is per-leaf, so a `computed` over one field only recomputes when that field actually changes, even though the whole projection re-ran.
|
|
309
|
+
|
|
310
|
+
```typescript
|
|
311
|
+
import { projection } from '@mmstack/primitives';
|
|
312
|
+
|
|
313
|
+
const users = signal<User[]>([]);
|
|
314
|
+
|
|
315
|
+
// return form: derive a filtered collection, reconciled by id
|
|
316
|
+
const active = projection<User[]>(() => users().filter((u) => u.active), [], {
|
|
317
|
+
key: 'id',
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
// mutate form: update fields on the draft
|
|
321
|
+
const summary = projection<{ total: number; active: number }>(
|
|
322
|
+
(draft) => {
|
|
323
|
+
draft.total = users().length;
|
|
324
|
+
draft.active = users().filter((u) => u.active).length;
|
|
325
|
+
},
|
|
326
|
+
{ total: 0, active: 0 },
|
|
327
|
+
);
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
Recompute is pull-based, exactly like `computed`: memoized, re-run on the first read after a dependency changes, coherent immediately after a write (no waiting on an effect flush), and skipped entirely while nobody reads. `fn` must be pure since it runs inside the reactive computation. Prefer `computed` for a plain value, and reach for `projection` when you want the per-property tracking of a store on top of a derivation. The standalone `reconcile(prev, next, key)` is exported too, for producing a reference-stable value by hand. Values must be structured-clonable (the draft is a clone of the current value). With an explicit store context (`createStoreContext()`) a projection is injector-free, so it also runs on a worker host.
|
|
331
|
+
|
|
306
332
|
## Effects
|
|
307
333
|
|
|
308
334
|
### `nestedEffect`
|
|
@@ -441,6 +467,10 @@ This is also the pattern for coordinating resources registered _above_ a boundar
|
|
|
441
467
|
|
|
442
468
|
**Forwarding scope (advanced).** `provideForwardingTransitionScope()` provides a scope that can be **re-pointed at a different target at runtime** via `setTarget(scope | null)` — reads follow the current target, while `add`/`remove` pin to the target a resource was registered under (so re-pointing never strands a registration). It's the building block for a coordinator that hosts several independent sub-scopes and switches which one it observes — e.g. a router outlet that, per navigation, points at the incoming route's own scope (read it from any injector with `getTransitionScope(injector)`). Most apps reach for `provideTransitionScope()`; this is for that one extra level of control.
|
|
443
469
|
|
|
470
|
+
**Cancellation — `scope.abortPending()`.** View-scoped work already dies with its view (a superseded transition destroys the hidden incoming view, which aborts its in-flight loads — and an aborted response can never settle into `@mmstack/resource`'s cache). For resources registered in a scope that _outlives_ the transition, `scope.abortPending()` is the manual lever: it calls `abort()` on every in-flight registered resource that exposes it (queries do; mutations deliberately don't — a POST can't be unsent) and returns how many it aborted. A shared resource aborts for _all_ its readers, so reach for this on interactions that invalidate the pending work, not as a reflex. Honest limit: only I/O is cancellable — no framework can preempt a running synchronous computation.
|
|
471
|
+
|
|
472
|
+
**SSR.** Scopes bridge into Angular's `PendingTasks` on the server automatically: while a scope has in-flight loads, serialization waits — so even custom (non-HTTP) loaders render settled. This is wired by the `provide*TransitionScope()` factories; call `bridgeScopeToPendingTasks(scope, injector)` yourself only for scopes you construct directly. Browser builds are untouched (client stability is deliberately not tied to loads).
|
|
473
|
+
|
|
444
474
|
### Hold-and-swap — `*mmTransition`
|
|
445
475
|
|
|
446
476
|
The transition itself, for any branch change — tabs, wizard steps, master-detail. Suspense decides placeholder-vs-content _within_ a branch, but it can't stop an `@switch` from unmounting the old branch the instant the value flips. `*mmTransition` holds it: when the bound value changes, the **old view stays mounted and visible** (keeping its old value) while the **new view mounts hidden with its own transition scope**; resources created in the incoming subtree register there just by existing, and once they've gone in flight and settled the views swap in one frame.
|
|
@@ -460,6 +490,52 @@ The transition itself, for any branch change — tabs, wizard steps, master-deta
|
|
|
460
490
|
|
|
461
491
|
The first render is immediate (nothing to hold). An interrupting change mid-hold destroys the half-ready hidden view and re-targets — the stable view stays visible until the newest branch settles. A branch that loads nothing swaps right after its first render, and per-view scopes mean the outgoing branch's background work can never delay the swap. `immediate: true` skips holding; `viewTransition: true` wraps the swap in `document.startViewTransition` (feature detected). This is `@mmstack/router-core`'s `<mm-transition-outlet>` without the router — same semantics, any signal as the trigger.
|
|
462
492
|
|
|
493
|
+
### Per-element morphs — `mmViewTransitionName`
|
|
494
|
+
|
|
495
|
+
When a swap is wrapped in the View Transitions API (`viewTransition: true` above, or the outlet's equivalent), the browser cross-fades the whole boundary by default. Name an element on both sides and it **morphs** instead — the hero image glides from the list card into the detail header:
|
|
496
|
+
|
|
497
|
+
```html
|
|
498
|
+
<!-- outgoing (list) and incoming (detail) views both name it: -->
|
|
499
|
+
<img [mmViewTransitionName]="'hero-' + item().id" [src]="item().img" />
|
|
500
|
+
```
|
|
501
|
+
|
|
502
|
+
The directive binds `view-transition-name` reactively and normalizes the value to a valid CSS ident; `''`/`'none'` clears it (the conditional opt-out). It works with holds precisely because the incoming view is `display: none` while held — unboxed elements aren't captured, so the same name on both sides is legal at each capture point. One rule stays yours: a name must be unique among elements **visible** at capture time, so derive names from ids for anything that can repeat.
|
|
503
|
+
|
|
504
|
+
### Async derivations — `latest()` / `use()`
|
|
505
|
+
|
|
506
|
+
A `computed` over resources: `use(res)` reads a resource's value inside a `latest(fn)` computation and reports it to the derivation, so pending-ness propagates **by read** — no wiring, no per-site `isLoading` checks:
|
|
507
|
+
|
|
508
|
+
```typescript
|
|
509
|
+
import { latest, use } from '@mmstack/primitives';
|
|
510
|
+
|
|
511
|
+
const fullName = latest(() => {
|
|
512
|
+
const u = use(user); // typed value — NO undefined checks in here
|
|
513
|
+
const org = use(orgFor(u)); // dependent (waterfall) resources compose too
|
|
514
|
+
return `${u.name} @ ${org.name}`;
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
fullName(); // holds its previous value while anything it read is in flight
|
|
518
|
+
fullName.pending(); // the aggregate flight indicator
|
|
519
|
+
```
|
|
520
|
+
|
|
521
|
+
Semantics worth knowing: a member with no value yet short-circuits the computation (that's why the body needs no `undefined` handling) — the result reports `hasValue: false` until every read member has produced one. `status` aggregates with `error` winning; the held value stays readable through an error (unlike a raw `ResourceRef`, `latest`'s value never throws). Results are themselves status-bearing, so they **nest** (a `latest` inside a `latest` propagates) and register into transition scopes via the same `register: 'indicator' | 'suspend'` vocabulary as resources. `use()` accepts anything structurally resource-shaped — Angular `resource()`/`httpResource`, `@mmstack/resource` queries, or another `latest` result.
|
|
522
|
+
|
|
523
|
+
Honest limit: the collector is a synchronous stack, so it covers derivations you own — not arbitrary template reads. Boundaries keep creation-time registration.
|
|
524
|
+
|
|
525
|
+
### `deferredValue`
|
|
526
|
+
|
|
527
|
+
`useDeferredValue` for signals: holds its previous value when the source changes and catches up at lower priority — after the next paint by default — so an expensive subtree keyed off the deferred value never blocks the urgent update that caused the change:
|
|
528
|
+
|
|
529
|
+
```typescript
|
|
530
|
+
const query = signal('');
|
|
531
|
+
const deferredQuery = deferredValue(query);
|
|
532
|
+
const results = computed(() => expensiveFilter(items(), deferredQuery()));
|
|
533
|
+
// typing echoes instantly; the big list re-renders one beat later
|
|
534
|
+
// deferredQuery.pending() → true while behind (dim the stale list)
|
|
535
|
+
```
|
|
536
|
+
|
|
537
|
+
Rapid changes coalesce latest-wins (the expensive subtree never sees intermediate values), `pending` compares by **value** — a change reverted before catch-up isn't pending — and an equal catch-up never notifies consumers. `strategy: 'idle'` defers to `requestIdleCallback` instead; a function strategy is the custom-scheduler/test seam. On the server it's a synchronous pass-through (SSR renders once — deferral would just mean stale content). This is a scheduling tool, not an async one: for async work compose `latest()`; for coordinated reveals use a transition scope.
|
|
538
|
+
|
|
463
539
|
### `injectStartTransition`
|
|
464
540
|
|
|
465
541
|
The analog of React's `useTransition`. `startTransition(fn)` runs your state mutations (which commit immediately); any resource that reloads as a result **holds its value and reveals together once everything settles** — so a multi-resource update lands as one consistent frame instead of a torn mix of new and stale. The returned handle gives you a unified `pending` signal and a `done` promise for imperative coordination (disable a button, await completion).
|
|
@@ -615,6 +691,32 @@ import { tabSync } from '@mmstack/primitives';
|
|
|
615
691
|
const cart = tabSync(signal([]), { id: 'shopping-cart' });
|
|
616
692
|
```
|
|
617
693
|
|
|
694
|
+
### `opLog`
|
|
695
|
+
|
|
696
|
+
A minimal **operation log** over any object-shaped `WritableSignal` that honors the copy-on-write contract (stores qualify, and so do plain immutably-updated model signals): each tick's changes are recovered as one batch of path-level `set`/`delete` ops by a reference-identity-pruned diff — O(changed paths), from *outside* the signal, zero cost when no log exists:
|
|
697
|
+
|
|
698
|
+
```typescript
|
|
699
|
+
import { opLog, store } from '@mmstack/primitives';
|
|
700
|
+
|
|
701
|
+
const state = store({ user: { name: 'Ann' }, items: [1, 2] });
|
|
702
|
+
const log = opLog(state);
|
|
703
|
+
|
|
704
|
+
log.subscribe((batch) => send(batch)); // lossless, ordered — sync/persistence feed
|
|
705
|
+
log.latest(); // Signal<OpBatch | null> — lossy sampling (devtools-style)
|
|
706
|
+
|
|
707
|
+
state.user.name.set('Bea');
|
|
708
|
+
// → { origin, version, ops: [{ kind: 'set', path: ['user','name'], next: 'Bea', prev: 'Ann' }] }
|
|
709
|
+
|
|
710
|
+
log.flush(); // synchronously emit any pending change now, instead of waiting for the tick. idempotent, no-op when clean.
|
|
711
|
+
log.apply(remoteBatch); // applies ops in ONE commit AND advances the diff baseline —
|
|
712
|
+
// so applying a remote batch emits no echo batch (sync loops terminate by construction)
|
|
713
|
+
invertBatch(batch); // prev-based inverse — undo is a data transform
|
|
714
|
+
```
|
|
715
|
+
|
|
716
|
+
Batching is per tick (two writes to one leaf in a tick emit one composed op), `prev` is always carried in-memory (structural sharing makes it free — wire serializers decide whether to keep it), arrays diff per-index at equal lengths and as whole-array ops on length change, and a `forkStore`'s `commit()` lands as a single batch — fork *is* the transaction primitive. Mutable stores are unsupported (in-place mutation defeats ref-identity diffing; dev warn). This is the substrate for worker mirrors, tab/mesh sync, persistence journals, and undo — one protocol, many consumers.
|
|
717
|
+
|
|
718
|
+
An `opLog` can also run with no Angular injector, which is what lets the graph mirror into a Web Worker. Pass `driver: microtaskOpLogDriver()` to drive emission off the microtask queue instead of an `effect()`, and build the store with `createStoreContext()` (a self-contained proxy cache) so `store` and `opLog` work in a worker or a plain Node process. The pure helpers `applyOps(root, ops)` and `diffOps(prev, next)` apply and produce batches without owning a log. [`@mmstack/worker`](https://www.npmjs.com/package/@mmstack/worker) is built directly on these seams.
|
|
719
|
+
|
|
618
720
|
## Performance helpers
|
|
619
721
|
|
|
620
722
|
### `chunked`
|