Browser modules reference
The browser modules of std: std::web::dom, std::web::ui, std::web::router,
std::web::storage. Available only for browser builds (std::web::ui has a
process twin for server rendering). Concepts:
Building UI, Routing.
std::web::dom
Opaque handles over real DOM objects.
external struct Element;
fun get_element_by_id(id: str): Element
fun create_element(tag: str): Element
fun create_element_ns(namespace: str, tag: str): Element // createElementNS
fun create_text_node(content: str): Text // a fresh text node
fun query_selector(selector: str): Element
fun query_selector_all(selector: str): List<Element>
fun active_element(): Element // document.activeElement — who holds focus
fun is_null(handle: Element): bool // the peek a nullable read needs
fun request_animation_frame(callback: || void) // requestAnimationFrame
struct DomRect { left: f64, top: f64, width: f64, height: f64 } // a VALUE, not a handle
impl DomRect {
fun right(self): f64 // left + width
fun bottom(self): f64 // top + height
}
impl Element {
fun set_text(self, text: str) // textContent =
fun set_class(self, name: str) // className =
fun set_attribute(self, name: str, value: str)
fun remove_attribute(self, name: str) // removeAttribute — a boolean attribute's off
fun set_style_property(self, name: str, value: str) // style.setProperty (an empty value REMOVES)
fun style_property(self, name: str): str // style.getPropertyValue
fun append(self, child: Element)
fun append_text(self, child: Text) // appendChild, text-node overload
fun insert_before(self, child: Element, anchor: Text) // insertBefore — the positional append
fun remove(self) // detach from the document
fun clear(self) // remove every child
fun set_hidden(self, hidden: bool)
fun bounding_rect(self): DomRect // getBoundingClientRect — forces layout
fun offset_width(self): f64 // offsetWidth — laid out, rounded
fun offset_height(self): f64 // offsetHeight
fun is_connected(self): bool // attached to the document?
fun tab_index(self): i32 // tabIndex — the sequential focus order, per-tag default
fun parent(self): Element // parentElement — null at the root
fun has_attribute(self, name: str): bool // hasAttribute — a boolean attribute's read
fun computed_style(self, name: str): str // getComputedStyle — the RESOLVED value; forces layout
fun contains(self, other: Element): bool // other is this element or inside it
fun query_selector_all(self, selector: str): List<Element> // scoped to this subtree
fun focus(self) // move keyboard focus here
fun is_tabbable(self): bool // can Tab reach it?
fun tabbable(self): List<Element> // every tabbable descendant, in TAB order
fun focus_first(self): bool // focus the first one, else this element at tabindex=-1
fun matches(self, selector: str): bool // element.matches(..)
fun value(self): str // an input's current text
fun set_value(self, value: str)
fun on(self, event: str, handler: || void)
fun on_event(self, event: str, handler: |Event| void)
fun off_event(self, event: str, handler: |Event| void) // listen's teardown
fun listen(self, event: str, handler: |Event| void): Subscription // must_use
fun on_event_capture(self, event: str, handler: |Event| void, capture: bool)
fun off_event_capture(self, event: str, handler: |Event| void, capture: bool)
fun listen_capture(self, event: str, handler: |Event| void): Subscription // must_use
fun observe_resize(self, on_resize: || void): Subscription // must_use
}
external struct ResizeObserver; // the raw class behind observe_resize
impl ResizeObserver {
fun new(callback: || void): ResizeObserver
fun observe(self, target: Element)
fun disconnect(self)
}
external struct Window; // the window — a listen target, like Element
fun window(): Window
impl Window {
fun on(self, event: str, handler: || void)
fun on_event(self, event: str, handler: |Event| void)
fun off_event(self, event: str, handler: |Event| void)
fun listen(self, event: str, handler: |Event| void): Subscription // must_use
fun on_event_capture(self, event: str, handler: |Event| void, capture: bool)
fun off_event_capture(self, event: str, handler: |Event| void, capture: bool)
fun listen_capture(self, event: str, handler: |Event| void): Subscription // must_use
}
external struct Text; // a text node — text only, no attributes
impl Text {
fun set_text(self, text: str) // textContent =
fun remove(self) // detach from the document
}
external struct Event;
impl Event {
fun prevent_default(self)
fun button(self): i32 // 0 = main button
fun meta_key(self): bool
fun ctrl_key(self): bool
fun shift_key(self): bool
fun alt_key(self): bool
fun key(self): str // "Enter", "Escape", "a", … — the CHARACTER
fun code(self): str // "KeyE", "Digit1", "Escape" — the PHYSICAL key
fun target(self): Element // the node the event was dispatched to
fun current_target(self): Element // the node whose listener is running
fun related_target(self): Element // relatedTarget — where focus went (or came from); null off-document
fun target_value(self): str // event.target.value — the input's text
fun target(self): Element // event.target — what contains() is asked about
fun pointer_x(self): f64 // clientX — where the pointer is, in the viewport
fun pointer_y(self): f64 // clientY
}
Raw element.on handlers do not establish a turn: that’s View.on’s
job — and the Window verbs are raw in the same way, so a window handler that
writes signals should wrap its body in turn(FlushPolicy::AtSuspension, …)
itself. Prefer the View layer; drop to dom for what it doesn’t cover.
window is a listen target. Events that aren’t delivered to any element —
resize, popstate, storage, message, and the pointer stream a drag needs
once the pointer has left the element it started on — hang off window(),
which carries exactly the verbs Element does.
on/on_event are fire-and-forget; listen is the removable form. An
element listener dies with its element, which is usually the whole answer.
Nothing about the window ever dies, so a window listener you can’t remove is a
leak by construction. listen hands back a
Subscription — the same handle
Source::sub returns, [must_use] for the same reason — and disposing it
unhooks the listener. Later events then deliver nothing. Ownership is yours,
as sub’s is: get_owner().take(window().listen(…)) ties it to a scope, or
call dispose() by hand. Disposing twice is safe.
let moves = window().listen("pointermove", |event| {
track(event.pointer_x(), event.pointer_y());
});
// … later, or when the enclosing scope ends:
moves.dispose();
off_event is what listen is built on, and removal is identity-matched:
the handler you pass must be the same value the host was handed, so a freshly
written closure removes nothing. listen exists so you don’t have to hold that
pairing right.
key is the character; code is the key. A shortcut table wants code:
"KeyE" is the same physical key on QWERTY, AZERTY and Dvorak, where key
reads "e", "e" and "." there — so a shortcut written against key moves
under the user’s layout and one written against code does not. A text-entry
handler wants key, for the mirror-image reason. Both use the host’s own value
spaces, so a binding table reads the same here as in the DOM docs it came from.
target is where the event started; current_target is which handler is
speaking. They coincide only when the event was dispatched straight at the
listening element. A click on the <span> inside a button has the span as
target and the button as current_target — so a row of buttons sharing one
handler reads current_target to learn which row it is in, and a
document-level dismiss listener reads target to ask “did this land inside
me?”. current_target is only meaningful
DURING dispatch: the host clears it when the handler returns, so read it in the
handler rather than out of a captured event.
target_value is how a listener reads what the user typed without holding
the element. An element that reaches its own listener and back is a cycle
straddling the language/host boundary, where no disposal can see both halves,
so bind_value/bind_draft are built on this — and a hand-written input
handler should be too:
<input on:input(|event| query.set(event.target_value())) />
Measurement is a value, and it forces layout. bounding_rect returns a
DomRect — four numbers copied out of the host box, so it compares, parks in a
signal, and does not change under whoever is holding it. Reading it costs a
layout, so measure once and pass the value on. offset_width/offset_height
are the cheap, rounded pair for “how big is it” where the rect is the
fractional “where is it”.
A detached element measures 0×0 whatever its styles say, which is why
is_connected exists: positioning that runs before the node is in the tree
positions against nothing, and reads as a layout bug rather than a timing one.
if panel.is_connected() {
let anchor = button.bounding_rect();
panel.set_style_property("--x", i"{anchor.left}px");
panel.set_style_property("--y", i"{anchor.bottom()}px");
}
listen_capture is listen in the other phase. Capture runs the
ancestors’ listeners on the way down to the target, before the target’s own;
bubble runs them on the way back up. Two things need the downward pass: events
that do not bubble at all (scroll, focus, blur) are heard from an
ancestor only in capture — which is how an anchored menu hears every scrolling
ancestor at once and stays on its element while the page moves under it — and a
listener that must run before the target’s own (a dismiss guard, a modal’s
key trap) has nowhere else to be. The phase is part of the identity the host
matches on, so a capture listener is not removable by the bubble-phase
off_event; that is why there is a second raw pair rather than a flag.
observe_resize is the wrapper over ResizeObserver, and the form to reach
for: it fires once when observation starts — so the first layout and every
later one run through the same callback — and its Subscription disconnects
the observer. The bare class is there for what the wrapper doesn’t cover, and
leaks unless you disconnect it yourself.
let sizing = get_owner().take(panel.observe_resize(|| reposition(panel)));
let scrolls = get_owner().take(window().listen_capture("scroll", |_| reposition(panel)));
let outside = get_owner().take(window().listen_capture("pointerdown", |event| {
if !panel.contains(event.target()) {
dismiss();
}
}));
remove_attribute is set_attribute’s other half, and the half a boolean
attribute needs: disabled, open, aria-hidden and the rest are read by
their presence, so the false state is the attribute being gone, not
="false".
if is_open {
panel.set_attribute("open", "");
} else {
panel.remove_attribute("open");
}
std::web::ui
struct View { element: Element }
fun view(tag: str): View
fun mount(id: str, view: View) // attach only
fun mount_root(id: str, body: (sync || View) context owner_scope): Owner
trait Slot { fun place(own self, parent: View) } // str | View | List<View>, and a Flow of each
trait AttrValue { fun apply(self, parent: View, name: str) } // str | Option<str>, and a Source of each
mount_root = fresh owner + turn boundary + attach; it returns the root
owner (most apps let it live forever). mount is the attach half alone.
Use it only when you already hold a boundary.
Both panic naming the id when nothing on the page carries it —
mount: no element with id 'app'. The lookup they share hands back the
host’s null typed as an Element, so the alternative was a
Cannot read properties of null from somewhere inside the attach, with
the one thing you got wrong appearing nowhere in the message. On the
server side that mismatch is caught before it can happen: the id is what
check_shell holds the document against.
view knows the SVG vocabulary: an SVG tag name (svg, path, rect,
clipPath, …; exact case) creates its element in the SVG namespace, so
inline icons and diagrams render; on the server the svg root serializes
with its xmlns. Tags that exist in both vocabularies (a, title,
style, script) resolve to HTML. class/styled set the class
attribute (not the className property), so styling works on SVG nodes
too.
View methods
| Method | Signature (self elided) | Notes |
|---|---|---|
text | (content: str): View | static text |
class | (name: str): View | static class |
styled | (style: Style): View | classes from a compiled style |
attr | (name: str, value: V): View; V: AttrValue | str sets once, any Source<str> tracks; an Option<str> sets it or leaves it off, and a Source<Option<str>> tracks with None removing it (A115) — so the element-syntax form <div data-dragging(maybe)> takes every shape bind_attr does. Every name is written as spelled: .attr("autofocus", "") is the native attribute (for a <dialog> or a popover); an element head’s written autofocus warns and steers to .autofocus() (A157) |
style_var | (name: str, source: S): View; S: Source<str> | reactive CSS custom property; registers with the enclosing boundary like every bind_* |
on | (event: str, handler: (|| void) context turn_scope): View | handler runs in a fresh turn |
on_event | (event: str, handler: (|Event| void) context turn_scope): View | same, with the DOM event |
child | (content: C): View; C: Slot | the child contract: str, View, List<View>, and a Source of each — text re-set in place, an element or a run replaced; an Option of any of them places its payload or nothing, and a Source<Option<View>> follows it. <>…</> is the List<View> literal (fragments) |
children | (items: List<View>): View | append several |
bind_text | (source: S): View; S: Source<str> | reactive text |
bind_class | (source: S): View; S: Source<str> | reactive class |
bind_styled | (source: S): View; S: Source<Style> | reactive compiled style — styled’s reactive twin |
bind_attr | (name: str, source: V): View; V: AttrBinding | reactive attribute, at two value types: a Source<str> always has a value, a Source<Option<str>> says the ATTRIBUTE comes and goes — Some(text) sets it, None removes it (and Some("") is a present, empty attribute, which is a different thing) |
toggle_attr | (name: str, source: S): View; S: Source<bool> | reactive BOOLEAN attribute — presence, not value (inert, disabled, hidden, open): present when true, removed when false |
bind_value | (signal: SignalCell<str>): View | two-way input bind — concrete Signal: it writes back |
bind_draft | (draft: Draft<str>): View | local-first input bind (drafts) |
show | (condition: S): View; S: Source<bool> | state-PRESERVING visibility toggle — sets the hidden attribute AND an inline display:none, restoring the element’s own inline display when it turns true |
on_mount | (action: sync |Element| void): View | run action with this element once it is in the document |
autofocus | (): View | focus this element once it is mounted AND rendered — the modal-input form HTML’s autofocus cannot serve; writes NO attribute: it registers the element (a WeakSet), which is how an enclosing focus scope’s focus_initial reads the author’s choice, and inside a live scope it leaves the initial focus to the scope’s show (A157). The SSR twin writes the native autofocus |
focus_scope | (containment: FocusContainment): View | make this element a focus scope and take the initial focus on autofocus’s clock — the sugar for the case with no show hook |
Semantics, choosing between show/when/swap, and examples: the
UI guide.
Focus scopes
An overlay that wants Tab to stay inside it installs a focus scope. It
does not use inert: the rest of the page stays pointer-live and stays in the
accessibility tree, which is what a menu needs.
enum FocusContainment { Contain, Wrap }
fun focus_scope(root: Element, containment: FocusContainment): FocusScope
Wrap is a MENU — Tab cycles inside the subtree while focus is inside it, and
focus that leaves by any other route is allowed to leave. Contain is a MODAL
— it adds a guard that pulls focus back when it arrives anywhere else. The
opt-OUT is not calling focus_scope at all. inert remains the app’s own
escape hatch for a true modal (set_attribute("inert", "") and
remove_attribute), and it is a stronger, different thing: it also removes the
subtree from the accessibility tree and blocks pointer events.
A scope focuses nothing by itself. focus() needs a target that is
connected, RENDERED and visible at the call, and an overlay panel is typically
hidden until layout places it, so focus is a consequence of the SHOW — see
FocusScope::focus_initial and View::focus_scope in the
UI guide.
Nesting is a stack, not DOM ancestry, because an overlay is a portal: a
submenu opened from inside a menu mounts beside its parent’s panel, not inside
it. std holds the stack, the guard belongs to whichever Contain scope is
topmost at event time, and a scope pops when the owner that installed it is
disposed — restoring focus to whatever held it before, if focus is still inside
the scope and the remembered element is still in the document. “Before” means
before the scope’s CONTENT took focus, too: a panel whose content was built
first, and whose autofocus therefore ran before the scope was installed,
gives focus back to the element autofocus took it from (the opener), not to
the field inside the panel.
FocusScope::on_leave(handler: |Element| void) hears focus LEAVING the scope,
from a capture-phase focusout: the handler runs with relatedTarget — the
element focus went to — when that element lies outside the scope and every
scope above it on the stack. A null relatedTarget (focus left the document)
does nothing. The listener is released with the scope; the SSR twin accepts
the handler and drops it.
The slot values
The conditional, the dynamic subtree and the keyed run are values, not
View methods. Each fills a child position, so it sits exactly where it is
written — between siblings, not after them. They are bare names in the
std::web::prelude, the web prelude, and
std::web::ui::{ when, when_some, swap, each, each_values, each_by } otherwise
(when_all_some is reached as ui::when_all_some or imported by name):
| function | signature | returns |
|---|---|---|
when | (condition: S, body: (sync || C) context owner_scope); S: Source<bool>, C: Slot | Conditional<S, C> |
when_some | (source: S, render: (sync |SignalCell<T>| C) context owner_scope); S: Source<Option<T>>, C: Slot | WhenSome<T, S, C> |
when_all_some | (flows: (U in T: dyn Flow<Option<U>>), render: (sync |(U in T: SignalCell<U>)| C) context owner_scope); T: (2..), C: Slot | WhenSome<T, ZipSome<T>, C> |
swap | (source: S, render: (sync |T| C) context owner_scope); T: PartialEq, S: Source<T>, C: Slot | Swap<T, S, C> |
each | (source: S, key: sync |T| K, render: (sync |T| C) context owner_scope); T: PartialEq, K: PartialEq + Hashable, S: Source<List<T>>, C: Slot | Each<T, K, S, C> |
each_values | (source: S, render: (sync |T| C) context owner_scope); T: PartialEq + Hashable, S: Source<List<T>>, C: Slot | EachValues<T, S, C> |
each_by | (source: S, key: sync |T| K, render: (sync |SignalCell<T>| C) context owner_scope); K: PartialEq + Hashable, S: Source<List<T>>, C: Slot | EachBy<T, K, S, C> |
Each returned struct implements Slot, so it fills any child
position — including a {hole} in element syntax. To place one at the
parent’s current end instead, hand it to child: parent.child(when(..)).
The View methods these replaced — when, swap, swap_split, bind_each,
bind_each_values, bind_each_by — were retired; parent.swap(s, r) is now
parent.child(swap(s, r)) or {swap(s, r)} in a child hole:
<ul>
<li>"Header"</li>
{each_values(items, |item: str| <li>{item}</li>)}
{when(more, || <li>"and more"</li>)}
<li>"Footer"</li>
</ul>
The run is named each rather than bind_each: the bind_ prefix means “one
property kept in sync” everywhere else in the module, and a value that IS a
child has no property to bind.
A source that knows what changed is followed by its changes. Placing an
Each asks one more thing of its source, DeltaFeed<T> (std::reactive), and
every Source<List<T>> has it, so nothing that placed before is refused now.
Over a plain SignalCell<List<T>> a change is a
whole-list pass: every key read, a diff, the plan applied. Over a ListCell<T>
or a KeyedCell — any DeltaSource<List<T>, SeqOp<T>> — each,
each_values and each_by apply the recorded ops to the rows instead: one
push into 1,000 rows builds ONE row and reads one key, a removal cuts one row,
and under each an element changed to an equal value costs nothing. Under
each_by an element changed in place under the same key is written into that
row’s cell and nothing is built — and only that row’s cell, where the pass
writes into every surviving row’s. A change that removes rows and puts others
in their place — a set_all, a reconcile_to that edits one element and
appends another — is matched by KEY within the span it covers, exactly as the
pass matches the whole run, so a row whose key survives it is kept and the rows
either side of it are not touched: the op path never builds more for one change
than the pass would. A wholesale set and a move_range fall back to the pass,
and a set that follows other changes in the same turn supersedes them. The server twin keeps its one read (a server render reads
the list once, so it has no change to follow).
A row, a body or a branch can be any Slot. A render closure returns
C: Slot, not View — so a row may be a fragment, a text node, or another
value form, and the run owns whatever the child placed:
<ul>
{each_values(items, |item: str| <>
<li>{item}</li>
<li class("sep") />
</>)}
</ul>
The reconciler moves and removes a row by its SPAN — an empty text marker is
planted before each row’s content, and the row is everything between that
marker and the next boundary — because a row that is itself a when or an
each grows and shrinks after it was placed, and no snapshot of its nodes
could follow that. One marker per row is what the generality costs; a run’s
own region still costs exactly one anchor.
Naming one in a return type. vilan has no trait objects and no opaque-type
kind, so a helper that hands one back spells its concrete type —
fun account(flag: SignalCell<bool>): Conditional<SignalCell<bool>, View>, the
last argument being the row shape the closure yields. The closures are FIELDS,
not type parameters, exactly so that type is nameable.
Ownership: the body of a when, the subtree of a swap and every row of an
each run under a fresh owner established where the value is PLACED — not
where it was built — and that owner is disposed with the instantiation.
Region — how a reactive child keeps its place.
struct Region { parent: Element, anchor: Text, live: Shared<List<View>> }
impl Region {
fun open(parent: View): Region // plant the anchor at the parent's current end
fun insert(self, child: View) // before the anchor — a live child MOVES
fun hold(self, live: List<View>) // the run that is live now, for close
fun close(self) // remove the live run, then the anchor
}
Everything reactive above owns a run of sibling nodes: when’s
instantiation, swap’s subtree, each’s rows, a Source<View> child’s
current view. Each opens a region where it is called — an empty text node
planted at that moment — and inserts its content before that anchor, so the
run keeps its position however the chain grows afterwards. You rarely name
this type; it is documented because the anchor is real, and because a walk
over element.children will meet it.
Disposal takes the region OUT of the document — every live node, then the anchor. That is invisible when the subtree was leaving with its parent, and it is the whole point for a portal, whose container outlives the boundary that filled it: nothing is left to remove by hand.
The anchor is an empty text node rather than a comment deliberately: it
serializes to nothing, so a browser tree and the @process twin’s HTML string
stay byte-comparable. On the process side a Region plants no node at all — a
server render is one pass in source order, so appending already is inserting
before the anchor.
A binding takes a Flow, not a Signal
Every binding above that only READS its argument is generic over
Flow<T> and consumes it (own), so a
Signal, a pipe (count.derive(..)), a sealed memo, a RemoteSource mirror or
a source of your own all drive it (the three each forms take a Source: seal a
derived list with .memo()):
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) }
}
Stored<str> now feeds bind_text, bind_class, bind_attr,
bind_styled, style_var, show, the six slot values (when, when_some, swap,
each, each_values, each_by) and chunk_preload — on both the browser
layer and the SSR twin.
Two things deliberately still ask for the concrete type:
bind_valueandbind_draft, because they WRITE BACK.Sourcedeclaresgetandon_settleand noset, so there is nothing to widen to yet — the write side is its own design question.attrandchild, whose reactive arms are theAttrValueandSlottraits. Widening a trait ARM is a blanket impl rather than a bound on a parameter, and that is a separate piece of machinery — which B158/B165 then built, so<div href(source)>and<p>{source}</p>do take anyFlowtoday — a source or a pipe;AttrValue’sOptionarms (A115) arrived the same way.
std::web::router
fun current_path(): SignalCell<str> // location.pathname, live (navigate + back/forward)
fun navigate(path: str) // pushState + update current_path
fun navigate_replace(path: str) // replaceState — same, WITHOUT a new history entry
fun location_url(): str // pathname + search + hash — the whole relative URL
fun segments(path: str): List<str> // "/w/3/task/7" → ["w", "3", "task", "7"], RAW
fun percent_decode(text: str): str // decodeURIComponent, total (a bad escape decodes to itself)
fun parse_query(query: str): HashMap<str, str> // "a=1&flag" → { "a": "1", "flag": "" }
struct PathParts { segments: List<str>, query: HashMap<str, str>, fragment: Option<str> }
fun parse_path(path: str): PathParts // cut, then decode — the READ direction
trait Routable { fun to_path(self): str } // route → URL
trait FromPath { fun from_segments(parts: List<str>): Self } // URL → route
fun from_path<R: FromPath>(path: str): R // parse_path, then from_segments
fun link<R: Routable>(label: str, route: R): View // a real <a>; intercepts plain left-clicks
impl View {
fun link_to<R: Routable>(self, route: R): View // `link`'s body on an anchor you built
}
// Route chunks (a `split = true` leg) — both are ordinary signals
fun pending(): SignalCell<bool> // a route chunk is in flight
fun chunk_error(): SignalCell<Option<str>> // the last fetch failed, with the reason
navigate pushes a history entry; navigate_replace rewrites the one that
is there. Reach for the replacing form wherever the entry would not be a
place a user meant to go back to — a redirect off a bare path onto its
canonical form, a sign-in bounce landing on the page they asked for, a filter
or tab written into the URL so a reload restores it. Pushing there is what
makes Back walk through the redirect and arrive where it started. Both
advance current_path() identically and both join the caller’s ambient turn;
there is no navigate(path, replace) because vilan has neither overloading
nor default arguments, and a bare true at the call site says less than the
name does.
current_path() is a singleton signal: every caller gets the same one, and
the popstate listener is wired on first use. link renders a real anchor
(middle-click, ctrl-click, and copy-link keep native behavior) and intercepts
only a plain left click, calling prevent_default + navigate. It also sets
draggable="false", which is the one non-native thing about it — see the
gotchas note. View::link_to(route) is that same
body without the <a> and the label, for an app that builds and styles its
own anchor. Route modelling (Routable/FromPath over enums): the
routing guide.
parse_path is the read direction, and it cuts before it decodes. The
fragment is delimited first (a ? after a # is fragment text), then the
query, then the path splits on / — and only then is every piece
percent-decoded, so an escaped separator (%2F, %26) stays inside the
segment or value it belongs to instead of becoming one. Empty segments do not
exist, so "/w/acme", "/w/acme/" and "//w//acme" parse alike and no match
arm has to name the trailing-slash case. A query key with no = is present
with an empty value ("?open" and "?open=" both give { "open": "" }), a
repeated key keeps the last, and query keys and values additionally read +
as a space — a path segment’s + is a literal plus. fragment is None when
the URL has no # at all and Some("") for a bare trailing one, because those
are different URLs.
segments stays raw — it is the splitter, not the reader — so
segments("/a%20b") is ["a%20b"] where parse_path("/a%20b").segments is
["a b"]. Route matching wants the decoded form.
FromPath is Routable’s inverse, and the pair has a law:
from_path(route.to_path()) is route, for every route the app can reach —
one round-trip test per route space instead of a drift nobody notices until a
link 404s. Its member takes segments rather than a path, because that is where
a route space is decided; from_path is a free generic function rather than a
trait default because an associated function has no receiver to inherit a
default through (the compiler says exactly that if you try). It is total: an
unrecognized URL is a route, not an Option.
current_path() is the pathname only, deliberately — a signal that
advanced on every #anchor would re-render the page for a scroll, and a route
whose to_path() prints a pathname would stop comparing equal to the location.
Reach for location_url() where the query matters (on load, and in a
popstate handler) and keep routing on the path.
pending() and chunk_error() describe a split = true leg’s route-chunk
fetches, and are ordinary signals — bind them with show, bind_text or a
class. A failed fetch means the navigation simply did not happen and nothing
is remembered as in flight, so clicking the link again retries; there is no
retry API because a link is one. Worked example:
the dev loop.
std::web::storage
localStorage / sessionStorage, string-keyed strings, in two forms: six
free functions for the site that touches one key, and a Storage handle for
the site whose subject is the store.
// The one-key form. A missing key reads as "".
fun get(key: str): str
fun set(key: str, value: str)
fun remove(key: str)
fun session_get(key: str): str
fun session_set(key: str, value: str)
fun session_remove(key: str)
// The handle.
impl Window {
fun local_storage(self): Storage // window.localStorage
fun session_storage(self): Storage // window.sessionStorage
}
external struct Storage;
impl Storage {
fun len(self): usize
fun key_at(self, index: usize): Option<str> // None past the end
fun keys(self): List<str> // every name, collected
fun get(self, key: str): Option<str> // None when ABSENT
fun has(self, key: str): bool
fun set(self, key: str, value: str)
fun remove(self, key: str)
fun clear(self) // every key on the origin
}
import std::web::storage;
fun main() {
storage::set("token", "abc");
let token = storage::get("token");
if token != "" {
storage::remove("token");
}
}
The free functions are not deprecated by the handle. They are shorter where
only one key is in play, and the flattening "" means nothing has to be
unwrapped. The handle is what a store-shaped job needs — and its get is the
one that can tell an ABSENT key from a key whose stored value is legitimately
"", which !get(key).is_empty() cannot. That difference is not academic: an
“initialize this key if it is unset” pass rewrites a legitimately-empty value on
every run when presence is spelled as non-emptiness.
Counting down is the shape an enumerating sweep wants, because removing a key renumbers everything above it — and the host’s key order is unspecified anyway, so a pass that both reads and removes must not assume it is stable.
keys() is the other half, and the easier one when the pass writes: it
collects every name first, so removing whichever you like in whatever order has
neither problem. It is a snapshot — a removal after the call does not change the
list already in hand — and a missing index is skipped rather than ending the
walk, since len and key_at are two host calls and another tab may remove a
key between them.
import std::web::dom::window;
import std::web::storage;
fun main() {
let store = window().local_storage();
for key in store.keys() {
if !key.starts_with("app.") {
store.remove(key);
}
}
}
The two reader verbs live on Window, so a module that calls them imports
both std::web::dom’s window and std::web::storage — the impl Window block is
declared in std::web::storage, and an impl is in scope only where its module is.
import std::web::dom::window;
import std::io::print;
import std::option::Option::{ self, None, Some };
import std::web::storage;
fun main() {
let store = window().local_storage();
mut index = store.len();
for index > 0 {
match store.key_at(index - 1) {
Some(let key) => {
if !key.starts_with("app.") {
store.remove(key);
}
}
None => print("the store shrank under the walk"),
}
index -= 1;
}
if !store.has("app.theme") {
store.set("app.theme", "");
}
}
Browser-only, with no process twin, deliberately. An SSR leg has no
window and no per-user store, so a twin could only be a stub that touches
window (a crash on the server) or an in-memory map answering reads with
values the browser never wrote — and the second turns a layer mistake into a
silent divergence between the two renders of one component. Importing
std::web::storage from a process module is a cross-platform error at analysis
instead. Server-side persistence is std::db; state that must
reach the browser rides the render or an rpc call.