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.