Routing
Routes, parameters, named routes, links, and the fact that the path is an ordinary signal.
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:
<router>
<route path="/" view="home-page" />
<route path="/crew" view="crew-list" :crew="crew" />
<route path="/crew/:id" view="crew-detail" :crew="crew" />
<route fallback view="lost-page" />
</router>
Like <slot>, a router leaves no box of its own behind: the matched view
expands in its place. Routes are tried in the order written and the first match
wins, so a fallback can sit anywhere among them. A path nothing matches and no
fallback catches renders nothing, and warns.
The path is an ordinary signal called route. That is the whole design:
{{ route }}, r-if="route == \"/about\"" and :class already understand
navigation, and a route change reconciles the router's subtree rather than
rebuilding the document.
Parameters. A :name segment matches anything and is handed to the view as
a prop, so /crew/grace reaches crew-detail with id set to "grace". A
match must account for the whole path, not just its front, or / would match
everything. A trailing slash is not a difference.
Nested routes. A <route> may contain <route> children, and the parent's
view places a <router-view /> where they render:
<router>
<route path="/" view="home-page" />
<route path="/crew" view="crew-list">
<route path="" view="crew-empty" />
<route name="crew-detail" path=":id" view="crew-detail" />
</route>
<route fallback view="lost-page" />
</router>
A child path is relative unless it begins with /, so a section can be moved
by editing one line. path="" is the index route: it fills the outlet at the
parent's own path, and without one /crew renders the list with an empty outlet
rather than an error. <router-view /> leaves no box of its own, like <slot>.
The parent stays mounted while the child changes under it, so a list keeps its state and its scroll position as you move between the things it lists.
Parameters are merged down the chain: a child view sees what its parent
captured, and the params signal outside the router sees what a child captured.
A name resolves to its full path, built from its ancestors, so
path_for("crew-detail", #{ id: "grace" }) returns /crew/grace from a name
written on the child.
A path that matches a parent but nothing under it is not a half match: the whole
branch fails and the next sibling is tried, ending at the fallback. That is why
/crew/grace/extra lands on lost-page rather than on the crew list.
Two mistakes are reported rather than rendered as silence: a route with children
whose view never places a <router-view />, and a <router-view /> in something
that is not a route's view.
Links. to="/path" makes an element tap to that path, announce as a link
rather than a button, and match :current when it names the path you are on,
which is how a nav bar shows where you are:
.tab:current { background: #89b4fa; color: #11111b; }
:to="…" is the computed form, for a list whose every row links somewhere
different (:to=""/crew/" + member.id"). An explicit @tap wins over
both, so a link can still do something else on the way.
Parameters are also readable from outside the matched view, as params:
<text r-if="params.id != ()">viewing: {{ params.id }}</text>
The view gets them as props, which is enough for the view. It is not enough for
a title bar or a breadcrumb, which sit in the document's own layout and are not
the matched view. params empties when a route captures nothing, rather than
keeping the last page's answer.
History. navigate("/path"), replace("/path"), back() and forward()
are callable from any handler. History is one list with a cursor, so going back
and then somewhere new drops what was ahead. Navigating to where you already are
is not a visit, or tapping the current tab would fill the history with repeats.
On the desktop, Alt+Left / Alt+Right and the mouse's side buttons walk it.
replace goes somewhere instead of where you are, overwriting the current
entry, and it is what a redirect needs rather than a nicety. Redirect with
navigate and the redirecting page stays in the history, so Back lands on it
and is redirected forward again: the Back button appears broken and nothing in
userland can fix it.
can_go_back and can_go_forward are signals, so a history button can grey
itself out:
<view class="step" :class="#{ dead: !can_go_back }" @tap="back()">
Signals rather than functions because what they are for is disabling a control, and disabling a control is a class, and a class reads signals.
Query strings are read through a query map, and are not part of the path:
<text>looking for {{ query.q }}</text> <!-- /search?q=dark+mode -->
route stays /search, so every route == "/search" already written keeps
meaning what it says. A query is an argument to a page rather than a different
page, so it takes no part in matching either. The history stores the whole
address, so going back to a search restores what was being searched for. + is
a space and %xx is decoded; a key with no = is present and empty; a repeated
key keeps the first.
Named routes. A path is written into every link that leads to it, so a URL
scheme that can never be changed afterwards is not much of a scheme. Name a
route and build its path with path_for:
<route name="crew-detail" path="/crew/:id" view="crew-detail" />
...
<view :to="path_for("crew-detail", #{ id: member.id })">
It returns a string, so it composes with to, :to, navigate and
replace rather than needing a second form of each. Values matching a :name
segment fill it; whatever is left over becomes a query string, which is what
makes path_for("search", #{ q: "rust" }) work for a route with no parameters
at all. Values are escaped on the way in and unescaped on the way out, so an id
containing a / survives the round trip. A missing parameter or an unknown name
warns, and produces a path that visibly does not work: landing on the fallback
page is a bug you can see, and landing on the wrong record is not.
route, params, query, can_go_back and can_go_forward are all provided,
and all reserved: a script declaring one is warned rather than quietly
overwritten.
A route's view starts fresh when you return to it. Instance state is keyed by
template position, so keeping it across a visit is what would happen by
accident; anything meant to outlive a visit belongs in a document signal. Driven
in examples/router.rux.
An app can open on a page other than its first one, which is what a link someone shared arrives as. On the desktop that is a flag:
rux run app.rux --route /crew/grace
The arrival page is the first page, not the second: there is no / behind it,
because no one visited one, so Back has nowhere to go. Saving the file while a
page other than / is showing now reloads onto that page instead of jumping
home, so an edit to a page three taps in can actually be seen.
On the web the URL bar is the app's address bar, if the page hands it over:
start(canvas, source, "/"); // served at the root of a domain
start(canvas, source, "/app/"); // served from a subdirectory
start(canvas, source); // leave the URL alone
The base is subtracted from the URL, so an app is written the same way wherever
it is deployed: the route is /crew, the URL is /app/crew. With a base given,
opening a URL opens that route, navigating adds a history entry, and the
browser's own Back and Forward walk the app, including a long-press that jumps
several entries at once. Each entry carries its position in the history, which
is what makes a multi-entry jump one move rather than a guess about direction.
Passing no base leaves the URL untouched, and that is the default on purpose:
the playground runs documents written by whoever is typing into them, and one of
them containing a <router> must not be able to rewrite the address of the page
hosting it.
A
<router>cannot render a route view on the web yet. A route's view is a component, a component is loaded from a file, and a browser has no filesystem: the web entry point is handed no components at all, so every<route>warns that its view is not imported and the router renders nothing. The URL half above is built and works, androuteis an ordinary signal, sor-if="route == "/about""does work on the web today. What is missing is the bundling of components into a web build, which is whatrux buildis for. Until then, treat the router as desktop-only.
Scroll restoration is on, and <router restore-scroll="false"> turns it off.
The flag means remember, not always restore: a page you open starts at the
top, and a page you go back to comes back where you left it. Which of the
two you get is decided by how you arrived rather than by a preference, which is
what every platform does. A flag meaning "always restore" would drop you into
the middle of a page you had just opened for the first time, which reads as a
bug. Turned off, every arrival is the top. A redirect through replace is an
arrival, not a return, so it lands at the top too.
Offsets are stored on the history entry, not on the route. A scroll region is identified by its position among the scrolling boxes in tree order, so those ids only line up when the tree has the same shape, and an entry is always one route: by the time the offsets are read back, the shape is the one they were recorded against.
Route guards. guard="expr" on a <router> runs on every navigation; on a
<route> it runs whenever that route is part of what matched, so a guard on a
section covers every page inside it without being written on each one. Outermost
first: a section's guard is the coarser question, and answering it second would
mean running the finer one for a place you were never going to reach.
The answers are vue-router's: false cancels, a string redirects to that
path, and anything else allows. Anything else includes (), which is what
a guard body with no explicit answer evaluates to, so the usual shape is object
or say nothing:
<route path="/sent" view="outbox" guard="gate()" />fn gate() {
if !signed_in {
return "/login";
}
}
to and from are in scope, along with whatever parameters the guard's own
level captured, so a guard on /crew/:id can read id and decide about that
member rather than only about the section.
A guard runs before the history moves, which is the whole reason it is here
rather than in a page: a refused navigation leaves no entry behind and opens no
route transition, and by the time a page could refuse to render itself both have
already happened. It follows that Back, Forward and a deep link go through
guards too. A guard written on navigate alone would protect nothing, since
Back reaches the same page without passing it, and Back is how anyone leaves a
login screen.
A guard that redirects to the path it was asked about has allowed it. A circle of
redirects is cut off after eight and reported, the same bound and the same reason
as an emit chain.
A guard that fails to evaluate refuses, and this is the one place in Rux
where a failing expression does not fall back to something harmless. Everywhere
else the document carries on with a benign default: an r-if goes false, a
{{ }} goes empty. A guard has no benign default, because its two answers are
"let them in" and "do not", and the reason anyone writes one is the second.
guard="user.is_admin" with user still loading is syntactically fine, so the
load-time check passes; before this it admitted everybody, warned into the
overlay, and looked exactly like a working app. The cost is that the route is
unreachable until the guard is fixed, and the overlay names the expression and
the reason.
Guards are synchronous. There are no promises in the script language, so a guard cannot await a network answer; it decides from state that is already there. Fetch first, then navigate.
A guard is compiled at load, on the same terms as a @tap handler, so a syntax
error in one is reported without anyone having to navigate. It hides longer than
a handler otherwise would: nobody taps a guard, so a broken one is found by
whoever navigates, and what they see is a link that does nothing.