Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

ItemKindOne line
Source<T>traitanything with state that can be read (requires get/on_settle); every source is a Flow
Flow<T>[resource] traitanything 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] traita move-only transformation; carries the sealing operations memo/cell/memo_global/cell_global/sample
Subscriberstructone observer’s record — its id (a turn’s dedup key), notify, liveness and class; what on_settle carries
MemoCell<T>structa sealed derivation’s read-only face — what .memo() returns
Constant<T>structSource::constant(v): a source that never changes
Derive, Switch, SwitchSome, AndThen, ThenSome, Combine, ZipSome, Distinct, DistinctBy[resource] structsthe pipe stages: hold their upstream until a consumer starts them; what the combinators return
Instance<T>structa started flow — the one consumer’s pull/attach/release (for stage authors)
TransientState, TransientSource, .transient()std::reactive::transientvalues 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)
trackmethod (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] structa pipe whose only dependencies are the ones its body tracks
tracking, TrackScope, Trackercontext / structsthe tracking scope a body’s run establishes; tracking.clear(..) is untrack
Resource<T>, ResourceState<T>struct/enuma value that may still be loading — pending, settled, failed — built by source.load(fetch) or Resource::pending(), read through its fallbacks
Signal<T>traitthe writable half (set/notify/set_with); Source is its supertrait
SignalCell<T>structthe canonical cell — mutable value plus subscribers
MaybeSignal<T>traita component value that may be static OR reactive
Subscriptionstructan explicit subscription; Disposable
combinefna pipe of the tuple of 2+ sources
divorcefnthe reverse: one derived pipe per position of a tuple-valued source
zip_somefnwait for several maybes: Some of the tuple of payloads while every input is Some
unzipmethod (SignalCell<T: (2..)>)one cell per position of a tuple-valued cell, written in place
selector, Selector<T>fn/structper-key selection: one subscription, two writes per change
Ownerstructdisposal bag; the lifetime unit
on_cleanupfnrun a cleanup when the ambient owner is released
run_with_owner, comp, get_owner, owner_scopefns/contextestablish/read the ambient owner
turn, batch, flush, at_settle, FlushPolicy, turn_scopefns/contextwrite batching
trace::tracefnthe reactive trace (std::reactive::trace): a line per wake, naming what woke it (debugging)
optimisticfnpaint → commit → confirm-or-rollback (one shot)
Optimistic<T>, WriteStatestruct/enumthe same lifecycle, observable and overlap-safe
draft, Draft<T>, DraftStatefn/struct/enumlocal-first editing cell
reconcile, ReconcilePlan, RowStepfn/structskeyed list diffing engine (K: PartialEq + Hashable)
SeqOp, MapOp, SetOp, DeltaLog, DeltaCursor, DeltaSourceenums/struct/traitthe change structure: a collection’s change as a value, and its log
DeltaFeed<T>traita consumer’s question “do you keep ops?” — answered by every Source<List<T>>
ListCell<T>structa List cell whose writes ARE its deltas
SequenceCell<T>, Sequence<T>, Tracked<T>traits/structtwelve mutators over one splice primitive; the &mut twin and its recorder
map_eachfng element-wise and incrementally — one call of g per arriving element
CollFlow<T>, CollPipe<T>, CollSource<T>traitscollections 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, flattenmethodsthe collection operators — each returns a pipe, and follows the flow its closure returns (IntoFlow, IntoElement)
ListMemo<T>structa sealed collection pipe: a read-only granular source
RowFeed<T>traitwhat each/each_by read their rows from: a list source or a collection pipe
coll, coll_by, Coll, CollBy, SameElementmethods/structs/traita 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, TrackedHashMapstructs/traitsa HashMap cell whose writes ARE its MapOps; at(key), a handle that depends on ONE key
HashSetCell<T>, HashSetEntry<T>, SetSignal, SetSourcestructs/traitsthe same for a HashSet; contains(x), a Source<bool> for one member
MapFlow, MapPipe, HashMapMemo, MemoEntry, SetFlow, SetPipe, HashSetMemotraits/structsthe 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_bymethodsthe map operators — a set pipe, collection pipes in insertion order (a Fenwick rank), map pipes, scalar pipes; each costs what the op names
KeySlotsstructthe 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.

  • set notifies through the ambient turn when one exists (writes coalesce); outside any turn it notifies immediately.
  • update mutates the stored value through a writable view and notifies once, unconditionally, after the closure returns — the collection door (design notes). It shares set’s notify half, so batching, drain affinity, and dedup behave identically. A read from inside the closure sees the in-progress value; a re-entrant update of the same signal is unsupported.
  • What the closure receives is a &mut subject, 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 growing list grows 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 with Option::take/replace. Only a SignalCell whose value is a wrapped collection meets this; update on 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 each row — .memo() registers with the ambient owner and dies when that boundary is disposed (a switch also 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 where effect demands one.
  • effect requires an ambient owner; calling it outside every owner is a compile error (context coverage). It fires once immediately.
  • sub fires once immediately with the current value, like effect, and then on every change; its Subscription is yours to dispose (or hand to owner.take).
  • on_change is the same subscription without that first call — and it is the primitive of the two: sub is on_change plus that call, and effect is effect_on_change plus 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 Source has state and can be read: SignalCell, a sealed MemoCell, Source::constant(value), a RemoteSource mirror, 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 no get. It is consumed exactly once: sealed with .memo(), .cell(), the _global twins or .sample(), or handed to a consumer (an effect, 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 derive transform (count.derive(|c| c + other.track()) follows other too), a switch/ switch_some/and_then selector (a tracked change re-selects), an effect body (a tracked change re-runs it with the input’s latest value), and the free derive(|| 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. tracking is a context and track() reads it with the strict get: a track() 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 by SignalCell, MemoCell, ListCell, KeyedCell, RemoteSource and KeyedSource (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) runs body with no scope: a closure minted inside holds none, and a track() written inside is refused — the layer’s untrack.
  • on_change primes. 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.

  • state is observable. Pending while the commit is on the wire, Rejected(reason) when one is refused — so a failure has somewhere to land besides the return value. write still returns the outcome; the state is an addition, not a replacement. Rejected is 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.
  • over wraps 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”.
  • write awaits, like optimistic. Fire-and-forget is let _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)
}
  • commit returns None on success, Some(reason) on failure. The parameter is async-typed so an RPC-calling closure flows in directly; a plain synchronous closure works too.
  • push is per-keystroke-safe: local-first (never waits on the wire), and a generation counter ensures only the newest push settles state: a slow older commit landing late is discarded.
  • adopt rules: value equal to the last synced value (an echo of your own push) → no-op; clean local (no unpushed edits) → adopt into local; dirty local → local wins, the remote value is remembered so the eventual push knowingly overwrites (last-write-wins).
  • On failure, state carries the reason and local keeps the user’s text; the next push retries 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. local and the Dirty state are still set synchronously inside push; 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 (adopt no-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, keeps local, 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. record trims 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:

recordsa derivation’s cost
splice and its twelve defaultsone SeqOp::Splicethe elements that arrived
set_all(values)one SeqOp::Splice over everythingevery element (they all arrived)
set(values) (the Signal impl)SeqOp::Resetevery element, rebuilt
reconcile_to(values)one Splice over what changedthe 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 diff each_by already runs (ReconcilePlan), O(n) per change with hashing. A reordered element is a Move, so it keeps its identity — and so does whatever follows it downstream. A matched element whose value changed is a SetAt when the element has ==; without one (a collection of sources), the same key is the same element.
  • .coll() is the positional fallback for T: 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:

writerecordswakes
insert, update, get_or_insert (a miss), remove (held)one Put or Deletethat key’s slot, then the whole-map observers
remove of an absent key, update of an absent key, get_or_insert (a hit)nothingnothing
set(map), clear()one ResetEVERY 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 changedonly those keys; an identical map, nothing
edit(body)one op per mutationeach 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.