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.