🎄 Sync 2026-08-17 19:39:03

This commit is contained in:
github-actions[bot] committed 2026-08-17 19:39:03 +08:00
1 parent 762c5f8cd4
commit 522b0fc0b6
58 files changed
+1243 -692

No files matched your search

@@ -4,7 +4,6 @@
'require fs-fit as fit';
'require fs-prefs as prefs';
'require fs-menutree as tree';
'require fs-version as version';
/* The chrome AROUND the content: the mode menu, the section tabs, the rail toggle, and the
* measurements that decide how much room any of it gets. The MAIN menu is not here — it is injected
@@ -278,43 +277,6 @@ function publishBarHeight(bar) {
root.style.setProperty('--fs-bar-live', `${h}px`);
}
/* ---- the theme names itself in the footer, beside LuCI and the distribution ----
*
* The footer's sentence is about what renders this page: LuCI's version, the distribution's, and —
* since the markup is the theme's — the theme's. Each of the other two is a link to where that
* software lives, so this one is too.
*
* WRITTEN FROM THE CLIENT, and that is the only reason it is here rather than in footer.ut: the
* version is stamped into fs-version.js by file name — the Makefile, tools/stage.sh and dev-sync.sh
* all sed that one literal — so a second copy in a template would be a second stamp site to keep in
* step, and a footer that quietly disagrees with the Appearance tab is worse than no version at all.
* The label is the one fs-version already prints there, dev builds included.
*
* Once per document: the footer survives a client navigation (navigate() sweeps the children of
* .fs-content, and the footer is its sibling), and the guard makes a second call a no-op rather
* than a second copy. No request, no translated string: a proper noun and a number. */
function nameThemeInFooter() {
const host = document.querySelector('footer.fs-footer span');
if (!host || host.querySelector('.fs-footer-self')) return;
const self = E('a', {
'class': 'fs-footer-self',
'href': version.REPO_URL,
'target': '_blank',
/* `noreferrer` alone, matching footer.ut's own two anchors: it implies noopener in every
* engine that ships `rel=noreferrer` at all, so the pair reads as two rules where there is
* one. fs-appearance.js's link to the same URL spells both — the two are now in step. */
'rel': 'noreferrer'
}, [ version.label() ]);
/* AFTER THE DISTRIBUTION, not at the end. The sentence lists the software that renders the page
* — LuCI, the distribution, the theme — and footer.ut appends one more fragment after them that
* is not a component but a state: "Lua compatibility mode active". Landing behind it read as if
* the theme were part of that notice. Two links means the second is the distribution's; with
* anything else there, appending is still correct. */
const links = host.querySelectorAll('a');
if (links.length >= 2) links[1].after(document.createTextNode(' / '), self);
else host.append(document.createTextNode(' / '), self);
}
/* ---- does the right-hand cluster still fit beside the brand? ----
*
* The same question as the menu's, one row up, and it cannot be asked the same way. The cluster is
@@ -497,7 +459,6 @@ function wireIndicatorCounts() {
return baseclass.extend({
setRenderMain,
renderChrome,
nameThemeInFooter,
wireIndicatorCounts,
/* registered with fs-fit by the theme's init(): the bar's "does the menu fit beside the brand"
* measurement rides the same engine as the data tables' */
@@ -42,14 +42,46 @@ function schedule() {
requestAnimationFrame(() => { _rafPending = false; run(); });
}
/* Watch an element's size. Any change re-fits everything — the fitters are cheap and few. */
/* WIDTH ONLY, and this is not an optimisation — it is what makes the theme usable on a phone.
*
* Every browser on iOS grows and shrinks the viewport HEIGHT while the user scrolls, because the
* URL bar slides away and comes back; each step of that animation is a resize, and a ResizeObserver
* on #view reports it. Measured with the bar's travel simulated on a 390px viewport — twenty
* height-only steps, width untouched — the fitters ran often enough to rewrite 1054 class
* attributes, each one a forced synchronous layout of a page the user is scrolling. That is the
* juddering reported from an iPhone.
*
* Nothing a fitter asks is about height: `roomFor()`/`overflows()` compare a table against its
* column, `fitChrome()` asks whether the menu fits beside the brand, the rail and the density axes
* change widths. A height-only change cannot alter any of those answers — and the one case that
* looks like a counter-example is not one: a vertical scrollbar appearing takes WIDTH from the
* content box, so the observer sees it as the width change it is.
*
* Per element, because the roots are observed separately and a dialog can resize while #view does
* not. The first entry for an element always counts as a change, so nothing is lost at start-up. */
const _lastWidth = new WeakMap();
function onResize(entries) {
let widthMoved = false;
for (const e of entries) {
/* contentRect, not getBoundingClientRect(): the observer already measured it, and asking
* again inside the callback is the forced layout this function exists to avoid. */
const w = Math.round(e.contentRect.width);
if (_lastWidth.get(e.target) !== w) {
_lastWidth.set(e.target, w);
widthMoved = true;
}
}
if (widthMoved) schedule();
}
/* Watch an element's size. A change in WIDTH re-fits everything — the fitters are cheap and few. */
function watch(el) {
if (!el) return;
/* No feature test: the shipped CSS needs :has() and container queries, both years younger than
* ResizeObserver in every engine, so a browser that can render this theme at all has it. The
* window-resize fallback that used to sit here was worse than nothing anyway — it cannot see a
* rail collapse or a layout toggle, which is the pair this file uses an observer FOR. */
if (!_ro) _ro = new ResizeObserver(schedule);
if (!_ro) _ro = new ResizeObserver(onResize);
_ro.observe(el);
}
@@ -120,21 +120,47 @@ function resolveSegs(segs) {
* Save/Apply footer key their disabled state off, so on a session with narrower ACLs than root's
* this is any page whose SECTION is read-only.
*
* OR down the path, exactly as check_acl_depends() folds the accumulated list: one read-only acl
* anywhere on it is enough. Feed it the RESOLVED segments, since that is the path the dispatcher
* accumulates over. */
* AND down the path, not OR — the operator is the whole of this function and it was the wrong one.
* check_acl_depends() (dispatcher.uc:312) is handed ONE list, the concatenation ctx_append() built
* from every node's `depends.acl` along the path (:457-470), and it answers `writable = true` as
* soon as ANY group in that list grants write. So a page is read-only exactly when NO group on the
* path is writable — and since apply_tree_acls() (:436-446) marks a node `readonly` when that
* node's own list yields no write, "no group on the path is writable" is "EVERY acl-bearing node on
* the path is readonly". One read-only ancestor is not enough: a leaf that declares a writable acl
* of its own re-opens the whole path.
*
* Measured, because a source reading is not a verdict: on the 25.12 stand, `admin/status/logs`
* carries the read-only `luci-mod-status-logs` and its child `syslog` was given a writable acl of
* its own; a full load then reported `nodespec.readonly` FALSE while this function said true — the
* same class of disagreement between a click and an F5 that the leaf-only reading used to produce,
* only in the opposite direction, and this time it TAKES AWAY a Save/Apply the server allows.
*
* A node with no `depends.acl` is not evidence either way and is skipped: the dispatcher puts
* nothing into ctx.acls for it. That is also why the answer needs `depends.acl` at all rather than
* the `readonly` flag alone — an acl-bearing node the session may write carries no flag, and is
* therefore indistinguishable from an ungated node without looking at its acl list. /admin/menu
* serves both fields (66 of 243 nodes carry an acl on the stand).
*
* Feed it the RESOLVED segments, since that is the path the dispatcher accumulates over. */
function readonlyForSegs(segs) {
let node = _tree;
if (node && node.readonly === true)
return true;
let gated = 0, locked = 0;
const weigh = (n) => {
const acl = n && n.depends && n.depends.acl;
if (!acl || !acl.length)
return;
gated++;
if (n.readonly === true)
locked++;
};
weigh(node);
for (let i = 0; i < segs.length; i++) {
node = node && node.children && node.children[segs[i]];
if (!node)
return false;
if (node.readonly === true)
return true;
break;
weigh(node);
}
return false;
return gated > 0 && gated === locked;
}
/* The view class a menu node instantiates, or null if the node isn't SPA-able. The Status→Overview
@@ -1,6 +1,7 @@
'use strict';
'require baseclass';
'require ui';
'require rpc';
'require fs-menutree as tree';
'require fs-chrome as chrome';
'require fs-sheets as sheets';
@@ -64,11 +65,11 @@ function clearViewIntervals() {
/* …and the ALIAS ITSELF is the first thing that can go — it is the deprecation the paragraph
* above is about, so this function cannot be the one place that reads it blind. Every other
* `L.Poll` read in this file is guarded, and an unguarded one here throws where a throw costs
* most: navigate() calls this AFTER #view has been swapped for the "Loading view…" spinner and
* BEFORE data-page, pushState and the require, so a TypeError would strand every click on the
* spinner with the old URL and the old menu mark — far worse than the leaked interval this
* exists to sweep. No alias is the same answer as an unreadable timer: do NOTHING, once,
* loudly. */
* most: navigate() calls this inside the staged render, after the chrome has already switched to
* the incoming page and before its module is required, so a TypeError would leave every click
* showing the previous page's content under the new page's title and menu mark, with the
* navigation dead in a rejected promise — far worse than the leaked interval this exists to
* sweep. No alias is the same answer as an unreadable timer: do NOTHING, once, loudly. */
if (!L.Poll) {
warnPollUnreadable('footstrap: L.Poll is gone from this luci-base, so LuCI\'s own tick cannot be '
+ 'told apart from a view\'s timers — leaving view intervals alone. fs-router.js needs '
@@ -202,6 +203,120 @@ function flushUciCache() {
}
let _uciCacheWarned = false;
/* ---- A DEAD SESSION ENDS THE DOCUMENT, AND THE ROUTER MUST NOT BROWSE THROUGH IT ----
*
* luci-base answers an expired session with `notifySessionExpiry()`: `Poll.stop()` plus a modal
* whose only button reloads the page, which the dispatcher then answers with the login form. Every
* navigation of ours does the opposite of both halves — `ui.hideModal()` and `L.Poll.stop()` +
* `start()` — so the first click after the session died dismissed luci-base's own warning and
* carried on. Measured on the stand before this existed: kill the session from inside the document,
* let one rpc reject (`SessionError`, luci-base's modal up, polling stopped), then click a menu
* link — the router swapped the view, the modal was gone, and the page sat on "Loading view…" with
* every call behind it failing. Only a reload got the user back to a login form.
*
* So the router LEARNS that the session is gone and stops claiming navigations; the next click is a
* real one and the dispatcher turns it into the login page. Nothing is reset — the flag dies with
* the document, exactly as the session did.
*
* WHICH SIGNALS, and they are luci-base's own two decision points rather than a guess (luci.js,
* `setupDOM`):
*
* 1. a `403` carrying `X-LuCI-Login-Required: yes` on any `L.Request` — upstream calls
* `notifySessionExpiry()` on exactly that pair;
* 2. the `session.access` probe luci-base fires after some other call came back `-32002`, when
* that probe REJECTS — which is what upstream's `.catch(notifySessionExpiry)` reacts to.
*
* `access: false` is deliberately NOT one of them, and that is where this differs from the same
* gate in luci-theme-aurora. The probe is declared `expect: { access: true }`, and rpc.js's
* `handleCallReply()` turns a missing or mistyped key into the expectation's own value and
* RESOLVES — so a probe answering `access: false` never reaches upstream's catch and never expires
* anything. It is an ACL answer, and treating it as a dead session would drop a restricted user out
* of the SPA for the rest of the document over a permission they simply do not have.
*
* The probe's rejection is read off the frame the interceptor is handed, because that is all an
* interceptor can see: `handleCallReply()` rejects on a frame that is not JSON-RPC 2.0 or on an
* `error` carrying both a code and a message, so those are the two conditions here. A foreign
* `session.access` call of somebody else's that errors would also match — the cost is one document
* of full loads, which is the safe direction to be wrong in.
*
* NEITHER INTERCEPTOR MAY THROW. luci-base runs both through `Promise.all(...).catch(req.reject)`
* (`luci.js` for Request, `rpc.js` for rpc), so an exception in here would not just be our bug — it
* would reject the caller's request, i.e. break the very page we are trying to protect. Hence the
* try/catch around each body and no optional-chaining shortcuts on shapes we do not own. */
let _expired = false;
let _sessionWired = false;
function markExpired() {
if (_expired) return;
_expired = true;
/* once per document, and it is the console's least alarming level the house rules allow
* (eslint no-console permits warn and error only): nothing is broken, the session simply
* ended, and the very next click sends the user to a login form. */
console.warn('footstrap: the LuCI session is gone — every navigation from here is a full load.');
}
function watchSession() {
if (_sessionWired) return;
_sessionWired = true;
const req = window.L ? window.L.Request : null;
if (req && typeof req.addInterceptor === 'function')
req.addInterceptor((res) => {
try {
if (res && res.status === 403 && res.headers &&
res.headers.get('X-LuCI-Login-Required') === 'yes')
markExpired();
}
catch (e) { /* see above: an interceptor that throws rejects the caller's request */ }
});
if (rpc && typeof rpc.addInterceptor === 'function')
rpc.addInterceptor((msg, r) => {
try {
if (!r || r.object !== 'session' || r.method !== 'access') return;
if (!msg || msg.jsonrpc !== '2.0' ||
(msg.error && msg.error.code && msg.error.message))
markExpired();
}
catch (e) { /* ditto */ }
});
}
/* ---- throw an element away the way luci-base throws one away ----
*
* `dom.data()` does not live on the element: luci.js keeps it in `dom.registry`, keyed by a
* `data-idref` attribute, and the ONE thing that ever deletes an entry is `dom.content()`
* (luci.js: it walks `[data-idref]` and deletes each key before emptying the node). A plain
* `remove()` therefore leaves the entry — and through it the element and whatever class instance
* was stored on it — reachable for the life of the document.
*
* `#view` is not affected, and that is why this is small: the incoming view's own
* `dom.content(#view, …)` reaps the outgoing page. What the router removes BY HAND is the rest —
* the siblings a template emitted next to `#view`, and the runtime notification banners — and those
* it used to drop with `remove()`.
*
* MEASURED, before writing it: over three pages on the 25.12 stand nothing the sweeps remove
* carries a `data-idref` at all (banners: 0, siblings: 0), and the registry does not grow across
* laps (83 entries after the first lap of four pages, 83 after the third). So this fixes no leak we
* can see today; it closes the class, for the same reason the stackables selector claims a table
* with no LuCI classes — coverage is a contract, and the alternative is discovering it from a
* third-party page nobody here runs.
*
* The bin is what makes the element's OWN entry go too: `dom.content()` reaps descendants of the
* node it is given, never the node itself, so the element is moved into a detached container first
* — which also takes it out of the live tree, i.e. does the removal. Public API only; no reaching
* into `dom.registry`. If luci-base ever moves that surface, the fallback is today's behaviour. */
function discard(el) {
try {
const dom = window.L ? window.L.dom : null;
if (!dom || typeof dom.content !== 'function') { el.remove(); return; }
const bin = document.createElement('div');
bin.appendChild(el);
dom.content(bin, null);
}
catch (e) {
el.remove();
}
}
let _wired = false;
/* The pathname whose view is CURRENTLY rendered — popstate compares against it to tell a real
* navigation from a mere fragment change (see there). Seeded from the served page. */
@@ -244,6 +359,9 @@ let _curId = null;
* goes first: a Map iterates in insertion order and `set` on an existing key does not refresh it,
* so the re-save below is delete-then-set. */
const SCROLL_MEM_MAX = 50;
/* The offset a popstate replay must land on, consumed by the commit that puts the page on screen —
* see the popstate handler for why it cannot be replayed any earlier. */
let _pendingRestore = null;
function newEntryId() { return _scrollSess + ':' + (++_histN); }
/* adopt the entry we are standing on: reuse its fsid if it has one, stamp one otherwise (entries
@@ -354,89 +472,6 @@ function titleHost() {
return _titleHost;
}
/* The view class the page CURRENTLY on screen wants (what _curPath resolves to). Read by the
* stale-render repair below to tell "the superseded render happened to paint the right view
* anyway" from "it painted the wrong one". */
function currentViewClass() {
const segs = tree.segsFromPath(_curPath);
const res = segs && tree.resolveSegs(segs);
return tree.viewClassFor(res && res.node);
}
/* ---- a superseded FIRST render cannot be cancelled, so undo it ----
*
* _navGen stops a stale require() from calling `new view.constructor()` — but only on the CACHED
* path. On a FIRST visit the require() IS the render (see navigate()): it constructs the view, whose
* __init__ runs load() → render() → dom.content(#view) and registers its pollers, inside a promise
* we do not own. Nothing to cancel.
*
* So the fast double-click is a real bug: click Firewall (uncached), click Wireless 100 ms later.
* navigate(Wireless) flushes L.Poll's queue BEFORE Firewall's poller is added; Firewall then paints
* into the #view that now belongs to Wireless and registers a poller the flush can no longer catch —
* leaving Wireless's URL/title/menu/data-page, Firewall's content, and Firewall's poller running on
* every page afterwards.
*
* Repair by re-running the current navigation: navigate() is exactly the "put the document back the
* way a fresh load leaves it" routine. push=false — the URL never moved, only the DOM under it; if
* it declines (the superseded view injected CSS), the reload does it the hard way. The className
* check terminates this: if the superseded render painted the class the current path wants anyway
* (A → B → A while A was still loading), the DOM and its poller are correct — and with two uncached
* views racing it is also what stops a repair triggering a repair. */
function repairStaleRender(className) {
if (className === currentViewClass())
return;
console.warn('footstrap: a superseded view (' + className + ') rendered into the live page; re-rendering ' + _curPath);
if (!navigate(_curPath, false))
window.location.reload();
}
/* ---- the generation must be checked at the PAINT, not at the dispatch ----
*
* _navGen is checked once, in the require() callback, i.e. BEFORE `new view.constructor()`. But a
* view's __init__ is async — `ready.then(this.load).then(this.render).then(nodes =>
* dom.content(document.getElementById('view'), nodes))` — so the DOM write happens two awaits later,
* and every await is a point at which the whole event loop runs, another navigation included. The
* dispatch-time check has expired by the time it matters.
*
* Measured on the router: leave the (cached) package manager for System after 150 ms, and the paints
* into #view land 16010 System, 16490 package-manager — the view we walked away from paints LAST and
* wins, permanently. URL, <title>, data-page and the menu highlight all say System; #view shows
* Software. Only a reload clears it. The cached path is the COMMON one — after warm-up every
* navigation takes it — and it had no guard at all: repairStaleRender() is called only when !cached.
*
* The fix has to be a paint-time check, and there are two constraints on where it can live.
* ClassConstructor DISCARDS __init__'s return value (`this.__init__.apply(this, arguments)`, no
* return), so the construction promise is unreachable — nothing to await. But __init__ resolves
* `this.render` while it builds its chain, i.e. during `new`, so a wrapper installed on the
* prototype BEFORE the construct is the one that gets bound. `new` then returns synchronously (the
* first load() is already a microtask), which is what makes stamping the generation on the INSTANCE
* right after it safe — and it must be the instance, not a map keyed by class name, or A → B → A
* with everything cached would have the second construct's generation overwrite the first's.
*
* A superseded render resolves to a promise that never settles: the chain simply stops before
* dom.content(), so nothing paints and addFooter() never runs. Returning empty nodes instead would
* paint the emptiness over the live page, and throwing would hand LuCI's own .catch an error box to
* render into the page we just opened.
*
* A view instance we did not construct (the singleton require() builds on a FIRST visit — the render
* IS the require, so there is no window in which to arm anything) carries no stamp and is left alone:
* that path is repairStaleRender()'s, and stays its. */
const _guarded = new WeakSet();
function armRenderGuard(cls) {
if (_guarded.has(cls)) return;
_guarded.add(cls);
const orig = cls.prototype.render;
cls.prototype.render = function () {
/* unstamped: not ours to judge (see above) */
const stale = () => this.__fsGen !== undefined && this.__fsGen !== _navGen;
if (stale())
return new Promise(() => {});
return Promise.resolve(orig.apply(this, arguments)).then((nodes) => {
/* re-check: render() itself awaits (an RPC, usually), which re-opens the window */
return stale() ? new Promise(() => {}) : nodes;
});
};
}
/* The exact URL LuCI.require() will fetch for a class name, cache-bust and all. Matching it
* byte-for-byte is what makes a hover prefetch a warm cache hit for the later require(). */
@@ -582,6 +617,223 @@ function seed() {
titleHost(); /* before any view can rename the tab — see there */
/* the served page's entry needs an id too, or the first Back TO it has nothing to look up */
adoptEntry();
/* ---- THE DOCUMENT'S OWN FIRST RENDER IS THE FIRST LINK IN THE CHAIN ----
*
* A navigation waits for the previous render because a LuCI view chain resolves `#view` at paint
* time and would otherwise paint into the newer navigation's stage. The very first chain in a
* document is not one of ours — `view.ut`'s inline `instantiateView()` is already running when
* the chrome comes up — and it is subject to exactly the same rule. Left untracked it was a real
* bug, not a theoretical one: measured at 350 ms latency, a click 150 ms after DOMContentLoaded
* ended with the URL, title and menu on System and the Processes the document had been loading
* painted over it, because that chain finished last and found the stage first in tree order.
*
* So the chain starts here, watching the live `#view` with the same observer a staged render
* uses. If the first view has already painted, `renderedIn()` resolves at once and the first
* click waits for nothing. The `.catch` is what keeps a document whose first view never renders
* (an error page, a dead RPC) from turning every later click into a rejected promise — and from
* logging an unhandled rejection at the 15 s mark on a page nobody clicked. */
const vp = document.getElementById('view');
if (vp) _inflight = renderedIn(vp).catch(() => {});
}
/* ---- THE INCOMING PAGE IS RENDERED OFF SCREEN AND SWAPPED IN WHEN IT IS READY ----
*
* What this replaces: the router used to empty `#view`, paint luci-base's "Loading view…" spinner
* on a cold route and let the incoming view render into the live page. Two things came with that.
* The user watched an empty page for as long as the module and its data took — measured at 600 ms
* latency, 1800 ms of nothing on a first visit — and a superseded render could not be stopped, so
* the file carried three mechanisms to repair the damage afterwards: a generation stamped on the
* instance, a wrapper around `prototype.render` that made a stale paint hang forever, and a
* re-navigation that undid a stale FIRST render after the fact.
*
* Rendering into a stage removes the damage instead of repairing it. `stageView()` puts a fresh
* `<div id="view">` inside a hidden wrapper as the FIRST child of `.fs-content`, and
* `getElementById` returns the first match in tree order — which is what LuCI's own view chain
* calls, twice: once in `View.__init__` for the spinner and again when the render resolves
* (`DOM.content(document.getElementById('view'), nodes)`). So the incoming view writes into the
* stage while the page the user is reading stays on screen, untouched, until the swap.
*
* HIDDEN, BUT LAID OUT. `visibility: hidden; height: 0; overflow: clip` — never `display: none`.
* Several views size themselves from the element they render into, and they find it the same way
* LuCI does: `view/status/load.js` does `document.querySelector('#view')` and then
* `view.offsetWidth - 2` (bandwidth.js, connections.js and wireless.js repeat it verbatim), so a
* `display: none` stage would hand them a zero width they keep for the life of the instance. The
* stage's width is the container's, exactly what it will be after the swap — measured rather than
* assumed: the Load graph comes out 1222px wide whether the page is reached by a full load or by a
* click. Only the height is taken away, so the page under it does not move.
*
* AND THE SWAP MOVES THE NODES, IT DOES NOT SWAP THE ELEMENT. The other obvious shape — insert the
* staged `#view` and delete the old one — would change the identity of `#view`, and this theme has
* observers bound to that element: `fs-fit`'s content MutationObserver and `fs-appearance`'s view
* observer are registered on the node that existed at chrome init. Swapping the element leaves both
* watching a detached node, i.e. the fitters silently stop re-running on content mutations. So the
* live `#view` keeps its identity and its children are replaced through `dom.content()`, which is
* also what reaps the outgoing page's `data-idref` registry entries.
*
* RENDERS ARE SERIALIZED, and this is what actually retires the repair machinery. Neither an
* in-flight LuCI XHR nor a running `View.__init__` chain can be cancelled — `L.Request` never
* exposes its handle — and every chain resolves `#view` at PAINT time, so a chain started by an
* older navigation would paint into whatever stage is first when it finishes, i.e. into the newer
* navigation's. A navigation therefore waits for the previous one to finish before it stages
* anything; the older chain paints into its own stage, which is then dropped unswapped. The cost is
* stated plainly: a click during a slow first load waits for that load. It buys the deletion of
* three mechanisms that only ever cleaned up afterwards.
*
* COMPLETION IS OBSERVED, NOT ASSUMED. `renderedIn()` watches the stage and resolves when a child
* that is not the spinner appears — or when a mutation leaves it empty, which is how a view that
* renders nothing at all finishes. A render that has not completed within RENDER_TIMEOUT is a
* FAILURE, not a completion: swapping a spinner in and releasing the serialization would let the
* still-running chain paint into a later navigation's stage, so it rejects into the same full-load
* fallback every other error takes. */
const RENDER_TIMEOUT = 15000;
/* the promise of the render currently in flight — this initial value only covers a document whose
* chrome came up without seed() having run; seed() replaces it with the document's own first render */
let _inflight = Promise.resolve();
function stageView(contentHost) {
const wrapper = document.createElement('div');
wrapper.className = 'fs-staging';
const view = document.createElement('div');
view.id = 'view';
wrapper.appendChild(view);
/* FIRST in tree order, or getElementById() would keep answering with the live one */
contentHost.insertBefore(wrapper, contentHost.firstChild);
return { wrapper, view };
}
function renderedIn(view) {
/* `.spinning` is luci-base's own placeholder, written by View.__init__ before load() runs;
* a <script> is what a template shell replays. Neither is the page. */
const painted = () => view.querySelector(':scope > :not(.spinning):not(script)') !== null;
if (painted()) return Promise.resolve();
return new Promise((resolve, reject) => {
const timer = window.setTimeout(() => {
finish(() => reject(new Error('the view did not render within ' + RENDER_TIMEOUT + ' ms')));
}, RENDER_TIMEOUT);
const mo = new MutationObserver(() => {
/* an empty render finishes too: the spinner is replaced by nothing at all, which is a
* mutation that leaves no element children behind. No "has it started" flag guards this:
* `painted()` was asked before the observer existed, and an observer only ever fires on a
* mutation, so an empty stage cannot resolve before the render has touched it. */
if (painted() || view.childElementCount === 0) finish(resolve);
});
function finish(settle) {
window.clearTimeout(timer);
mo.disconnect();
settle();
}
mo.observe(view, { childList: true });
});
}
function dropStage(stage) {
if (stage && stage.wrapper && stage.wrapper.parentNode) discard(stage.wrapper);
}
/* Put the staged page on screen: clear what the outgoing page left beside `#view`, move the staged
* children into the live `#view` through `dom.content()`, and drop the wrapper. */
function commitStage(stage, contentHost) {
/* the outgoing page is going off screen now, so the sheets it owned may finally be darkened —
* the half navigate() spared while it was still being read */
sheets.scopeToCurrentPage();
sweepAround(contentHost);
const live = liveView(contentHost, stage);
const nodes = Array.from(stage.view.childNodes);
const dom = window.L ? window.L.dom : null;
if (live && dom && typeof dom.content === 'function')
dom.content(live, nodes);
else if (live)
live.replaceChildren(...nodes);
dropStage(stage);
}
/* The `#view` the document keeps between navigations — the one the observers are bound to. It is
* whichever `#view` is NOT the stage; a document that has none (a template page that emitted no
* `#view` at all) gets one, once. */
function liveView(contentHost, stage) {
for (const el of contentHost.querySelectorAll(':scope > #view'))
if (el !== stage.view) return el;
const v = document.createElement('div');
v.id = 'view';
contentHost.appendChild(v);
return v;
}
/* Clear what the OUTGOING page left as a SIBLING of #view inside .fs-content: dom.content()
* replaces only #view's OWN children, so anything a page emitted next to it rides along — the
* Status→Overview template emits <h2 name="content">Status</h2> there, hidden only by a
* body[data-page='admin-status-overview'] rule, so after an SPA nav the orphan showed on EVERY page
* until a full reload. Keep only the chrome that legitimately outlives a page (tabs, server
* notices, <noscript>) and the stage itself.
*
* …and the RUNTIME notifications, which live one level up and therefore survived every navigation.
* `ui.addNotification()` does `mc.insertBefore(msg, mc.firstElementChild)` on #maincontent — this
* theme's <main class="fs-main"> — while the sweep above only reaches children of .fs-content, a
* descendant of it. A full load clears them; SPA never did, so "Upload request failed" and every
* third-party banner stacked up over each following page for the rest of the session. The
* .fs-content banners kept above are the SERVER's notices (notices.ut) and legitimately outlive a
* page; these are not the same thing. */
function sweepAround(contentHost) {
Array.from(contentHost.children).forEach((c) => {
if (c.id !== 'view' && c.id !== 'tabmenu' && !c.classList.contains('fs-staging') &&
!c.classList.contains('alert-message') && c.nodeName !== 'NOSCRIPT')
discard(c);
});
const mainHost = document.getElementById('maincontent');
if (mainHost)
Array.from(mainHost.children).forEach((c) => {
if (c.classList.contains('alert-message')) discard(c);
});
}
/* ---- and something has to say that a slow navigation IS a navigation ----
*
* With the outgoing page left on screen until the incoming one is ready, a cold route would
* otherwise look like a click that did nothing: the chrome switches instantly (title, URL, menu),
* the content does not move for as long as the module and its first RPC take. The spinner this
* replaced was worse — it threw the page away to say the same thing — but silence is not the
* answer either.
*
* A hairline at the top of the content, shown only when the navigation OUTLIVES `PROGRESS_DELAY`.
* Below that threshold a bar would flash on and off on every warm click, which reads as a glitch
* rather than as progress; above it, it is the browser's own affordance for exactly this. The
* counter is what makes overlapping navigations share one bar. Reduced motion is handled in CSS,
* where the transitions live. */
const PROGRESS_DELAY = 150;
let _progressPending = 0;
let _progressTimer = 0;
function progressBar() {
let bar = document.getElementById('fs-nav-progress');
if (!bar) {
bar = document.createElement('div');
bar.id = 'fs-nav-progress';
/* it says nothing a screen reader needs: the live region already announces the page, and a
* decorative bar in the accessibility tree is noise on every navigation */
bar.setAttribute('aria-hidden', 'true');
/* ON <body>, not in the content column: it is `position: fixed`, and `.fs-shell` carries
* `contain: paint` in the sidebar layout, which would make it position against the shell and
* clip it. Inside `.fs-main` — a column flex container — its 2px were shrunk to zero
* outright. Both measured; see styles/theme/20-shell.css. */
document.body.insertBefore(bar, document.body.firstChild);
}
return bar;
}
function progressStart() {
_progressPending++;
window.clearTimeout(_progressTimer);
_progressTimer = window.setTimeout(() => { progressBar().dataset.state = 'active'; }, PROGRESS_DELAY);
}
function progressEnd() {
if (--_progressPending > 0) return;
_progressPending = 0;
window.clearTimeout(_progressTimer);
const bar = progressBar();
if (bar.dataset.state !== 'active') return;
bar.dataset.state = 'done';
_progressTimer = window.setTimeout(() => {
if (bar.dataset.state === 'done') delete bar.dataset.state;
}, 300);
}
/* Attempt an in-place navigation to `pathname`. Returns true if handled as a
@@ -596,6 +848,11 @@ function navigate(pathname, push, kbd) {
* the only exit that leaves BOTH pages correct is a real navigation. See fs-sheets.js. */
if (sheets.documentPoisoned()) return false;
/* …and a document whose session has died is spent in the same way, for a different reason: the
* only page it can still render correctly is the login form, and only a real navigation gets
* there. See watchSession(). */
if (_expired) return false;
/* `segs` is what the user clicked, `rsegs` the leaf it resolves to; they differ for an
* alias/firstchild link, and a full load keeps BOTH — URL and pathinfo as requested,
* requestpath/dispatchpath/nodespec/title resolved. Mirror that split exactly, or an F5
@@ -641,94 +898,24 @@ function navigate(pathname, push, kbd) {
/* from here on the navigation is committed */
const gen = ++_navGen;
/* THE SAVED OFFSET BELONGS TO THIS NAVIGATION, so it is taken out of the module slot here
* rather than read at the swap. Left in the slot it outlives its own navigation: Back sets it,
* a click supersedes that render before it commits, and the clicked page — a page the user
* never asked to be restored — is scrolled to the Back target's offset, under a generation that
* passes because it is the newer one's. Clearing it on the superseded path is not the answer
* either: a second popstate may legitimately have put ITS offset there by then. Reported in
* review. */
const restoreTo = _pendingRestore;
_pendingRestore = null;
_curPath = pathname; /* what is on screen from now on — read by the popstate handler */
/* Ensure a #view, and clear what the OUTGOING page left as a SIBLING of #view inside .fs-content:
* dom.content() replaces only #view's OWN children, so anything a page emitted next to it rides
* along — the Status→Overview template emits <h2 name="content">Status</h2> there, hidden only
* by a body[data-page='admin-status-overview'] rule, so after an SPA nav the orphan showed on
* EVERY page until a full reload. Keep only the chrome that legitimately outlives a page (tabs,
* server notices, <noscript>); this also gives a template page that emits no #view a fresh one. */
const contentHost = document.querySelector('.fs-content');
if (!contentHost) return false;
Array.from(contentHost.children).forEach((c) => {
if (c.id !== 'view' && c.id !== 'tabmenu' &&
!c.classList.contains('alert-message') && c.nodeName !== 'NOSCRIPT')
c.remove();
});
/* …and the RUNTIME notifications, which live one level up and therefore survived every
* navigation. `ui.addNotification()` does `mc.insertBefore(msg, mc.firstElementChild)` on
* #maincontent — this theme's <main class="fs-main"> — while the sweep above only reaches
* children of .fs-content, a descendant of it. A full load clears them; SPA never did, so
* "Upload request failed" and every third-party banner stacked up over each following page for
* the rest of the session (reproduced: the banner outlives a nav, and only F5 removes it).
* The .fs-content banners kept above are the SERVER's notices (notices.ut) and legitimately
* outlive a page; these are not the same thing. */
const mainHost = document.getElementById('maincontent');
if (mainHost)
Array.from(mainHost.children).forEach((c) => {
if (c.classList.contains('alert-message')) c.remove();
});
if (!document.getElementById('view')) {
const v = document.createElement('div');
v.id = 'view';
contentHost.appendChild(v);
}
/* SAY THAT SOMETHING IS LOADING, on a route whose module has never been fetched.
*
* The chrome switches instantly — title, URL, body[data-page], the menu highlight — but the
* sweep above deliberately does not touch the children of #view, and LuCI's own spinner is
* written by `View.__init__`, i.e. only once the view module has ARRIVED. So between the click
* and the last round-trip of the module chain the user reads the PREVIOUS page's content under
* the new page's title, with nothing moving. Measured on the router at 600 ms latency: at 150 ms
* and 400 ms #view still held the System page while everything else said Processes; the
* "Loading view…" spinner appeared at 900 ms and the content at 1800 ms. A full load would have
* shown the browser's own progress for that whole window.
*
* Only for a COLD route: `_seen` means the class has already been through require(), where
* View.__init__ paints its spinner synchronously and there is no gap to fill.
*
* Deliberately the SAME markup and the SAME msgid luci-base uses, not a skeleton of our own:
* when View.__init__ replaces it there is nothing to see, and the string arrives already
* translated in the ~40 languages this theme ships no catalogue for (the chrome rule — a
* context-free msgid is how we inherit luci-base's translation). */
if (!_seen.has(className)) {
const vp = document.getElementById('view');
if (vp) {
const s = document.createElement('div');
s.className = 'spinning';
s.textContent = _('Loading view…');
vp.replaceChildren(s);
}
}
/* the page being LEFT, captured before L.env is re-pointed a few lines down: the sheet scoping
* spares it until the swap takes it off screen (see there) */
const leaving = (L.env.dispatchpath || []).slice();
/* teardown: drop the outgoing view's pollers, then put the poll loop back into the state a FRESH
* LOAD leaves it in. The only non-view poller LuCI adds is the transient apply/reboot
* reachability check, so flushing the queue is safe.
*
* The re-arm matters: LuCI runs one 1 s tick and fires a queue entry only when
* `tick % interval == 0`, so leaving the OUTGOING page's tick running makes the incoming poller
* wait for the next multiple of its interval — up to `pollinterval`, 5 s. Wireless draws its
* station list from the first poll and sat spinning for 4950 ms against ~360 ms on a full load.
*
* stop() alone is NOT the fix: it deletes `tick`, and Poll.add() only auto-starts when
* `tick != null`, so the incoming pollers would never start at all. stop()+start() on an EMPTY
* queue leaves what a fresh document has (`tick = 0`, no timer armed); the view's first
* poll.add() then starts it and steps immediately — upstream's own sequence, since on a full
* load initDOM() runs Poll.start() on an empty queue before the view renders. */
if (L.Poll && L.Poll.queue) {
L.Poll.queue.length = 0;
L.Poll.stop();
L.Poll.start();
}
/* kill the outgoing view's plain setInterval pollers too (podkop's log tailer) — a full load
* would have. L.Poll's own tick survives. */
clearViewIntervals();
/* and drop uci's document-scoped config cache, which a full load would not have carried into the
* incoming page either (see flushUciCache). What it hands back is the refill of the packages
* network.js will never load again — awaited below, before the view renders. */
const uciWarm = flushUciCache();
/* the outgoing page's links are about to become a detached tree — do not hold one of them */
_lastHovered = null;
/* run every registered navigation callback — today the search palette's recent-pages record and
@@ -769,21 +956,6 @@ function navigate(pathname, push, kbd) {
L.env.nodespec = { satisfied: true, action: node.action, title: node.title,
depends: node.depends, readonly: tree.readonlyForSegs(rsegs) };
/* Keep <body data-page> in sync with the route: the server stamps the dispatch path
* (`ctx.path`) on every full load, and page-scoped CSS keys off it. `rsegs` is the RESOLVED
* leaf, so a firstchild URL like /admin/status yields the same "admin-status-overview" whether
* it arrives as a full load or a client nav. Without the re-stamp the incoming page keeps the
* previous page's data-page and its scoped styles silently do not apply. */
document.body.setAttribute('data-page', rsegs.join('-'));
/* …and immediately hand the new page to fs-sheets, which darkens every foreign sheet that
* belongs to a DIFFERENT page and re-lights the ones that belong to this one. This is what lets
* an invasive sheet stay in the document without spending it — the alternative was a full load
* on the way out of any page carrying one, which stock LuCI's five realtime views all do (see
* the page-ownership block in fs-sheets.js). It must run AFTER the stamp above and BEFORE the
* view renders, so nothing paints through a sheet that no longer owns the page. */
sheets.scopeToCurrentPage(rsegs);
/* Re-navigating to the page already on screen must REPLACE its history entry, not push a
* second one. Clicking the active menu item is ordinary, and a duplicate entry makes Back do
* nothing: popstate fires, `location.pathname === _curPath`, and the fragment guard below
@@ -870,60 +1042,134 @@ function navigate(pathname, push, kbd) {
* and the wait introduces a window in which we may never get there. Marking it up front would
* make the NEXT navigation take the cached branch — `new view.constructor()` on a class whose
* require() is what renders it — i.e. two renders and two pollers for one page. */
/* `uciWarm` rides alongside the prefetch wait rather than after it: both are in flight already, and
* a view must not be constructed until the uci cache it reads is back (see flushUciCache). It never
* rejects — the refill catches its own failure — so it cannot cost the navigation a full reload. */
Promise.all([ warmedThen(className), uciWarm ]).then(() => {
/* THE PREVIOUS RENDER IS WAITED FOR, not raced. Every LuCI view chain resolves `#view` at PAINT
* time, so a chain still running from an older navigation would paint into whatever stage is
* first when it finishes — i.e. into this one's. Waiting is what lets the stale chain land in
* its own stage, which is then dropped unswapped, and it is what retired the three repair
* mechanisms this file used to carry. The wait rides alongside the prefetch wait rather than
* after it: both are already in flight. */
const previous = _inflight;
let release;
_inflight = new Promise((r) => { release = r; });
progressStart();
Promise.all([ warmedThen(className), previous.catch(() => {}) ]).then(() => {
/* superseded while waiting: never start the require. On a first visit the require IS the
* render, so starting it here would paint a page the user has already left and hand
* repairStaleRender() a mess that only exists because a require in flight cannot be stopped. */
* render, so starting it here would spend a module fetch and a round of RPCs on a page the
* user has already left. */
if (gen !== _navGen) return null;
/* ---- teardown, now that the previous render is finished and cannot re-register ----
*
* Drop the outgoing view's pollers, then put the poll loop back into the state a FRESH LOAD
* leaves it in. The only non-view poller LuCI adds is the transient apply/reboot
* reachability check, so flushing the queue is safe.
*
* The re-arm matters: LuCI runs one 1 s tick and fires a queue entry only when
* `tick % interval == 0`, so leaving the OUTGOING page's tick running makes the incoming
* poller wait for the next multiple of its interval — up to `pollinterval`, 5 s. Wireless
* draws its station list from the first poll and sat spinning for 4950 ms against ~360 ms on
* a full load.
*
* stop() alone is NOT the fix: it deletes `tick`, and Poll.add() only auto-starts when
* `tick != null`, so the incoming pollers would never start at all. stop()+start() on an
* EMPTY queue leaves what a fresh document has (`tick = 0`, no timer armed); the view's first
* poll.add() then starts it and steps immediately — upstream's own sequence, since on a full
* load initDOM() runs Poll.start() on an empty queue before the view renders. */
if (L.Poll && L.Poll.queue) {
L.Poll.queue.length = 0;
L.Poll.stop();
L.Poll.start();
}
/* kill the outgoing view's plain setInterval pollers too (podkop's log tailer) — a full load
* would have. L.Poll's own tick survives. */
clearViewIntervals();
/* and drop uci's document-scoped config cache, which a full load would not have carried into
* the incoming page either (see flushUciCache). What it hands back is the refill of the
* packages network.js will never load again — awaited below, before the view renders. */
const uciWarm = flushUciCache();
/* Keep <body data-page> in sync with the route: the server stamps the dispatch path
* (`ctx.path`) on every full load, and page-scoped CSS keys off it. `rsegs` is the RESOLVED
* leaf, so a firstchild URL like /admin/status yields the same "admin-status-overview"
* whether it arrives as a full load or a client nav. Without the re-stamp the incoming page
* keeps the previous page's data-page and its scoped styles silently do not apply.
*
* WHERE THIS SITS, AND WHAT IT COSTS. It has to be before the staged render: page-scoped CSS
* is keyed on `body[data-page]`, so a view that renders under the wrong value measures itself
* through the wrong rules — and the fitters run inside the stage. It cannot be moved to the
* swap for the same reason. What that buys is a correct incoming page; what it costs is that
* the OUTGOING page, still on screen until the swap, wears the incoming page's name for the
* staging window. The one artifact that is visible today is the Overview's own
* `<h2 name="content">Status</h2>`, hidden by a `body[data-page='admin-status-overview']`
* rule and therefore un-hidden while leaving that page — the same stray heading the sweep at
* the swap removes. Moving the stamp later would trade that for a wrongly measured incoming
* page, which is worse and lasts longer. Reported in review; stated here rather than left as
* a claim the placement does not have. */
document.body.setAttribute('data-page', rsegs.join('-'));
/* …and hand the new page to fs-sheets, which darkens every foreign sheet that belongs to a
* DIFFERENT page and re-lights the ones that belong to this one. This is what lets an
* invasive sheet stay in the document without spending it — the alternative was a full load
* on the way out of any page carrying one, which stock LuCI's five realtime views all do (see
* the page-ownership block in fs-sheets.js). It must run AFTER the stamp above and BEFORE the
* view renders, so nothing paints through a sheet that no longer owns the page.
*
* IN TWO HALVES, because unlike `data-page` this one can be split. Enabling the incoming
* page's sheets is what the staged render needs; DISABLING the outgoing page's is what would
* strip an app's own stylesheet off the content the user is still reading — for the whole
* staging window, which on a cold route is the 600-1800 ms this design exists to fill. So the
* page being left is spared here and swept at the swap (see commitStage). Reported in
* review. */
sheets.scopeToCurrentPage(rsegs, leaving);
const stage = stageView(contentHost);
const painted = renderedIn(stage.view);
_seen.add(className);
/* NAME THE OWNER for the length of this require, AND ONLY WHEN THE MODULE HAS YET TO BE
* EVALUATED. On a first visit the require IS the render, so any <style> the module injects
* belongs to THIS page — even if a newer navigation has stamped data-page by the time it
* lands, which is exactly the case repairStaleRender() below exists for. Without it fs-sheets
* credited such a sheet to the page that superseded this one and bound it there for the life
* of the document (measured: luci-app-filemanager's `.cbi-button-save { display: none
* !important }` disabled on its own page and live on System -> System, across return visits).
*
* `!cached` is what makes the hint survive long enough to be read, and its absence is why the
* measured case stopped being covered. A CACHED require injects nothing — the module was
* evaluated on the first visit and `require()` hands back the singleton — but it still ran
* this line, and its `.finally` fires within a microtask, so the navigation that superseded a
* cold require would set the hint to itself and clear it to null while that require was still
* in flight. The sheet then landed with no hint at all, i.e. credited to the superseding page,
* which is the exact bug. A require that cannot inject has no business naming an owner. */
* belongs to THIS page. Without it fs-sheets credited such a sheet to whichever page was
* stamped when it landed and bound it there for the life of the document (measured:
* luci-app-filemanager's `.cbi-button-save { display: none !important }` disabled on its own
* page and live on System -> System, across return visits). A CACHED require injects nothing
* — the module was evaluated on the first visit and `require()` hands back the singleton — so
* it has no business naming an owner. */
if (!cached) sheets.attributeTo(rsegs, gen);
return RT.require(className).finally(() => { if (!cached) sheets.attributeTo(null, gen); });
}).then((view) => {
if (view == null) return;
if (!(view instanceof RT.view))
throw new TypeError('Loaded class ' + className + ' is not a view');
if (gen !== _navGen) {
/* A newer navigation superseded this one. On the CACHED path nothing has happened
* yet — skipping the constructor below is the whole cancellation. On the FIRST-visit
* path the require() has ALREADY rendered into the live page and registered its
* pollers, with nothing to cancel: undo it. See repairStaleRender(). */
if (!cached)
repairStaleRender(className);
return;
}
if (cached) {
/* singleton: its __init__ already ran, re-run it. Arm the paint-time guard BEFORE the
* construct (__init__ binds this.render as it builds its chain) and stamp the
* generation right after it (the first load() is a microtask, so this lands first) —
* this navigation can still be superseded while that render awaits its RPC. */
const cls = view.constructor;
armRenderGuard(cls);
const inst = new cls();
inst.__fsGen = gen;
}
/* `uciWarm` is awaited before the construct, never after: a cached module resolves within a
* microtask, well before the refill lands, and the view would read the cache we just emptied.
* It never rejects — the refill catches its own failure — so it cannot cost a full reload. */
return Promise.resolve(uciWarm)
.then(() => RT.require(className))
.finally(() => { if (!cached) sheets.attributeTo(null, gen); })
.then((view) => {
if (!(view instanceof RT.view))
throw new TypeError('Loaded class ' + className + ' is not a view');
/* require() does not hand back a class — it caches an INSTANCE, so requiring a class
* not seen before CONSTRUCTS it, and a view's __init__ IS its render. On a first
* visit the require has therefore already painted (into the stage); only on a REVISIT
* is there a singleton whose __init__ has to be re-run. `cached` was read before the
* waits above, since the require is what fills LuCI's cache. */
if (cached) new view.constructor();
return painted;
})
.then(() => {
/* superseded while rendering: the chain painted into ITS OWN stage, so there is
* nothing to repair — drop it and leave the live page to the newer navigation. */
if (gen !== _navGen) { dropStage(stage); return; }
commitStage(stage, contentHost);
/* now, and only now, is there one height to read: the incoming page's */
if (restoreTo) restoreScroll(restoreTo, gen);
})
.catch((e) => { dropStage(stage); throw e; });
}).catch((e) => {
/* the full reload is a correct fallback, but swallowing the reason made every SPA-router
* regression look like "the page is just slow to load". Log, then fall back. */
console.error('footstrap: SPA nav to ' + className + ' failed, falling back to a full load', e);
if (gen === _navGen) window.location = pathname;
}).then(() => {
progressEnd();
release();
});
return true;
@@ -960,10 +1206,39 @@ function prefetchFrom(ev) {
* lasts until the pointer happens to cross some other link. */
let _lastHovered = null;
/* ---- a document the router could not have rendered is not one it may navigate AWAY from ----
*
* A `call`, `cbi` or `function` node — and a `template` other than the one Status→Overview node we
* serve — is a page this theme did not build and cannot rebuild. What it leaves behind is the
* problem: such a page may carry inline scripts and timers of its own, set before this module was
* even evaluated, so the interval hook at the top of this file never saw them and no teardown of
* ours can retire them. Only the document's death does. So the first click away from one is a full
* load, which is what a user got before the router existed.
*
* NARROWER THAN "did the current path resolve", deliberately. A path that resolves to nothing is
* not evidence of a hostile document — a wildcard URL (`admin/network/wireless/radio0.network1`)
* simply is not in the tree, since nodeForSegs() stops at the first unknown segment, and refusing to
* wire there would turn the router off for the whole document on some of the most-used pages in
* LuCI. Only a node we CAN see and CANNOT serve disables it.
*
* On the stands this is a no-op today and is written for the case it is not: of 243 menu nodes,
* the 110 `call` and 8 `function` ones answer with JSON or a redirect and never render this theme
* at all (a probe on those URLs finds no `L` in the document), and the single `template` node is
* the Overview, which viewClassFor() maps to `view.status.index`. */
function bootDocumentIsOurs() {
const node = tree.currentNode();
if (!node || !node.action)
return true; /* unknown to the tree: a wildcard page, and today's behaviour is right */
return tree.viewClassFor(node) != null;
}
function wireRouter() {
if (_wired) return;
_wired = true;
if (!bootDocumentIsOurs())
return;
document.addEventListener('click', (ev) => {
if (ev.defaultPrevented || ev.button !== 0 ||
ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)
@@ -1029,11 +1304,18 @@ function wireRouter() {
* adopt the entry we arrived on and look up what IT recorded when it was left */
saveScroll();
adoptEntry();
const target = _scrollMem.get(_curId);
if (!navigate(window.location.pathname, false))
/* Handed to navigate() rather than started here, and that is not tidiness — it is a bug the
* staged swap introduced and this is where it is fixed. restoreScroll() writes as soon as the
* scroller is TALL ENOUGH for the saved offset, and while the incoming page renders off
* screen the OUTGOING one is still on it, so the height that satisfies the test can be the
* old page's. Measured in the top layout: parked at 386, restored at 386 while Processes was
* still up, then the swap replaced it with a shorter page and the browser clamped to 197.
* Started after the commit instead, there is only one height it can be reading. */
_pendingRestore = _scrollMem.get(_curId) || null;
if (!navigate(window.location.pathname, false)) {
_pendingRestore = null;
window.location.reload();
else
restoreScroll(target, _navGen);
}
});
}
@@ -1054,6 +1336,11 @@ document.addEventListener('poll-stop', () => {
}
});
/* At module eval, like the listener above and for the same reason: the session can die during the
* FIRST view's own data calls, i.e. before anything has called wire(), and an interceptor that was
* not registered by then never sees the answer that said so. */
watchSession();
/* Pause LuCI's 1s poll loop while the tab is hidden: LuCI has no visibilitychange handler, so an
* overview left open in a background tab hammers ubus 24/7 (notably the pricey iwinfo getAssocList)
* on a low-power router. stop() only clearInterval()s (the queue survives); start() re-arms and runs
@@ -1073,7 +1360,10 @@ function wireVisibility() {
wasActive = L.Poll.active();
if (wasActive) L.Poll.stop();
}
else if (wasActive) {
/* …but never resume a poll the session can no longer answer: luci-base stopped it on
* purpose when it put its "Session expired" modal up, and restarting it on tab-show
* would spend a burst of failing calls behind a page the user cannot use anyway. */
else if (wasActive && !_expired) {
L.Poll.start();
}
} catch (e) { console.error('footstrap: the poll pause/resume threw', e); }
@@ -733,12 +733,24 @@ function setEnabled(el, on) {
* OWN are touched: a clean sheet is harmless and an invasive one we could not attribute still
* poisons the document, so it keeps the full-load path rather than being silently disabled on its
* own page. */
function scopeToCurrentPage(segs) {
/* `keep` is the page still ON SCREEN, and it exists because a client navigation now has a window in
* which two pages are real: the incoming one renders into a hidden stage while the outgoing one is
* still being read (fs-router's staged swap). Scoping is needed BEFORE the staged render — a view
* must not measure itself through a sheet that does not own its page — but doing both halves then
* strips the outgoing page's own stylesheet off content the user is looking at, for the 600-1800 ms
* the staging window exists to fill. So the router calls this twice: once with `keep` set to the
* page it is leaving, which enables the incoming page's sheets and leaves the departing page's
* alone, and once with nothing at the swap, which is the ordinary sweep. */
function scopeToCurrentPage(segs, keep) {
if (segs) _curKey = appKey(segs);
const key = currentKey();
const spared = (keep && keep.length) ? appKey(keep) : null;
document.querySelectorAll(VIEW_SHEETS).forEach((el) => {
if (!outlivesPage(el) || !_owner.has(el)) return;
setEnabled(el, _owner.get(el) === key);
const owner = _owner.get(el);
/* the page on screen keeps what it owns until the swap takes it off screen */
if (spared !== null && owner === spared && owner !== key) return;
setEnabled(el, owner === key);
});
}
@@ -27,14 +27,17 @@ const FS_VERSION = '0.0.0-dev';
* `git describe` ('0.9.4-12-gabc1234') and that must keep counting as a real version. */
function isReal() { return ((/^\d+\.\d+/).test(FS_VERSION)) && !((/-dev$/).test(FS_VERSION)); }
/* Two exports, and the two that were dropped are worth a line each so they are not re-added out of
* habit: `VERSION` (the bare string) and `isReal` had no caller anywhere in the theme or the
* templates — `label()` is what every reader actually wants, and it is the one place that decides
* how a dev build names itself. An export nobody imports is not free: it is a promise to keep the
* shape stable. */
return baseclass.extend({
VERSION: FS_VERSION,
/* the project's page: the link at the foot of the Appearance tab, and the one
* nameThemeInFooter() writes into the footer. It was assembled from a separate FS_REPO
* constant that the retired self-update script needed to build API paths from, and nothing
* has needed the bare `owner/name` since. */
/* the project's page: the link at the foot of the Appearance tab, and its only reader since
* the footer line was withdrawn. It was assembled from a separate FS_REPO constant that the
* retired self-update script needed to build API paths from, and nothing has needed the bare
* `owner/name` since. */
REPO_URL: 'https://github.com/VizzleTF/luci-theme-footstrap',
isReal,
/* what the Appearance tab's version row prints */
label: () => (isReal() ? ('Footstrap v' + FS_VERSION) : 'Footstrap (dev)')
});
@@ -58,8 +58,6 @@ return baseclass.extend({
fit.add(chrome.fitChrome);
chrome.renderChrome();
/* once per document — the footer outlives every client navigation */
chrome.nameThemeInFooter();
/* Both of these ADD to a stock page rather than owning a route: each watches
* body[data-page] and does nothing anywhere else. A theme may not register a
* dispatcher node — it outlives the theme that registered it. */
@@ -312,8 +312,18 @@ return baseclass.extend({
/* a clamp computed at the old width is wrong at the new one; coalesced via fit.frame
* (the shared coalescer) because resize fires dozens of times a second while a window
* is dragged */
window.addEventListener('resize', fit.frame(clearClamps));
* is dragged.
*
* WIDTH ONLY, for the reason fs-fit's observer states at length: every browser on iOS
* fires `resize` continuously while the URL bar slides away, and a clamp computed for a
* width that did not change is work done on a page the user is scrolling. */
let lastWidth = window.innerWidth;
const reclamp = fit.frame(clearClamps);
window.addEventListener('resize', () => {
if (window.innerWidth === lastWidth) return;
lastWidth = window.innerWidth;
reclamp();
});
/* Appearance -> Submenus -> auto-collapse. fs-prefs.js owns the stored value and says only
* that it changed; applying it is entirely ours, because every piece of the state is —