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

05 — Todolist web

One page app — a todolist with a simulated server — where every DOM move and every timer goes through ten declared host crossings, and the page’s brain is a rut project of two packages. ui is the framework: an atom store (the jotai shape, in rut), a widget type, a keyed diff, and a component vocabulary. biz is the domain: the todolist machine expressed as store mutations, plus the app shell. The host — web-sys on wasm32 in the browser, a fake-DOM twin on native in the tests — owns the loop; rut owns the state. Pure rut: no event loop, no async keywords on the rut side; every asynchronous fact enters through a door named for its event.

Run it

cargo test -p todolist-web       # the twin + the law (95 tests), from the repo root
cd examples/05-todolist-web
node tests/e2e-browser.mjs       # the through-the-artifact gate (node tier + browser tier)

cargo test runs the whole story on the fake-DOM twin — a HashMap element tree with real DOM semantics and a virtual timer clock — over the same rut sources the browser runs. The e2e script builds nothing silently and skips nothing silently: tier 1 (plain node) drives the real wasm artifact through the loader’s own ABI on a fake DOM; tier 2 (when geckodriver + Firefox exist) types and clicks the live page.

To open the page yourself:

cd examples/05-todolist-web
cargo build -p todolist-web --target wasm32-unknown-unknown --release
wasm-bindgen --target web --out-dir gen --out-name web_host \
  ../../target/wasm32-unknown-unknown/release/todolist_web.wasm
python3 -m http.server        # then open http://localhost:8000/

What you’ll see: type a title — the status line echoes the typing and the counts never move (the draft is not a counts dependency). Press Add — an italic pending line paints immediately, the counts line flips to 1 in flight, and ~400 ms later the committed row replaces it. Toggle a row — the title goes italic for ~250 ms, then the check fills and the title strikes through. The row animates because the keyed diff kept the node alive — nothing is cleared and rebuilt, ever.

Code tour

The ABI is the page: main plus the doors

rut/biz/app.rut — the boot turn builds the app container and returns it as an opaque (rut has no mutable module state, so the host holds the state and re-passes it every turn — the 00 — Todolist pattern at page scale):

entry fn main() -> opaque {
    let boot: World = world_boot("booted — type a title, press Add");
    let root = AppRoot {
        world: boot,
        t1: t1_mount("app"),
    };
    paint(root);
    return opaque(root);
}

Every asynchronous fact enters through a door named for its event — on_click/on_input for DOM events, on_timer for the clock. Each door re-passes the container and answers the entry-err pair: (opaque(c), "") on a clean turn, (nil, why) on a soft failure the pump reports while keeping the page alive.

fn dom_event(mut r: AppRoot, id: str, detail: str) -> (?opaque, str) {
    let booked = t1_event(r.t1, id, detail);
    let req = opaque.downcast<Req>(booked);
    if (req != nil) {
        tim_after(req.latency, req.tag);
    }
    paint(r);
    return (opaque(r), "");
}

The “server” is a request table, not a scheduler: adding never touches the list — the machine books a request and the app books one tim_after(latency, tag); the list changes only when the timer’s turn runs the answer mutation (per-kind latency, so deadline-ordered commits are visible across kinds).

The ten crossings

web.d.rut (flat at the example root, registered by hand in both lanes) declares the whole host surface. Elements cross as opaque handles; everything else is strings, ints, and bools — no app names, no data shipping, no callbacks (the host boundary):

pub host fn ui_get(id: str) -> opaque;
pub host fn ui_create(tag: str) -> opaque;
pub host fn ui_set_text(el: opaque, text: str);
pub host fn ui_attr(el: opaque, name: str, value: str);
pub host fn ui_append(parent: opaque, child: opaque);
pub host fn ui_remove(parent: opaque, child: opaque) -> bool;
pub host fn ui_clear(el: opaque);
pub host fn ui_set_input_value(el: opaque, v: str);
pub host fn ui_listen(el: opaque, event: str) -> i64;
pub host fn tim_after(ms: i64, tag: str);

A wiring bug is loud: a missing id traps (web::ui_get: no element '#x'), a kind mismatch names both sides. The host never grew an app-specific crossing — the entire widget system fits over these ten rows, unchanged.

