Files
op-packages/luci-theme-aurora/.dev/docs/router.md
T

40 KiB
Raw Blame History

English | 简体中文

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-aurora.js by its Vite plugin; this theme keeps only the markers, the luci-navigate listener in menu-aurora.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-aurora.js, loaded from footer.ut next to menu-aurora.js. No changes to luci-base or to any view — the router is additive theme JS plus three small template hooks (a patch manifest, a <footer> boundary, and data-aurora-* markers on the stylesheets header.ut itself renders).

One LuCI navigation: what the device does, what the browser does, and which of those steps the same-document router deletes

Prior art

luci-theme-footstrap 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

Cudy TR3000 (mediatek/filogic, ARMv8), OpenWrt SNAPSHOT r0-20d94d5, this branch deployed, plain HTTP, warm cache, 2026-08-18. Both paths are measured in one loop (bench-fullload.mjs, RUNS=10, medians over the 8 pages below) so they see the same device state. Run-to-run spread is ±40 ms on a full load: the ratios are the claim, the digits are not.

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.utheader.ut
dispatch #2 — admin/translations/<lang> 124→209 a second CGI process, parser-blocking, uncacheable
DOMContentLoaded 215 the shell is back, byte-identical to the one just 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.

What one dispatch costs the device

Measured on the device itself, so no network is in the number (bench-dispatch.sh).

request ms bytes
page HTML, a view node 75.4 18,583
admin/translations/en 62.7 13
admin/translations/zh-cn 60.3 229,503
admin/menu — once per session, not per navigation 68.2 45,022
static main.css 0.8 191,899
inside one dispatch, per process ms
fork + ucode VM 2.2
import luci.dispatcher (runtime, http, ubus, uci, core, authplugins) 37.2
menu tree: stat 8 menu.d files + parse the 28,307 B index cache 13.8
session.get + session.access over ubus 6.0

The en row is the control: a 13-byte response still costs 62.7 ms, while 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 that block twice. (zh-cn clients additionally re-transfer 229,503 B every navigation: write_headers() in http.uc sets Cache-Control: no-cache and Expires: 0 with no ETag or Last-Modified, so there is nothing to revalidate against.)

End to end, click → view painted

Median of 10 per page, full load = navigation start → first non-spinner child of #view; router = click → the navigation's finished promise.

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 5087 %. 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.

Speculation-rules prefetch cannot reach it: the API is secure-context only, so it is inert on HTTP, and on HTTPS a document prefetch hides only the first dispatch — 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 <div id="view"> with an inline L.require('ui').then(ui => ui.instantiateView('<path>')), 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 scroll on traversal / scrolls to top on push, so the router carries no pushState/popstate code, no scroll bookkeeping, and no fragment-vs-navigation heuristics.

Why this API rather than the History API 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-aurora.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. Verified live in Chrome 151 (headless, bench-router.mjs); 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), ui.menu.load()'s session-cached tree with satisfied / firstchild_ineligible / wildcard / action.type (view, alias, firstchild, template), the poll-status indicator id setupDOM's poll-start handler registers (the teardown hides it by that name), 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 -32002session.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: 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-aurora.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.<path>
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. The node and its action are in the tree, but following it means re-implementing dispatcher.uc's splice(request_path, 0, action.remove) and re-dispatching from the result; an off-by-one there 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 <h2> 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 between #tabmenu and <footer> is kept as the page's shell — every node cloned, #view replaced by an empty div, the inline instantiateView('…') script read for the class name, the remaining inline scripts (the helpers) replayed into global scope on staging. luci-base's own bootstrap (luci.js and L = new LuCI(env)) also lives in that region and is filtered out. If the document is that template (the session started on Overview), the shell is taken from the live region and no fetch happens. A template node is only intercepted once its shell is known — so a Lua template page (no instantiateView call) is remembered as unservable after one hover fetch and never enters the router's error path, and a template clicked without a prior hover is a plain full load that seeds the shell for the rest of the document. Its status include modules are singletons carrying oneshot/hide state that a full load would reset — verified against a real full load, not against expectation.

The navigation procedure

