# The client-side router > The router now ships from `@eamonxg/luci-theme-devkit` (`runtime/router.js`, > page-scoped patches in `runtime/patches.js`), built into > `resources/router-shadcn.js` by its Vite plugin; this theme keeps only the > markers, the `luci-navigate` listener in `menu-shadcn.js` and the CSS. The > design below is unchanged; the devkit copy is the maintained one. How the theme turns a menu click into an in-document view swap instead of a full page load, where it deliberately does not, and the invariants a router inside LuCI has to keep. Source: `.dev/src/resource/router-shadcn.js`, loaded from `footer.ut` after `menu-shadcn.js`. **No changes to luci-base or to any view** — the router is additive theme JS plus small template hooks (a patch manifest, `data-shadcn-*` markers on the stylesheets header.ut itself renders, the asset version, a focusable `#maincontent`). Ported from `luci-theme-aurora`'s `router-aurora.js` and kept in step with it (currently its `58d6498`, 2026-08-17 — same-URL reloads handed to the server, the Turbo-shaped progress bar, no focus ring on the landmark); the kernel, resolver, teardown and gates are the same code, and the theme-specific parts — hostname/title format, the sidebar sync in `menu-shadcn.js`, `#maincontent` being the scroller — are the only divergences, each called out below. ## At a glance ![One LuCI navigation: what the device does, what the browser does, and which of those steps the same-document router deletes](https://raw.githubusercontent.com/eamonxg/assets/master/shared/architecture/same-document-router-architecture.svg) Two swimlanes over a shared millisecond axis — what the OpenWrt device does, what the browser does — then a ledger of which steps the router deletes, which stay on the device, and which the browser takes over, including the teardown it now owes because the document no longer dies. **The figures in it were measured with `luci-theme-aurora` deployed**, on the shared code path (see "Why it pays" below). Two of them are aurora's alone and do not transfer: the `header.ut` cost (1 ubus · 2 uci · 1 lsdir · 2 readfile — shadcn's header.ut makes 1 ubus and 1 `lsdir`) and the static `main.css` row (191,899 B — shadcn's is 146,992 B). Everything else is dispatcher and luci-base work, identical under either theme. ## Prior art [luci-theme-footstrap](https://github.com/VizzleTF/luci-theme-footstrap) (credited in the README) solves the same problem, and reading it informed two pieces here: pausing `L.Poll` on a hidden tab, and folding a view's read-only state along its dispatch path (both below). The rest is independent, and one choice diverges deliberately. footstrap drives navigation through the **History API** (`pushState`/`popstate`) with its own scroll bookkeeping and a `prototype.render` guard to repair stale renders; this router is built on the **Navigation API** instead (see "Kernel"), which hands scroll, history and supersession to the browser and needs none of that — at the cost of running only on newer browsers, where the theme falls back to the plain MPA it already is. On top of that shared base this router also adds a session-expiry gate, reproduces `template` pages from the server's own shell rather than hand-porting them, and cross-fades the swap with a view transition — each its own section below. ## Why it pays, measured Numbers below were taken with the aurora theme's router on the same code path (`bench-fullload.mjs` / `bench-dispatch.sh`, 2026-08-18); the shadcn port shares the kernel, the resolver and the view render, so the shape holds and the ratio is the point. Device: **Cudy TR3000** (mediatek/filogic, ARMv8), OpenWrt SNAPSHOT r0-20d94d5, plain HTTP, warm cache, RUNS=10, medians over the 8 pages below. Run-to-run spread is ±40 ms on a full load. Where a full load's time goes: | stage | ms | what it is | | ----------------------------------------- | ------: | ------------------------------------------------------ | | dispatch #1 — the page HTML | 0→123 | TTFB 118: menu tree, ACL fold, `view.ut` → `header.ut` | | dispatch #2 — `admin/translations/` | 124→209 | a _second_ CGI process, parser-blocking, uncacheable | | DOMContentLoaded | 215 | the shell is back, byte-identical to the one discarded | | view module + ubus data + render | 215→321 | static assets are already cache hits | **209 of the 321 ms passes before anything page-specific has happened.** Both dispatches re-derive a shell the browser already had on screen; the view's own ubus calls do not start until 227 ms. A same-document swap deletes both dispatches and keeps the last row — the same 8 pages land at a median of **91 ms** warm, with the data calls starting at 2 ms instead of 227 ms. The dispatch cost is the dispatcher's, not the theme's: measured on the device over loopback, a `view` node's HTML is 75.4 ms, `admin/translations/en` is 62.7 ms for a **13-byte** body, and a 191,899-byte static file is served in 0.8 ms. The cost is the dispatch, not the payload — and a full page load pays it twice. End to end, click → view painted: | page | full load | router (warm) | faster | | ----------------------- | --------: | ------------: | -----: | | status/routesj | 326 | 92 | 72 % | | status/nftables | 316 | 90 | 72 % | | status/logs | 281 | 100 | 64 % | | status/processes | 457 | 228 | 50 % | | status/channel_analysis | 401 | 54 | 87 % | | status/realtime | 211 | 37 | 82 % | | system/system | 496 | 132 | 73 % | | system/admin | 231 | 40 | 83 % | Median **73 % faster**, range 50–87 %. `bench-router.mjs timing`, an independent harness, was run twice the same day and landed at 72 % and 74.5 % — all of that is inside the device's own spread, so treat the range, not the digit, as the result. Document prefetch cannot reach it: it hides the first dispatch at best, and the catalog is a subresource fetched after the document arrives. ## Why it is possible For a `view` node the dispatcher renders `view.ut`: the theme header, then `
` with an inline `L.require('ui').then(ui => ui.instantiateView(''))`, then the theme footer. The server decides _which_ view; the client renders it. The router repeats what `view.ut` does without the reload: resolve the path against the menu tree the client already holds (`ui.menu.load()` serves it from `sessionStorage`), swap the content region, re-instantiate the view, and let the browser own the URL. ## Kernel: the Navigation API, and only that `navigation.addEventListener('navigate', …)` + `event.intercept()`. One event covers link clicks, `location.assign`, back/forward traversals to same-document entries, and our own `navigation.navigate()`; the browser writes the URL and history entry, exposes `event.signal` for supersession, and (with `scroll: 'after-transition'`) restores window scroll on traversal / scrolls to top on push, so the router carries no `pushState`/`popstate` code and no fragment-vs-navigation heuristics. The one piece of scroll bookkeeping it does keep is for `#maincontent`, the theme's own scroller (see "The navigation procedure", step 7). Why this API rather than the History API `luci-theme-footstrap` uses: - **The browser owns URL, history and scroll.** `pushState` puts the router in charge of all three and of keeping them consistent with what it rendered; here it only ever renders. - **Supersession is built in.** A newer navigation aborts the older one's `event.signal`; the generation gate below is a check, not a state machine, and no `render` guard is needed to repair a stale paint. - **Every navigation kind arrives at one listener** — link click, `location.assign`, back/forward to a same-document entry, our own `navigation.navigate()` — so there is exactly one path to keep correct. - **The fallback is free.** Where the API is missing, the theme is the MPA it already was; nothing has to be polyfilled or feature-forked. **Browsers without the API stay MPA.** `footer.ut` only requires the module when `window.navigation` exists, and `__init__` re-checks the surface it actually uses: `navigation.addEventListener`, `NavigateEvent`, and `intercept` on its prototype. Chrome/Edge **105+**, Safari 26.2+, Firefox 147+ get the router — 105, not the 102 that first shipped the Navigation API, because the method was called `transitionWhile()` until Chrome 108 and `canIntercept` was `canTransition`; gating on `intercept` is what makes the floor 105. The theme's declared floor (Chrome 111 / Safari 16.4 / Firefox 128) keeps working as it does today. This is a deliberate trade: one code path, correct by construction, over a second history-API path that would double the surface of everything below. ## Compatibility ### Browsers — per platform feature | Feature | Used for | Required? | Chrome / Edge | Safari | Firefox | Without it | | --------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------- | -------------- | ------------- | --------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------ | | Navigation API (`navigation.addEventListener('navigate')`, `NavigateEvent.intercept()`, `event.destination/signal`, `navigation.navigate()/back()`) | the whole router | **yes — gate** | 105+ (2022) | 26.2+ (2026-01) | 147+ (2026-01) | `router-shadcn.js` is not even loaded (`footer.ut` checks `window.navigation`); the theme is the plain MPA it was before | | `document.startViewTransition()` (same-document) | crossfade at the swap | no | 111+ | 18+ | 144+ | swap without animation; also off under `prefers-reduced-motion` | | `fetch(url, { priority: 'low' })` | hover module prewarm | no | 101+ | 17.2+ | 132+ | the option is ignored, the fetch still runs at default priority | | `MutationObserver`, `DOMParser`, `WeakSet`, `URL`, `Element.replaceWith`, `:scope`, `matchMedia`, optional chaining / `??=` | render completion, template shells, poison gate, staging | yes | ≥ 85 | ≥ 14 | ≥ 79 | all inside the theme's declared floor (Chrome 111 / Safari 16.4 / Firefox 128) | So the router's effective floor is Chrome/Edge 105, Safari 26.2, Firefox 147; everything older keeps the theme's existing floor and behaviour. The aurora port was verified live in Chrome 151 (headless CDP); Safari/Firefox by feature detection only — the gate is the same API surface, not a UA sniff. ### OpenWrt / LuCI The theme already requires OpenWrt 23.05+ (ucode templates). Except for the two version-scoped items called out below, the router touches only luci-base surfaces that are identical in the `openwrt-23.05`, `openwrt-24.10`, `openwrt-25.12` and `master` branches of `openwrt/luci` (checked against the branch sources, 2026-08): `L.require` with instance caching and `prototype.constructor`, `L.view`, `L.dom.content` and the `data-idref` registry, `L.env.{scriptname, base_url, resource_version, media, requestpath, dispatchpath, pathinfo, nodespec}`, `L.hasSystemFeature`, `L.Poll.{queue, start, stop, active, timer}` (and `start()`'s reset of `tick`, which is what re-arms an incoming view's first poll), the `poll-status` indicator id `setupDOM`'s `poll-start` handler registers, `ui.menu.load()`'s session-cached tree with `satisfied` / `firstchild_ineligible` / `wildcard` / `action.type` (`view`, `alias`, `firstchild`, `template`), `ui.instantiateView`, `ui.hideIndicator`, `ui.hideModal`, `uci.state.values` / `uci.unload()` / `uci.load()`, `network.js`'s uci-backed state, `Request.addInterceptor` / `rpc.addInterceptor` and the `-32002` → `session.access` probe in `setupDOM`, `dispatcher.uc`'s `ctx_append` acl folding, `view.ut`'s `#view` + inline `instantiateView` shell, and `dispatcher.uc`'s `resolve_firstchild` / `node_weight` / alias re-dispatch semantics (ported line for line). **Two surfaces are not the same across those branches**, and the resolver is written against the newer one: - **`node.css`** entered `build_pagetree`'s schema in master only (7c6d8ff, 2026-08). 23.05, 24.10 and 25.12 carry no `css` on any node, so `nodeCss()` returns `null` and the feature is simply inert there. - **Wildcard descent.** `wildcardaction` exists in 25.12 and master, not in 23.05 or 24.10 — an absent key just falls back to `node.action`, which is what those releases do anyway, so that part is safe. The _resolution rule_ around it is not: 25.12 and master descend into a matching `satisfied` child before treating trailing segments as args, while 23.05 and 24.10 capture every remaining segment the moment a `wildcard` node is reached. The router ports the 25.12/master rule. On 23.05 or 24.10 a tree that has both `foo/*` and a real `foo/bar` child would therefore resolve differently in the router than in the dispatcher — the exact "click opens one page, F5 opens another" failure this resolver exists to avoid. Both the rule and `wildcardaction` came in as one commit (df90c60a7, 2026-01-17) whose stated purpose is to let `path/*` carry an action distinct from the bare path, so the shape had no defined behaviour before it and a tree written for 23.05/24.10 is unlikely to use it — but that is an argument, not a survey of every installed `menu.d`, and the router has not been run on either release. Treat 23.05/24.10 as inspected, not verified. Live verification so far: the aurora build of this router on OpenWrt SNAPSHOT r0-20d94d5 (2026-08, mediatek/filogic) and an earlier SNAPSHOT on ipq60xx. 23.05 / 24.10 / 25.12 by branch source only, not on device. That list is also executable: `contract()` in `router-shadcn.js` looks every one of those surfaces up at boot (`L.view`, `L.require`, `L.dom.content`, `L.env.{base_url,resource,media}`, `L.Request.addInterceptor`, `L.uci.{load,unload,state}`, `rpc.addInterceptor`, `poll.{queue,start,stop,active}`, `ui.menu.load`, `ui.hideModal`, `ui.hideIndicator`, `E`) and, if any is missing, logs which and does not activate — the theme is the MPA it was, not a broken router, on a luci-base that moved. ## What is intercepted A `navigate` event is intercepted only when **all** hold: - `event.canIntercept` (same-origin, not cross-document-only), not `hashChange`, no `downloadRequest`, no `formData`, `navigationType !== 'reload'`; - the destination is **not the document's own URL** (fragment aside). A same-URL navigation arrives as `navigationType: 'replace'`, not `'reload'`, yet it is a reload by another name: luci-base's `ui.changes.apply/revert` end in `window.location = window.location.href.split('#')[0]` (and the expiry modal's button in the same) precisely so the server re-renders the shell — a theme switched under System → Language and Style, a new language, a new hostname, a changed menu tree — and intercepting it left the swap showing the old shell until F5. A click on the current page's own link is the same reload it is in the MPA; - the destination path (minus `L.env.scriptname`) resolves in the menu tree to a **serviceable node** (below); - the document is not **poisoned** (below) and its session is not known to be **expired** (below); - the router **activated** in this document: it does so only when the page it booted on is itself serviceable. A `call`/`cbi`/`function` page carries scripts (legacy `XHR.poll`, inline timers) that only a document death retires; the first click away from one is always a full load. Anything else falls through untouched: the browser performs the ordinary full navigation, i.e. exactly what the theme did before. Modifier-clicks and `target=_blank` never reach the event. ### Serviceable nodes Resolved with a port of the dispatcher's own rules, not a paraphrase: - `alias` → jump to `action.path` from the root and continue; - `firstchild` → the same `resolve_firstchild()` / `node_weight()` the dispatcher runs: candidates are `satisfied` children with a `title` and an object `action`; weight `min(order ?? 9999, 9999)` +10000 for `auth.login`; a `firstchild` candidate counts only if it resolves further; `firstchild_ineligible` excluded; ties keep key order. The ACL check is skipped because `/admin/menu` is already filtered for the session; - `wildcard` nodes are descended into first — a segment that matches a `satisfied` child wins over arg capture — and only the remainder becomes request args; with args present the node's `wildcardaction` (the `path/*` entry's own action) runs, and `action` for the bare path. This is the 25.12/master rule; 23.05 and 24.10 capture at the first `wildcard` node instead — see "OpenWrt / LuCI" above; - a hop counter (32) breaks cycles in a foreign `menu.d`; - **any segment that does not match a `satisfied` child ends the attempt.** The dispatcher would fall back to the deepest satisfied ancestor and re-resolve from there; the router returns `null` and hands the navigation to the server. Deliberate: the fallback costs one full load, guessing the ancestor wrong costs the wrong page. Two tracks are kept, as a full load keeps them: **requested** segments → `L.env.requestpath`, `L.env.pathinfo`, `body[data-page]`; **resolved** segments → `L.env.dispatchpath`, `L.env.nodespec`, the menu highlight, the title. Pick a different child than the dispatcher would and a click opens one page while F5 opens another — that is why the resolver is a port. | node | served | | --------------------------------------------------------- | ------------------------------------- | | `view` | yes — `view.` | | `alias`, `firstchild` | yes — resolved to a leaf, recursively | | `template` whose page is a view shell (Status → Overview) | yes — shell fetched once, see below | | Lua `template`, `call`, `function`, `cbi`, `rewrite` | no → full load | `rewrite` is deliberately not resolved: it is not in the tree and a splice mistake opens the wrong page, which is worse than the reload it falls back to. ### Template nodes: the server's own shell, never a hand port `admin/status/overview` is a `template` whose server side defines page globals (`progressbar`, `renderBox`, `renderBadge`), emits an `

` and a `div.includes` (server-rendered Lua includes), and then instantiates `view.status.index`. A first version re-implemented those helpers in the router and drifted on the first real page (the network badges lost their labels: upstream's `renderBadge` takes extra `L.itemlist` arguments the port did not know about). So the router does not port anything: when a link to a template node is hovered or focused, its page is **fetched once per document** (the in-flight request is shared by every intent event that arrives before it resolves), parsed with `DOMParser`, and the content region after `#tabmenu` (to the end of `#maincontent` — this theme renders no `