std::reactive reference
Signals, effects, ownership, turns, and the higher-level cells. Concepts and usage patterns: the reactive guide. The fine-grained version of a whole struct — a handle per field, generated from the type — is a store.
Import what you use:
import std::reactive::{
Signal, SignalCell, Source, Flow, Pipe, MemoCell, MaybeSignal, Subscriber, Subscription,
Disposable, combine, divorce, zip_some, selector, Selector, derive, tracking,
Owner, owner_scope, get_owner, run_with_owner, comp,
Turn, FlushPolicy, turn_scope, turn, batch, flush, at_settle,
optimistic, Optimistic, WriteState,
draft, Draft, DraftState,
reconcile, ReconcilePlan, RowStep,
};
At a glance
| Item | Kind | One line |
|---|---|---|
Source<T> | trait | anything with state that can be read (requires get/on_settle); every source is a Flow |
Flow<T> | [resource] trait | anything a pipeline starts from — every source and every pipe; carries the consumers (on_change/sub/effect/effect_on_change) and the combinators (derive/switch/distinct_by; switch_some/and_then/then_some/flatten/distinct as blankets), all own self |
Pipe<T> | [resource] trait | a move-only transformation; carries the sealing operations memo/cell/memo_global/cell_global/sample |
Subscriber | struct | one observer’s record — its id (a turn’s dedup key), notify, liveness and class; what on_settle carries |
MemoCell<T> | struct | a sealed derivation’s read-only face — what .memo() returns |
Constant<T> | struct | Source::constant(v): a source that never changes |
Derive, Switch, SwitchSome, AndThen, ThenSome, Combine, ZipSome, Distinct, DistinctBy | [resource] structs | the pipe stages: hold their upstream until a consumer starts them; what the combinators return |
Instance<T> | struct | a started flow — the one consumer’s pull/attach/release (for stage authors) |
TransientState, TransientSource, .transient() | std::reactive::transient | values that come and go — pending, ready, refreshing, failed with the stale value, absent; a flow of tasks sealed so the latest task wins (std::reactive::transient) |
track | method (every Source) | read AND make the source a dependency of the body that is running — tracked reads (A142 §7) |
derive (free), TrackedDerive<T> | fn / [resource] struct | a pipe whose only dependencies are the ones its body tracks |
tracking, TrackScope, Tracker | context / structs | the tracking scope a body’s run establishes; tracking.clear(..) is untrack |
Resource<T>, ResourceState<T> | struct/enum | a value that may still be loading — pending, settled, failed — built by source.load(fetch) or Resource::pending(), read through its fallbacks |
Signal<T> | trait | the writable half (set/notify/set_with); Source is its supertrait |
SignalCell<T> | struct | the canonical cell — mutable value plus subscribers |
MaybeSignal<T> | trait | a component value that may be static OR reactive |
Subscription | struct | an explicit subscription; Disposable |
combine | fn | a pipe of the tuple of 2+ sources |
divorce | fn | the reverse: one derived pipe per position of a tuple-valued source |
zip_some | fn | wait for several maybes: Some of the tuple of payloads while every input is Some |
unzip | method (SignalCell<T: (2..)>) | one cell per position of a tuple-valued cell, written in place |
selector, Selector<T> | fn/struct | per-key selection: one subscription, two writes per change |
Owner | struct | disposal bag; the lifetime unit |
on_cleanup | fn | run a cleanup when the ambient owner is released |
run_with_owner, comp, get_owner, owner_scope | fns/context | establish/read the ambient owner |
turn, batch, flush, at_settle, FlushPolicy, turn_scope | fns/context | write batching |
trace::trace | fn | the reactive trace (std::reactive::trace): a line per wake, naming what woke it (debugging) |
optimistic | fn | paint → commit → confirm-or-rollback (one shot) |
Optimistic<T>, WriteState | struct/enum | the same lifecycle, observable and overlap-safe |
draft, Draft<T>, DraftState | fn/struct/enum | local-first editing cell |
reconcile, ReconcilePlan, RowStep | fn/structs | keyed list diffing engine (K: PartialEq + Hashable) |
SeqOp, MapOp, SetOp, DeltaLog, DeltaCursor, DeltaSource | enums/struct/trait | the change structure: a collection’s change as a value, and its log |
DeltaFeed<T> | trait | a consumer’s question “do you keep ops?” — answered by every Source<List<T>> |
ListCell<T> | struct | a List cell whose writes ARE its deltas |
SequenceCell<T>, Sequence<T>, Tracked<T> | traits/struct | twelve mutators over one splice primitive; the &mut twin and its recorder |
map_each | fn | g element-wise and incrementally — one call of g per arriving element |
CollFlow<T>, CollPipe<T>, CollSource<T> | traits | collections per shape (A142 §6): anything a collection pipeline starts from; a move-only collection pipe (memo/memo_global/sample); a granular source |
map, filter, filter_map, any, all, count, flatten | methods | the collection operators — each returns a pipe, and follows the flow its closure returns (IntoFlow, IntoElement) |
ListMemo<T> | struct | a sealed collection pipe: a read-only granular source |
RowFeed<T> | trait | what each/each_by read their rows from: a list source or a collection pipe |
coll, coll_by, Coll, CollBy, SameElement | methods/structs/trait | a flow of whole lists into a collection pipe: the positional diff (prefix/suffix trim) and the keyed one (ReconcilePlan, emits Move) |
HashMapCell<K, V>, HashMapEntry<K, V>, MapSignal, MapSource, TrackedHashMap | structs/traits | a HashMap cell whose writes ARE its MapOps; at(key), a handle that depends on ONE key |
HashSetCell<T>, HashSetEntry<T>, SetSignal, SetSource | structs/traits | the same for a HashSet; contains(x), a Source<bool> for one member |
MapFlow, MapPipe, HashMapMemo, MemoEntry, SetFlow, SetPipe, HashSetMemo | traits/structs | the Map and Set shapes’ pipes: anything a map (set) pipeline starts from; a move-only map (set) pipe (memo/memo_global/sample); what it seals into |
keys, values, entries, map_values, filter, count, sum_by | methods | the map operators — a set pipe, collection pipes in insertion order (a Fenwick rank), map pipes, scalar pipes; each costs what the op names |
KeySlots | struct | the per-key slot table both stand on: a slot per WATCHED key, counted per subscription |
Signal and SignalCell
Source reads and Signal writes; SignalCell is the canonical cell that
implements both. A component that only observes takes a Source, one that
writes back takes a Signal, and one that needs the cell’s own surface — an
in-place update — names SignalCell.
trait Signal<T> with Source<T> {
fun new(own value: T): SignalCell<T> // default body: the canonical cell
fun set(self, value: T) // required
fun notify(self) // required
fun set_with(self, transform: sync |T| T) // default: set(transform(get()))
}
Signal::new(v) is the everyday spelling and does not dispatch: there is no
receiver to select an implementation from, so it resolves statically to the
trait’s own default body and means the canonical cell in every file. The value
it hands back is a SignalCell<T>.
impl SignalCell<type T> with Signal<T> {
fun new(own value: T): SignalCell<T>
fun set(self, value: T) // write + notify
fun notify(self) // publish without changing
// from the trait default:
fun set_with(self, transform: sync |T| T) // read-modify-write
}
impl SignalCell<type T> {
fun update(self, mutate: sync |&mut T| void) // mutate in place, notify once
}
impl SignalCell<type T> with Source<T> {
fun get(self): T
fun on_settle(self, subscriber: Subscriber): Subscription // push the record as given
fun attach_observer(self, observer: |T| void, immediately: bool): Subscription // `observe`
}
// Every source is a `Flow` (the blanket), so the cell has the consumers and the
// combinators too — each taking `own self`, which for a cell is a copy:
// on_change / sub / effect / effect_on_change (an owner per run)
// derive / switch / distinct_by, and the bounded blankets below
impl type F: Flow<Option<type T>> {
fun switch_some<U, I: Flow<U>>(own self, select: sync |T| I): SwitchSome<F, T, I, U>
fun and_then<U, I: Flow<Option<U>>>(own self, select: sync |T| I): AndThen<F, T, I, U>
}
impl type F: Flow<Option<type I: Source<type U>>> {
fun flatten(own self): SwitchSome<F, I, I, U> // `None` detaches; `Some` follows
}
impl type F: Flow<bool> {
fun then_some<T, S: Source<T>>(own self, source: S): ThenSome<F, S, T>
}
impl type F: Flow<type T: PartialEq> {
fun distinct(own self): Distinct<F, T>
}
flatten is the join of a flow of Option<source>: None reads None
and detaches from whichever inner was live, and Some(inner) follows that
inner from its current value. A pipe cannot travel through a flow — its values
are copied into every consumer — so a flow of flows is always a flow of
sources. The total join of a flow of sources (A4’s flatten over a
SignalCell<SignalCell<U>>) is .switch(|inner| inner), one stage.
switch, switch_some and and_then are the dynamic dependencies (A123).
derive and combine are static: the expression fixes what the result reads.
switch follows whichever flow its selector builds for the current value and
re-selects on every change — Rx’s switchMap. switch_some is the same over
an Option (None detaches), and and_then is the Kleisli composition of
Source<Option<T>> (Option::and_then one level up): the outer None and the
inner None collapse into one. Each is ONE stage, and its selector runs exactly
once per change of its input, inside the one instance its consumer started; what
a previous selection started is released at re-selection. A selector must
BUILD the flow it answers — a closure cannot capture a pipe built outside it —
and answering a source (a cell, a memo, a mirror) is always fine.
update is inherent to the cell, deliberately: its value is in-place
mutation with one notification, and a generic default could only
read-copy-mutate-write-back — the copy it exists to avoid. An implementation may
want its own update logic or none at all, so a consumer that needs it asks for
SignalCell<T> rather than for a bound.
A trait may be written as a let annotation, where it is a checked
constraint rather than the binding’s type: let count: Signal<i32> = SignalCell::new(1) asserts that SignalCell<i32> implements Signal<i32> and
leaves count a SignalCell<i32>, update and all. Checked wide, kept narrow.
setnotifies through the ambient turn when one exists (writes coalesce); outside any turn it notifies immediately.updatemutates the stored value through a writable view and notifies once, unconditionally, after the closure returns — the collection door (design notes). It sharesset’s notify half, so batching, drain affinity, and dedup behave identically. A read from inside the closure sees the in-progress value; a re-entrantupdateof the same signal is unsupported.- What the closure receives is a
&mutsubject, not a binding, and a pattern binder under it is a binding like any other:Some(mut list)takes rule 1’s copy (spec §6.1), so growinglistgrows the copy and the cell keeps the value it had. Write back through the subject —held = Some(list)— or move the payload out and put one back withOption::take/replace. Only aSignalCellwhose value is a wrapped collection meets this;updateon the collection itself mutates the storage directly, as the example above does.
import std::reactive::{ Signal, SignalCell, Owner, batch };
fun main() {
let owner = Owner::new();
let todos: SignalCell<List<str>> = Signal::new([]);
owner.take(todos.sub(|list| print(list.len()))); // 0
todos.update(|&mut list| { list.push("write docs"); }); // 1
// Two updates, one notification: `update` batches like any write.
batch(|| {
todos.update(|&mut list| { list.push("ship it"); });
todos.update(|&mut list| { list.push("rest"); });
}); // 3
}
The write-back, both spellings, on a cell whose value is a wrapped list:
import std::option::Option::{ self, None, Some };
import std::reactive::{ Signal, SignalCell };
fun main() {
let held: SignalCell<Option<List<str>>> = Signal::new(Some([]));
// Through the `&mut` subject: the binder's copy is put back.
held.update(|&mut held| {
match held {
Some(mut list) => {
list.push("one");
held = Some(list);
}
None => {}
}
});
// Or move the payload out and put one back.
held.update(|&mut held| {
mut list = held.take().unwrap_or([]);
list.push("two");
held.replace(list);
});
match held.get() {
Some(let list) => print(i"{list.len()}"), // 2
None => print("none"),
}
}
- A pipe registers nothing until it is consumed. A sealed derivation’s
subscription is detachable: made inside a boundary — a mounted view, an
eachrow —.memo()registers with the ambient owner and dies when that boundary is disposed (aswitchalso releases whichever inner it follows). - Sealed outside every boundary — the top of
main— a derivation has no owner to register with and lives as long as its source. At module level that lifetime is spelled.memo_global(), and it is why the seals read the ambient owner optionally whereeffectdemands one. effectrequires an ambient owner; calling it outside every owner is a compile error (context coverage). It fires once immediately.subfires once immediately with the current value, likeeffect, and then on every change; itsSubscriptionis yours to dispose (or hand toowner.take).on_changeis the same subscription without that first call — and it is the primitive of the two:subison_changeplus that call, andeffectiseffect_on_changeplus it. The eager pair is right for a UI binding — the immediate call is the initial paint — and wrong for an effect that must not fire on the state the program starts in: a “you have unsaved changes” prompt, an analytics ping, a derivation that already seeded its own first value..memo()attaches this way.
import std::reactive::{ Disposable, Signal, SignalCell , Subscriber };
fun main() {
let title: SignalCell<str> = Signal::new("untitled");
// Nothing prints here — the current value is not a change.
let watch = title.on_change(|value| print(i"renamed to {value}"));
title.set("plans"); // renamed to plans
watch.dispose();
}
Source, Flow and Pipe
trait Source<T> with Flow<T> {
fun get(self): T // required
[must_use]
fun on_settle(self, subscriber: Subscriber): Subscription // required; no payload
[must_use]
fun attach_observer(self, observer: |T| void, immediately: bool): Subscription // default
fun identity(self): Option<i32> // default `None`; for tracked reads
fun constant(value: T): Constant<T> // `Source::constant(v)`
fun track(self): T // a blanket: read + track (§ below)
}
[resource]
trait Flow<T> {
[must_use]
fun start(own self): Instance<T> // the stage author's member (required)
[must_use]
fun on_change(own self, observer: (|T| void) context tracking): Subscription
// no first call; a callback (cleared);
// a default over `start`, a source's own
[must_use]
fun sub(own self, observer: (|T| void) context tracking): Subscription
// + one immediate call; cleared; likewise
fun effect_on_change(own self, body: (|T| void) context (owner_scope, tracking, ambient_nursery))
// owner-registered; an owner per run; cleared
fun effect(own self, body: (|T| void) context (owner_scope, tracking, ambient_nursery))
// the same, eager; the body tracks
fun derive<U>(own self, transform: (sync |T| U) context (owner_scope, tracking, ambient_nursery)): Derive<Self, T, U>
fun switch<U, I: Flow<U>>(own self, select: (sync |T| I) context (owner_scope, tracking, ambient_nursery)): Switch<Self, T, I, U>
fun distinct_by<K: PartialEq>(own self, key: sync |T| K): DistinctBy<Self, T, K>
…
}
[resource]
trait Pipe<T> with Flow<T> {
fun memo(own self): MemoCell<T> // seal: read-only, owner-tied
fun cell(own self): SignalCell<T> // seal: writable, owner-tied
fun memo_global(own self): MemoCell<T> // for the life of the program
fun cell_global(own self): SignalCell<T>
fun sample(own self): T // start, read once, release
}
Two kinds of reactive value (A142’s pipe model):
- A
Sourcehas state and can be read:SignalCell, a sealedMemoCell,Source::constant(value), aRemoteSourcemirror, and any type of yours. - A pipe is a transformation —
derive,switch,switch_some,and_then,then_some,flatten,distinct,distinct_by,combine— a description with noget. It is consumed exactly once: sealed with.memo(),.cell(), the_globaltwins or.sample(), or handed to a consumer (aneffect, a UI binding). A pipe built as a bare statement runs nothing, and the compiler WARNS about it (unused pipe) — whichever function built it, std’s or your own;let _ = …says the drop is meant.
Flow is what both are: every Source through a blanket, and every pipe
stage directly. The consumers and the combinators live there, and every one of
them takes own self — for a source that is a copy, for a pipe the move that
makes a second consumer a compile error (use of 'p' after it was moved).
Pipe adds the sealing operations, which exist only on pipes: a cell has no
.memo(), and a pipe has no get().
dyn Flow<T> holds either, once. Flow and Pipe are declared
[resource] (A142 R39), so a pipe stage erases into dyn Flow<T> and the
object is move-only: consumed once, like the pipe it may hold. The attribute
belongs to the trait that declares it, so dyn Source<T> stays copyable (a
source is data) though Source extends Flow. Arms of different types meet in
the object where one is expected, which is how a selector hands back a pipe from
one arm and a root from the other:
import std::reactive::{ Flow, MemoCell, Signal, SignalCell, Source };
fun arm(scaled: bool, count: SignalCell<i32>): dyn Flow<i32> {
if scaled {
count.derive(|value| value * 100)
} else {
count
}
}
fun main() {
let scaled = Signal::new(true);
let count = Signal::new(1);
let shown: MemoCell<i32> = scaled.switch<i32, dyn Flow<i32>>(|on: bool| arm(on, count)).memo();
print(shown.get()); // 100
scaled.set(false);
count.set(3);
print(shown.get()); // 3
}
(The type arguments are written: switch does not yet infer its U through the
object’s trait argument.)
A source of your own implements get and on_settle. Everything else —
on_change, sub, effect, derive — arrives through Flow:
import std::reactive::{ Owner, Signal, SignalCell, Source, Subscriber, Subscription };
/// A signal with a place to hang persistence, and no `set` on the trait.
struct Stored<T> {
inner: SignalCell<T>,
}
impl Stored<type T> with Source<T> {
fun get(self): T {
self.inner.get()
}
[must_use]
fun on_settle(self, subscriber: Subscriber): Subscription {
self.inner.on_settle(subscriber)
}
}
impl Stored<type T> {
fun new(value: T): Stored<T> {
Stored { inner = Signal::new(value) }
}
fun set(self, value: T) {
self.inner.set(value);
}
}
fun main() {
let owner = Owner::new();
let width: Stored<i32> = Stored::new(400);
owner.take(width.sub(|value| print(value))); // 400
width.set(320); // 320
}
attach_observer is the source-side answer to on_change (immediately
false) and sub (true). Its default attaches a pulling subscriber through
on_settle; a root overrides it to skip the indirection (SignalCell attaches
directly) or to keep a contract of its own — a mirror’s seeding frame arrives
inside its lease over an in-process transport, so its eager form leases first
and makes the one immediate call itself.
Anything that only reads takes a Flow, not a Signal. Every read-only
binding in std::web::ui — bind_text, bind_class,
bind_attr, bind_styled, style_var, toggle_attr, when, when_some,
show and swap — is generic over Flow<T> and consumes it (own), so
Stored<str> above, a cell and a pipe all drive them, on the browser layer and
on the SSR twin alike. each, each_values and each_by take a Source: a
derived list is sealed with .memo() first. The Slot and AttrValue arms
element syntax dispatches through are Flow arms too, so <p>{stored}</p>,
<p>{count.derive(|n| i"{n}")}</p> and <a href(stored)> all work. What asks
for a Signal is what writes: bind_value and its SSR twin bound on
Signal<str>, and optimistic on Signal<T>. Optimistic::over takes any
Signal<T> too — the cell STORES it in a field, and a field must name a real
type, so the cell names it: Optimistic<T, S>.
A function of yours that consumes a flow takes it own, bounded on Flow<T>;
one that reads its argument more than once asks for a Source<T>, and the
caller seals.
on_settle — the attach every stage forwards
on_settle attaches a Subscriber and hands it no value: the subscriber is
told that this source may have changed and reads get() if it wants to know
what to.
That is what makes a pipe cost nothing per hop. A consumer starts its pipe
(Flow::start, which builds the pipe’s one instance) and attaches ONE
subscriber; every stateless stage forwards it to its upstream untouched — no
subscriber minted, nothing computed — so a five-stage chain sealed into one
.memo() puts one record on its root cell and evaluates the five stages fused,
once per change. When two arms of one consumer’s pipe reach the same root (a
diamond — combine((a, a.derive(f).memo()))), the root holds that one record
twice. Its id is the consumer’s, and a turn’s queue is keyed on it, so inside a
turn or a batch the diamond’s two notifications are one call — reading a
settled value, because every read is a pull of state that has already been
written.
The handle on_settle returns has one obligation: disposing it retires the
subscriber — it never fires again. Handing the subscriber to a cell’s
on_settle meets that for free, because the cell’s handle carries the
subscriber’s own liveness cell. A stateful stage that keeps a registration of
its own (a switch re-selecting what it follows) registers a relay of its own
and never forwards the consumer’s record to a registration it will dispose by
itself — disposing that would retire the consumer.
Sealing: memo, cell, sample — and distinct, the stage that compares
.memo() seals a pipe into a read-only MemoCell<T>, backed by one cell:
the pipe’s one consumer, run once per change, with its value cached for every
reader. Where a sealed value belongs is a question of readers: a derivation
read by two consumers, or read with get(), is sealed once and shared. It is
owner-tied — made inside an owner, it dies with the owner — a turn settles it
before any effect reads it, and it is a source again, so a pipe can continue
through it. .cell() is the writable twin, a SignalCell<T>: a local set
holds until the next change upstream overwrites it.
.sample() reads a pipe once — start it, take its value, release whatever
its bodies built — and subscribes to nothing, so it opens no channel. Its cost
is written at the call: on_click(|| submit(total_of(cart).sample())) runs the
chain once per click.
A module-level seal is .memo_global() (.cell_global()). A .memo() is
owner-tied, and a module binding’s initializer has no owner, so there its
registration would stay on the upstream for the life of the program — a loop
nothing releases, behind a spelling that promises a scope. The compiler refuses
.memo() and .cell() written directly in a module binding’s initializer and
steers to the two doors: seal under the owner that reads it (inside the view, or
an owner_scope.run), or write the _global twin, which says that lifetime — it
registers with no owner even inside one, and nothing releases it:
import std::reactive::{ MemoCell, Signal, SignalCell, Source };
let path: SignalCell<str> = Signal::new("/");
let depth: MemoCell<usize> = path.derive(|value| value.len()).memo_global();
fun main() {
path.set("/docs");
print(depth.get()); // 5
}
The refusal reads the initializer’s own calls: a .memo() in a closure the
initializer creates, or inside a function it calls, is not caught — and leaks
the same way, because a module-level closure keeps the (owner-less) context it
was created in. Spell those .memo_global() too.
Sealing does not compare: every change above a .memo() is a set, and a
set always notifies. .distinct() is the stage that compares — it asks
T: PartialEq and nothing else does — and passes a change on only when the
value differs from the last one it passed on; .distinct_by(key) compares
key of the value and passes the whole value on:
import std::reactive::{ Signal, SignalCell, Source };
fun main() {
let width = Signal::new(320);
let watch = width.distinct().on_change(|value| print(i"now {value}"));
width.set(320); // nothing: the same value
width.set(1024); // now 1024
watch.dispose();
}
The guide has the rule for where a derivation lives.
A pipe’s type spells its whole upstream, so the editor shows a pipe stage by
the trait it is used as — : ~Pipe<Option<str>> — through the
[hint(Pipe<U>)] each stage declares, and a sealed MemoCell as the
~Source<T> it is; a package author’s own stage takes the same attribute
(the editor).
Resource — a value that may still be loading
enum ResourceState<T> { Pending, Settled(T), Failed(str) }
impl type S: Source<type T> {
fun load<U>(self, fetch: async |T| Result<U, str>): Resource<U> // re-fetch at every change
}
impl Resource<type T> {
fun pending(): Resource<T> // the hand-driven form
fun settle(self, value: T)
fun fail(self, reason: str)
fun pend(self)
fun state(self): SignalCell<ResourceState<T>>
fun or(self, default: T): Derive<..> // `default` while pending or failed — RESETS on a re-pend
fun latest(self, default: T): Derive<..> // HOLDS the last settled value across a re-pend
fun optional(self): Derive<..> // `None` while pending or failed
fun is_pending(self): Derive<..> // the spinner
}
A fetch in flight is not a Source — it cannot answer get — so it is a
Resource, a three-state machine (pending → settled → pending again when its
source changes; failed is a third state a retry re-pends), and it becomes a
source only through a fallback. That is where the Option of “not there
yet” is confronted, once, instead of in every derive below it. source.load(fetch)
runs fetch for the source’s current value and again at every change; the newest
load wins. .or and .latest differ exactly on a re-pend: .or shows the
default again (a spinner’s product), .latest keeps the old value on screen
while the new one loads.
import std::reactive::{ Resource, Signal, SignalCell, Source };
import std::result::Result::{ self, Ok };
async fun fetch_name(id: i32): Result<str, str> {
Ok(i"user {id}")
}
fun main() {
let id: SignalCell<i32> = Signal::new(1);
let user: Resource<str> = id.load(|n| fetch_name(n));
let shown = user.or("loading…").derive(|name| name + "!");
print(shown.sample()); // loading…! — the fetch has not landed
}
An owner per run — every body a pipe runs
fun effect(own self, body: (|T| void) context (owner_scope, tracking, ambient_nursery))
fun effect_on_change(own self, body: (|T| void) context (owner_scope, tracking, ambient_nursery))
fun derive<U>(own self, transform: (sync |T| U) context (owner_scope, tracking, ambient_nursery)): Derive<Self, T, U>
fun switch<U, I: Flow<U>>(own self, select: (sync |T| I) context (owner_scope, tracking, ambient_nursery)): Switch<Self, T, I, U>
fun on_cleanup(cleanup: || void)
[deprecated] fun scoped_effect(own self, body: (sync |T| void) context owner_scope) // = effect
Every run of a body a pipe runs gets its own Owner (A142 R2/R14): an
effect’s, a derive’s, a switch/switch_some/and_then selector’s.
Whatever the body registers — an on_cleanup, a nested effect, a .memo(),
an owner.take, a mirror’s lease — is released before the next run, and the
last run’s when the consumer is released (the enclosing boundary, for an
effect or a .memo(); the call itself, for .sample()). A task the body
starts runs in the run’s nursery and is cancelled when the run is released,
so a superseded fetch stops rather than landing late. The task is OWNED by that
nursery — spawned in the body or in anything the body calls — so its
cancellation is absorbed, never reported as an unhandled task error. A pipe has exactly one
consumer, so every body runs once per change inside one instance, and “a run”
always means “one run per change”.
The owner is allocated LAZILY: a stage keeps one owner cell for the life of its
instance, each run is the next epoch of it, and the cleanup list is made at the
run’s first registration — a body that registers nothing costs a read and a
write, and allocates no owner. A run’s nursery has to exist before the body
runs (the spawn machinery registers a task at the async expression), but a
run that started no task hands its nursery on to the next run: a stage makes a
nursery at its first run, and again only after a run that spawned. The bindings in std::web::ui do not pay even that:
their bodies write the DOM and register nothing, so they attach plainly.
import std::reactive::{ Owner, Signal, SignalCell, Source, on_cleanup, run_with_owner };
fun main() {
let selected = Signal::new(1);
let detail = Signal::new("loading");
let page = Owner::new();
run_with_owner(page, || {
// One subscription on `detail` at a time, not one per selection.
selected.effect(|id: i32| {
on_cleanup(|| print(i"closing {id}"));
detail.effect(|text: str| print(i"{id}: {text}"));
});
});
selected.set(2); // closing 1, then the new run subscribes
page.dispose(); // closing 2
}
on_cleanup(cleanup) is get_owner().defer(cleanup) under a name, and it is
one name whose meaning the ambient owner decides: inside a body the ambient
owner is that run’s, so the cleanup runs per run; inside any other boundary — a
mounted view, a swap instantiation, an each row — it is the boundary’s, so
it runs once, at teardown. Like effect, it requires an enclosing owner
statically: a cleanup with nothing in scope to run it is a compile error, not
a silent no-op.
The order inside a run is: release the previous run, install the fresh epoch,
call the body. So a body that throws has already had its owner installed, and
what it registered before throwing is released by the next run (or by the
consumer’s release). A registration that arrives after its run was released —
an await that resumed late — is released on the spot.
The body’s owner and nursery are INJECTED (the context clause), which is what
makes them the RUN’s rather than the boundary’s. A body is a closure literal, a
local closure, or a named function or variant that reads none of the injected
contexts — count.derive(label) and count.derive(Some) hand the call its
body as directly as count.derive(|n| label(n)) does; a function that reads
one (a .track() inside it) is refused as a value and written as a literal
at the call. scoped_effect and
scoped_effect_on_change are effect’s and effect_on_change’s names before
the two merged, kept one release as deprecated aliases.
swap, when and each are not built on this — they keep their own
per-instantiation owners.
Tracked reads — track() and the free derive
let tracking: Context<TrackScope> // the scope of the running body
impl type S: Source<type T> {
fun track(self): T // get() + a dependency of the scope
}
fun derive<T>(body: (sync || T) context (owner_scope, tracking, ambient_nursery)): TrackedDerive<T>
trait Source<T> { fun identity(self): Option<i32> … } // `None` by default
The optional sugar layer (A142 §7, R6): inside a body a pipe runs, source.track()
reads the source and makes it a dependency of that body. Nothing else
tracks — get() never registers — so code that does not opt in is unchanged.
The guide’s tracked reads chapter is the tour.
- Which bodies open a scope. Every body a pipe runs: a
derivetransform (count.derive(|c| c + other.track())followsothertoo), aswitch/switch_some/and_thenselector (a tracked change re-selects), aneffectbody (a tracked change re-runs it with the input’s latest value), and the freederive(|| body), a pipe whose only dependencies are the ones it tracks — a stage like any other, sealed once or consumed once. Callbacks (on_change,sub,effect_on_change) and collection operators’ per-element closures open none. - Strict, at compile time.
trackingis a context andtrack()reads it with the strictget: atrack()no body encloses is the coverage error, with the uncovered path traced. There is no run-time lookup. - Dependencies are the last run’s reads. After each run the stage compares
its reads with the edges it holds, in order: an edge to a source read again is
kept, a new read attaches one, an edge nothing read is detached. A source keeps
its edge across runs when it can say what state it is —
Source::identity(), answered bySignalCell,MemoCell,ListCell,KeyedCell,RemoteSourceandKeyedSource(their value cell);None, the default, re-attaches each run and releases the old edge. A source that names its identity and is read twice in one run is one edge. - Glitch-free. An edge wakes through a DERIVATION relay (the turn’s first
phase), where the consumer’s own wake from its input dedups with it: a turn
that changes an input and a tracked read runs the body once, on settled values.
An
effect’s re-run is ordered after its input’s delivery, so the same holds for an effect. - A scope is one run. A closure created inside a body captures the run’s
scope; a
track()through it after the run returned registers nothing (a run-time guard; the static form, callbacks run with the scope cleared, is a planned follow-up — A142 §7.3).tracking.clear(body)(B458) runsbodywith no scope: a closure minted inside holds none, and atrack()written inside is refused — the layer’suntrack. on_changeprimes. A consumer that does not read at once runs a pipe’s bodies once when it subscribes (the value discarded, the observer not called), so a tracked read is a dependency before the first change.
import std::reactive::{ Owner, Signal, SignalCell, Source, derive, run_with_owner };
fun main() {
let flag: SignalCell<bool> = Signal::new(true);
let a: SignalCell<i32> = Signal::new(1);
let b: SignalCell<i32> = Signal::new(100);
let owner = Owner::new();
run_with_owner(owner, || {
derive(|| if flag.track() { a.track() } else { b.track() })
.effect(|value: i32| print(i"saw {value}")); // saw 1
});
b.set(200); // nothing: the last run read `a`
flag.set(false); // saw 200
a.set(2); // nothing: the branch that read `a` was not taken
owner.dispose();
}
TrackScope, Tracker, TrackRuns, TrackLists, Dependency and TrackedEdge
are the layer’s own records (a run’s scope, a stage’s dependencies across runs —
its lists made at the stage’s first track() — one read, one edge);
an application never builds them.
selector — per-key selection
“Is this row the selected one?”, asked once per row and answered live. The
obvious spelling derives a boolean per row off the selection signal — and every
one of them recomputes on every change, so moving a highlight one row costs n
notifications. selector takes one subscription on the source and keeps a
cell per key, so a change writes exactly two: the key that left and the key
that arrived.
fun selector<T: Hashable + PartialEq, S: Source<T>>(source: S): Selector<T>
impl Selector<type T: Hashable + PartialEq> {
fun of(self, key: T): SignalCell<bool>
}
import std::reactive::{ Signal, SignalCell, selector };
import std::web::ui::{ View, each, mount_root };
fun main() {
let rows: SignalCell<List<i32>> = Signal::new([1, 2, 3]);
let current: SignalCell<i32> = Signal::new(1);
let selected = selector(current);
let _root = mount_root("app", || {
<ul>
{each(rows, |id| id, |id| {
<li .bind_class(selected.of(id).derive(|on| if on { "row current" } else { "row" }))>
i"row {id}"
</li>
})}
</ul>
});
}
of(key) creates the key’s cell on first ask (seeded against the source’s
current value) and hands back that same cell every time after, so it is safe to
call in a row’s render body. The entry’s removal is deferred to the ambient
owner — which inside an each row is the row’s own — so the map stays the
size of the live list rather than of every list the session ever showed.
Selector is a handle with a method rather than the bare closure Solid’s
createSelector returns, and that is forced rather than chosen: a closure
captures its context at creation, so a closure built inside selector would
defer every key’s cleanup to whatever owner was ambient where selector was
called — the component, never the row. A method call threads the caller’s
ambient owner the ordinary way.
The key type is bounded on Hashable (the canonical key HashMap and HashSet use)
and on PartialEq (to seed a fresh cell against the current value). A key
nobody has asked about has no cell and costs nothing: a change into it writes
only the outgoing one.
Writing a Signal
Implement Source’s get/on_settle and Signal’s set/notify, and the type is
usable anywhere a signal is wanted. The setter is where custom behaviour lives —
a clamp, a persistence write, a debounce — and there is only one value, so
whatever set stores is what every reader and every observer sees.
import std::display::Display;
import std::reactive::{ Signal, SignalCell, Source, Subscriber, Subscription };
struct Clamped { inner: SignalCell<i32>, max: i32 }
impl Clamped {
fun new(initial: i32, max: i32): Clamped {
Clamped { inner = SignalCell::new(initial), max }
}
}
impl Clamped with Source<i32> {
fun get(self): i32 { self.inner.get() }
[must_use]
fun on_settle(self, subscriber: Subscriber): Subscription { self.inner.on_settle(subscriber) }
}
impl Clamped with Signal<i32> {
fun set(self, value: i32) {
self.inner.set(if value > self.max { self.max } else { value });
}
fun notify(self) { self.inner.notify(); }
}
/// A component. It bounds on the writable trait and knows no implementation.
fun width_control<S: Signal<i32>>(width: S) {
width.set(1000);
let seen: i32 = width.get();
print(i"stored: {seen}");
}
fun main() {
width_control(SignalCell::new(0)); // stored: 1000
width_control(Clamped::new(0, 800)); // stored: 800
}
The trait promises nothing about notification frequency. SignalCell
notifies unconditionally — set never compares — and an implementation that
wants “don’t publish an unchanged value” writes that in its own set.
MaybeSignal
trait MaybeSignal<T> {
fun bind(self, react: |T| void);
}
One parameter that takes a static value or a reactive one, with no ceremony at
the call site. bind is effect-shaped rather than getter-shaped, which is what
lets one signature serve both: the static implementation fires the handler once,
the reactive one subscribes and keeps firing.
import std::reactive::{ MaybeSignal, Owner, Signal, SignalCell, comp };
fun badge<V: MaybeSignal<str>>(label: V) {
label.bind(|text| print(i"[{text}]"));
}
fun main() {
let (_value, _owner) = comp(|| {
badge("draft"); // [draft]
let live = Signal::new("saved");
badge(live); // [saved]
live.set("synced"); // [synced]
0
});
}
std ships two implementations: a blanket impl type T with MaybeSignal<T> (the
static case) and impl type S: Source<type T> with MaybeSignal<T> (the reactive
one). Which runs is settled at the call by the specificity order, with no
runtime discrimination anywhere. The reactive arm registers with the ambient
owner, so a component’s subscription dies with the boundary that built it — and
that is why bind may only be called under one.
combine
fun combine<T: (2..)>(sources: (U in T: dyn Source<U>)): Combine<T>
A pipe of the tuple of the sources’ current values, changing when any source
changes. Variadic over tuples of mixed element types, and each element is any
Source — a cell, a sealed memo, a mirror — erased to dyn Source<U> at the
call. A derived input is sealed first: a pipe may become a dyn Flow, never a
dyn Source, since a source is copied freely and a pipe is not:
import std::reactive::{ Signal, SignalCell, Source, combine };
fun main() {
let flag = Signal::new(true);
let count = Signal::new(2);
let both = combine((flag, count.derive(|n: i32| n * 10).memo())).memo();
let (_on, current) = both.get();
print(current);
}
(Destructuring names the parts, which reads better than positions;
both.get().1 also works.) Like derive, it is a pipe: .memo() it where the
tuple is shared or read.
divorce
fun divorce<T: (2..), S: Source<T>>(source: S): (U in T: Derive<S, T, U>)
The reverse of combine: one derived pipe per position of a tuple-valued
SOURCE (it reads its argument more than once, so it asks for a source and a
combined pipe is sealed first). Each output is a derive reading its own
position through a std::tuple key, so divorce(combine((a, b)).memo())
re-derives a’s and b’s values:
import std::reactive::{ Signal, SignalCell, Source, divorce };
fun main() {
let pair = Signal::new((1, "one"));
let (number, word) = divorce(pair);
pair.set((2, "two"));
print(i"{number.sample()} {word.sample()}");
}
Every output subscribes to the WHOLE source, so each one fires on every change
of it — a change to another position, and a set that changes nothing,
included. A consumer that wants its output to fire only when that position
moved gates it: divorce(pair).0.distinct(), which asks PartialEq of that
one element. .memo() an output where it is shared or read.
zip_some
fun zip_some<T: (2..)>(flows: (U in T: dyn Flow<Option<U>>)): ZipSome<T>
Wait for several maybes at once: a pipe that is Some of the tuple of the
inputs’ payloads while EVERY input is Some, and None while any one is
None. Over flows of Option<Message> and Option<User> it is a
Pipe<Option<(Message, User)>>, so the negative states are handled in one
place — where the zip is read — and nothing past it re-matches them. It is
combine and Option::zip written once: variadic over the payload types,
stateless, and needing no owner, so it composes like any stage. Each input is
any flow of a maybe — a cell, a sealed memo, a pipe such as a mirror’s
.latest() or a transient’s .derive(|state| state.ready()) — and a pipe input
is moved in:
import std::reactive::{ Flow, Pipe, Signal, SignalCell, Source, zip_some };
fun main() {
let name: SignalCell<Option<str>> = Signal::new(Some("Ada"));
let age: SignalCell<Option<i32>> = Signal::new(None);
let both = zip_some((name, age.derive(|value| value))).memo();
print(both.get().is_none());
age.set(Some(36));
if both.get() is Some((let who, let years)) {
print(i"{who} {years}");
}
}
A dependent input needs nothing new — message.and_then(|m| m.author.user())
sealed with .memo() beside message itself. A diamond like that one notifies
the zip once per arm outside a turn, as any combine does; inside a batch
or a turn it runs once, on settled values.
What it does not do is hand back a flow per part: the parts of a zipped value
are values. For a body that wants one live cell per part, std::web::ui’s
when_all_some((a, b), |(a, b)| ..) is when_some over zip_some plus the
split below.
unzip — a tuple cell, split
impl SignalCell<type T: (2..)> { fun unzip(self): (U in T: SignalCell<U>) }
One cell per position of a tuple-valued cell, each seeded with its part and
written IN PLACE on every change of the whole, so a binding over a part updates
where it stands. It is a materialising node, like .cell(): the follow
registers with the ambient owner when there is one, and dies with it. Every
part is written on every change of the whole (a cell never compares, so T
needs no PartialEq), and a part’s own set does not write back:
import std::reactive::{ Signal, SignalCell, Source };
fun main() {
let pair = Signal::new((1, "one"));
let (number, word) = pair.unzip();
pair.set((2, "two"));
print(i"{number.get()} {word.get()}");
}
Subscription, Disposable
trait Disposable { fun dispose(self); }
struct Subscription { … } // impl Disposable
impl Subscription {
fun teardown(release: || void): Subscription // a subscription over no signal
}
Disposing a subscription guarantees no later deliveries — none, including
ones already in flight. A subscriber carries a liveness flag that dispose
lowers before it detaches anything, and both notification loops skip a lowered
one, so a delivery is stopped whether it sits in a wave the drain has already
taken out of the queue, in a turn the disposer’s own extent cannot reach, or in
the snapshot an inline notify is walking. (Until A110 a delivery queued in the
currently-draining turn could still land once. It cannot now.)
Subscription::teardown is the registration shape for a source outside the
signal graph: dispose runs the hook once and does nothing else. std::web::dom’s
listen is built on it — a DOM listener’s whole teardown is the call that
unhooks it. The hook is one-shot, so disposing twice is safe.
Owner
impl Owner {
fun new(): Owner
fun take<T: Disposable>(self, item: T): T // adopt a disposable; returns it
fun defer(self, cleanup: || void) // run cleanup at dispose
fun is_disposed(self): bool // has this owner already been disposed?
}
impl Owner with Disposable {
fun dispose(self) // dispose everything collected + run defers; idempotent
}
let owner_scope: Context<Owner>
fun get_owner(): Owner // read the ambient owner
fun on_cleanup(cleanup: || void) // = get_owner().defer(..)
fun run_with_owner<T>(owner: Owner, body: (sync || T) context owner_scope): T
fun comp<T>(body: (sync || T) context owner_scope): (T, Owner) // fresh owner + result
body parameters marked context owner_scope receive the ambient owner
implicitly: your component functions thread ownership without mentioning it.
Establish owners at disposal boundaries (places where a subtree can die),
not per object; in UI code the framework’s boundaries (mount_root,
each rows, when/swap bodies) already do this.
An owner has a disposed state, and it is what makes ownership hold across
await. A registration is a promise to release, and an async continuation
registers whenever it happens to run — a route switched away before a handle’s
reply, an each row rebuilt while its first fetch is in flight. take and
defer on an owner that is already disposed therefore run the cleanup now
rather than parking it: the extent it would have belonged to is over, so the
only way left to keep the promise is to keep it immediately. dispose itself is
idempotent, and is_disposed reports the flag for a caller that can do
something cheaper than register-and-immediately-release.
An effect registered this late still makes its one immediate call — that call
is the observer’s contract, not a subscription — and then never fires again.
A disposal group finishes: if one cleanup throws, the rest still run and the first failure is raised once the group is released. That is the opposite of the drain’s rule, on purpose — an owner holds a list of independent promises to release, so abandoning the list at the first failure would leak everything after it, permanently.
Turns
enum FlushPolicy { AtEnd, AtSuspension }
let turn_scope: Context<Turn>
fun turn<T>(policy: FlushPolicy, body: (|| T) context turn_scope): T
fun batch<T>(body: (sync || T) context turn_scope): T // join or create
fun flush() // drain the ambient turn now
fun at_settle(id: i32, action: || void) // run `action` at the ambient settle; now if none
fun at_release_settle(id: i32, action: || void) // the same, from a subscription's release hook
Inside a turn, signal writes are recorded and each subscriber runs once with
final values when the turn settles. The body is asyncness-polymorphic (spec
§7.4): a synchronous body settles at the end of its synchronous extent, and
an awaiting body holds every notification until it fully completes (a
transaction). Framework boundaries establish turns for you: UI event handlers
and mount_root (AtSuspension), RPC service handlers (AtEnd). Writes
landing after a settle (from spawned work) drain in per-segment microtasks.
at_settle defers a plain action the same way a notification is deferred:
it rides the ambient turn’s queue, deduped by id (repeat deferrals of one
action in one turn run it once), joins the currently draining turn when
called from inside a settle, and runs inline when no turn is ambient. It is
the primitive under a remote mirror’s deferred Unsubscribe
(std::rpc); library code that wants “after this turn, once” uses it with
an id that cannot collide with a subscriber’s (fresh_id() mints one).
“Ambient” is the context rule’s: reached through a stored closure — an
owner’s cleanup, a subscription’s release hook — the turn at_settle sees is
the one that closure captured when it was created. That is right for a write
from a stored callback and wrong for a RELEASE, which belongs to whoever
disposed the subscription and not to whoever took it; at_release_settle is
the release hook’s spelling, and it resolves against the turn ambient at the
dispose. Outside a release hook the two are the same function.
If an observer throws, the settle is abandoned at that observer — the rest of the wave does not run — and the error keeps unwinding out of the write that started the settle, with its own type, message and stack. What it cannot do is leave the scheduler broken: the draining flags are restored on the way out, so the next write settles normally, this turn included. (Before this, one throwing observer left its turn draining forever, and every later write in the program queued into it and was never flushed.)
trace — who woke what
// std::reactive::trace
fun trace(on: bool)
trace(true) (import std::reactive::trace::trace;) writes a line to stderr
(the browser’s console) for every wake a write causes — each subscriber the
write reached, as it runs — naming what woke it:
[reactive] effect #4 woken by cell #1 (made at src/main.vl:6:14), set at src/main.vl:12:2
[reactive] derivation #2 woken by cell #1, set at src/main.vl:9:7
[reactive] effect #5 woken by cell #3, set by derivation #2
[reactive] effect #6 woken by derivation #2
A subscriber is an effect (it acts on a value) or a derivation (a pipe’s
stage, a .memo(), a relay), numbered by its id; a cell by its identity,
which is the same number on every line. The places are [track_caller]
locations: where the cell was made by SignalCell::new, and the set or
update that wrote it — each named only when it is your code’s, never std’s.
A write std makes inside a traced wake (a .memo() publishing its value)
says set by that wake instead, and a relay’s wake (distinct, switch)
names the relay. A cell made by Signal::new, and a write through a
S: Signal<T> bound or a dyn Signal, carry no place: a trait member cannot
track its caller.
The lines are the same bytes on both backends. Turn the trace on before the
cells you want named are made (a cell remembers where it was made only while
the trace is on), and off with trace(false). The scheduler reaches the trace
through hooks trace(true) installs, so a program that never imports it
carries none of it.
Under the vilan debugger (VS Code) the lines also appear in the Debug
Console, and a wake that waited in a turn’s queue — run when the turn settles,
on a stack without the write — shows the write’s stack under the woken frame,
below a label naming the cause (node’s async stack tagging,
console.createTask; the native backend has no host task to tag).
optimistic
fun optimistic<T, E, S: Signal<T>>(signal: S, value: T, commit: async || Result<T, E>): Result<T, E>
Paint value into signal now, await commit, then reconcile: the
confirmed value on Ok, the previous value rolled back on Err. Returns
the outcome for error UX. For continuous editing, use draft instead:
rollback is wrong mid-typing.
The one-shot spelling: one write, no state to bind, and the rollback target is whatever the signal held at the call. If more than one write can be in flight over the same signal, or anything needs to render “saving…”, use the cell below.
Optimistic — the observable lifecycle
[derive(PartialEq, Debug)]
enum WriteState {
Confirmed, // nothing in flight; the value is the last confirmed truth
Pending, // the newest write is on the wire
Rejected(str), // the newest write was refused; the cell rolled back
}
struct Optimistic<T, S: Signal<T>> {
value: S, // the signal you handed to `over`; bind it
state: SignalCell<WriteState>, // bind a spinner, a disabled button, a banner
… // internals: the confirmed shadow, two generations
}
impl Optimistic<type T, type S: Signal<T>> {
fun over(signal: S): Optimistic<T, S>
fun write(self, value: T, commit: async || Result<T, str>): Result<T, str>
}
The second parameter is the signal’s own type. A field must name a real
type, so widening the cell past SignalCell means naming what it holds — and
inference binds both from the call, so Optimistic::over(title) needs nothing
written. You only spell S where you write the cell’s type out: a field or a
return, Optimistic<str, SignalCell<str>>.
The same lifecycle as optimistic, with the two things a free function has
nowhere to keep.
stateis observable.Pendingwhile the commit is on the wire,Rejected(reason)when one is refused — so a failure has somewhere to land besides the return value.writestill returns the outcome; the state is an addition, not a replacement.Rejectedis sticky until the next write.- Overlapping writes are safe. Only the newest write paints the cell; a superseded write’s outcome is discarded (it still returns to its own caller). And a rollback lands on the last value the server confirmed, not on whatever the signal happened to hold — a distinction that only shows up once writes overlap, and one that gets a counter of its own so an out-of-order reply cannot walk it backwards.
overwraps an existing signal, so adopting the cell changes no binding, and it seeds the confirmed value from it.- Every transition is one wave. The value and the state are published together, so an observer of both never sees “new value, still confirmed”.
writeawaits, likeoptimistic. Fire-and-forget islet _sent = async cell.write(..).
The commit returns Result<T, str> — the confirmed value or a reason — so an
rpc-calling closure maps its error the same way a Draft commit does.
Unlike Draft, there is no re-push on reconnect: a re-push is
at-least-once, which is safe for a draft’s “set this field to this value” and
unsafe for the one-shot actions this cell is for. The rollback is the
recovery; the user re-issues the action.
A cell over a mirrored signal is out of scope for now — the mirror writes behind the cell’s back, so its confirmed value goes stale. Wrap a local signal (design notes §8).
Draft — local-first cells
enum DraftState {
Synced, // local matches the last pushed/adopted value
Dirty, // local edits not yet confirmed (in-flight included)
Failed(str), // last push errored; local KEPT, not rolled back
}
struct Draft<T> {
local: SignalCell<T>, // bind inputs to this; read like any signal
state: SignalCell<DraftState>, // bind a status label to this
… // internals: synced value, generation, debounce window
}
fun draft<T: PartialEq>(initial: T, commit: async |T| Option<str>): Draft<T>
impl Draft<type T: PartialEq> {
fun push(self, value: T) // set local + SPAWN the commit (returns immediately)
fun adopt(self, remote: T) // fold in a remote value
fun debounce(self, millis: i32): Draft<T> // coalesce pushes; returns self for chaining
fun commit(self) // send now, cancelling any pending window
fun repush(self) // re-send iff local != synced (the reconnect path)
}
commitreturnsNoneon success,Some(reason)on failure. The parameter isasync-typed so an RPC-calling closure flows in directly; a plain synchronous closure works too.pushis per-keystroke-safe: local-first (never waits on the wire), and a generation counter ensures only the newest push settlesstate: a slow older commit landing late is discarded.adoptrules: value equal to the last synced value (an echo of your own push) → no-op; clean local (no unpushed edits) → adopt intolocal; dirty local → local wins, the remote value is remembered so the eventual push knowingly overwrites (last-write-wins).- On failure,
statecarries the reason andlocalkeeps the user’s text; the nextpushretries naturally.
debounce — one commit per burst
debounce(millis) coalesces pushes: the commit fires millis after the
last one, carrying the value as of that moment. 0 (the default) commits
on every push.
- Local-first is unaffected.
localand theDirtystate are still set synchronously insidepush; only the commit waits out the window. - Trailing edge. Three keystrokes inside the window produce one commit.
commit()— the explicit save (a blur, a Save button) — cancels a pending window and sends now. Exactly one commit results, not two.- The window belongs to the cell, so every copy of a draft agrees about it.
repush — recover the edits an outage swallowed
repush() re-sends the local value iff local != synced — an edit whose
commit never left, or one caught in flight by a drop (a failed commit keeps
the local value and does not advance synced). A clean draft sends nothing.
A pending debounce window is cancelled and the value goes immediately.
Wire it to a transport’s reconnect hook and a dropped connection stops losing work; it is also the “retry” behind a failure banner’s button:
client.transport.on_reconnect(|| title.repush());
- Delivery is at-least-once. A commit the server applied but could not
acknowledge before the socket died is indistinguishable here from one that
never arrived, so the server may see it twice.
Draft’s own reconcile absorbs the duplicate (adoptno-ops on an echo; the generation counter discards the superseded commit’s outcome), but your commit closure must tolerate a repeat: “set the remote to this value” does, “append this entry” does not. - A failed re-push is not retried on a timer. It settles
Failed, keepslocal, and the next reconnect sends it again — so a value the server is permanently refusing cannot spin.
UI wiring: View.bind_draft(draft); see the browser reference.
The reconnect hook is in the rpc reference.
Delta sources: the change structure
A collection’s change is a value (tracker A112, proposal/incremental-collections.md).
Every collection shape has an OP type; a cell whose writes RECORD their ops can
be read by “what changed” instead of “what is now”, and an operator over it runs
once per changed element rather than once per element.
enum SeqOp<T> {
Splice(usize, List<T>, List<T>), // at: these left, these arrived
SetAt(usize, T, T), // at: was this, is now this
Reset(List<T>), // the collection BECAME this list
Move(usize, usize, usize), // from, count, to — the same elements, elsewhere
}
enum MapOp<K: Hashable, V> { Put(K, Option<V>, V), Delete(K, V), Reset(HashMap<K, V>) }
enum SetOp<T: Hashable> { Add(T), Remove(T), Reset(HashSet<T>) }
Every arm carries what left as well as what arrived. That is a requirement,
not a courtesy: the O(1) derivative of a fold over a group (sum, count,
mean) is “add what arrived, subtract what left”, and a payload that only
counts what left would send the operator back to read the collection — the O(N)
rerun the whole design deletes.
SetAt is deliberately not Splice(at, [old], [new]) even though the
collection cannot tell them apart: for a per-element owner the two are
different events, and the difference is whether that owner survives.
The log and its cursors are the machinery:
let delta_log_limit: usize = 1024;
struct DeltaCursor { … } // one consumer's place in a log
struct DeltaLog<O> { … }
impl DeltaLog<type O> {
fun new(): DeltaLog<O>
fun with_limit(limit: usize): DeltaLog<O>
fun record(self, op: O) // trims first, then appends
fun cursor(self): DeltaCursor // minted at the current sequence
fun drop_cursor(self, cursor: DeltaCursor)
fun since(self, cursor: DeltaCursor): Option<List<O>> // `None` = lost history
fun trim(self)
fun held(self): usize
fun at(self): i32
fun oldest(self): i32
}
trait DeltaSource<C, O> with Source<C> {
fun cursor(self): DeltaCursor
fun drop_cursor(self, cursor: DeltaCursor)
fun since(self, cursor: DeltaCursor): List<O> // never fails: a lost cursor gets `Reset`
}
since on the LOG answers an Option and since on the SOURCE does not: the
log does not know what a Reset is for its op type and the cell does, which is
the only place the two layers need to know about each other.
A consumer that can USE ops but must not REQUIRE them — each takes any
Source<List<T>> — bounds on DeltaFeed<T>, which brings Source<List<T>>
with it:
trait DeltaFeed<T> with Source<List<T>> {
fun delta_cursor(self): Option<DeltaCursor> // `None`: no log, take the whole value
fun delta_since(self, cursor: DeltaCursor): List<SeqOp<T>>
fun drop_delta_cursor(self, cursor: DeltaCursor)
}
impl type S: Source<List<type T>> with DeltaFeed<T> // every list source: `None`
impl type S: DeltaSource<List<type T>, SeqOp<T>> with DeltaFeed<T> // a logged one: its log
Both blankets match a ListCell, and the second is chosen because a subject
bound by a subtrait is the more specific one. A consumer is written once and
serves both kinds of source.
Writes in one turn coalesce into ONE notification, and a consumer drains every op at the settle — so the log is as long as one turn’s writes. Two behaviours are worth stating because they are easy to state wrongly:
- A log with no consumers holds one op, not zero.
recordtrims and then pushes. - The log is trimmed at the next WRITE, not at the drain. A cell written once and then read for ever keeps one op’s worth of history.
Past the limit the history is dropped and base jumps, so a consumer that
stopped draining is answered with one Reset carrying the collection: bounded
memory, at the cost of one whole-collection payload to whoever could not keep
up.
KeyedCell<K, T> (std::rpc) is the shipped delta source: it records the
positional ops and translates them into the wire’s Delta<K, T> in its own
since, which is the one place the two vocabularies meet. SignalCell<List<T>>
is NOT a delta source — it keeps no log — so anything derived from one is on the
Reset path by construction, which is exactly today’s behaviour.
ListCell — a list whose writes are its deltas
struct ListCell<T> { … } // the list, a DeltaLog<SeqOp<T>>, one notification
impl ListCell<type T> {
fun new(): ListCell<T>
fun of(elements: List<T>): ListCell<T>
fun with_limit(elements: List<T>, limit: usize): ListCell<T>
fun peek<U>(self, read: sync |&List<T>| U): U // read IN PLACE, no copy of the run
fun set_at(self, at: usize, value: T) // an element changed IN PLACE
fun move_range(self, from: usize, count: usize, to: usize)
fun edit(self, body: sync |&mut Tracked<T>| void) // many mutations, ONE notification
fun logged(self): usize
}
impl ListCell<type T: PartialEq> { fun reconcile_to(self, items: List<T>) }
// and: Source<List<T>>, Signal<List<T>>, SequenceCell<T>, DeltaSource<List<T>, SeqOp<T>>
ListCell<T> is an ordinary Source<List<T>> — each takes it, derive takes
it, an effect takes it — that also records what each write DID. Nothing that
ignores the ops pays for them, and the three each runs do not ignore them:
over a ListCell they build only the rows a write names.
A write copies nothing it does not have to. The cell holds its list itself
rather than inside a SignalCell, so size and every write read it in place;
get still hands out a VALUE — a copy of the run, as a SignalCell’s get
does — and peek is the read that does not copy: the closure is lent the
cell’s own list for the length of the call.
import std::reactive::{ ListCell, SequenceCell };
fun main() {
let rows: ListCell<str> = ListCell<str>::of(["a", "b", "c"]);
let length = rows.peek(|list| list.len()); // no copy of the run
let second = rows.peek(|list| list.get(1)); // copies ONE element
print(i"{length} {second.unwrap_or("none")}");
}
peek is for reading: its closure must not write the cell it is peeking at.
push_many(items) appends a whole run — another list, a set, any iterator —
as ONE Splice, so a reader of the cell sees one change for the batch where a
loop of push would record one op per element. extend(values) is the same
write over a list alone.
Its mutators are trait defaults over ONE primitive, so there is one place a write is recorded and no method can forget:
trait SequenceCell<T> {
fun size(self): usize;
fun splice(self, at: usize, removed: usize, inserted: List<T>);
// twelve defaults over those two:
// is_empty, push, prepend, insert_at, insert_all, remove_at,
// remove_range, pop, extend, clear, set_all, truncate
}
Sequence<T> is the same surface with &mut self receivers, and its
implementor is Tracked<T> — a plain list plus the ops that produced it. That
is what edit hands a body, and it is why an algorithm can be written against
the BOUND rather than against a cell:
import std::reactive::{ ListCell, Sequence };
fun fill<S: Sequence<str>>(target: &mut S) {
target.push("first");
target.push("second");
}
fun main() {
let rows: ListCell<str> = ListCell<str>::new();
rows.edit(|&mut list| {
fill(&mut list);
list.remove_at(0);
}); // ONE notification, three ops
print(rows.get().len());
}
Three doors write a whole list, and they cost differently on purpose:
| records | a derivation’s cost | |
|---|---|---|
splice and its twelve defaults | one SeqOp::Splice | the elements that arrived |
set_all(values) | one SeqOp::Splice over everything | every element (they all arrived) |
set(values) (the Signal impl) | SeqOp::Reset | every element, rebuilt |
reconcile_to(values) | one Splice over what changed | the elements that changed |
set_all and set cost a list view the same: each and each_by match a
Splice’s span by key exactly as they match a Reset, so a row whose key
survives either one is kept, never rebuilt.
reconcile_to is the compat door: it diffs the common prefix and the common
suffix and records ONE Splice over what is between them, so an append, a
prepend, an insertion, a removal or an edited span each cost only the elements
they really touched, and an identical list records nothing and notifies nobody.
It does not find a REORDER — a rotated list shares no prefix and no suffix, so
that is one Splice over the whole run, which is honest (every element did
move) and is what each’s keyed pass is for. A source that knows it reordered
says move_range.
map_each — the first derivative
fun map_each<T, U, S: DeltaSource<List<T>, SeqOp<T>>>(
source: S, g: sync |T| U,
): ListCell<U>
map_each(source, g) is source.get().map(g) kept up to date by running g
once per element that ARRIVES or CHANGES, and zero times for anything else —
the derivative of map g, where Splice(at, left, arrived) becomes
Splice(at, left, arrived.map(g)):
import std::reactive::{ ListCell, SequenceCell, map_each };
fun main() {
let raw: ListCell<str> = ListCell<str>::new();
let parsed = map_each(raw, |text: str| {
print("ran");
text.parse_f64().unwrap_or(0f)
});
raw.push("10.5"); // "ran" — once
raw.remove_at(0); // nothing
print(parsed.get().len());
}
Three pushes are three calls; a removal, a clear, a pop and a move_range
are none; a Reset is the honest N. It takes any DeltaSource, so it serves
ListCell, std::rpc’s KeyedCell and anything an app writes.
The result is itself a ListCell<U>, so map_each composes: a chain runs one
call of each g per arriving element, at every step.
Two rules to hold on to. The subscription is a DERIVATION and the ambient
owner’s, like every other combinator’s, and the cursor goes with it — a
disposed derivation stops pinning the source’s history. And g must be pure IN
THE ELEMENT: its result is kept, so a g that reads another signal will not
re-run when that signal changes. That is derive’s contract already; here nothing
re-runs it at all, which makes the contract sharper rather than different.
Collection pipes — operators per shape
[resource]
trait CollFlow<T> { // anything a collection pipeline starts from
fun open(own self): CollInstance<T>; // the node author's member
}
trait CollSource<T> with DeltaSource<List<T>, SeqOp<T>> {} // ListCell, ListMemo
[resource]
trait CollPipe<T> with CollFlow<T> {
fun memo(own self): ListMemo<T> // seal: a read-only CollSource
fun memo_global(own self): ListMemo<T> // ... for the program (A130)
fun sample(own self): List<T> // one read: start, take, release
}
impl type F: CollFlow<type T> {
fun map<R>(own self, transform: |T| R): CollMap<F, T, R> // R: IntoElement<U>
fun filter<R>(own self, keep: |T| R): CollFilter<F, T, R> // R: IntoFlow<bool>
fun filter_map<R>(own self, select: |T| R): CollFilterMap<F, T, R> // R: IntoFlow<Option<U>>
fun any<R>(own self, test: |T| R): CollTally<F, T, R, bool> // a scalar Pipe<bool>
fun all<R>(own self, test: |T| R): CollTally<F, T, R, bool>
fun count<R>(own self, test: |T| R): CollTally<F, T, R, usize>
}
impl type F: CollFlow<type S: Source<type U>> {
fun flatten(own self): CollFlatten<F, S, U> // follow each source
}
A collection OPERATOR returns a move-only collection pipe (CollPipe),
§3’s split one level up: it is consumed once — sealed with .memo() into a
ListMemo (a granular source, so a pipeline starts from it again), handed to
each/each_by, or read once with .sample(). What travels between stages
is not a value but the CHANGE, in the shape’s one vocabulary (SeqOp), so an
operator is written once per shape and every stage does work proportional to
what changed:
import std::reactive::{ ListCell, SequenceCell, comp };
fun main() {
let numbers: ListCell<i32> = ListCell::of([1, 2, 3, 4]);
let (big, scope) = comp(|| numbers.filter(|n| n > 2).map(|n| n * 10).memo());
print(big.get()); // [30, 40]
numbers.push(7); // the closures run for 7 alone
numbers.remove_at(0); // nothing runs
print(big.get()); // [30, 40, 70]
scope.dispose();
}
The operators start from a granular source — a ListCell, a ListMemo — and
from a collection pipe. A coarse Source<List<T>> converts at the boundary
(.coll(), .coll_by(key), below).
An operator follows the flow its closure returns. A closure may answer a
plain value or any Flow — a source or a pipe (IntoFlow). The
implementation is chosen per closure when the code is generated, so
filter(|n| n > 2) subscribes to nothing and keeps no record per element,
while filter(|row| row.is_pending()) starts the pipe is_pending builds for
each element and follows it: an element that flips in or out is ONE splice at
its output position (the count of kept elements before it, from a Fenwick
index), and any/all/count are counters that move by one.
import std::reactive::{ ListCell, Signal, SignalCell, comp };
fun main() {
let online: List<SignalCell<bool>> = [Signal::new(true), Signal::new(false)];
let people: ListCell<usize> = ListCell::of([0usize, 1usize]);
let ((here, anyone), scope) = comp(|| (
people.filter(|at| online[at].derive(|on| on)).memo(),
people.any(|at| online[at].derive(|on| on)).memo()
));
print(here.get()); // [0]
online[0].set(false); // one splice out
print(anyone.get()); // false
online[1].set(true); // one splice in; the counter moves once
print(here.get()); // [1]
scope.dispose();
}
filter, filter_map, any, all and count follow a returned flow,
because their output shape fixes what the flow’s value means. map never
follows a returned Source: a source is data and may be the element —
ids.map(open_mirror) is a collection OF mirrors, and .flatten() is the
explicit follow. A returned PIPE is started per element and its value carried
(IntoElement), since a pipe cannot be an element. filter_map(|x| x) over a
collection of Option-valued flows is the join; a closure answering
Option<Source<X>> keeps the sources.
Every run has an owner. A closure runs once per element that arrives and
again per element that changes; what a run registers — an on_cleanup, a
.memo(), a subscription — is released when the element leaves or re-runs,
and a removed element’s followed flow is detached. The owner is split off
only when the run registered something, so a plain closure costs no owner at
all. A task a closure starts belongs to the stage and is cancelled when the
pipe’s consumer releases it.
coll and coll_by — coarse into granular
impl type F: Flow<List<type T>> {
fun coll_by<K: PartialEq + Hashable>(own self, key: sync |T| K): CollBy<F, T, K>
}
impl type F: Flow<List<type T: PartialEq>> {
fun coll(own self): Coll<F, T>
}
A flow of WHOLE lists — a SignalCell<List<T>>, a derivation, a mirror sealed
with .memo() — threw its change away upstream, so the only way back to a
granular collection is a diff, and these two are the doors. Both return a
collection pipe.
.coll_by(key)is the keyed diffeach_byalready runs (ReconcilePlan), O(n) per change with hashing. A reordered element is aMove, so it keeps its identity — and so does whatever follows it downstream. A matched element whose value changed is aSetAtwhen the element has==; without one (a collection of sources), the same key is the same element..coll()is the positional fallback forT: PartialEq: it trims the common prefix and suffix and splices what is between, so an append, an insertion and a removal are each one op of one element.
import std::option::Option::{ self, None, Some };
import std::reactive::{ CollPipe, Signal, SignalCell, comp };
fun main() {
let fetched: SignalCell<List<i32>> = Signal::new([1, 2, 3]);
let (evens, scope) = comp(|| fetched.coll().filter(|n| n % 2 == 0).memo());
fetched.set([1, 2, 3, 4]); // one splice of one element: 4 arrived
print(evens.get()); // [2, 4]
scope.dispose();
}
The real fix is upstream — keep the source of truth granular (ListCell), and
diff only at the boundaries: a fetch result, a wire snapshot.
HashMapCell and HashSetCell — maps and sets whose writes are their deltas
struct HashMapCell<K: Hashable, V> { … } // the map, a DeltaLog<MapOp<K, V>>, one notification, per-key slots
impl HashMapCell<type K: Hashable, type V> {
fun new(): HashMapCell<K, V>
fun of(own map: HashMap<K, V>): HashMapCell<K, V>
fun with_limit(own map: HashMap<K, V>, limit: usize): HashMapCell<K, V>
fun get(self, key: K): Option<V> // the per-key READ
fun contains_key(self, key: K): bool
fun len(self): usize
fun is_empty(self): bool
fun peek<U>(self, read: sync |&HashMap<K, V>| U): U // read IN PLACE
fun at(self, key: K): HashMapEntry<K, V> // the per-key HANDLE
fun edit(self, body: sync |&mut TrackedHashMap<K, V>| void) // many writes, ONE notification
fun logged(self): usize
fun watched(self): usize // keys with a live slot
}
impl HashMapCell<type K: Hashable, type V: PartialEq> { fun reconcile_to(self, own target: HashMap<K, V>) }
trait MapSignal<K: Hashable, V> with Signal<HashMap<K, V>> { // the writes
fun insert(self, key: K, value: V); // Put(key, what was there, value)
fun remove(self, key: K); // Delete(key, what left); absent: nothing
fun update(self, key: K, mutate: sync |&mut V| void); // Put(key, Some(before), after)
fun get_or_insert(self, key: K, make: || V): V; // Put(key, None, made) on a miss
fun clear(self); // ONE Reset; empty: nothing
}
trait MapSource<K: Hashable, V> with DeltaSource<HashMap<K, V>, MapOp<K, V>> {}
// HashMapCell: Source<HashMap<K, V>>, Signal<HashMap<K, V>> (`set` is a Reset), MapSignal, MapSource
// HashMapEntry<K, V>: Source<Option<V>> on its key's slot, Signal<Option<V>> (Some = insert, None = remove)
struct HashSetCell<T: Hashable> { … } // the set, a DeltaLog<SetOp<T>>, one notification, per-member slots
impl HashSetCell<type T: Hashable> {
fun new(): HashSetCell<T>
fun of(own set: HashSet<T>): HashSetCell<T>
fun len(self): usize
fun peek<U>(self, read: sync |&HashSet<T>| U): U
fun contains(self, value: T): HashSetEntry<T> // a Source<bool> for one member
fun reconcile_to(self, own target: HashSet<T>)
}
trait SetSignal<T: Hashable> with Signal<HashSet<T>> {
fun insert(self, value: T): bool; // Add, and true, when it was new
fun remove(self, value: T): bool; // Remove, and true, when it was held
fun clear(self); // ONE Reset
}
A HashMapCell<K, V> is what ListCell is for a list: a HashMap whose writes are
recorded as the Map shape’s ops (MapOp: Put(key, what was there, what is there now), Delete(key, what left), Reset(map)), so a delta consumer is told what
each write did. What it adds is the reason a map wants a cell at all: most readers
of a map read ONE KEY, and at(key) is a handle that depends on that key only.
import std::hash_map::HashMap;
import std::reactive::{ HashMapCell, comp };
fun main() {
let scores: HashMapCell<str, i32> = HashMapCell::of([("ada", 1), ("bo", 2)].to_map());
mut runs = 0;
let (doubled, scope) = comp(|| scores.at("ada").derive(|score| {
runs += 1;
score.unwrap_or(0) * 2
}).memo());
scores.insert("bo", 20); // another key: nothing runs
scores.insert("cy", 3); // nor here
scores.insert("ada", 5); // runs once
print(i"{doubled.get()} runs={runs}"); // 10 runs=2
scope.dispose();
}
at(key) is a Source<Option<V>> — derive, effect, switch, a tracked
.track(), a UI binding all take it — and a Signal<Option<V>> whose
set(Some(v)) is insert(key, v) and set(None) is remove(key). It is data,
not a subscription: two consumers of one at(key) need no seal, and an [rpc]
method may answer it — it crosses as a RemoteSource<Option<V>> mirror that the
key’s writes alone update. Its
subscriptions land on the key’s SLOT, a notification-only cell made by the first
subscription and dropped with the last (watched() counts them), so the table is
the size of what is watched and a write to a key nobody watches costs one hash
probe. A thousand readers of a thousand keys and a thousand single-key writes are
a thousand observer calls, where a SignalCell<HashMap<..>> with a
derive(|map| map.get(key)) per reader is a million projections, each of which
copies the whole map.
What wakes, per write:
| write | records | wakes |
|---|---|---|
insert, update, get_or_insert (a miss), remove (held) | one Put or Delete | that key’s slot, then the whole-map observers |
remove of an absent key, update of an absent key, get_or_insert (a hit) | nothing | nothing |
set(map), clear() | one Reset | EVERY watched key — a Reset cannot say which keys changed without comparing, and a cell never compares |
reconcile_to(map) | a Delete/Put per key that left, arrived or changed | only those keys; an identical map, nothing |
edit(body) | one op per mutation | each key it named, once; one whole-map notification |
The per-key read is get(key), and vilan has no overloading, so the WHOLE map is
read through the Source bound — any generic consumer does that already — or as
Source::get(cell) with the type written, or in place with peek:
import std::hash_map::HashMap;
import std::reactive::{ HashMapCell, Source };
fun main() {
let ages: HashMapCell<str, i32> = HashMapCell::new();
ages.insert("ada", 36);
print(ages.get("ada").unwrap_or(0)); // 36: one key
print(ages.peek(|map| map.len())); // 1: in place, no copy
let whole: HashMap<str, i32> = Source::get(ages); // a copy of all of it
print(whole.keys());
}
The map iterates in INSERTION order, as every HashMap does: an overwrite keeps
its key’s place, and a key removed and inserted again goes to the end.
reconcile_to keeps a held key’s place and appends an arriving one in the
target’s order, so the cell equals the target (== is order-insensitive) without
taking its order.
A HashSetCell<T> is the same cell for the Set shape (SetOp: Add, Remove,
Reset), over the same slot table. Its per-member handle is contains(x), a
Source<bool> that wakes for x alone; insert and remove answer whether they
changed the set and record only when they did.
import std::hash_set::HashSet;
import std::reactive::{ Disposable, Flow, HashSetCell };
fun main() {
let online: HashSetCell<str> = HashSetCell::new();
mut flips = 0;
let watch = online.contains("ada").on_change(|here| flips += 1);
print(online.insert("bo")); // true — "ada" does not wake
print(online.insert("ada")); // true — it does
print(online.insert("ada")); // false: nothing recorded, nothing woken
print(flips); // 1
watch.dispose();
}
Both are DeltaSources (MapSource, SetSource), with cursor/since/reader
as ListCell has them: a cursor that fell behind the log is told the whole
collection as one Reset.
Map pipes — operators for maps and sets
[resource]
trait MapFlow<K: Hashable, V> { fun open(own self): MapInstance<K, V>; } // MapSource, every map pipe
[resource]
trait MapPipe<K: Hashable, V> with MapFlow<K, V> {
fun memo(own self): HashMapMemo<K, V> // seal: a read-only MapSource with get(key)/at(key)
fun memo_global(own self): HashMapMemo<K, V>
fun sample(own self): HashMap<K, V>
}
impl type F: MapFlow<type K, type V> {
fun keys(own self): MapKeys<F, K, V> // a SetPipe<K>
fun values(own self): MapValues<F, K, V> // a CollPipe<V>, in insertion order
fun entries(own self): MapEntries<F, K, V> // a CollPipe<(K, V)>
fun map_values<R>(own self, transform: |V| R): MapMapValues<F, K, V, R> // R: IntoElement<U>
fun filter<R>(own self, keep: |K, V| R): MapFilter<F, K, V, R> // R: IntoFlow<bool>
fun count(own self): MapCount<F, K, V> // a Pipe<usize>
fun sum_by<N: Add + Sub + Default, R: IntoFlow<N>>(own self, measure: |V| R): MapSum<F, K, V, R, N> // a Pipe<N>
}
// SetFlow<T>, SetPipe<T> (memo → HashSetMemo<T>, with contains(x)), the Set shape's twins
A map OPERATOR is a collection pipe for the Map shape: it is consumed once —
sealed with .memo(), handed to a consumer, or read once with .sample() — and
what travels between its stages is the MapOp, which names KEYS. So every
operator costs what the op names: map_values runs its closure once per Put and
never for a Delete, filter once per Put, keys() turns a new key’s Put
into an Add and ignores an overwrite, and count/sum_by add what arrived and
subtract what left.
import std::hash_map::HashMap;
import std::reactive::{ CollPipe, HashMapCell, SetPipe, comp };
fun main() {
let stock: HashMapCell<str, i32> = HashMapCell::of([("pens", 3), ("ink", 0)].to_map());
let ((names, low, total), scope) = comp(|| (
stock.keys().memo(), // a HashSetMemo
stock.filter(|item, count| count < 2).values().memo(), // a ListMemo
stock.sum_by(|count| count).memo()
));
stock.insert("pens", 1); // one Put: the filter runs once
stock.insert("pads", 9); // the key set gains one Add
print(names.get().len()); // 3
print(low.get()); // [0, 1]: "pens" joined the filter after "ink"
print(total.get()); // 10
scope.dispose();
}
The positional face is where order lives. values() and entries() are
collection pipes in the map’s insertion order — what each_by and a ListMemo
consume. A new key is a one-element Splice at its RANK, the number of live keys
before it, which a Fenwick tree over the keys’ slots answers in O(log n); an
overwrite is a SetAt there, a removal a one-element Splice out; a removed and
re-inserted key goes to the end, as the map puts it. Every other operator is
order-free — and a map PIPE’s order is the order its keys arrived in it: a key
that flips into a filter joins its output at the end.
Following and owners, as for the collection operators: a filter predicate
that answers a flow is FOLLOWED per key (a flip is a Put or a Delete
downstream), a map_values closure that answers a pipe has it started per key
and its value carried, and each key’s run gets an owner of its own — released
when the key’s value is replaced or the key leaves. A sum_by measure that answers
a flow is followed the same way: the sum moves by the difference.
A sealed map pipe is a HashMapMemo: a read-only granular map source with get(key),
len, peek, and at(key) — a read-only per-key handle on the sealed map’s
slots, so a key’s reader wakes only when the pipe changed that key.
reconcile: keyed list diffing
enum RowStep {
Keep(usize), // reuse old row at index (moved into the new order)
Refresh(usize), // same key, changed value: rebuild, dispose old index
Fresh, // a new row
}
struct ReconcilePlan {
steps: List<RowStep>, // one per NEW item, in the new order
removed: List<usize>, // old indices gone entirely
}
fun reconcile<T, K: PartialEq + Hashable>(
old_keys: List<K>, old_items: List<T>, items: List<T>, key_of: sync |T| K,
same: sync |T, T| bool,
): ReconcilePlan
The pure engine under ui::each; duplicate keys claim the first
surviving row once. Reach for it directly only when building a custom
list-rendering primitive.
“Unchanged” is the caller’s predicate, not T: PartialEq. The key decides
identity and whether the row moves; same decides, for a surviving key, reuse
against dispose-and-rebuild. each passes |a, b| a == b;
each_by passes |_a, _b| true, which is why it never emits a Refresh
and asks nothing of T.
A pass is one walk, not N of them (tracker M82). The matcher used to scan
the old keys from 0 for every new item, so a list that did not reorder cost
N(N+1)/2 iterations whatever the change was — 500,500 at 1,000 rows, for one
append. It keeps a position index instead: per canonical key, the chain of old
positions holding it. Measured over each’s scan half, callgrind Ir per change
under node --jitless: 355.1 M → 29.1 M at 1,000 rows, and 95.1 M → 14.8 M
at 500 with 1,369.9 M → 58.5 M at 2,000 — 1.97× and 2.01× per doubling where it
used to be 3.73× and 3.86×, which is linear where it was quadratic.
A REORDER is linear too, and K: Hashable is what pays for it (tracker
A125). The index was keyed on the canonical hash with nothing binding that
hash to K’s equality, so an earlier equal key could sit outside the chain the
index named and the stretch from the smallest unclaimed index up to the
candidate had to be scanned as well. That stretch is empty when nothing moved —
which is why the append case above went linear — and it is the WHOLE prefix
when a list is reversed: N(N+1)/2 key comparisons, 500,500 at 1,000 rows, on
every sort-in-place. The bound states the obligation Hashable already names,
a == b implies a.hash() == b.hash(), so every key equal to this item’s is
somewhere in this item’s chain and the scan is gone. A 1,000-row reversal costs
1,000 key comparisons, down from 500,500 (vilan/test/reconcile-reorder.vl
counts them); callgrind Ir per reversal under node --jitless: 738.4 M → 14.6 M at 1,000
rows, a factor of 50.7, and 1.98× then 2.00× per doubling across 500 / 1,000
/ 2,000 rows where the scan was 3.92× from 500 to 1,000 — linear where it was
quadratic.
A COLLISION is still fine: two keys that are not == may share a hash — a
hand-written impl hashing a subset of the fields its eq reads — and the walk
steps past them along the chain, which is what any hash container does. What
the bound forbids is the other direction, an == coarser than the hash, which
would hide a moved row; that is now a compile error rather than a quadratic
scan. The plan is gated by a differential rather than by a golden: the
pre-index scan is reproduced verbatim in vilan/test/reconcile-index.vl and
1,415 cases — named shapes, 600 randomized, 400 with a coarse == and coarse
hash, 400 with a coarse hash alone — are compared plan for plan.