intercept({ handler, focusReset: 'manual', scroll: 'after-transition' }), handler in order:

  1. Generation. const gen = ++this.gen; every later DOM write is gated on it. event.signal aborts our own awaits, but it cannot cancel a LuCI XHR (L.Request hands back a bare promise; the XMLHttpRequest only surfaces on the resolved Response, too late to abort) or a View.__init__ chain already running, so the generation is the correctness mechanism and the signal is hygiene.

  2. Teardown of the departing document state, i.e. what a document death would have done for free:

    • Poll: queue.length = 0; stop(); start() — three steps. The flush drops the old view's pollers; stop() drops the tick; start() on an empty queue re-arms tick = 0 so the incoming view's poll.add() auto-starts and fires immediately instead of waiting up to interval seconds for the surviving tick to align. Upstream's initDOM() does the same Poll.start() on an empty queue before the first view.
    • uci: unload() every package present in state.values or uci.loaded (documents start with an empty cache; four shipped apps read load()'s return as an existence check and draw an error over the page when the cache answers []; and uci.loaded keeps a package's request promise — a rejected one included — until unload(), so a failed load left there would be handed to every later view). Then, if L.network has been loaded, load(['network','luci']) — plus 'wireless' when L.hasSystemFeature('wifi') — is re-issued and awaited — and a rejection propagates to the hard-load fallback rather than leaving network.js on an empty config: network.js fills its _state once and from then on answers out of the uci cache (getWifiDevices() is uci.sections('wireless','wifi-device')), so dropping those without refilling hands every consumer an empty config for the rest of the document. Unsaved local edits die with the page as they would on a full load; saved changes live on the server and the Unsaved-changes indicator is unaffected.
    • bare setIntervals registered since the router booted are cleared (setInterval/clearInterval are hooked in __init__, i.e. when L.require('router-aurora') instantiates the class; poll.timer, the one interval L.Poll owns, is skipped). setTimeout and rAF are not touched: the core keeps tooltips, notification timeouts and a request timeout on setTimeout, and there is no self-rescheduling timeout in any shipped view.
    • window/document listeners a view registered while it rendered are removed. Several shipped views add them per render (statistics graphs: an anonymous resize that later throws against detached DOM; nlbwmon: tooltip-open/touchstart; the core's own dropdown widgets: one window click/touchstart per instance), so they accumulate and act on pages that are gone. The hook records registrations inside the render window only. A warm render evaluates no module, so its registrations are per-render by construction and go on the next teardown. A cold render also runs the module's top level, whose registrations must survive (removing them is one-way — an editor's module-eval listeners never come back), so cold registrations are only credited to the class and released when a later warm render of the same class registers the same target/type, which proves them per-render.
    • ui.hideIndicator('poll-status') — luci-base leaves a Refreshing / Paused indicator behind that a document death would have taken with it.
    • ui.hideModal(), the theme's own surfaces (mega menu, mobile drawer, palette) close.
    • page-scoped patch CSS is disabled and its JS patch unmounted (below).

    The uci flush is the one part of this that is awaited rather than fire-and-forget, so it is a separate step after teardown() returns.

  3. Environment. L.env.requestpath/dispatchpath/pathinfo/nodespec, body[data-page], document.title. An alias is re-dispatched server-side, so requestpath and data-page carry the alias target while pathinfo keeps the URL as requested; a firstchild keeps the requested path in both. The title suffix ( - hostname) is read off the initial document, so it matches whatever the template emitted. nodespec drives L.hasViewPermission() and therefore the Save/Apply footer's readonly state — and its readonly is folded down the dispatch path the way dispatcher.uc does it: ctx_append collects every node's depends.acl and one check_acl_depends() over the union is writable as soon as any group is writable, so a page is readonly only when every acl-bearing node on its path is. The tree's per-node flag (apply_tree_acls) covers that node's own acl alone; handing the leaf node over as-is gave a read-only user a live Save & Apply on every page under a read-only group. The tree object is not mutated (nodespec is a copy). data-page keys ui.tabs session state and the theme's page-scoped CSS.

  4. Chrome. menu-aurora.js exposes syncRoute(): it re-marks is-active-page/aria-current from L.env.dispatchpath across every nav surface, expands the active sidebar/mobile group and collapses the rest, rebuilds the header crumb and re-renders #tabmenu for the new section. Menus are not rebuilt — the mega menu measures and binds on construction and the palette index is a flat array of the same model — only their state changes.

  5. Staging. A fresh <div id="view" class="view-staging"> is inserted right after #tabmenu, i.e. first in tree ordergetElementById('view') returns the first match, so everything LuCI's view chain writes goes into the staged element while the outgoing page stays on screen (dimmed, .view-leaving). The stage is invisible but laid out (visibility:hidden; height:0; overflow:hidden, never display:none): the realtime graphs size themselves from #view.offsetWidth inside render(), and a display:none stage handed them a 0-wide canvas. Nothing is removed yet.

  6. Patches. header.ut emits the installed on-demand patch stems as body[data-patches]; the router applies the same segment-prefix rule the template applies at render time: matching patches/<stem>.css links are ensured (<link data-luci-patch>, enabled for the page on screen, disabled — not removed — for the rest, so a return costs nothing); matching patches/<stem>.js files are loaded once and their window.luciPatches[stem] { mount, unmount } pair is driven per visit (the list of stems to mount belongs to the navigation that computed it, so a superseded one mounts nothing later); URLs the router adds carry the same ?v=PKG_VERSION luci.mk stamps on the template's own links, read from body[data-asset-version], so they hit the same cache entry — a JS patch that registers nothing is simply executed once, MPA-style. A patch script mounts itself when it evaluates; if the user has navigated on before it arrives, its load handler checks whether the current page still wants that stem and unmounts it otherwise (a same-stem page reached meanwhile keeps it mounted). A menu.d node's own css (header.ut links <resource>/<node.css> for the dispatched node, marked data-luci-node-css) is kept the same way: one <link> per stylesheet, enabled for the page whose resolved leaf declares it, disabled for every other page, never removed. Both attributes are exempt from the poison gate.

  7. View.

    • cold (view.<path> never required in this document): window.L.require(className) — the require is the render (LuCI instantiates on first require) and it must go through window.L, the runtime instance, never the prototypal L a module factory receives (ui hangs itemlist/showModal on window.L; a view required through the wrong L dies three modules down on L.itemlist is not a function, and because require() caches by name the binding is fixed by the first requirer);
    • warm: require() hands back the cached instance whose __init__ already ran; LuCI's class system sets prototype.constructor, so new instance.constructor() runs a fresh __init__load()render()dom.content('#view'), exactly what a full load starts from. Either way the required value is checked with instanceof L.view; anything else throws into the hard-load path rather than staging a non-view.
    • completion is observed, not assumed: a MutationObserver on the staged element resolves when a non-spinner child lands (or the spinner is removed for an empty render). Not completing within 15 s is a failure, not a completion: committing the spinner and releasing the serialization would let the still-running chain paint into a later navigation's #view, so the timeout rejects and the catch path hard-loads the destination. On completion — and only if this navigation is still the latest — the outgoing region (everything between #tabmenu and <footer> except the staged element) is removed and the staged view is unhidden inside document.startViewTransition() when available and reduced motion is off; the navigation's finished promise resolves after that swap. Each departing element goes through L.dom.content(el, null) before remove(): that is what drops its data-idref registry entries, which would otherwise hold the detached subtree and its class instances alive — which is what the soak test below measures.
    • Renders are serialized. Neither an in-flight LuCI XHR nor a running View.__init__ chain can be cancelled (same reason), and every chain paints into whichever #view is first at paint time. So a navigation first awaits the previous one's completion (bounded by the same timeout) before it tears anything down or stages anything — the previous chain finishes into its own staged element, which is then discarded. Rapid A→B→C therefore never interleaves: B is skipped when C arrives before B ran (event.signal / generation), and C waits for whichever render is actually in flight. The document's initial LuCI-rendered view is tracked the same way, so a click during the first load cannot be painted over by it — and a first render that never completes rejects that wait, so the first navigation takes the hard-load fallback instead of staging next to a chain that may still paint. That first render also runs inside a render window (opened after the router's own listeners are registered), so the listeners it adds are credited to its class like a cold render's; the ones it registered before the router loaded are out of reach. The cost is that a click during a slow load waits for that load; the alternative — wrapping prototype.render per class and repairing stale cold renders by re-navigating — leaves a real window open and needs three mechanisms where one suffices.
  8. Focus and announcement. #maincontent (tabindex=-1) with preventScroll; the new document.title is written into #luci-nav-status (role=status, aria-live=polite), since a same-document swap fires no load a screen reader would announce. The landmark carries outline-none: iOS WebKit (Safari and Chrome for iOS alike) paints its focus ring for programmatic focus, and the ring's top edge just under the sticky header was reported as "a progress bar that never goes away" — it was never the bar.

  9. Progress. A navigation that outlives 150 ms gets #luci-nav-progress inserted — Turbo Drive's bar, in shape: a hairline at the top whose width is driven inline and trickles in ever-smaller steps (+ (100 - w) / 30 every 300 ms) until commit, so a slow render keeps visibly moving instead of looking stuck; on commit it fills to 100 %, fades (data-state="done") and is removed from the DOM. Shorter navigations stay silent, overlapping ones share the bar. The browser's own progress bar is no help here: it only shows for document loads, which is exactly what a same-document swap is not — hence GitHub, YouTube, Turbo/HEY and every nprogress user draw their own. Reduced motion drops the transitions, not the bar.

  10. Any exception → console.error (a silent fallback makes every router regression look like "the page is just slow") → location.href = destination — a hard full load, never a stuck page. A bypass flag is set first so the navigate event that write produces passes straight through instead of being intercepted back into the failing path, and the handler then parks on a never-settling promise so nothing else runs against a document that is on its way out.

The expiry gate

luci-base answers a dead session with notifySessionExpiry(): Poll.stop() plus a modal whose only button is a hard reload. A same-document swap would hideModal() and Poll.start() right through it and browse on, every page erroring in turn (measured: bench-router.mjs expiry against the previous router — expiredFullLoad: false). So the router listens for the same two signals luci-base acts on — a 403 with X-LuCI-Login-Required: yes on any L.Request, and the session.access probe luci-base fires after a -32002 coming back denied or errored — and from then on intercepts nothing: the next click is a full load, which the dispatcher turns into the login page. A denied call on any other object is an ACL matter and is ignored. Nothing is reset: the flag dies with the document, as the session did. The same flag keeps the visibility gate (below) from restarting a poll the expiry stopped.

Hidden tabs

luci-base keeps polling in a background tab. The router stops Poll on visibilitychange → hidden when it was active and starts it again on return — unless the user had paused it, or the session died meanwhile. On a weak router that is RPC work nobody is looking at.

The poison gate

A <style>/<link rel=stylesheet> a view writes into <head> dies with the document on a full load and survives a same-document swap, painting every page after it (a shipped file manager hides Save/Reset on every config page with one unlayered !important rule). Removing it is not an option: a library that imports CSS at module eval never runs again, so deletion is one-way (an editor page came back as a black rectangle two million pixels tall). Hence a gate, not a sweep: before intercepting, any sheet outside #view that is not one of the theme's own marks the document poisoned and the navigation is a full load — the fresh document carries no view CSS, so the router resumes immediately. "Own" means marked: header.ut stamps everything it renders (data-luci-shell on main.css, the font, custom and token <style>s; data-luci-patch on patches; data-luci-node-css on the menu.d node css). The boot snapshot the gate compares against is filtered by those markers, so a sheet the boot page's own modules inserted before the router loaded still counts as foreign instead of being grandfathered in for the rest of the document. Correctness over speed, never the other way.

An owner-based refinement (stamp each sheet with the inserting module off the call stack, enable it for pages whose dependency closure holds that module, disabled for the rest) was built, verified on the device and removed again: on this device one view page inserts its own CSS, the saving is one reload when leaving it, and the price was three monkeypatches plus an inline template script whose failure mode — a page silently missing a shared library's CSS — is worse than the reload it avoids. Revisit only with a real corpus of self-styling view pages.

Module prewarm on hover

Entering (pointerover/focusin/pointerdown) a link to a serviceable node fetch()es its view module with priority: 'low' — not require(), which would render it. The URL is built byte-for-byte as LuCI.require() builds it (<base_url>/<name with . → />.js?v=<resource_version>) or it misses the HTTP cache. The walk is transitive: the fetched body's first 4 KB is scanned for the leading run of 'require x' string literals with a regex that is not line-anchored (shipped files are minified onto one line), and dotted names are warmed the same way; dotless names are either luci-base's file-less built-ins (view, baseclass, dom, poll, request, session) or flat libraries the chrome has already loaded, so they are declined outright. Deduplicated per class name; stops once a navigation to that link has committed. Cold navigations are the only place this shows; warm ones are already 0-byte cache hits.

What is deliberately not done

  • No history-API path. See "Kernel".
  • No document prefetch while the router is active. The speculationrules script is removed at boot when the router takes over — a hover prefetch of a document the router will never load is pure router CPU. Browsers without the Navigation API keep the rules and the MPA path.
  • No unload/beforeunload, ever (bfcache).
  • No cancellation of in-flight XHR — there is no handle to cancel with (see step 1); the generation gate makes it a waste, not a bug. Upstream-only.
  • No sweeping of a view's global listeners or timeouts — one-way deletions of module-eval registrations. If a per-render offender ever appears, the answer is a targeted teardown, not a global hook.
  • ui.changes.confirm/revert and awaitReconnect keep their hard window.location writes — a rollback/reboot boundary should be a fresh document.

Verification matrix

  • Unit (.dev/tests/router.test.js): resolver against a fixture tree (alias chain, nested firstchild, weights, ineligible, unsatisfied, wildcard args, cycle); URL → segments; patch prefix matching; pragma scan on a minified head; readonly folding; expiry signals; the same-URL reload rule; node css of the resolved leaf; the contract check.
  • Device (.claude/skills/aurora-performance/scripts/bench-router.mjs, CDP):
    1. full walk of every clickable node in each nav mode, each compared against a real full load of the same URL — data-page, dispatchpath, URL, title, tab count, footer presence, console clean;
    2. click → view painted, median of N, router vs full load, warm and cold;
    3. soak: 60 navigations over 12 pages, heap / DOM nodes / listeners / poll queue length flat after the first pass;
    4. back/forward chain through alias and firstchild URLs — no reload;
    5. poison gate: a foreign <style> in <head> makes the next navigation a full load, the one after is a same-document swap again; 5b. sheets: the same, on every walked view page that really inserts its own sheets (found on the walk) instead of an injected one — reached same-document and landed on directly (its modules insert before the router boots), leaving is a full load either way; 5c. hygiene: no progress bar left in the DOM after a swap, live region present and carrying the title, a hidden tab stops polling and a visible one resumes it;
    6. nodecss: a page whose menu.d node declares css — link enabled on arrival, disabled after leaving, re-enabled without a duplicate on return (skipped when no installed node declares one);
    7. expiry (last, destroys the session): logout fetched from inside the document, one failing RPC → luci-base's modal and Poll.stop(); the next navigation is a full load landing on the login form. The walk also compares nodespec.readonly, L.hasViewPermission(), the set of enabled node-css links and the live-region text against the full load, and reports which pages carry sheets that are not the theme's.
  • Device (bench-fullload.mjs, CDP): where one full load's time goes — dispatch #1, the parser-blocking catalog, DOMContentLoaded, the view's own ubus window — and the same page over the router, both in one loop so the two are subtractable.
  • Device (bench-dispatch.sh, run on the router): what one CGI dispatch costs before any page-specific work — process, module graph, menu tree, session probe — plus the loopback cost and size of each response a navigation pulls. The en-catalog row is the control that separates dispatch cost from payload.
  • The perf skill (.claude/skills/aurora-performance/) documents all three harnesses in references/measuring.md; the server-side cost this router removes is the S1/S2 budget in references/server.md.