Router and page lifecycle
<xo-router> is a History-API router written in markup. Routes and layouts are templates that stay inert until they match, so a view inside a route starts, and fetches, only when its route is shown.
Public sites can skip all of this: one HTML file per page needs no router, and prerenders well. The router is for apps with a persistent shell, such as an admin (decision record 0008).
Routes are templates
<xo-router base="/admin" base-links>
<template xo-layout="shell">
<nav><a href="/users">Users</a></nav>
<main xo-outlet></main>
</template>
<template xo-route="/users" layout="shell" title="Users">...</template>
<template xo-route="/users/:id" layout="shell" title="User" description="One user">
<xo-view get="getUser" p-id=":id">...</xo-view>
</template>
<template xo-route="*">Not found</template>
</xo-router>import { router } from '@xoong/ui';
import { configure } from '@xoong/data';
// after the router has started (it starts once the page is parsed)
router.guard(async (to, from) => ((await loggedIn()) ? true : '/login')); // true, false, or a path to go to instead
router.beforeNavigate((to, from) => !editing || window.confirm('Leave?')); // false: stay where you are
await router.navigate('/users/3', { replace: true }); // resolves true when it happened
router.current; // { path, params, query, url, route }
configure({ api, onUnauthorized: () => router.navigate('/login') }); // a 401 sends the person to log in- When the router starts (once the page is parsed) it reads its direct child
<template>elements:xo-layout="name"declares a layout,xo-route="/path"a route. A route may name alayout, and a layout may name an outerlayout. - A route path has static segments and
:params.*matches anything and is the not-found route. When several match, the most specific wins: static segments rank above parameters, and parameters above*. A trailing slash is ignored, parameters are URL-decoded, and a malformed escape such as%zzis kept as written instead of throwing. - A location is
{ path, params, query, url, route }:pathis without thebase,urlis as the address bar shows it. xoong checkreports a route without a path or declared twice (XO260), an undeclared layout (XO261), a layout withoutxo-outlet(XO262), and ap-x=":name"that the route does not have (XO263).
The steps of a navigation
Four things start a navigation: the first load (which replaces the current history entry), a click on a link, Back or Forward, and router.navigate(path). A click is taken only when it is a plain left click on an <a href> that goes to the same origin and inside base, has no target (other than _self), no download and no rel="external", and is not a jump to an anchor of the current page. Everything else stays the browser's.
One navigation. A newer navigation that starts while a veto or guard is still waiting takes over, and the older one stops. In motion, a dot goes down the steps in order, the way a navigation that nothing stops goes, with side trips to the screen: the address changes, the content changes, and the title and focus are set. The two ways out, to Stay, are not played. Use the slider, or press a number, to look at one step.
- Same place. The same path and query as now does nothing (
navigate()resolvesfalse). If only the hash differs, there is no render: the page scrolls to the element with that id. - Vetoes. The
beforeNavigate()hooks run in the order they were added. The router adds one of its own at start, which asks first when a form has unsaved changes. The firstfalsestops the navigation. They are skipped on the first load. - Guards. The
guard()hooks may be async.falsecancels. A string is a redirect: a new navigation to that path that replaces the entry, up to ten in a row, after which the router throws "too many redirects". Guards also run on the first load, so a login redirect works for a deep link. - Cancelled or vetoed. For Back and Forward the address bar has already moved, and the platform cannot stop that. The router stores a position in
history.stateand puts the address back withhistory.go(-delta), so the history stays as it was, forward entries included. Where the position is unknown (an entry something else created) it replaces that entry, so the forward entries stay. - History.
pushStatefor a link ornavigate(),replaceStatefor a redirect, the first load and{ replace: true }. The scroll position of the entry being left is saved, andhistory.scrollRestorationis set tomanual. - Render, next section.
- After. The route's
titlebecomesdocument.titleand itsdescriptionthe description meta tag. Every<a href>on the page getsaria-current="page"when it points at this path, oraria-current="true"when it points at a parent section, unless the link, its nav, or the router hasxo-current. Except on the first load, the focus moves to the outlet (it is giventabindex="-1"), and the page scrolls: to the top for a new page, to the anchor if there is one, to the saved position on Back or Forward. Last,xo-navigatefires with{ to, from }.
A navigation that starts while an earlier one is still waiting in a veto or guard takes over; the older one stops quietly.
An empty route or layout template with src is fetched before that route is shown. A link or navigate() writes the URL only after the fetch succeeds, so a failure leaves the address bar and the screen where they were. On Back or Forward the browser has already moved the URL, so a failed fetch undoes that move with history.go(-delta): the URL, the screen and the stored history position stay on the page that was showing, one xo-error fires, and the next visit fetches again. On the first load the address bar is the document the browser opened: a failed src leaves that URL, does not render the route, fires one xo-error, and is fetched again on the next visit. A newer navigation does not restore, render, or report the fetch it replaced (decision record 0011).
Layouts and rendering
Between two routes that use the same layout, only the content of the outlet is replaced; the layout and its nav stay.
For a route, the router builds the chain of layouts, outermost first. It keeps the layouts that the previous route also had, and replaces the rest. Each layout is a div.xo-layout filled from its template; its xo-outlet element is where the next layout, or the route, goes. The route's template is cloned into the innermost outlet with replaceChildren, which is the moment views inside it start and fetch. Leaving a route removes its content, which stops its views: their effects are disposed and their queries released (a request still running is cancelled).
Content that xoong render wrote into the router earlier is removed at start and drawn again, so a prerendered page and the live page end up the same.
Because the cache outlives the views, coming back to a list shows its data at once if the query is still held (gcTime, five minutes by default) and refetches it when it is older than staleTime (30 seconds by default).
Base, links and history
base="/admin"is the part of the address that is not the route. Routes are written without it.base-linksmakes a root-relative link inside the router relative to the base:href="/users"means/admin/users, so the same screens can be mounted under any base. After each navigation thehrefis rewritten to the real address, so hover and "open in a new tab" show it; a link added later is resolved on click.rel="external"or atargetleave the app.- The server must answer every path under
basewith the app's HTML (xoong dev --spa, or"spa": trueinxoong.config.json).
The unsaved-changes guard
A form that has a form model announces itself: it fires xo-dirty with { dirty } each time that changes (see Forms flow). The router listens at the document and keeps the set of dirty forms (forms that are no longer in the page are dropped). The built-in veto asks a question when the set is not empty. The question is the form's own xo-guard text, else "You have unsaved changes. Leave this page?" (the key xoong.guard.leave). xo-guard="off" on a form leaves it out. Closing the tab asks through beforeunload.
How the question looks is not the router's business. It calls the confirm function that @xoong/data/view holds: the browser's confirm() by default, a native modal <dialog> once @xoong/ui is imported, or your own after setConfirm(fn). The same function answers xo-confirm.
How routes and views meet
- Route params. The router tells
<xo-view>how to read them (setParamSource).p-id=":id"takesidfrom the current location when the view starts. A view that starts before the router has (the order elements were defined) waits until the router is ready. - Query params.
p-id="?id"reads the page URL. A view withurl-params="status,q"keeps those params in the query string, and the router carries the write (setUrlSync): it updates the address, adds a history entry for a click and replaces it for typing, and does not render the route again. Back and Forward over such a change do render the route again, and the new views read their params from the URL. - Authentication. A 401 is the data layer's to detect and the router's to answer:
configure({ onUnauthorized: () => router.navigate("/login") }). Guards are plain functions, not markup, so there is one way to write one. - Prerendering.
xoong render page.html --url /users/7renders the route of that URL, with its title and description. See Build and delivery.
Not yet: nested routers, route-level data loading hooks, per-route code splitting, and relative paths in navigate().
Where to look in the code
packages/ui/src/router.js: the whole routerpackages/data/src/view.js:setParamSource,setConfirm,p-*params,url-paramspackages/data/src/url-state.js: the query-string hook the router replacespackages/cli/src/check.js: XO260 to XO263docs/admin.md: the router in use, hosting and SEO