AkurAI Build
Menu

Public repository / master

AkurAI-Framework / site/content/docs/spa-router.md

Latest change 5097caa5d055c8539c451aeda1036baafb1d495f - OLI-245: hotkeys.js helper + SPA router/IdP return_to docs by Ólafur Búi Ólafsson

# SPA router

The framework ships a tiny client-side router — `spa.js` — that turns ordinary
multi-page navigation into a single-page experience without a build step, a
dependency, or a framework runtime. It's a few KB of plain browser JavaScript in
the same spirit as the rest of the stack: native ES module, modern browser APIs,
zero dependencies.

It is **opt-in and progressive**. A page enhances itself by including the module:

```html
<script type="module" src="/spa.js"></script>
```

With the script absent — or JavaScript disabled — every link is an ordinary
full-page navigation. The router only ever *enhances*; it can never be the reason
a link fails.

## What it does

1. Intercepts same-origin link clicks.
2. `fetch`es the target page's HTML.
3. Parses it with `DOMParser` and swaps the live `<main>` for the fetched
   document's `<main>`, updating `document.title`.
4. Pushes a new history entry and scrolls to the top.
5. Animates the swap with the **View Transitions API** when available.

The header, footer, and any scripts they loaded stay put — only the content
region changes, so navigation feels instant.

## Link interception rules

A click is handled by the router only when *all* of these hold. Anything that
fails a check is left to the browser as a normal navigation:

- **Left click, unmodified.** `Ctrl`/`Cmd`/`Shift`/`Alt`-clicks (open in new
  tab/window, etc.) pass through untouched.
- **Same origin.** Links to other origins are real navigations.
- **HTTP(S) only.** `mailto:`, `tel:`, and other schemes are ignored.
- **Not a new-tab target.** `target="_blank"` (or any non-`_self` target) opts out.
- **Not a download.** `download` links opt out.
- **Not external.** `rel="external"` opts out.
- **Not a hash-only / in-page anchor.** Links to `#section` on the current page
  scroll natively; they are not intercepted.
- **Escape hatch.** Add `data-no-spa` to any link to force a full navigation.

Only `GET`-style navigations are involved — links never carry a body, so there's
nothing else to honor.

## Fetch, swap, history

On an intercepted click the router calls `preventDefault()` and fetches the URL
with `accept: text/html` and same-origin credentials. The response is parsed and
its `<main>` replaces the current one; the `<title>` is copied over. Then
`history.pushState` records the new URL and the window scrolls to the top.

Scroll restoration is set to `manual` so the router controls it: a forward
navigation scrolls to the top, while **back/forward** (`popstate`) lets the
browser restore the previous scroll position. `popstate` re-fetches and swaps the
target page **without** pushing a new entry.

## View Transitions & reduced motion

When the browser supports `document.startViewTransition`, the DOM swap is wrapped
in it for a smooth cross-fade between the old and new content. Where it isn't
supported, the swap happens immediately — same result, no animation.

The router respects `prefers-reduced-motion: reduce`: if the user has asked for
less motion, it skips the View Transition and swaps instantly.

## Progressive enhancement guarantee

The router is additive and fail-safe:

- Without `spa.js`, links are plain navigations.
- If a fetch or parse fails, or the fetched document has no `<main>`, the router
  falls back to a real navigation (`location.assign`, or a reload on `popstate`).
- It claims at most one global, `window.SpaRouter`, and initializes once, guarded.

So the worst case is simply "the browser navigates normally" — never a broken
link.

## The `spa:navigated` hook

After each successful navigation the router dispatches a `spa:navigated`
`CustomEvent` on `document`. Because a swap replaces `<main>`, any scripts that
wired up behavior inside it should re-initialize on this event:

```js
document.addEventListener("spa:navigated", (e) => {
  // e.detail = { url, mode }   mode is "push" or "pop"
  initMyWidgets(); // re-bind anything that lived inside the swapped <main>
});
```

`mode` is `"push"` for a clicked link and `"pop"` for back/forward navigation.

## API

The module auto-initializes on load. It also exposes a minimal handle:

- `SpaRouter.navigate(url)` — navigate programmatically (pushes history).
- `SpaRouter.start()` — idempotent; the module calls it for you.

There's a live demo at [`/spa`](/spa).

## Auth redirects and the IdP `return_to` flow

Protected pages and the SPA router interact through two paths. Treat them
differently.

**API calls (`fetch` to JSON endpoints) — redirect in your code.** On a `401`,
send the browser to the IdP login with the current location as `return_to`, using
a real navigation (never `SpaRouter.navigate`, which would swap the login page
into `<main>`):

```js
const loginUrl = (returnTo) => `/auth/login?return_to=${encodeURIComponent(returnTo)}`;
if (res.status === 401) window.location.assign(loginUrl(location.pathname + location.search));
```

The IdP's `safe_return_to` accepts only a same-origin relative path (`/x`, not
`//x`) or an absolute URL on its own `base_url` origin; anything else becomes `/`.
After login it redirects back to `return_to`.

**Link clicks to a protected page — the router follows redirects.** The router
`fetch`es with `redirect: "follow"`:

- A redirect to another origin fails the fetch (CORS) and falls back to a real
  navigation, so the browser lands on the login page normally.
- A redirect to a same-origin login page succeeds, and the router swaps that
  page's `<main>` in but pushes the *original* `href` into history. Avoid this:
  mark links to auth-gated pages `data-no-spa`, or have the gated page's server
  answer with a non-HTML/error status so the router hard-navigates.

Rule of thumb: anything that may end at the IdP is a full navigation.

## Keyboard shortcuts: `hotkeys.js`

`/hotkeys.js` is a dependency-free helper for page shortcuts with focus scoping:

```js
import { createHotkeys } from "/hotkeys.js";
const hk = createHotkeys({
  window,
  keymap: {
    global: { "?": "help", Escape: "close", c: "compose" },
    selection: { e: "archive" },                       // only when hasSelection()
    list: { scope: "[data-list]", keys: { j: "next" } } // only focus inside [data-list]
  },
  hasSelection: () => selected !== null,
  preventDefault: ["help", "next"],
  onAction: (action, event) => run(action),
});
hk.setEnabled(false); // silences all but Escape; hk.destroy() unbinds
```

Chords with Ctrl/Meta/Alt are ignored, and typing targets (inputs, textareas,
selects, contenteditable) are skipped — `Escape` there resolves to `closeTyping`
so you can blur. Because a SPA swap replaces `<main>`, bind once on `window` (it
survives swaps) rather than per page.