mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-09-14 12:24:33 +08:00
666 lines
40 KiB
Markdown
666 lines
40 KiB
Markdown
<h4 align="right"><strong>English</strong> | <a href="router_zh.md">简体中文</a></h4>
|
||
|
||
# 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).
|
||
|
||

|
||
|
||
## Prior art
|
||
|
||
[luci-theme-footstrap](https://github.com/VizzleTF/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.ut` → `header.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 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.
|
||
|
||
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 `-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: 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 `setInterval`s 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 order** —
|
||
`getElementById('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`.
|