Building UI
std::web::ui is a declarative view layer with no virtual DOM. A View
describes a DOM element. Methods chain to build it. Where React re-runs
components and diffs the result, Vilan binds individual DOM properties to
signals: when a signal changes, exactly that text node or attribute
updates and nothing else runs.
Available in browser builds (target = "browser" in vilan.toml, or
vilan build --target browser).
import std::web::ui::mount_root;
import std::reactive::{ Signal, SignalCell };
fun main() {
let count = Signal::new(0);
let _root = mount_root("app", || {
<div>
<p>{count.derive(|n: i32| i"clicked {n} times")}</p>
<button on:click(|| count.set_with(|n| n + 1))>"+1"</button>
</div>
});
}
Read that top to bottom: make a div, give it a paragraph whose text
follows the counter, give it a button that bumps the counter. That’s the
whole mental model.
The markup is element syntax, and it is sugar: each
element lowers to a chain of View methods — view("div"), then one
.child(..) per child — before the compiler analyses anything. This
guide writes markup wherever a tree’s shape is fixed, and the chain
where it is not (Markup or chain says which is
which).
Views
view(tag) makes a fresh element. Methods chain, and each returns the
view so you can keep going:
- Static content:
.text(content),.class(name),.attr(name, value),.styled(style)(see Styling). - Structure:
.child(content),.children(views). - Events:
.on(event, handler), or.on_event(event, |e| …)when you need the DOM event itself (prevent_default,key(), modifiers,pointer_x()/pointer_y()). For window-level events, and for a listener you need to remove, drop tostd::web::dom— Escaping to the DOM. - Reactive bindings:
.bind_text(source),.bind_class(source),.bind_attr(name, source),.toggle_attr(name, flag),.style_var(name, source).
Every bind_* sets the property now and re-sets it whenever the source
changes. There is no render loop to trigger.
A read-only binding asks for a
Flow<T>, not the concrete
SignalCell<T> — a signal is one, a pipe (count.derive(..)) is one, and so is
any source you implement get/on_settle on — and it consumes it. Only the
bindings that write back (bind_value, bind_draft) need a real signal.
Text children and mixed content
child takes more than a View. Anything that can fill a child
position works — the value’s type decides what lands in the DOM. Four
static arms, and a reactive twin for each:
- a
Viewappends as an element; aSource<View>appends the view it holds and replaces it whenever the source changes; - a
strappends as a text node; aSource<str>appends a text node kept in sync; - a
List<View>appends every view, in order; aSource<List<View>>appends the run and replaces the whole run on every change.<>…</>is the literal for one (see Fragments); - an
Optionof any of them places its payload when it isSomeand nothing when it isNone— the static twin ofwhen, so<p>"item"{badge}</p>over abadge: Option<View>needs nomatch; aSource<Option<View>>places the view it holds, replaces it, and takes it out when the source turnsNone.
That pairing is the whole contract: whatever may be a child statically
may be a child reactively, and {expr} in element syntax means the same
thing either way. A number or a bool is none of these, so
<p>{count}</p> over a Signal<i32> is refused at the hole, with the
text form written out: {count.derive(|value| i"{value}")}, or {i"{5}"}
for a plain value. The reactive arms register one subscription with the
nearest boundary, so a {signal} child inside an each row stops
replacing anything when the row is disposed — but the views themselves
arrive already built, so each one’s own bindings belong to the scope
that constructed it. Reach for swap(source, |value| …) when every
subtree must be built and disposed per value; reach for a Source<View>
child when the views are values the app already holds.
A reactive child keeps its place. The replacement lands where the
{expr} is written, not at the end of the parent — and so do when’s
body, swap’s subtree and each’s rows. Each plants an empty text
node where it is called and inserts before it, so
<nav>{brand}{when(..)}{footer}</nav> puts the conditional between the
two, and it is still between them after it toggles off and on. No
wrapper element, and nothing to remember about ordering.
A Source<List<View>> is not a reconciler: it replaces the run rather
than moving surviving rows. each is the keyed form, and it is
what a list of data wants.
Text nodes make mixed content direct: prose around an inline element is a run of siblings, not a pile of wrapper spans.
import std::web::ui::{ View, mount_root };
fun tip(): View {
<p>
"Update any time with "
<code>"vilan upgrade"</code>
"."
</p>
}
fun main() {
let _root = mount_root("app", || tip());
}
attr is typed the same way: a str value sets once, a SignalCell<str>
re-sets whenever it changes — attr("href", signal) and
bind_attr("href", signal) are the same binding, chosen by type or by
name. (text is unchanged: it still replaces everything the element
contains, text nodes included, like the DOM’s textContent.)
An attribute that comes and goes is a third case, and both forms take it
directly: over an Option<str> or a Source<Option<str>>, a Some(text)
sets the attribute and a None leaves it off — removing it, where the value
is reactive and the attribute was there a moment ago. bind_attr spells it by
name and element syntax by type, so <div data-dragging(drag_status)> is the
same binding as the chain below. Reach for it whenever a selector reads presence —
[data-dragging="row"] * matches an element that has the attribute, so
writing "" between drags leaves the rule firing. Some("") is still a
present, empty attribute, which is why the absence has to be its own value
rather than a sentinel string.
// `None` while nothing is being dragged; `Some("row")`/`Some("col")` while
// something is.
view("div").bind_attr("data-dragging", drag_status.derive(|status| match status {
DragStatus::Still => None,
DragStatus::Vertical => Some("row"),
DragStatus::Horizontal => Some("col"),
}))
A BOOLEAN attribute is a different thing and has its own binding:
inert, disabled, hidden and open mean present, so there is no
string that turns one off — attr("disabled", "false") is a disabled
control. .toggle_attr(name, flag) takes a Source<bool> and writes
the attribute when it is true, removes it when it is false:
shell.toggle_attr("inert", modal_open).
attr and child dispatch through traits rather than a bound, and
their reactive arms are blanket impls over Source, so a derived
signal, a RemoteSource or a mirror of your own fills either position
exactly as a cell does.
Element syntax
The chain has a markup coat. An element expression is HTML-shaped sugar that lowers, before analysis, to exactly the chain you would have written — the same methods, in the same order, emitting the same code:
import std::reactive::{ Signal, SignalCell };
import std::web::ui::{ View, mount_root };
fun counter(): View {
let count = Signal::new(0);
<div>
<h2>"Counter"</h2>
<button on:click(|| count.set_with(|n| n + 1))>"+1"</button>
<p>{count.derive(|n: i32| i"clicked {n} times")}</p>
</div>
}
fun main() {
let _root = mount_root("app", || counter());
}
One rule governs the head — everything between <tag and >:
- An undotted
name(value)is an attribute:.attr("name", value), the value’s type deciding static vs tracked as always. A bare name (disabled) is a boolean attribute. Keyword and hyphenated names (type,data-state,aria-label) are ordinary attribute names — hyphens are ordinary attribute-name characters, exactly as in HTML, so everydata-*/aria-*attribute is written in the same undotted form and emitted verbatim. - A leading dot is the chain, verbatim:
.styled(card),.bind_value(draft),.show(flag),.child(each(rows, |r| r.id, |r| row(r))). EveryViewmethod works in head position — the dot is what keeps attributes and methods from ever colliding, so a new method can never change what existing markup means. on:click(handler)is an event. A zero-parameter closure literal lowers to.on, a one-parameter literal to.on_event; a named one-parameter handler is written in chain form (.on_event("click", handler)).
Children — everything between > and </tag> — are nested elements,
quoted strings (i"…" interpolation included), and {expression}
holes; each lowers to .child(…) in written order. Text children are
quoted because Vilan’s lexer is context-free and stays that way — and
the payoff is that interpolation, escapes, and expressions work in
markup exactly as they do everywhere else. Bare text is a parse error
that suggests the quoted form.
An element is an ordinary expression: it nests in holes, sits in match arms, and takes postfix chains. The two forms mix freely —
import std::reactive::{ Signal, SignalCell };
import std::web::ui::{ View, each, mount_root };
fun panel(items: SignalCell<List<str>>, flag: SignalCell<bool>): View {
<section class("panel")>
<input placeholder("What needs doing?") />
<ul>{each(items, |t| t, |t| <li>{t}</li>)}</ul>
<p .show(flag)>"empty"</p>
</section>
}
fun main() {
let _root = mount_root("app", || panel(Signal::new(["alpha"]), Signal::new(false)));
}
Fragments
A fragment groups several children under one hole, with no wrapper
element. <>…</> is the nameless head, and it lowers to a list
literal of its children — so its type is List<View>, the arm child
already places:
import std::web::ui::{ View, mount_root };
fun labelled(name: str, value: str): List<View> {
<>
<dt>{name}</dt>
<dd>{value}</dd>
</>
}
fun main() {
let _root = mount_root("app", || {
<dl>
{labelled("host", "localhost")}
{labelled("port", "8080")}
</dl>
});
}
<> and </> are written tight, like /> and </. A fragment takes
no attributes and no chain links — there is no element to put them on —
and it has no self-closing form; the empty fragment is <></>.
Its type is where its uses are, and where its limits are. A fragment is
a List<View>, so it fills a child position and every position a list
fills, and a Source<List<View>> of fragments keeps its place like any
other reactive child. It is not a View: a fun …: View return
wants one view, and a fragment there is a type error that says so. A
when body, a swap render and an each row are not that position —
each takes anything Slot,
a fragment included. It also does not
flatten — a fragment written directly inside another is a list inside a
list, which the literal refuses; nest through a child position instead.
Components stay what they are — functions returning View — and are
called in holes: {todo_row(items, todo)}. Reactivity stays explicit:
an if or match inside a hole runs once at build, exactly as it does
in a chain; reactive structure is .show in head position and the
when/swap/each values in holes, with Signal values in slots. The sugar adds no
semantics: an element means std::web::ui::view whatever the file has
imported, so element syntax needs no view import of its own (a View
you write as a TYPE still needs one, and the editor offers it), and
everything this guide says about ownership, boundaries, and binding
types applies unchanged.
Markup or chain
Both forms build the same View, so the choice is about reading, and
one rule settles it: write markup wherever the shape of the tree is
fixed when you write it, which is nearly everywhere — and this book,
the examples and the site all follow it:
- Static text is a quoted child (
<h2>"Counter"</h2>); reactive text is a hole (<p>{count.derive(..)}</p>); a component is a call in a hole ({note_row(note)}). - Attributes are undotted (
href("/"),aria-label("Close")); everything else in the head is a dotted chain link, verbatim (.styled(card),.show(open),.bind_value(draft)). - A handler written as a closure is
on:event(..); a handler held in a name stays a chain link (.on("click", submit)), because the head picks.onor.on_eventfrom the closure’s parameter count, which a name does not show. - Give an element its class once.
.styled(..),.class(..)and an undottedclass(..)each SET the class attribute, so on one element the last of them wins; compose styles with+instead.
The chain is still the right spelling in three places: a tag chosen
at run time (view(tag)), a view passed around or extended as a
value (base.child(extra) on a view something else built), and a
page about a View method itself, where the method is the subject.
vilan fmt lays markup out and sorts each run of undotted attributes
(id, name, type, for, href, src first); a dotted link is a
barrier the sort does not cross.
Components are just functions
A “component” is a function that returns a View. There is no
registration, special types, or props system; the parameters are the
props:
import std::web::ui::{ View, mount_root };
import std::reactive::{ Signal, SignalCell };
fun labelled_input(label: str, value: SignalCell<str>): View {
<label>
{label}
<input .bind_value(value) />
</label>
}
fun main() {
let name = Signal::new("");
let _root = mount_root("app", || labelled_input("Name", name));
}
mount_root(id, body) builds the body and attaches it to the page
element with that id. It also establishes the root owner, which is
why you never think about cleanup: every binding you create, at any
depth of function calls, registers with the nearest owner automatically
(the reactive guide explains owners).
If you create a reactive binding — a bind_*, a Signal in a slot, a
when/swap/each — outside any root, you’ll get a compile
error mentioning owner_scope. It means “wrap this in mount_root”
(or run_with_owner in a test). Purely static structure needs no
boundary: mount("app", <div><p>"hi"</p></div>) is
fine, because nothing in it subscribes. That holds through your own
generic helpers too — a fun card<T: Slot>(content: T): View called
with static content needs no boundary, while the same helper called
with a Signal keeps the requirement: the compiler follows each call’s
actual instantiation.
Events run in turns
Each event dispatch runs your handler inside a fresh turn: all the signal writes one click causes are batched, and watchers see the final state once. Handlers die with their DOM node, so there is nothing to unsubscribe.
<button on:click(|| count.set_with(|n| n + 1))>"+1"</button>
<input on:keydown(|pressed| {
if pressed.key() == "Enter" { submit(); }
}) />
Inputs
Two ways to wire an <input>, for two different situations:
bind_value(signal) is the simple two-way bind: the input shows the
signal, typing writes it back. Use it for local state: a search box, a
“new item” field.
bind_draft(draft) binds the input to a local-first
draft whose
commit is typically an rpc. Typing updates the input instantly and
commits in the background. A remote update folds in without re-sending.
An echo of your own edit never moves the caret. Use it for fields that
edit server state as you type:
import std::web::ui::mount_root;
import std::reactive::{ draft, Draft, DraftState };
import std::option::Option::{ self, Some, None };
fun main() {
let name = draft("initial", |value: str| {
let _would_send = value; // an rpc call in a real app
None
});
let _root = mount_root("app", || {
<div>
<input .bind_draft(name) />
<span>
{name.state.derive(|state: DraftState| match state {
DraftState::Synced => "",
DraftState::Dirty => "saving…",
DraftState::Failed(let reason) => i"failed: {reason}",
})}
</span>
</div>
});
}
Lists: each
each(source, key, render) renders one row per element of any
Source<List<T>> — a signal, a derived one, a mirror, a type of your own.
It is a value: write it in a child hole, or hand it to child to place
the run at the parent’s current end. Rows are keyed, like React’s key
prop, and the key does real work here:
- A row whose key survives a change is reused. Its element moves to the new position with its state and subscriptions intact.
- A row whose key survives but whose value changed re-renders only
that row (that’s why
T: PartialEq). - Removed rows are disposed: each row is its own owner, so a row’s bindings die with the row.
import std::web::ui::{ each, mount_root };
import std::reactive::{ Signal, SignalCell };
[derive(PartialEq)]
struct Todo {
id: i32,
title: str,
}
fun main() {
let todos: SignalCell<List<Todo>> = Signal::new([
Todo { id = 1, title = "write docs" },
]);
let _root = mount_root("app", || {
<ul>{each(todos, |todo| todo.id, |todo| <li>{todo.title}</li>)}</ul>
});
}
fun each<T: PartialEq, K: PartialEq, S: Source<List<T>>, C: Slot>(
source: S,
key: sync |T| K,
render: (sync |T| C) context owner_scope,
): Each<T, K, S, C>
Three forms, one engine
each asks two things of your items — a key and an equality —
and most lists only have one of them to give. The other two forms each
drop one bound:
| Signature | Key | Unchanged row | Asks of T | |
|---|---|---|---|---|
each(source, key, render) | render: |T| C | key(item) | reused; changed → rebuilt | PartialEq |
each_values(source, render) | render: |T| C | the item itself | reused; changed → rebuilt | PartialEq + Hashable |
each_by(source, key, render) | render: |SignalCell<T>| C | key(item) | always reused; the row’s cell is rewritten | nothing |
The key is always PartialEq and Hashable, in all three forms:
the reconciler finds a moved row through a hash index, so a key’s hash has
to agree with its equality — a == b implies a.hash() == b.hash(). Ids
and strings satisfy it for free. A struct key takes [derive(Hashable)]
when it is equal by all of its fields, and a hand-written
impl Key with Hashable hashing exactly the fields its eq reads when it
is not. That agreement is what makes a reorder cost one comparison per
row instead of N.
-
each_valuesiseach(source, |x| x, render)written once. Reach for it whenever the item is the identity — every|x| xkey in the wild is this. -
each_byis Solid’s<Index>besideeach’s<For>. A row whose key survives keeps its element, its owner and its bindings, and std writes the new item into that row’s ownSignalCell<T>, so the row updates through the bindingsrenderalready made. That’s what lets it take aTwith no equality at all — a struct carrying a closure, a handle, anything you can’t derivePartialEqfor.The trade:
setnever compares, so every kept row’s cell is written on every change of the list, and every binding in every row re-runs. Reach foreachwhenTcompares cheaply and rows are expensive to rebuild; reach foreach_bywhenTcan’t compare, or when the row’s own bindings are the natural update path.
import std::web::ui::{ each_by, each_values, mount_root };
import std::reactive::{ Signal, SignalCell };
struct Task {
id: i32,
title: str,
}
fun main() {
let tasks: SignalCell<List<Task>> = Signal::new([
Task { id = 1, title = "write docs" },
]);
let names: SignalCell<List<str>> = Signal::new(["ada", "grace"]);
let _root = mount_root("app", || {
<div>
// the item is the key
<ul>{each_values(names, |name| <li>{name}</li>)}</ul>
// `Task` needs no PartialEq: the row updates through its cell
<ol>
{each_by(tasks, |task: Task| task.id, |task: SignalCell<Task>| {
<li>{task.derive(|current| current.title)}</li>
})}
</ol>
</div>
});
}
After the element lands: on_mount, autofocus
An element is built before it is placed; it is not in the document until
whatever appends it does. .on_mount(action) runs action with the element once
it is — at every attachment site, including a when body or an
each row that appears in a later change.
<input type("text") .on_mount(|element| element.focus()) />
<input type("text") .autofocus() /> // and then some
autofocus is the reason the hook exists. HTML’s autofocus attribute
fires only on a document’s initial parse, so it does nothing for a modal
mounted later — and the workaround it forces (mint a uuid, set it as the
id, start a 1 ms timer, look the element back up) is three lines of
ceremony around a value you already had. There is nothing to look up:
the callback is handed the element.
on_mount promises the element is IN THE DOCUMENT, and that is all it
promises. A microtask runs before the frame’s rendering step, so at that
moment the element is connected but not yet rendered: no layout, no
resolved style, and no ResizeObserver reaction to either. focus() has
a precondition — connected, rendered, visible, not inert, all of them at
the call — and the platform’s answer to a target that is not is to do
nothing, silently. An overlay panel held at visibility: hidden until a
ResizeObserver places it and flips it visible is exactly the shape that
refuses.
So autofocus is not on_mount(|e| e.focus()): it attempts in the
microtask, and if the element did not take focus it attempts again on the
next animation frame and once more on the frame after, then stops. Three
attempts on the platform’s own clock — no timer, no millisecond to tune.
Written out, with std::web::dom::request_animation_frame as the clock and
matches(":focus") as the read-back (focus() returns nothing):
<input .on_mount(|element| {
element.focus();
if !element.matches(":focus") {
request_animation_frame(|| element.focus());
}
}) />
Two things no retry fixes. iOS Safari ignores a programmatic focus()
outside a user gesture whatever frame it runs on; and the sound shape for
an overlay is that focus is a consequence of the SHOW — a hook the driver
runs when it flips visibility, or <dialog>.showModal(), whose focusing
steps run once the dialog is rendered.
The hook is a microtask, which is enough because the whole
synchronous build — and the mount that finishes it — runs to
completion before any microtask does. On the SSR twin on_mount accepts
and drops, like the event binders: there is no document to be in.
autofocus writes no attribute in the browser. It registers the
element with std (in a WeakSet, so nothing is kept alive), and a focus
scope reads that registration — see below. The native autofocus
attribute is not free on an element inserted after the page parsed: the
document queues it as an autofocus candidate and refuses it once
something has focus, which Chromium reports on the console once per page.
So .autofocus() is the one spelling, and an element head’s written
autofocus attribute (<input autofocus />) gets a warning with a quick
fix that rewrites it to .autofocus(). The native attribute is still
yours when you mean it — .attr("autofocus", "") writes it as spelled,
for a <dialog> or a popover, whose own focusing steps read it. On the
SSR twin .autofocus() writes the native attribute: a served page’s
initial parse is the one place it works, and it logs nothing there.
Focus scopes
An overlay usually wants more than one focused input: it wants Tab to stay inside it while it is open, and it wants focus back where it was when it closes. That is a focus scope.
<div .focus_scope(FocusContainment::Wrap)>
<input .autofocus() />
<button>"Close"</button>
</div>
Wrap is a menu: Tab cycles inside the subtree, and focus that leaves by
any other route may leave. Contain is a modal: it also pulls focus back
when it lands anywhere else. Neither writes anything outside the panel —
the rest of the page stays clickable and stays in the accessibility tree,
which is exactly what a menu needs and what inert would take away.
inert is still yours for a true modal (set_attribute("inert", "") and
remove_attribute), and it is a bigger hammer: it removes the subtree
from the accessibility tree and blocks pointer events.
focus_scope on a view is the ordinary case. A driver that owns its own
visibility flip — which is every overlay system that positions a panel
before showing it — installs the scope and takes the focus as two
acts, because focus has a precondition the mount cannot satisfy:
// at mount, or wherever the panel element is in hand:
let scope = focus_scope(panel, FocusContainment::Contain);
// …in the pass that flips it visible:
let _took = scope.focus_initial();
focus_initial focuses the first descendant, in tree order, that
.autofocus() registered; else the first carrying a native [autofocus];
else the first tabbable one; else the panel itself at tabindex="-1" —
and it answers whether the focus was taken, so a show that fired too
early is simply asked again on the next pass. It is idempotent: once
focus has been taken, a later call leaves alone whatever the user has
since moved to. Inside a scope, .autofocus()’s own bounded clock stands
aside and lets the show decide.
The scope does not have to exist before its content is built: the walk
happens at the show, over whatever the panel holds then, so an element
that mounts later — a when that opens inside the panel — is found in its
place, and an element removed since it registered is simply not there.
Scopes NEST as a stack, not by DOM ancestry, because an overlay is a
portal: a submenu opened from inside a menu mounts beside its parent’s
panel rather than inside it. The topmost Contain scope is the one that
guards. A scope lives as long as the boundary it was installed in — when
that boundary is disposed the scope pops, and focus goes back to whatever
held it when the scope opened, unless the app moved focus deliberately in
the meantime or the remembered element has since left the document.
A scope can also tell you when focus leaves it, and for where:
scope.on_leave(|to| close_menu());
The handler runs when focus moves from inside the scope to an element
outside it AND outside every scope above it — so opening a submenu is not
leaving the menu, and leaving the submenu for the page leaves both. It is
handed the element focus went to. Focus leaving the document altogether
(for the browser’s chrome, another window) does nothing: there is no
element to hand over, and the user has not gone anywhere on the page. A
Tab never fires it under Wrap, which keeps Tab inside; under Contain
a click outside fires it with the clicked element, and then the guard
pulls focus back. The listener lives as long as the scope.
Element::tabbable() is the query underneath, and it is public: a router
that moves focus to the new page’s heading, or a menu that implements
arrow-key navigation, wants it too.
Conditionals: show, when, swap
Three primitives. Pick by what should happen to the content while it’s not visible:
| Content while off | State | Use for | |
|---|---|---|---|
.show(condition) | mounted, hidden | preserved | tabs, collapsibles, anything that should keep its input text |
show makes two writes: the hidden attribute, which selectors and
assistive technology read, and an inline display: none, which is what
actually hides it. The attribute alone would not — the preflight’s
[hidden]{display:none} sits in @layer vilan.preflight and a compiled
Style’s rules are unlayered, so any display you set beats the reset
outright. Showing again puts back the element’s own inline display,
captured before the first toggle. If you write this element’s inline
display yourself after binding show, the next toggle takes it: style
through a Style and the two never meet.
| when(condition, body) | unmounted, disposed | dropped | content that shouldn’t exist while off (an editor for a missing record) |
| when_some(source, render) | unmounted, disposed | dropped | the same, when the content NEEDS the value — an editor for the selected record |
| swap(source, render) | previous subtree disposed on change | per-value | pages on a route signal, any value-driven subtree |
show is a View method — it binds a property of the element it is written
on. when, when_some and swap are values: each fills a child
position, so it lands exactly where it is written.
.show(open) // any Source<bool>
{when(present, || task_editor(…))} // any Source<bool> + (sync || C: Slot)
{when_some(selected, |record| // any Source<Option<T>>
task_editor(record))} // + (sync |SignalCell<T>| C: Slot)
{swap(route, |current| match current { // any Source<T> + (sync |T| C: Slot)
Route::Home => home_page(),
Route::NotFound => not_found(),
})}
All three build their content under a fresh owner each time, so everything
inside cleans up when the content goes away. swap re-renders only when the
value changes (T: PartialEq), so navigating to the page you’re already on
does nothing.
when_some is the one that binds the value, and it is the reason to reach for
it over when(source.derive(|value| value is Some(_)), ..): the body gets the
payload, and it gets it as a SignalCell<T> rather than as a T. What decides
structure is the PRESENCE — a None → Some builds, a Some → None
disposes — so a changed payload writes to the cell and the row stands, with
whatever bound to it updating in place. That is each_by’s row cell applied to
a single row, and it is why T needs no PartialEq: nothing is compared.
Read the cell inside a binding, exactly as an each_by row does:
{when_some(selected, |account| <p>{account.derive(|current| current.name)}</p>)}
When the content needs SEVERAL maybes — a message and its author — reach for
ui::when_all_some rather than nesting when_somes. It takes a tuple of flows
of Options and builds while EVERY one is Some; the body takes ONE
parameter, a tuple of cells, destructured where it is written. The cells are
made under the body’s owner, written in place while everything stays Some,
and released with the body when any part goes None — so the negative states
are handled once, here, and the body reads plain values:
{ui::when_all_some((message, author), |(message, author)| <p>
<b>{author.derive(|user| user.name)}</b>
{message.derive(|current| current.content)}
</p>)}
Position, and placing one at the end
A value fills a child position, so the conditional or the run sits exactly
among the siblings it is written between. std::web::ui exports six —
when, when_some, swap, each, each_values, each_by — and each
returns something that fills a child slot:
<ul>
{header_row()}
{each_values(items, |item: str| <li>{item}</li>)}
{when(more, || <li>"and more"</li>)}
{footer_row()}
</ul>
To place one at the parent’s current end — where the retired View methods
put it — hand it to child: view("ul").child(each(rows, key, render)). That
is the mechanical rewrite for old code, since parent.swap(s, r) was never
anything but parent.child(swap(s, r)). The run is called each rather than
bind_each because bind_ means “one property kept in sync” everywhere else,
and a value that is a child has no property to bind.
A helper that returns one names its type, since vilan has no trait objects — the last argument is the shape the closure yields (below):
fun account_menu(signed_in: SignalCell<bool>): Conditional<SignalCell<bool>, View> {
when(signed_in, || <nav>"Account"</nav>)
}
A row, a body or a branch can be anything Slot
The render closures are not limited to View. A row may be a fragment, a bare
string, or another value form, and the run owns whatever it placed:
<ul>
{each_values(items, |item: str| <><li>{item}</li><li class("sep")/></>)}
</ul>
That is what makes a wrapper element unnecessary in the last place one was
still needed — a row that is several nodes. It costs one empty text marker per
row: a row’s content can grow after it was placed (a when inside a row
toggles later), so the reconciler moves and removes a row by the SPAN between
its marker and the next one rather than by a list of nodes it remembered.
Ownership is the same either way: the body, the subtree and every row run under a fresh owner established where the value is placed, and that owner is disposed with the instantiation.
The ownership picture
Here is the whole cleanup model in one picture. Owners exist at the
places marked ◆: the boundaries where a subtree can die. Every
binding registers with the nearest boundary above it, no matter how
many plain function calls sit in between:
◆ mount_root("app", …) the root owner — lives forever
│
├── <header> static: no boundary of its own
│ └─ {title} → registers with the ROOT
│
├── ◆ {swap(route, |page| …)} one owner PER PAGE shown
│ └─ home_page()
│ └─ {…} → registers with the PAGE
│
└── ◆ {each(todos, key, |t| …)} one owner PER ROW
├─ row(id = 1)
│ └─ .bind_class(…) → registers with ROW 1
└─ row(id = 2)
└─ .on("click", …) → dies with ROW 2's DOM node
Navigate away, and the page’s owner is disposed. Every binding the page created dies with it. Delete row 2, and only row 2’s bindings die. This is why there is no unsubscribe code anywhere in a Vilan app: the tree of boundaries is the cleanup logic, and the framework already placed them where subtrees end.
A boundary also removes what it placed. Disposing it takes the
nodes out of the document: when’s body, swap’s subtree,
each’s rows, a {signal} child’s view or run or text node, and
the invisible marker each of them keeps its position with. That is
usually invisible — the subtree was leaving with its parent anyway —
and it is the whole story for a portal, a container that outlives
the boundary filling it: an overlay, a tooltip layer, a modal host
mounted once at the top of the page. Fill one from a component’s
boundary, dispose the component, and the container is empty; there is
nothing to remember to clean up by hand. The three static child
arms are untouched, deliberately: a str, a View or a List<View>
child belongs to the parent element it was appended to, not to a
boundary.
Server-side rendering
The same component code runs on the server. On a Node build std::web::ui
builds an HTML string instead of live DOM, and render(view)
serializes it: first paint and SEO, before any JavaScript. A route
handler calls your own app() and splices the markup into its HTML
shell. The server-side rendering guide walks the whole loop.
import std::web::ui::{ View, render };
import std::reactive::{ Signal, SignalCell };
fun greeting(name: SignalCell<str>): View {
<p class("greeting")>{name}</p>
}
fun main() {
let name = Signal::new("world");
print(render(greeting(name)));
// <p class="greeting">world</p>
}
Two rules make one component serve both legs:
- Bindings read once.
bind_text,bind_attr,each,when, andswapembed the source’s value at render time: no subscription is created, and nothing survives the request (create, serialize, discard). Build pure, bind reactive: a component that leans on effect side-channels at build time renders stale. Text and attribute values are escaped, so a hostile string is inert. - No
mount/mount_rooton the server. Mounting is a client entry, not a renderable view, so the natural factoring is a sharedfun app(): Viewwith a per-legmain:mount_root("app", app)in the browser,render(app())on the server. Event handlers (on) are accepted and discarded; a server-rendered<button>is a plain button.std::web::domstays browser-only, so a component reaching for raw DOM cannot SSR; the cross-platform error says so at the import.
Escaping to the DOM
View is a thin wrapper over std::web::dom::Element (it’s right there as
view.element). For anything the chain doesn’t cover, use std::web::dom
directly: get_element_by_id, query_selector,
element.set_attribute, and so on. See the
browser reference.
Two things live only down there, because they aren’t about a view at all.
Events that no element receives — resize, popstate, storage, message —
hang off window(), which carries the same on / on_event verbs an element
does. And when a listener has to stop before its target does, listen is the
removable form: it hands back a Subscription, and disposing it unhooks the
listener.
A pointer drag needs both, which is why it can’t be a View method: the
pointer leaves the element the moment the drag starts moving, so the stream has
to come from the window, and it has to stop on pointerup.
element.on_event("pointerdown", |down| {
let start = down.pointer_x();
mut stop;
let moves = window().listen("pointermove", |event| {
width.set(start_width + event.pointer_x() - start);
});
let ups = window().listen("pointerup", |_| { stop(); });
stop = || { moves.dispose(); ups.dispose(); };
})
Window handlers are raw, like element.on: they establish no turn, so wrap the
body in turn(FlushPolicy::AtSuspension, …) when a handler writes several
signals that should settle as one wave.
Traps
showkeeps bindings live while hidden, and they keep firing. If the hidden content is expensive, usewhen.- Inline SVG works:
<svg viewBox(…)><path … /></svg>creates real SVG-namespace elements, and the server render carries thexmlns.showworks on an SVG subtree too — it writes the inlinedisplay, which SVG honours, not only the HTML-onlyhiddenattribute that SVG ignores. bind_valuefights remote updates (every keystroke overwrites). For server-backed fields, usebind_draft.- The
owner_scopecompile error means you built UI outside every boundary. Wrap the entry point inmount_root. - Don’t create owners per element or per component function. Boundaries belong where subtrees can die: roots, rows, conditionals. The framework already puts them there.