@mmstack/primitives 22.4.1 → 22.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 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`