@mmstack/primitives
v22.21.0
Published
**Signal-native utilities for Angular — debounce, throttle, two-way derivations, deep stores, undo/redo, sensors, and more.**
Maintainers
Readme
@mmstack/primitives
Signal-native utilities for Angular — debounce, throttle, two-way derivations, deep stores, undo/redo, sensors, and more.
@mmstack/primitives is a low-level toolbox of Angular Signal primitives. Every value-producing helper is a pure derivation — no effect(), no RxJS bridges, no zone churn — so you can compose them freely inside computed() graphs without worrying about side-effect lifetimes. Effect-shaped helpers (tabSync, nestedEffect, sensors) clean up via DestroyRef.
Install
npm install @mmstack/primitivesEverything that is not a directive or component is also exported from @mmstack/primitives/core. That entry has no Angular templates, so it loads in plain Node without the Angular compiler or linker. Reach for it in a worker, a relay, an agent process or any other headless consumer; @mmstack/mesh and @mmstack/worker/host already do. The root entry re-exports all of it, so browser code keeps importing @mmstack/primitives.
Contents
- Writable signal variants —
mutable,derived,store/mutableStore,forkStore,toWritable - Timing & propagation —
debounced,throttled,until - Reactive collections —
indexArray,keyArray,mapObject,projection - Effects —
nestedEffect - Concurrency & transitions —
keepPrevious, keep-alive (MmActivity),pausable*/providePausableOptions, Suspense & the census (mm-suspense, error slots,mmRetryFailed,*mmSuspenseError, retry rounds), hold-and-swap (*mmTransition), per-element morphs (mmViewTransitionName), async derivations (latest/use/useAll),deferredValue,startTransition/startTransaction, optimistic writes (guessable,optimistic,optimisticStore),holdUntilReady - History & persistence —
withHistory,storeHistory,stored,persistedStore,tabSync,opLog - Sync & convergence —
opSync,tabSync(store), merge policies (lww,mergeThree,keyedArray,preserve),Conflicted, keyed containers (keyedContainer,wrappedContainer,orderedEntries,posBetween),rebaseOps,policyStrategy,syncedFork - Async state values & sentinels —
loading/error/done,joined/settle, precedence &joinAbsorbers, strict sentinels, the/algebrakit - Observability —
provideConcurrencyInstrumentation,perfCustomTracks, failure / retry / dismiss hooks - Performance helpers —
chunked,pooled/pooledArray/pooledMap/pooledSet - Sensors —
sensor()facade + browser-state signals - Pipelines —
piped/pipeable, operators (select,map,filter,filterWith,distinct,combineWith,tap,startWith,pairwise,scan)
Writable signal variants
mutable
A WritableSignal with .mutate() and .inline() for in-place updates. Cheaper than update(prev => ({...prev, ...})) for large objects or arrays, while still notifying dependents.
import { mutable } from '@mmstack/primitives';
const user = mutable({ name: 'John', age: 30 });
user.mutate((prev) => {
prev.age++;
return prev;
});
user.inline((prev) => {
prev.age++;
}); // void return — same effectCaveat: A
computed()that returns a non-primitive value derived from a mutable signal must declareequal: false(or() => false) — otherwise the reference-equality default suppresses the change notification. This is documented inline onmutableitself.
derived
A two-way-bound slice of another WritableSignal. Writes to the derived signal update the source; changes to the source flow through. Use a key/index shorthand for object/array slices, or pass a { from, onChange } pair for custom mappings.
import { derived } from '@mmstack/primitives';
const user = signal({ name: 'John', age: 30 });
const name = derived(user, 'name'); // WritableSignal<string>
const list = signal([1, 2, 3]);
const second = derived(list, 1); // WritableSignal<number>
// Full custom mapping
const upper = derived(user, {
from: (u) => u.name.toUpperCase(),
onChange: (next) => user.update((u) => ({ ...u, name: next.toLowerCase() })),
});When the source is a MutableSignal, the derived signal is also a MutableSignal — derived(state, 'items').mutate(arr => { arr.push(...); return arr }) propagates correctly.
Pass vivify on the key/index form to create a missing container when writing through a null/undefined source — instead of throwing (mutable / array) or dropping the write. Choose 'object', 'array', 'auto' (an array for index keys, an object otherwise), or a () => container factory; it defaults to off.
const user = signal<{ name: string } | null>(null);
derived(user, 'name', { vivify: 'object' }).set('Ada');
// user() === { name: 'Ada' }store / mutableStore
Proxies an object (or signal of an object) into a tree of WritableSignals — one per property, lazily created and cached via WeakRef. Arrays expose indices as signals plus a .length signal and Symbol.iterator. Mutability propagates: if the root is a MutableSignal, every child is too.
import { store, mutableStore } from '@mmstack/primitives';
const state = store({
user: { name: 'Alice', address: { city: 'NYC', zip: 10001 } },
tags: ['admin', 'editor'],
});
state.user.address.city(); // Signal read: 'NYC'
state.user.address.zip.set(90210); // Two-way write into the source
state.tags[0](); // 'admin'
state.tags.length(); // 2 (reactive)
const settings = mutableStore({ notifications: { email: true } });
settings.notifications.mutate((n) => {
n.email = false;
});Autovivification (opt-in). By default, a write through a null/undefined path is dropped. Pass vivify to create the missing intermediate containers instead:
const form = store(
{ user: null as { address?: { city: string } } | null },
{ vivify: 'auto' },
);
form.user.address.city.set('NYC');
// form() === { user: { address: { city: 'NYC' } } }Each level's shape is resolved from what's known: a value that is currently an object/array re-creates as that same shape (resolved per path and cached, so it survives the value later being nulled), while genuinely-unknown levels follow your option — 'auto' (an array for index keys, an object otherwise), 'object', 'array', or a () => container factory. false (the default) keeps writes through null as no-ops. Adding a key that simply wasn't present on an existing object always works and needs no vivify.
Top-level array support isn't exposed yet — use indexArray / keyArray for those.
Union leaves (perf opt-in). noUnionLeaves: true promises no node ever flips between a leaf and a sub-store, so each node's leaf-ness is resolved once on first access and cached instead of staying reactive. Off by default — leave it off if a value can switch between a primitive and an object/array.
Unions are fully supported by default. A node may flip between array ↔ record ↔ primitive ↔ null freely: routing (keys/iteration/prototype) follows the live kind, and a child signal you grabbed before a flip stays correct after it — reads resolve against the new shape (undefined through a null parent, no throw) and writes copy by the container's live shape, so writing through a pre-flip child never turns an array into a plain object.
Reserved keys:
set,update,mutate,inline,asReadonly(andextend, until its removal next minor) resolve to the signal's own methods, so record keys with those names aren't reachable as child stores — read them off the value (s().set) instead.
extendStore (scoped overlay)
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.
import { extendStore, store } from '@mmstack/primitives';
const app = store({ user: { name: 'Alice' }, theme: 'dark' });
const scope = extendStore(app, { draft: '' }); // inherits user + theme, adds a local draft
scope.user === app.user; // true — the same signal (shared, two-way)
scope.user.name.set('Bob'); // writes through to the parent
scope.draft.set('hello'); // local only — `app` never gains `draft`
scope(); // { user: { name: 'Bob' }, theme: 'dark', draft: 'hello' }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.
The seed may also be a signal of the matching kind, so an existing (externally-owned, reactive) signal becomes the local layer:
const draft = signal({ title: '' });
const scope = extendStore(app, draft); // writes to scope.title flow out to `draft`, and back inA few release notes:
- The scope inherits the parent's config (
vivify/noUnionLeaves) and its injector-scoped proxy cache, so both inherited and local paths vivify when the parent was created withvivify.extendStoredoesn't acceptvivify/noUnionLeaves— they always come from the parent. - Reserved names —
asReadonlyStoreand the signal methods (set/update/mutate/inline/asReadonly) — shadow same-named data keys, as on any store. scope.asReadonlyStore()returns a read-only snapshot view of the merge (reactive reads, no writes); it does not share sub-store identity.
forkStore
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.
import { store, forkStore } from '@mmstack/primitives';
const base = store({ user: { name: 'Alice', age: 30 }, theme: 'dark' });
const draft = forkStore(base);
draft.store.user.name.set('Bob'); // local only — base still reads 'Alice'
base.user.name(); // 'Alice'
draft.commit(); // flush the edits onto the base
base.user.name(); // 'Bob'
// draft.discard(); // …or throw the edits awayThe 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:
'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 —forkwarns and falls back to'coarse').'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.- a
ReconcileFn<T>—(ancestor, mine, theirs) => merged, for bring-your-own merge (array-by-id, Immer patches, CRDT-ish).
The fork inherits the base's
vivify/noUnionLeavesand its injector-scoped proxy cache automatically, so its write semantics match the base. Pass them explicitly only to override (advanced).
When the base is a synced store, a fork is also how an agent proposes a change for review. Wire it with syncedFork(sync, base) (or mesh.fork() on a meshed store) so the commit cites what the fork observed: an edit that lands mid-review stays a concurrent value the merge policy decides, never silently overwritten by the approval. The agent writes to the fork, ops() is the staged change to render for a person, and commit() emits the approved change. See the @mmstack/mesh README for the pattern.
toWritable
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.
import { toWritable } from '@mmstack/primitives';
const user = signal({ name: 'John' });
const name = toWritable(
computed(() => user().name),
(next) => user.update((u) => ({ ...u, name: next })),
);Timing & propagation
debounced
A WritableSignal that holds its read value ms milliseconds after the last write. The underlying source is exposed as .original for callers that want the immediate value.
import { debounce, debounced } from '@mmstack/primitives';
const query = debounced('', { ms: 300 }); // create + debounce
const wrapped = debounce(signal(''), { ms: 300 }); // debounce an existing signal
effect(() => fetch(query())); // fires 300ms after typing stops
effect(() => preview(query.original())); // fires immediatelythrottled
Rate-limits read propagation to at most one value per ms window. Defaults to trailing-edge only (the latest write within the window lands at the end). Pass leading: true to emit the first write immediately, trailing: false to suppress the trailing fire.
import { throttled } from '@mmstack/primitives';
// Trailing edge only — first write held until window closes (default)
const t = throttled(0, { ms: 200 });
// Lodash-style leading + trailing
const both = throttled(0, { ms: 200, leading: true, trailing: true });
// Leading edge only — fires immediately, ignores writes during cooldown
const lead = throttled(0, { ms: 200, leading: true, trailing: false });Same .original escape hatch as debounced.
until
Resolves a Promise when a signal value satisfies a predicate. Supports type-narrowing predicates, optional timeout, and auto-cancellation when the consuming context is destroyed.
import { until } from '@mmstack/primitives';
const event = signal<Event | null>(null);
// Narrowing predicate — promise resolves with MouseEvent
const click = await until(
event,
(e): e is MouseEvent => e instanceof MouseEvent,
);
// With a timeout
await until(progress, (p) => p === 100, { timeout: 5_000 });Reactive collections
indexArray / keyArray
Map a source array signal into a stable array of derived values. indexArray stabilizes by position — each index gets a writable signal whose value is the item at that index. keyArray stabilizes by identity (via an optional key selector) — moving an item preserves its mapped output and just updates the item's index signal.
Both pool their internal buffers, so reordering a 10k-item list is much cheaper than .map() of a computed.
import { indexArray, keyArray, mutable } from '@mmstack/primitives';
const items = mutable([
{ id: 1, name: 'A' },
{ id: 2, name: 'B' },
]);
// Position-stable: `child` is a MutableSignal<{ id, name }> for the current index.
const labels = indexArray(items, (child, index) =>
computed(() => `Item ${index}: ${child().name}`),
);
// Identity-stable: `child` is the item value, `index` is a Signal<number>.
const keyed = keyArray(
items,
(child, index) => computed(() => `${index()}: ${child.name}`),
{ key: (item) => item.id },
);indexArray is the cheaper default. Reach for keyArray only when DOM/instance reuse across reorders matters — <for> blocks rendering heavy components, charts, drag-and-drop reordering, etc.
mapObject
The object equivalent of keyArray: map Record<K, V> into Record<K, U> with referential stability for unchanged keys. The mapping function receives the key and a writable signal slice (if the source is writable).
import { mapObject } from '@mmstack/primitives';
const settings = signal<Record<string, boolean>>({
wifi: true,
bluetooth: false,
});
const controls = mapObject(
settings,
(key, value) => ({
label: key.toUpperCase(),
isActive: value, // WritableSignal<boolean>
toggle: () => value.update((v) => !v),
}),
{ onDestroy: (entry) => console.log(`Removed ${entry.label}`) },
);projection
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.
import { projection } from '@mmstack/primitives';
const users = signal<User[]>([]);
// return form: derive a filtered collection, reconciled by id
const active = projection<User[]>(() => users().filter((u) => u.active), [], {
key: 'id',
});
// mutate form: update fields on the draft
const summary = projection<{ total: number; active: number }>(
(draft) => {
draft.total = users().length;
draft.active = users().filter((u) => u.active).length;
},
{ total: 0, active: 0 },
);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.
Effects
nestedEffect
A SolidJS-style hierarchical effect: a nestedEffect created inside another nestedEffect is automatically destroyed and recreated when the parent re-runs. The outer effect only tracks the dependencies you read in its body; the inner effect's deps are tracked only while it's alive.
import { nestedEffect } from '@mmstack/primitives';
// `coldGuard` changes rarely, `hotSignal` fires often.
nestedEffect(() => {
if (coldGuard()) {
nestedEffect(() => {
// Only tracks `hotSignal` while coldGuard is true.
console.log(hotSignal());
});
}
});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.
Concurrency & transitions
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.
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).
keepPrevious
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.
import { keepPrevious } from '@mmstack/primitives';
const held = keepPrevious(resource.value); // drops to undefined mid-reload → keeps last value
const rows = keepPrevious(resource.value, { fallback: [] }); // [] only until the first value landsfallback is yielded only while nothing has ever been defined; after the first defined value the previous value covers every gap, never the fallback. Like any linked signal the hold is lazy: it carries a value it has computed with, so place it over the value your readers read — reading is what feeds it. @mmstack/resource does exactly that for its keepPrevious option.
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.
Keep-alive — MmActivity / injectPaused / providePaused
*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.
<section *mmActivity="tab() === 'editor'">
<!-- heavy stateful editor — kept alive across tab switches -->
</section>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).
Pausable primitives — pausableSignal / pausableComputed / pausableEffect
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).
import {
pausableSignal,
pausableComputed,
pausableEffect,
} from '@mmstack/primitives';
const scroll = pausableSignal(0); // while paused: reads hold; writes land and surface on resume
const total = pausableComputed(() => expensiveDerive(data())); // holds + does NOT recompute while paused
pausableEffect(() => poll(url())); // body skipped while paused; deps collapse so a change can't wake itWhile 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.
providePausableOptions
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:
import { providePausableOptions } from '@mmstack/primitives';
// e.g. in app.config.ts
providers: [providePausableOptions({ pause: true })];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.
Suspense & the census
<mm-suspense> shows a placeholder while the data its subtree needs is loading, the content once it is there, and an error slot when a load failed and there is nothing to show:
<mm-suspense>
<user-profile />
<!-- its queries register here -->
<span placeholder>Loading…</span>
<!-- first load only -->
<span busy>Updating…</span>
<!-- during a reload; content stays mounted -->
<p error>Could not load the profile. <button mmRetryFailed>Retry</button></p>
<!-- failed, nothing to show -->
</mm-suspense>Boundary tracking (the census). The boundary provides a transition scope that coordinates all async tasks registered inside its subtree. Each registered task (a query, mutation, or latest() derivation) reports its status through a unified contract:
readiness: whether it blocks the boundary's first paint (register: 'suspend').pending: whether it currently has no content to display.inFlight: whether a network request is currently active (drivesaria-busyand the[busy]slot).failure: any error that occurred.retry: a function to re-trigger the load.
Anything async registers cleanly:
- Resources.
@mmstack/resource'sregister: 'suspend' | 'indicator'option, orregisterResource(ref, { suspends, displayName })for anyResourceRef. The scope adapts the ref:pendingmeans "suspends, has no content, has not failed",inFlightisisLoading(),failurefollowsstatus() === 'error'and carries the error'smessage, andretrycalls the ref'sreload(). latest()derivations. The sameregister: 'indicator' | 'suspend'option and adapter.- Direct members.
censusResource({ site, displayName, ...resourceOptions })is an Angularresourcethat joins the nearest boundary's tracking automatically.scope.census.register(member)takes any custom task, andscope.census.enroll(descriptor)returns a handle you drive yourself (started(generation)/settled(generation, outcome)) for external asynchronous work.
Boundary state resolution. scope.census.foldState() determines the aggregate boundary state:
pending: at least one readiness member has no content yet (the boundary shows the[placeholder]).error: a readiness member failed with nothing to show (the boundary displays the[error]slot).idle: all required data is ready.
suspended('value'), the boundary's default type, stays in placeholder mode while the state is pending, and leaves it once data arrives or an error occurs. suspended('loading') (type="loading") is true whenever any suspending registration has a request in flight, even during background reloads. Meanwhile, scope.pending() monitors overall network activity across all registrations to drive aria-busy and the [busy] indicator.
failed vs errored. Two different questions:
scope.failed(): is something failing with nothing to show? True when the fold iserrorand a failing suspending registration has no content, or when a failing member was registered directly (a direct member has no content reading, so its failure counts as empty).scope.errored(): which members are failing right now, content or not? AkeepPreviousquery whose background reload failed still holds its last value (hasContent()stays true), sofailed()stays false, the rows stay on screen, and the failure appears inerrored()as{ member, failure }withfailure.displayNameandfailure.message.
The boundary checks failure before suspense. When failed(), the [error] slot replaces both placeholder and content; its default content is one line, Failed to load.. Otherwise the placeholder or the content renders as above, and while errored() is not empty the optional [failed] slot renders beside the content and the host carries a data-failed attribute (mm-suspense[data-failed] in CSS).
Error UI. Projected content cannot receive template context, so error content reaches the failures through DI: anything inside the boundary that calls injectTransitionScope() gets this boundary's scope. Two directives wrap that. mmRetryFailed calls retryAll() on click (the example at the top). *mmSuspenseError renders its template while errored() is not empty, with the context keys $implicit (the entries), retry (retryAll(), returns the round), dismiss(entry) and dismissAll():
<mm-suspense>
<ul error *mmSuspenseError="let entries; retry as retry">
@for (e of entries; track e.member.id) {
<li>{{ e.failure.displayName }}: {{ e.failure.message }}</li>
}
<li><button (click)="retry()">Retry</button></li>
</ul>
<orders-table />
</mm-suspense>A structural directive on an element that carries error or failed projects into that slot. Give the failure a name with registerResource(ref, { suspends: true, displayName: 'orders' }); a registration made through the resource register option takes the resource's displayName option and is called 'resource' without one.
Retry rounds. scope.retryAll() and scope.retry(id) each run one round: every member that can retry and is not already in flight runs again, once. The round returns { dispatched, settled() }: how many members it re-ran, and a promise for when those runs finish. Each member counts its runs as generations, and a round waits only on the generation it started, so a later round that re-runs the same member takes that wait over. A duplicated retry cannot be constructed: a round skips any member already in flight, so a second click while the first round is still loading dispatches nothing (dispatched: 0). A @mmstack/resource registration (query, manual, infinite) retries through its reload().
Dismissal. dismiss(entry) hides one entry from errored() until that member fails again; dismissAll() hides every entry it can. Dismissal is keyed to the failure's generation, so the member's next failure shows. Only a member without a retry can be dismissed (a retryable failure is meant to be retried), and only a failure that carries a generation, which in practice means an enrolled facade or a failed mutation (below). For any other resource registration dismiss does nothing. scope.failures() lists every failure and ignores dismissal.
Settlement and the deadline. await scope.settled() resolves once the fold has left pending and the graph has drained: 'idle' when everything settled cleanly, 'error' when something failed. A member that never answers would hold its placeholder forever, so a scope can carry a backstop:
providers: [provideTransitionScope({ deadline: { ms: 10_000 } })];Each suspending member gets its own 10 s, counted from its own registration. A member still pending when its time runs out is declared failed with a message that names it (orders did not settle within 10000ms …), and the boundary shows its error slot. If the member settles later, the synthesized failure goes away; a failure of its own always wins over it. Indicator-only members are never armed: they cannot hold anything.
Precedence. When one suspending member is still loading and another has failed, the scope has to pick what to show. 'pending-first', the default, keeps the placeholder until every suspending member has settled, then shows the error slot if a failure is still there. 'error-first' shows the error slot as soon as a failure leaves nothing to show, even while others load; a failure that does not blank (indicator-only, or content still held) never ends the placeholder while a suspending member has nothing to show. Why pending-first: with three members, one loading, one failed, one done, showing the failure at once means retrying it alone, and if the loading member then fails too the boundary shows a second error and needs a second round for one incident. Waiting for the loading member shows every failure together, and one retryAll() covers them. <mm-suspense> uses the default; for 'error-first', provide the scope yourself and read it with <mm-unscoped-suspense> (below):
providers: [provideTransitionScope({ precedence: 'error-first' })];Angular's @boundary. mmstack resources surface errors as values; @boundary is for rendering throws. A failed @mmstack/resource query never throws into the template: its value() does not throw (unlike a plain Angular resource), and neither does a latest(). A throwing opt-in is rejected for now. So the census sees the failure and @boundary sees nothing. A template that throws while rendering is the other way round. The two compose, with one rule: put @boundary inside the branch it guards (inside the *mmTransition template, inside the routed component), never around *mmTransition or <mm-transition-outlet>. On a throw @boundary removes its whole block, and around a swap primitive that block holds both the outgoing and the incoming view. provideViewErrorTelemetry() from @mmstack/telemetry-core records each throw a @boundary catches as a VIEW_ERROR finding (the throwing component and the boundary's component) and passes the error on to your ErrorHandler.
$reset() clears the boundary's error and renders the block again from the same inputs; it fetches nothing. When the throw came from data owned outside the block, reload that data in the same handler:
@boundary {
<order-chart [orders]="orders.value()" />
} @error {
<p>
The chart failed. <button (click)="orders.reload(); $reset()">Retry</button>
</p>
}$reset() re-renders at once, before the reload lands, and a reloading resource still holds the value that threw. Guard the block on orders.isLoading() (or render only once it is not loading), or the re-render throws on the stale data again.
@boundary does not catch everything. Errors in event listeners, root effects, afterRender callbacks, promises and @defer loads go to the ErrorHandler, as does a view effect that runs while its view is only traversed. Projected content belongs to the template that declares it, so a @boundary around <ng-content> does not cover it.
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 is created inside the boundary (for example in a component projected between the tags). A query declared on the component that renders<mm-suspense>sits above it and is not seen.
Single-component variant. To 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:
import { Component } from '@angular/core';
import {
UnscopedSuspenseBoundary,
provideTransitionScope,
} from '@mmstack/primitives';
import { queryResource } from '@mmstack/resource';
@Component({
selector: 'user-profile',
imports: [UnscopedSuspenseBoundary],
providers: [provideTransitionScope()], // the scope lives on THIS component…
template: `
<mm-unscoped-suspense>
<span placeholder>Loading…</span>
{{ user.value()?.name }}
</mm-unscoped-suspense>
`,
})
export class UserProfile {
// …so this query registers into it, and the boundary below reads the same scope.
readonly user = queryResource<User>(() => '/api/users/me', {
register: 'suspend',
});
}The same pattern coordinates resources registered above a boundary: the outer provideTransitionScope() is the shared scope, and any number of <mm-unscoped-suspense> boundaries observe it.
Forwarding scope (advanced). provideForwardingTransitionScope() provides a scope that can be re-pointed at another target at runtime with setTarget(scope | null). Reads follow the current target, while add / remove (and direct census members) stay with the target they registered under, so re-pointing never strands a registration. It is the building block for a router outlet that points at the incoming route's own scope (getTransitionScope(injector) reads it).
Cancellation, scope.abortPending(). View-scoped work dies with its view: a superseded transition destroys the hidden incoming view, which aborts its loads, and an aborted response never settles into @mmstack/resource's cache. For resources registered in a scope that outlives the transition, abortPending() calls abort() on every in-flight registration that has it (queries do; mutations do not, a POST cannot be unsent) and returns how many it aborted. A shared resource aborts for all its readers, so use it when an interaction makes the pending work pointless. Only I/O is cancellable; no framework can preempt a running synchronous computation.
SSR. On the server a scope holds an Angular PendingTask while it has loads in flight, so serialization waits and custom (non-HTTP) loaders render settled. The provide*TransitionScope() factories wire this; call bridgeScopeToPendingTasks(scope, injector) yourself only for a scope from createTransitionScope(). Browser stability is not tied to loads.
Mutations in the census
A mutation registered with register: 'indicator' is a member of its boundary that never holds first paint. While it runs it drives pending() (aria-busy, the [busy] slot) and suspended() does not change. When it fails, the failure stays in errored() after the mutation has returned to idle, until it is dismissed (dismiss(entry), dismissAll(), or the dismiss in *mmSuspenseError's context) or the next mutate() starts. retryAll() skips it, because a write is not assumed to be safe to send twice. The ref carries the same failure as lastFailure().
register: 'suspend' on a mutation behaves as 'indicator' and warns once in dev mode. To hold the UI while a save runs, use a transaction (injectStartTransaction), not suspense.
In-place error boundary: *mmErrored
*mmErrored catches a render error the way @boundary does, but keeps what it guards. The content renders from the directive's template. When rendering it throws, the content view is detached and hidden, its components and their state stay alive, and the fallback template renders in its place with the error and a retry:
<form *mmErrored="failed; name: 'profile form'">
<profile-fields />
</form>
<ng-template #failed let-error let-retry="retry">
<p>{{ error.message }} <button (click)="retry()">Retry</button></p>
</ng-template>retry() re-attaches the kept view and renders it again, synchronously, with the same instances, so a half-filled form keeps what the user typed. A computed that cached the throw throws again until one of its dependencies changes, so a retry before the fix lands on the fallback again and nothing broken is shown. The view that threw renders on retry even when it is an OnPush component with nothing changed. The longhand is <ng-template [mmErrored]="failed">; without a fallback the content is only hidden.
When which. @boundary destroys its block on a throw and builds it again on $reset(). Use it for widgets with nothing worth keeping. Use *mmErrored when the subtree holds state the user would lose: a form mid-edit, a scrolled list, an editor.
Hidden, not frozen. The content is hidden while it is faulted (element roots get display: none, text roots are blanked) because its DOM is whatever the throwing pass wrote before the throw: some bindings new, some old. Prefer an element root.
Creation throws. A throw while the content itself is first built (a constructor, a DOM instruction) leaves no view to keep, so the fallback shows and retry() builds the content again, with new instances. Anything the half-built view registered outside itself before the throw stays registered, as with @boundary.
Rebuilding. retry({ rebuild: true }) destroys the faulted content and builds it again from the template, the way @boundary resets, so the instances and their state are new. The fallback's retry takes the same option: (click)="retry({ rebuild: true })". Use the plain retry() for faults that pass, such as a failed load or a value fixed since. Use a rebuild when the kept state is itself what throws, so every plain retry would land on the fallback again. A rebuild also brings back a block branch left empty by the limit below. The census member stays until the rebuilt view renders clean, and a fault in the rebuilt view is caught and reported like any other.
Reporting and the census. Angular does not report errors a custom interceptor catches, so the directive reports each catch to your ErrorHandler once (onViewError when it has one, otherwise handleError). Inside a transition scope each fault also joins the scope's census as a member that does not block first paint: errored() lists it under the name (default 'view'), failed() stays false, retryAll() retries it, and the member leaves once a retry renders clean.
Nesting. The nearest boundary catches: an inner @boundary or *mmErrored wins, and a fallback that throws goes to the next boundary out. Inside *mmTransition, put the boundary in the branch template. A faulting incoming branch shows its fallback inside the branch and still commits.
Limits. Init hooks (ngOnInit and friends) that already ran, or started and threw, do not run again on retry. A block (@if, @for, @switch) whose new branch threw while being built has already taken its new condition, so after a retry that branch stays empty until the condition changes again. Errors in event listeners, root effects, afterRender callbacks, promises and @defer loads are not caught, and a view effect that runs while its view is only traversed (not refreshed) throws past this boundary to the nearest boundary further up whose view is refreshing. Compare with Solid 2.0's <Errored>, whose reset re-runs the readers that threw rather than rebuilding the tree; that is the rule retry() follows.
Hold-and-swap — *mmTransition
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.
<div *mmTransition="selectedTab(); let tab">
@switch (tab) { @case ('overview') {
<overview-pane />
} @case ('activity') {
<activity-pane />
} }
</div>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. A transaction that holds the incoming branch's scope keeps the swap back until it releases, and the branch commits once. That includes a transaction on the page around it, since a hold on a scope also holds the scopes provided inside it, so a tab switched mid-save commits with the saved values, together with the rest of the page. 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.
Reveal order with <mm-reveal>
Sibling boundaries settle in whatever order their data lands, so a dashboard can fill in bottom-up. <mm-reveal> schedules when each of its boundaries shows its content:
<mm-reveal order="forwards" collapsed>
<mm-suspense><app-profile /></mm-suspense>
<mm-suspense><app-feed /></mm-suspense>
<mm-suspense><app-suggestions /></mm-suspense>
</mm-reveal>With order="forwards" (the default) a boundary shows its content once it is ready and every boundary before it has shown. "backwards" is the mirror, and "together" shows them all in one frame once every one is ready. A boundary held back by an earlier one shows its placeholder; with collapsed it renders nothing, so only the next boundary in line shows a placeholder.
Failures follow onError. Under 'settled' (the default) a failed boundary shows its error slot and the ones after it carry on. Under 'blocks' it shows its error and holds the rest until a retry succeeds, for flows where the order means "this needs that first". Once a boundary has shown its content it stays shown, even if it suspends again later; its own placeholder or busy state takes over as usual.
Only the boundaries directly inside the reveal take part. A boundary nested inside one of them belongs to that boundary. Boundaries are ordered by where they are on the page, whether they come from @if, @for or a plain element, and the order follows them when they move. A boundary that leaves the page (an @if turned off, a cached route) stops holding the others and keeps whatever it already showed. One rendered elsewhere (a portal or overlay) orders by where it renders; provide severReveal() at the portal host to keep it out. Boundaries inside a shadow root and outside it can't be put in one order: while a reveal spans both, nothing new is shown. The reveal schedules display and nothing else: held content is created and loads as usual, and a reveal inside a held *mmTransition view does its ordering while hidden and shows up with the swap. Compare with Solid 2.0's <Reveal>, which this follows. A boundary's readiness counts only once it has rendered, so a state seen before its inputs are bound or its content has run can't show it early. Custom boundaries join through injectRevealSlot() from their directive or component (so the slot takes its host element's place in the order), call the slot's mount() from ngAfterViewInit, and provide severReveal() for their content. Driving createRevealCoordinator() over the DOM without <mm-reveal>? Pass each slot's host to register() and call relayout() after rendering. Until mount() is called the slot stays pending and holds the boundaries after it.
Boundaries that come from an @for? Give the reveal the same array and track function, and each boundary its row: <mm-reveal [items]="rows()" [track]="byId"> with <mm-suspense [item]="row"> inside the loop (track defaults to the row itself, like @for). Order and membership then come from the data: a moved row is seen at once, a row that leaves the array stops holding the others and keeps what it showed, and nothing is read from the DOM. A boundary with no [item], or whose key is not in the array, is held and holds nothing (dev mode warns about a missing [item] and about duplicate keys). Without [items] the reveal orders by the page, as above.
Per-element morphs — mmViewTransitionName
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:
<!-- outgoing (list) and incoming (detail) views both name it: -->
<img [mmViewTransitionName]="'hero-' + item().id" [src]="item().img" />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.
Async derivations — latest() / use()
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, with no per-site isLoading checks:
import { latest, use } from '@mmstack/primitives';
const fullName = latest(() => {
const u = use(user); // typed value, no undefined checks in here
const org = use(orgFor(u)); // dependent (waterfall) resources compose too
return `${u.name} @ ${org.name}`;
});
fullName(); // holds its previous value while anything it read is in flight
fullName.outcome(); // the value, or the loading / error sentinel this evaluation settled on
fullName.pending(); // the aggregate flight indicatorWhen a resource has nothing to show yet or has failed, use() throws its sentinel (loading or error) and the rest of the callback does not run this round. That is why the body needs no undefined handling. A resource that is idle with nothing to show (disabled, not started) throws loading too: from the derivation's point of view it is still waiting. The thrown value is an internal control-flow signal, so avoid broad try/catch blocks inside latest(); if you do catch, re-throw anything where isAbsorbing(err) is true. (For combining independent resources without thrown exceptions, prefer joined() described below). latest catches it and keeps the previous result: fullName() is the held value, hasValue() says whether there has ever been one, and the held value stays readable through an error (unlike a raw ResourceRef, it never throws).
Two axes. outcome() is the value plane: the result of the last evaluation, or the sentinel it stopped on. pending() (alias isLoading()) is activity: whether any resource the last evaluation read is loading / reloading. They are separate on purpose. A keepPrevious member that is reloading still has its old value to give, so outcome() is that value while pending() is true. status() composes the two: an error outcome is 'error'; otherwise activity is 'reloading' (a value is held) or 'loading' (none yet); otherwise a value outcome is 'resolved' and a waiting one is 'idle'. error() is the cause behind an error outcome, else the first error among the resources it read.
Several absorbers. use() stops at the first absorber, so a plain sequential callback only ever meets one. useAll(a, b, …) reads several resources independently: every one is read and reported, and if any has nothing to show or failed it throws their ranked join.
const card = latest(() => {
const [u, org] = useAll(user, orgOf);
return `${u.name} @ ${org.name}`;
});errors decides what outcome() says when an evaluation read more than one absorber. 'first' (the default) is the absorber the evaluation actually stopped at. 'aggregate' is the ranked join over everything the evaluation read, under precedence ('pending-first' by default, 'error-first' to invert). The options are a discriminated union, so precedence without errors: 'aggregate' is a compile error:
const total = latest(() => use(a) + use(b), {
errors: 'aggregate',
precedence: 'pending-first',
});errors: 'aggregate' changes the answer only when an evaluation reads past an absorber (useAll, or a callback that catches a use() throw and keeps reading); with plain sequential use() both modes agree, so reach for useAll when the reads are independent. useAll itself always joins, under 'pending-first' unless the latest sets an aggregate precedence.
Results are status-bearing, so they nest (a latest read by use inside another passes its outcome() through) and register into transition scopes with the same register: 'indicator' | 'suspend' vocabulary as resources. use() accepts anything structurally resource-shaped: Angular resource() / httpResource, @mmstack/resource refs, or another latest. A source with an outcome() is read through it; any other is read through outcomeOf(source), which derives the same answer from status, content, value and error.
Limit: the collector is a synchronous stack, so it covers derivations you own and nothing after an await. For a read in a template, see *mmOutcome below.
Reading a resource in a template with *mmOutcome
A boundary learns about a resource when the resource registers in it. *mmOutcome lets a template read do the same. It takes the resource itself, renders its template with the value, and while the resource is loading or failed it joins the nearest boundary's census as a member of its own. A resource created above the boundary, where no registration can reach it, still holds the boundary once it is read inside:
<mm-suspense>
<span placeholder>Loading…</span>
<h2 *mmOutcome="user; let u; error: failed; name: 'profile'">
{{ u?.name }}
</h2>
<ng-template #failed let-error let-retry="retry">
Could not load the profile. <button (click)="retry?.()">Retry</button>
</ng-template>
</mm-suspense>The template renders while outcome() is a value (undefined counts: nothing was requested). While it is loading the template is removed, the optional loading template renders instead, and the member is pending, so the boundary shows its placeholder. While it is error the optional error template renders with the error as $implicit and a retry that reloads the resource when it can; the member reports the failure under its name ('resource' without one), and the boundary blanks only when the resource has nothing to show. A done outcome renders nothing and is not pending. The member leaves when the directive is destroyed. A latest() works the same way, read through its outcome().
Each directive is its own member. A resource that is also registered in the same boundary counts once there: one member in the fold, one entry in errored(), one reload per retry round. Registered in an outer boundary and read under an inner one, both boundaries hold, each in its own census.
Reach for latest where you would write computed over resources; reach for the directive where you would read a resource in a template.
Three readings. The value, the activity and the boundary answer different questions, and they can disagree on purpose. Take a registered latest that reads a user and their orders, after the orders request failed while the user reloads:
const summary = latest(() => `${use(user).name}: ${use(orders).length} orders`);
summary.status(); // 'error': the last evaluation stopped at the failed orders
scope.pending(); // true: the user is still reloading
scope.suspended('value'); // false: the held summary stays on screenstatus() and outcome() describe the result, pending() describes work in flight, and suspended() / failed() describe whether the boundary can show its content.
Limit: under type="loading" a boundary reads only its registrations, so a resource that is only read does not suspend it there.
deferredValue
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:
const query = signal('');
const deferredQuery = deferredValue(query);
const results = computed(() => expensiveFilter(items(), deferredQuery()));
// typing echoes instantly; the big list re-renders one beat later
// deferredQuery.pending() → true while behind (dim the stale list)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.
injectStartTransition
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).
const startTransition = injectStartTransition();
const t = startTransition(() => filters.set(next)); // queries refetch, view holds stale meanwhile
button.disabled = t.pending();
await t.done; // resolves once everything has settledinjectStartTransaction
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.
const startTransaction = injectStartTransaction();
const t = startTransaction(() => applyBulkEdit()); // live state updates; the displayed grid stays put
// later: t.abort() → roll back the recorded writes and release the hold
await t.done; // committed, display revealed in one frameRollback covers recorded writes, and only those. The store records its root on the first write inside a transaction, and derived, keepPrevious and the pausable signals record the source they forward to. A plain signal of your own records through transactional(sig), or by calling activeTransaction()?.record(sig) before the write. Nothing is intercepted: a write made any other way stays after abort(), and an unrecorded writer that re-lands the same value is invisible to the rollback. A mutable() is snapshotted when it is recorded, so an in-place write cannot reach the rollback point.
Every exit settles: a throwing body rolls back, and if the calling context is destroyed
mid-flight the hold is released (writes kept) and done resolves — a transaction can never
leave a surviving ancestor scope frozen.
Attribution is per transaction: a load already in flight when it starts is not adopted —
it can neither commit the transaction early nor block its settle. (The same applies to
startTransition.) A pre-existing flight re-triggered by the transaction's own writes counts
once it restarts, provided the resource keeps a loads counter: @mmstack/resource queries,
mutations and streams do, and latest() sums its members' counters. A load started inside the
transaction's body, or inside a tx.enter(...) slice, belongs to that transaction and to no
other. A load started anywhere else after the transaction began counts for every transaction
open at the time: it says "started since", not "caused by". A plain Angular resource has no
counter, so a load of it that restarts or settles and refires between two reads stays excluded.
After an await. Pass an async body and you get back { pending, done, abort }, where done
resolves with how the transaction ended: { kind: 'completed' }, { kind: 'aborted', reason }
('abort', 'superseded' or 'destroyed') or { kind: 'failed', error }. It never rejects.
The hold lasts until the body's promise resolves, the loads attributed to the transaction drain
and every tx.retain() is released.
const t = startTransaction(async (tx) => {
draft.set(next); // before the first await: recorded as usual
const saved = await api.save(next);
tx.set(revision, saved.revision); // a write after an await, still the transaction's
});
const outcome = await t.done; // { kind: 'completed' } | { kind: 'aborted', ... } | { kind: 'failed', ... }Code after an await runs outside the transaction. Write through it with tx.set(sig, value) or
tx.update(sig, fn): the write is recorded wherever it happens, and a plain signal() is recorded
too, so abort() undoes it. tx.enter(() => ...) puts a whole synchronous block back in: writes and
loads started in the block are the transaction's. A plain sig.set(...) after an await still
lands and the display stays held, but abort() does not undo it. A load Angular
starts later in response to the slice's writes is attributed by time, like any load started while
the transaction is open. A rejected body rolls back and settles failed; abort() or destroying
the calling context rolls back and settles aborted. Once settled, tx.set, tx.update,
tx.enter and tx.retain throw and the body's later result is ignored, but nothing stops a leftover continuation from
writing a signal directly. A startTransaction called inside a slice joins the outer transaction:
its writes, its abort() and its done are the outer one's. There is no re-trigger policy here;
for latest-wins or a FIFO queue use mutationResource. Reject-while-running and parallel runs are
not provided.
A view that mounts while a transaction holds (an @if that opens mid-transaction, a branch
swapped in below the page) reads through scope.hold like the rest of the page. It shows each
recorded signal as it was before the first write since its scope's hold began, counting holds
inherited from enclosing scopes. Any transaction's write counts, in any scope, settled or not, so
the new view matches the held page and reveals with it when the hold ends, or keeps that value
after an abort. A hold on a scope also holds the scopes provided inside it (nested boundaries,
*mmTransition branches); their pending and census stay their own. The one exception is the
fallback scope a forwarding scope uses while it has no target: it cannot take part in an
observable hold. Anything else the view reads (an unrecorded signal, a derived value, a store
leaf) shows the live value.
The history this needs is reclaimed as holds end: once no held scope can read an entry any more, it is dropped, even while other, unrelated holds keep overlapping. A scope that stays held keeps every recorded entry since its hold began, since a view mounted there may still need it.
Optimistic writes
A guess is a value shown before its truth is known. It is gone when its transaction settles, however it settles, so a failed save never leaves a phantom row behind. There are two tiers and no default: pick one per write.
Live (in place). guessable(sig) wraps a writable signal; tx.guess(node, value) lays a guess owned by the transaction. The live signal shows the guess, with an undo log over a truth shadow beneath it and a stamp on every write. Every reader sees the guess: templates, derived values, request functions. Use it when you expect the write to succeed almost always (a like toggle).
const liked = guessable(signal(false));
const t = startTransaction(async (tx) => {
tx.guess(liked, true); // everyone sees true now
const saved = await api.like(postId);
tx.set(liked, saved.liked); // the server's answer, recorded
});Readers see the most recent open guess, else the truth. An authoritative write is a set or update on the guessable (from the body, a user, a refetch, another transaction) or a change of the wrapped signal itself, such as a linkedSignal recomputing after a refetch. A write from outside the transaction that guessed replaces every open guess, for good: undoing that write later brings back the truth beneath, never the guess. A write inside the transaction that laid a guess on that node (tx.set, or a write in tx.enter, typically the server's answer) resolves the guess instead: it records the truth as any write does, and the guess takes the written value until the transaction settles. So a held reader moves from the guess to th
