@mmstack/primitives 22.0.3 → 22.1.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 +232 -1
- package/fesm2022/mmstack-primitives.mjs +650 -60
- package/fesm2022/mmstack-primitives.mjs.map +1 -1
- package/package.json +1 -1
- package/types/mmstack-primitives.d.ts +399 -6
package/README.md
CHANGED
|
@@ -15,10 +15,11 @@ npm install @mmstack/primitives
|
|
|
15
15
|
|
|
16
16
|
## Contents
|
|
17
17
|
|
|
18
|
-
- [Writable signal variants](#writable-signal-variants) — `mutable`, `derived`, `store` / `mutableStore`, `toWritable`
|
|
18
|
+
- [Writable signal variants](#writable-signal-variants) — `mutable`, `derived`, `store` / `mutableStore`, `forkStore`, `toWritable`
|
|
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
23
|
- [History & persistence](#history--persistence) — `withHistory`, `stored`, `tabSync`
|
|
23
24
|
- [Performance helpers](#performance-helpers) — `chunked`, `pooled` / `pooledArray` / `pooledMap` / `pooledSet`
|
|
24
25
|
- [Sensors](#sensors) — `sensor()` facade + browser-state signals
|
|
@@ -146,6 +147,32 @@ A few release notes:
|
|
|
146
147
|
- Reserved names — `extend`, `asReadonlyStore`, and the signal methods (`set` / `update` / `mutate` / `inline` / `asReadonly`) — shadow same-named data keys, as on any store.
|
|
147
148
|
- `scope.asReadonlyStore()` returns a read-only **snapshot view** of the merge (reactive reads, no writes); it does not share sub-store identity.
|
|
148
149
|
|
|
150
|
+
### `forkStore`
|
|
151
|
+
|
|
152
|
+
`forkStore(base)` creates an **isolated, writable overlay** on a base store. Writes stay _local_ to the fork (the base is untouched); paths the fork hasn't edited read through to the base. `commit()` flushes the fork's value onto the base; `discard()` drops the staged writes. Use it for drafts, edit-and-cancel dialogs, and optimistic branches — anywhere you want a throwaway, structurally-shared copy you can keep or roll back.
|
|
153
|
+
|
|
154
|
+
```typescript
|
|
155
|
+
import { store, forkStore } from '@mmstack/primitives';
|
|
156
|
+
|
|
157
|
+
const base = store({ user: { name: 'Alice', age: 30 }, theme: 'dark' });
|
|
158
|
+
|
|
159
|
+
const draft = forkStore(base);
|
|
160
|
+
draft.store.user.name.set('Bob'); // local only — base still reads 'Alice'
|
|
161
|
+
base.user.name(); // 'Alice'
|
|
162
|
+
|
|
163
|
+
draft.commit(); // flush the edits onto the base
|
|
164
|
+
base.user.name(); // 'Bob'
|
|
165
|
+
// draft.discard(); // …or throw the edits away
|
|
166
|
+
```
|
|
167
|
+
|
|
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:
|
|
169
|
+
|
|
170
|
+
- **`'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
|
+
- **`'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
|
+
- **a `ReconcileFn<T>`** — `(ancestor, mine, theirs) => merged`, for bring-your-own merge (array-by-id, Immer patches, CRDT-ish).
|
|
173
|
+
|
|
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.
|
|
175
|
+
|
|
149
176
|
### `toWritable`
|
|
150
177
|
|
|
151
178
|
Turn any read-only `Signal<T>` into a `WritableSignal<T>` by providing custom `set` / `update` implementations. Powers `derived` internally; use it directly when you have a `computed` you want to expose as writable.
|
|
@@ -290,6 +317,210 @@ nestedEffect(() => {
|
|
|
290
317
|
|
|
291
318
|
Composes with `indexArray` to give each mapped item its own effect that's automatically torn down when the item is removed — see the doc comments on `nestedEffect` for the pattern.
|
|
292
319
|
|
|
320
|
+
## Concurrency & transitions
|
|
321
|
+
|
|
322
|
+
The Angular signal-native equivalent of React's `<Suspense>`, `useTransition`, `useOptimistic`, or `<Activity>` — nor Vue's `<keep-alive>`. This is that vocabulary, expressed with Angular signals: keep a stale value on screen while the next one loads, hold a whole subtree until its data settles, pause a hidden tab's background work, freeze the display through a multi-resource update and reveal it in one frame. It's mostly built on `linkedSignal` (the one primitive that hands a computation its own previous output), so the value-holding pieces add no `effect()` and no zone churn.
|
|
323
|
+
|
|
324
|
+
The pieces compose, but each stands alone — reach for only what you need. `@mmstack/resource` and `@mmstack/router-core` plug into the same machinery (a resource opts into the nearest scope with its `register` option; `<mm-transition-outlet>` turns navigation into a transition).
|
|
325
|
+
|
|
326
|
+
### `keepPrevious`
|
|
327
|
+
|
|
328
|
+
The foundation of stale-while-revalidate. Wraps a signal so it **holds its last defined value whenever the source becomes `undefined`** — surfacing the previous result instead of flashing empty during a reload.
|
|
329
|
+
|
|
330
|
+
```typescript
|
|
331
|
+
import { keepPrevious } from '@mmstack/primitives';
|
|
332
|
+
|
|
333
|
+
const held = keepPrevious(resource.value); // drops to undefined mid-reload → keeps last value
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
If the source is writable, `set` / `update` / `asReadonly` (and `mutate` / `inline` / `from` for mutable / derived sources) are forwarded through, so it stays a drop-in replacement. `@mmstack/resource` uses it under the hood for its `keepPrevious` option.
|
|
337
|
+
|
|
338
|
+
### Keep-alive — `MmActivity` / `injectPaused` / `providePaused`
|
|
339
|
+
|
|
340
|
+
`*mmActivity="visible"` is the Angular analog of React's `<Activity>` / Vue's `<keep-alive>`: the wrapped subtree is **mounted once and kept**. When the condition is false it's hidden (`display:none`) and its change detection is paused — preserving state (scroll, inputs, a `<video>`'s position, loaded data); when true it's shown and CD resumes. It's never destroyed until the directive is.
|
|
341
|
+
|
|
342
|
+
```html
|
|
343
|
+
<section *mmActivity="tab() === 'editor'">
|
|
344
|
+
<!-- heavy stateful editor — kept alive across tab switches -->
|
|
345
|
+
</section>
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
It also provides a **paused context** (= the negation of `visible`) to the subtree. Read it with `injectPaused()` (a `Signal<boolean>`, `true` while hidden); descendants use it to pause effect-driven work. CD-detach pauses _pull-based_ work for free (templates and the computeds they read), but **not** effects or RxJS timers — so polling and `effect()`s inside a hidden tab keep running unless you gate them on `injectPaused()` (or use the pausable primitives / a `PAUSED`-aware resource, which do it for you). `providePaused(signal)` sets up your own boundary; on the server nothing is ever paused (the full tree renders).
|
|
349
|
+
|
|
350
|
+
### Pausable primitives — `pausableSignal` / `pausableComputed` / `pausableEffect`
|
|
351
|
+
|
|
352
|
+
Signal/computed/effect that suspend their work while paused. By default they read the ambient paused context (so dropping them inside an `*mmActivity` subtree just works); pass `pause: () => boolean` (a `Signal<boolean>` counts) for an explicit source, or `pause: false` to opt out — which returns the **bare primitive with zero overhead** (no wrapper allocated).
|
|
353
|
+
|
|
354
|
+
```typescript
|
|
355
|
+
import {
|
|
356
|
+
pausableSignal,
|
|
357
|
+
pausableComputed,
|
|
358
|
+
pausableEffect,
|
|
359
|
+
} from '@mmstack/primitives';
|
|
360
|
+
|
|
361
|
+
const scroll = pausableSignal(0); // while paused: reads hold; writes land and surface on resume
|
|
362
|
+
const total = pausableComputed(() => expensiveDerive(data())); // holds + does NOT recompute while paused
|
|
363
|
+
pausableEffect(() => poll(url())); // body skipped while paused; deps collapse so a change can't wake it
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
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
|
+
|
|
368
|
+
### Suspense — `<mm-suspense>` and the transition scope
|
|
369
|
+
|
|
370
|
+
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`):
|
|
371
|
+
|
|
372
|
+
```html
|
|
373
|
+
<mm-suspense>
|
|
374
|
+
<user-profile />
|
|
375
|
+
<!-- its queries register here -->
|
|
376
|
+
<span placeholder>Loading…</span>
|
|
377
|
+
<!-- shown on FIRST load only -->
|
|
378
|
+
<span busy>Updating…</span>
|
|
379
|
+
<!-- shown during a reload, content stays mounted -->
|
|
380
|
+
</mm-suspense>
|
|
381
|
+
```
|
|
382
|
+
|
|
383
|
+
- **First load** (no value yet) → show the `[placeholder]`.
|
|
384
|
+
- **Reload** (a value is already held via `keepPrevious`) → keep the real content mounted, set `aria-busy`, and optionally show the `[busy]` slot — no flash back to the placeholder.
|
|
385
|
+
|
|
386
|
+
`type` selects what "not ready" means: `'value'` (default — suspend until a first value lands, then hold through reloads) or `'loading'` (strict — suspend on every in-flight load). When you register a resource you choose whether it `suspends` (blocks first paint — for code/data the subtree can't render without) or only drives the indicator (`suspends: false` — in-region data that should hold-stale, not blank the boundary).
|
|
387
|
+
|
|
388
|
+
> **Where the resource must live.** Registration resolves the scope _up_ the injector tree, and `<mm-suspense>` provides its scope to its **content children** — so a resource is captured only when it's created _inside_ the boundary (e.g. a component projected between the tags). A query declared on the component that _renders_ `<mm-suspense>` sits above it and won't be seen.
|
|
389
|
+
|
|
390
|
+
**Single-component variant.** When you'd rather keep the boundary and the resource on the **same** component, provide the scope on that component and use `<mm-unscoped-suspense>`, which **reads an ambient scope** instead of opening its own. Now the scope is an ancestor of both the resource and the boundary:
|
|
391
|
+
|
|
392
|
+
```typescript
|
|
393
|
+
import { Component } from '@angular/core';
|
|
394
|
+
import {
|
|
395
|
+
UnscopedSuspenseBoundary,
|
|
396
|
+
provideTransitionScope,
|
|
397
|
+
} from '@mmstack/primitives';
|
|
398
|
+
import { queryResource } from '@mmstack/resource';
|
|
399
|
+
|
|
400
|
+
@Component({
|
|
401
|
+
selector: 'user-profile',
|
|
402
|
+
imports: [UnscopedSuspenseBoundary],
|
|
403
|
+
providers: [provideTransitionScope()], // the scope lives on THIS component…
|
|
404
|
+
template: `
|
|
405
|
+
<mm-unscoped-suspense>
|
|
406
|
+
<span placeholder>Loading…</span>
|
|
407
|
+
{{ user.value()?.name }}
|
|
408
|
+
</mm-unscoped-suspense>
|
|
409
|
+
`,
|
|
410
|
+
})
|
|
411
|
+
export class UserProfile {
|
|
412
|
+
// …so this query registers into it, and the boundary below reads the same scope.
|
|
413
|
+
readonly user = queryResource<User>(() => '/api/users/me', {
|
|
414
|
+
register: 'suspend',
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
This is also the pattern for coordinating resources registered _above_ a boundary (e.g. an app-builder page whose connectors register at a higher injector): the outer `provideTransitionScope()` is the shared scope, and any number of `<mm-unscoped-suspense>` boundaries observe it.
|
|
420
|
+
|
|
421
|
+
### `injectStartTransition`
|
|
422
|
+
|
|
423
|
+
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).
|
|
424
|
+
|
|
425
|
+
```typescript
|
|
426
|
+
const startTransition = injectStartTransition();
|
|
427
|
+
|
|
428
|
+
const t = startTransition(() => filters.set(next)); // queries refetch, view holds stale meanwhile
|
|
429
|
+
button.disabled = t.pending();
|
|
430
|
+
await t.done; // resolves once everything has settled
|
|
431
|
+
```
|
|
432
|
+
|
|
433
|
+
### `injectStartTransaction`
|
|
434
|
+
|
|
435
|
+
A transactional generalization of the above. `startTransaction(fn)` **holds the display** at its pre-transaction value while the transaction is in flight, records the writes in an undo log, then either commits on settle or rolls them back via `abort()`. The writes land on _live_ state immediately (so derived signals and connector requests see the new values and refetch) — only the _display_ is frozen, then revealed atomically when everything settles.
|
|
436
|
+
|
|
437
|
+
```typescript
|
|
438
|
+
const startTransaction = injectStartTransaction();
|
|
439
|
+
|
|
440
|
+
const t = startTransaction(() => applyBulkEdit()); // live state updates; the displayed grid stays put
|
|
441
|
+
// later: t.abort() → roll the writes back and release the hold
|
|
442
|
+
await t.done; // committed, display revealed in one frame
|
|
443
|
+
```
|
|
444
|
+
|
|
445
|
+
### `holdUntilReady`
|
|
446
|
+
|
|
447
|
+
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.)
|
|
448
|
+
|
|
449
|
+
```typescript
|
|
450
|
+
import { holdUntilReady } from '@mmstack/primitives';
|
|
451
|
+
|
|
452
|
+
const shown = holdUntilReady(targetView, () => !scope.pending());
|
|
453
|
+
```
|
|
454
|
+
|
|
455
|
+
### Putting it together
|
|
456
|
+
|
|
457
|
+
A filterable list that suspends on first load, holds its rows through every filter change, and never flashes empty — combining the Suspense boundary, `keepPrevious`, and a transition. The data comes from [`@mmstack/resource`](https://www.npmjs.com/package/@mmstack/resource), whose `register` option drops a query into the nearest scope.
|
|
458
|
+
|
|
459
|
+
The list lives **inside** the boundary (so its query and `startTransition` resolve the boundary's scope); the boundary itself is a thin wrapper above it:
|
|
460
|
+
|
|
461
|
+
```typescript
|
|
462
|
+
import { Component, signal } from '@angular/core';
|
|
463
|
+
import { SuspenseBoundary, injectStartTransition } from '@mmstack/primitives';
|
|
464
|
+
import { queryResource } from '@mmstack/resource';
|
|
465
|
+
|
|
466
|
+
@Component({
|
|
467
|
+
selector: 'user-list',
|
|
468
|
+
template: `
|
|
469
|
+
<input [value]="search()" (input)="filter($any($event.target).value)" />
|
|
470
|
+
<ul>
|
|
471
|
+
@for (u of users.value() ?? []; track u.id) {
|
|
472
|
+
<li>{{ u.name }}</li>
|
|
473
|
+
}
|
|
474
|
+
</ul>
|
|
475
|
+
`,
|
|
476
|
+
})
|
|
477
|
+
export class UserList {
|
|
478
|
+
private readonly startTransition = injectStartTransition();
|
|
479
|
+
protected readonly search = signal('');
|
|
480
|
+
|
|
481
|
+
// `register: 'suspend'` → this query blocks the boundary's first paint.
|
|
482
|
+
// `keepPrevious` holds the rows through every refetch, so a filter change never
|
|
483
|
+
// re-suspends — it just flips the boundary to its [busy] state.
|
|
484
|
+
protected readonly users = queryResource<User[]>(
|
|
485
|
+
() => ({ url: '/api/users', params: { q: this.search() } }),
|
|
486
|
+
{ register: 'suspend', keepPrevious: true },
|
|
487
|
+
);
|
|
488
|
+
|
|
489
|
+
protected filter(q: string) {
|
|
490
|
+
// One pending/done for the whole update (await it, disable a control…).
|
|
491
|
+
// With several registered resources, they hold and reveal together — one frame.
|
|
492
|
+
this.startTransition(() => this.search.set(q));
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
@Component({
|
|
497
|
+
selector: 'users-page',
|
|
498
|
+
imports: [SuspenseBoundary, UserList],
|
|
499
|
+
template: `
|
|
500
|
+
<mm-suspense>
|
|
501
|
+
<!-- genuine first load -->
|
|
502
|
+
<span placeholder>Loading users…</span>
|
|
503
|
+
<!-- a filter change: rows stay, just flagged busy -->
|
|
504
|
+
<span busy>Updating…</span>
|
|
505
|
+
<user-list />
|
|
506
|
+
</mm-suspense>
|
|
507
|
+
`,
|
|
508
|
+
})
|
|
509
|
+
export class UsersPage {}
|
|
510
|
+
```
|
|
511
|
+
|
|
512
|
+
What each layer does here:
|
|
513
|
+
|
|
514
|
+
- **first load** → `<mm-suspense>` shows `Loading users…` (the registered query has no value yet, and it `suspends`);
|
|
515
|
+
- **a filter change** → `keepPrevious` holds the current rows, the boundary sets `aria-busy` and reveals the `[busy]` slot, and `startTransition` hands you one `pending` / `done` for the operation;
|
|
516
|
+
- nothing ever flashes empty between states.
|
|
517
|
+
|
|
518
|
+
Scale the same machinery outward:
|
|
519
|
+
|
|
520
|
+
- wrap the page in **`<mm-transition-outlet>`** ([`@mmstack/router-core`](https://www.npmjs.com/package/@mmstack/router-core)) and navigation gets the same hold-and-swap — the old route stays until the incoming route's registered resources settle;
|
|
521
|
+
- put a heavy panel behind **`*mmActivity`** to keep it alive across tab switches, and its `pausable*` / `PAUSED`-aware resources go quiet while it's hidden;
|
|
522
|
+
- need an edit-and-cancel form over that data? **`forkStore`** gives you the throwaway draft.
|
|
523
|
+
|
|
293
524
|
## History & persistence
|
|
294
525
|
|
|
295
526
|
### `withHistory`
|