@mmstack/primitives 21.0.29 → 21.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 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`