xoong
Menu · Router
DocsArchitectureRouter

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

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.

The steps of one navigation A navigation starts from a link click, Back or Forward, or router.navigate(). The steps run down the left: the same-place check, the vetoes (the beforeNavigate hooks and the built-in question when a form has unsaved changes), the guards (each returns true, false, or a path to redirect to), the history, the render, and what happens after. A veto or a guard that returns false stops here: the URL is put back, and a Back or Forward is undone with history.go. On the right, at the top, is the code you write: an xo-router with one route template. At the bottom right is the screen the person sees: a window with an address bar, a navigation that stays and a content area that changes. The code window shows index.html: <xo-router> <template xo-route="/users/:id" title="User"> <xo-view get="getUser" p-id=":id">...</xo-view> </template> </xo-router>. Steps: 1. You click a link to /users/3 (or press Back, or the code calls router.navigate()). The router checks whether it is already at that place, same path and query. It is not, so it goes on. 2. The vetoes run: the beforeNavigate() hooks, and the built-in question when a form has unsaved changes. None of them says stop. 3. The guards run. Each returns true, false, or a path to go to instead. Here all of them say true. 4. The router writes the history with pushState (or replaceState, with a position). 5. The address bar of the browser now shows /users/3. 6. The layout stays. The router clones the template of the route /users/:id into the outlet. 7. The new content is on the screen. The navigation stayed; only the content changed. Its views start and fetch now. 8. After the render, the router sets the title and the description, marks the current link, moves the focus and the scroll. 9. The screen: the window shows the new title, and the focus is on the new content. The xo-navigate event fires. When the animation plays, 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. Triggerlink, Back/Forward, router.navigate() Same place?same path and query: nothing to do VetoesbeforeNavigate() hooks;a form with unsaved changes asks first Guardsguard() hooks: true, false,or a path to redirect to HistorypushState or replaceState, with a position Renderkeep shared layouts, clone the route template Aftertitle, description, aria-current, focus,scroll, then the xo-navigate event StayURL put back;Back/Forward undonewith history.go() 1 2 3 4 5 URL 6 7 draws 8 9 focus false false

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.

  1. Same place. The same path and query as now does nothing (navigate() resolves false). If only the hash differs, there is no render: the page scrolls to the element with that id.
  2. 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 first false stops the navigation. They are skipped on the first load.
  3. Guards. The guard() hooks may be async. false cancels. 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.
  4. 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.state and puts the address back with history.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.
  5. History. pushState for a link or navigate(), replaceState for a redirect, the first load and { replace: true }. The scroll position of the entry being left is saved, and history.scrollRestoration is set to manual.
  6. Render, next section.
  7. After. The route's title becomes document.title and its description the description meta tag. Every <a href> on the page gets aria-current="page" when it points at this path, or aria-current="true" when it points at a parent section, unless the link, its nav, or the router has xo-current. Except on the first load, the focus moves to the outlet (it is given tabindex="-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-navigate fires 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

The elements the router builds Nested frames. The outer frame is the xo-router element, whose templates stay inert. Inside it is a div with class xo-router-root. Inside that is a div with class xo-layout, which is the layout named shell and stays while only the route changes. The layout holds a nav on the left, and on the right the element marked xo-outlet. In the outlet is the route content, cloned from the route template, where views start and fetch. xo-router: its templates stay inert div.xo-router-root div.xo-layout (shell): stays between routes navlinks witharia-current main[xo-outlet] Route contentcloned from template[xo-route]views start and fetch now

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).

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

Not yet: nested routers, route-level data loading hooks, per-route code splitting, and relative paths in navigate().

Where to look in the code