The store: jotai’s shape over private traits

rut/ui/store.rut is the kernel. Handles are keys, not objects — Source<T>, Derived<T>, Mutation<A, R> carry ids and have no logic of their own beyond routing through their store. The machinery traits are module-private, so the domain package cannot name them even to import them — which is how “a derived cell is not writable” is enforced at the type level (Traits and dispatch):

trait Readable<T> {
    fn atom_id(self) -> u32;
    fn materialize(self, st: Store) -> nil; // first touch: the seed lands
}

trait Writable<A, R> {
    fn atom_id(self) -> u32;
}

Freshness is pull-on-read: a write bumps a generation, and a get recomputes a stale derived at most once per write-set. There is no flush step anywhere. Dependencies are discovered, never declared — the ctx.get calls inside a derive closure ARE the dependency list, re-recorded on every recompute. The domain’s counts line is the worked example (rut/biz/world.rut):

let counts$ = store.derive(fn (ctx) -> str {
    let items = ctx.get(items$);
    let reqs = ctx.get(reqs$);
    let mut open = 0;
    for (let t of items) {
        if (t.done == false) {
            open += 1;
        }
    }
    let done_n = items.len() - open;
    return f"{open} open | {done_n} done | {reqs.len()} in flight";
});

Writes go through mutation fns — add_m, toggle_m, remove_m, answer_m — multi-step write programs that run in the one write lane; the machine’s counters are sources too, because with no domain container there is nowhere else for state to live.

Widgets are data; the diff does the DOM

view(r) is a pure function from app state to a Widget tree — it runs no crossing and reads nothing (reads are reactive props, resolved at the render door). t1_render diffs the new tree against the previous one held in T1Root and fires only deltas: match children by key, patch in place, re-append on reorder, retire gone keys’ listeners. Nothing changed = nothing fires (HashMaps and sets carry the path-keyed tables):

pub struct T1Root {
    parent: opaque; // the mounted root element
    prev: ?Widget = nil; // the previous tree — the diff's left side
    els: HashMap<str, opaque>; // path -> live handle (ops application)
    regs: HashMap<str, Ev>; // listener id (as crossed) -> the
    // widget's event wiring (a data-shaped binding)
    lids: HashMap<str, i64>; // path -> listener id (retirement)
}

Event wiring rides the widgets as data, not closures — a row’s toggle mutation carries its argument bound (.on_row(w.toggle_row, t.id)), and the framework resolves a firing listener id to that wiring. Styling is a token contract: the stylesheet keys only the lowered t1-* tokens, and the app spells no class.

Two packages, two manifests

A real project is not one file — but the package boundary is also the privacy boundary, which is why there are exactly two (Project structure and rut.toml):

# rut/biz/rut.toml — the project root
name = "app"
entry.lib = "./biz.rut"
entry.libs = ["./domain.rut", "./world.rut", "./app.rut"]

[deps]
ui = { path = "../ui" }
pouch = { path = "../../../../rut/pouch" }
nmapset = { path = "../../../../rut/nmapset" }

entry.libs splices several files into ONE module (base first, array order), so a name private to store.rut is visible to components.rut and to nothing outside ui — single-file privacy, kept at package scale. ui is marked inline = true (its generic exports splice by law), and the native lane mounts the directory — the manifest, not a Rust fn, is the module list. The wasm lane mounts the same packages by hand as a mirror, and a test pins both lanes to byte-identical binaries.

Takeaways

  • The page ABI is main plus doors named for their events; state lives in the container the host re-passes every turn.
  • Ten thin crossings carried an entire reactive framework — the host stayed generic and never learned the word “todo”.
  • The store is jotai’s shape in rut: discovered deps, pull-on-read freshness, mutations as the write lane, and type-level non-writability through private traits.
  • Widgets are values; the keyed diff is the only thing that speaks DOM, and patch-in-place is what makes the UI animatable.
  • Soft failures are data ((nil, why) and the pump keeps draining); a panic alone kills the page, loud.

The async shape this page simulates (the request table + timers) is the real thing in 06 — GitHub viewer CLI; the store’s building blocks are in the swappable packages.