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

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

MethodSignature (self elided)Notes
text(content: str): Viewstatic text
class(name: str): Viewstatic class
styled(style: Style): Viewclasses from a compiled style
attr(name: str, value: V): View; V: AttrValuestr 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): Viewhandler runs in a fresh turn
on_event(event: str, handler: (|Event| void) context turn_scope): Viewsame, with the DOM event
child(content: C): View; C: Slotthe 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>): Viewappend 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: AttrBindingreactive 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>): Viewtwo-way input bind — concrete Signal: it writes back
bind_draft(draft: Draft<str>): Viewlocal-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): Viewrun action with this element once it is in the document
autofocus(): Viewfocus 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): Viewmake 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):

functionsignaturereturns
when(condition: S, body: (sync || C) context owner_scope); S: Source<bool>, C: SlotConditional<S, C>
when_some(source: S, render: (sync |SignalCell<T>| C) context owner_scope); S: Source<Option<T>>, C: SlotWhenSome<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: SlotWhenSome<T, ZipSome<T>, C>
swap(source: S, render: (sync |T| C) context owner_scope); T: PartialEq, S: Source<T>, C: SlotSwap<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: SlotEach<T, K, S, C>
each_values(source: S, render: (sync |T| C) context owner_scope); T: PartialEq + Hashable, S: Source<List<T>>, C: SlotEachValues<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: SlotEachBy<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_value and bind_draft, because they WRITE BACK. Source declares get and on_settle and no set, so there is nothing to widen to yet — the write side is its own design question.
  • attr and child, whose reactive arms are the AttrValue and Slot traits. 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 any Flow today — a source or a pipe; AttrValue’s Option arms (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.