← All posts

Rux v0.6.0: apps bigger than one screen

Components can take children, hold their own state and report back. There is a router, with parameters, named routes, query strings, deep links and scroll restoration. Lists have keys. Values can be computed and effects can run. Plus the bug a test suite was green through for three weeks.

Everything Rux could express before this release fit in one file and one screen. A component could be given props but could not take children, could not hold state of its own, and had no way to tell its caller that anything had happened. There was no router, though the spec had promised one since v0.1. A list had no identity, so reordering it rebuilt more than it needed to and put the caret in the wrong row. v0.6.0 is the set of things you hit the moment one screen stops being enough.

What landed

Components are real now. A <slot /> renders whatever the caller wrote between the tags, in the caller's scope and the caller's stylesheet, so a card, a panel or a modal is finally expressible: before this, children written between the tags were silently thrown away, which made every component a fixed shape. A component's <script> runs once per instance, so three <counter> elements are three counts. And emit("change", payload) with @change="…" on the tag closes the loop the other way, so state can stay in the component that owns it instead of being hoisted into the document so the document can watch it change.

There is a router. A <router> renders the one <route> whose path matches, and a route maps a path to a component, so a page is a component like any other. Parameters come out of :id segments. Links are to="/path", which also match :current, so a nav bar shows where you are with a CSS rule and no JavaScript-shaped ceremony. navigate, replace, back and forward are callable from any handler, Alt+Left and the mouse's side buttons walk the history, and can_go_back and can_go_forward are signals so a Back button can grey itself out.

The path is an ordinary signal called route, and that is the whole design: {{ route }}, r-if, :class and the change diff already understood navigation before the router existed. Query strings are read through a query map and deliberately kept out of route, so route == "/search" still means what it says. Named routes build their paths with path_for("crew-detail", #{ id: member.id }), which returns a string, so it works anywhere a path works rather than needing a second form of navigate.

An app can open on a page other than its first one, which is what a shared link arrives as: rux run app.rux --route /crew/grace on the desktop, and on the web the URL bar becomes the app's address bar if the host page hands it over. Going back restores where you had scrolled to, and opening a page fresh does not, which is the distinction every platform makes and the reason the flag is called restore-scroll rather than something that sounds like a preference.

Lists have identity. r-key stamps a row so a reorder moves rows instead of rebuilding them. Values can be computed, with computed total = a + b;, and effects can run, with effect { … }, subscribing to whatever they actually read. A document can include an external stylesheet with <style src="palette.css">, so a shared palette no longer has to be pasted into every file. And the ten library crates have READMEs, which they shipped without on purpose at v0.5 and should not have shipped without twice.

What it cost

The lesson worth carrying out of this release is about identity. r-model is recorded as written, so every row of an r-for carries the same one. Three separate bugs came out of that single fact: the caret appeared in every row of a list at once, :focus lit every row, and typing into a row did nothing at all. They looked like three bugs and were one. An input is now identified by (model, row key) everywhere, threaded from the cascade through the layout to the shell, and the reorder case falls out of it for free, because the identity is the row rather than the position and nothing has to be remapped afterwards.

Two smaller things found on the way, both invisible to the test suite. Writing a path never worked: r-model="user.name" created a variable called user.name and left user untouched, in or out of a list. And rhai's for row in list iterates copies, so row.field = x inside such a loop changes nothing, which cost real time in an example that read as correct.

The scripting constraint shaped the component event design more than any preference did. No stock-rhai callable can mutate a signal, so emit could not run the caller's code itself; it records an emission that the runtime drains. And a listener is @event="body" on the tag rather than a prop, because a prop is evaluated on every build, and a statement that ran once per build would be the exact opposite of an event.

The bug you only find by looking

Three of them this time, and the third is the one worth the space.

A box with max-width was sized for one line and drew three outside its own border. Two faults, both the same mistake: something was asked about a width the box could never have. Text answered min-content with its single-line width, when min-content for text is the longest word. And taffy sizes a capped box from its uncapped content and never revisits the height. Padding on <text> was the second: glyphs ignored it, and the second copy of that arithmetic lived in the focus region the caret is resolved against, so the caret agreed with the painted text only because both were wrong in the same direction.

The third was found on the last day, driving the router in the window. A focused row scrolled up out of a list drew its focus ring across the paragraph above the list. The ring is built as its own scene and appended after the document's, so it never passed through the clip the scroller puts around its children. Nothing was wrong with the clip; the ring was simply never inside it.

None of these had a failing test. All three were obvious within seconds of looking at the window, which is the only reason any of them were found.

And one that nothing was watching at all. The web build had not compiled since a commit three weeks earlier. The suite never builds for wasm32, so it was green throughout, and the only workflow in the repository deploys the site from main, so nothing ran on the branch the work was happening on. It surfaced because this release's browser checks needed a wasm build, and there wasn't one. There is now a CI job that runs on every push to every branch, and it runs the same gates a release runs.

What this release is not

The router does not work on the web yet. A route's view is a component, a component is loaded from a file, and a browser has no filesystem, so every <route> warns that its view is not imported and the router renders nothing. The URL half is built and verified, and route is an ordinary signal, so an r-if on it does work today. What is missing is bundling components into a web build, which is what rux build is for, and rux build is not in this release. Treat the router as desktop-only.

computed and effect are stripped inside a component, so they are document-level only. There are no nested routes and no navigation guards. A component that leaves the screen now loses its state, which is a change: it used to keep it for the life of the process and hand it back, along with growing the instance map forever.

There is still no animation of any kind, not a transition, not a fade, nothing. That was an unplanned gap rather than a deferred decision: the word appeared exactly once in the entire documentation set. It now has a milestone.

And mobile remains what it has been since the beginning: a promise in the README with no code behind it.

Getting it

cargo install ruxlang
rux examples/router.rux

304 tests, and every example driven in the window rather than only asserted against.