Files
op-packages/luci-theme-footstrap/htdocs/luci-static/resources/fs-router.js
T

1047 lines
60 KiB
JavaScript

'use strict';
'require baseclass';
'require ui';
'require fs-menutree as tree';
'require fs-chrome as chrome';
'require fs-sheets as sheets';
/* ---- SPA client router ----
*
* Kills the full page reload for `view`-type menu nodes — 54 of 74 menu leaves (~73%) on the dev
* router; the rest are call/function/template. LuCI already renders every page client-side into
* #view; only NAVIGATION is server-dispatched. So intercept link clicks and re-instantiate the
* target view in place — what the dispatcher's view.ut does via ui.instantiateView(), minus the
* reload. Purely additive: anything that is not a satisfied `view` node (call/function/template/
* alias/firstchild, external, download, cross-origin, modified click) or any error falls through to
* a normal navigation, and deep links / F5 keep working because we pushState the real URL.
*
* Re-instantiation: L.require('view.x') returns a cached SINGLETON whose __init__ (the render)
* already ran, so calling it again repaints nothing. Take the class off the instance
* (prototype.constructor) and `new v.constructor()` for a fresh __init__ → load()+render(), which is
* what a full load does anyway. docs/spa-router.md.
*
* The path->node half lives in fs-menutree.js (the chrome needs it too); the "has a view poisoned
* this document with its CSS?" half in fs-sheets.js. */
/* --- stray-interval teardown for SPA nav ---
* A full load kills every window.setInterval the outgoing page set; SPA nav does not, so a view's
* poller keeps firing against a page that is gone (luci-app-podkop's log tailer runs
* `podkop check_logs` forever after you navigate away). Track view-set ids and clear them on nav,
* keeping L.Poll's own 1s tick (also a setInterval); L.Poll's queue is flushed in navigate().
* Hooked at module eval — before any view render can set a timer, and LuCI resolves a module's
* dependencies BEFORE running the dependent's factory, so this runs no later than it used to when
* it sat in menu-footstrap-common.js itself. */
const _viewIntervals = (window.__fsViewIntervals || (window.__fsViewIntervals = new Set()));
(function hookIntervals() {
if (window.__fsIntervalsHooked) return;
window.__fsIntervalsHooked = true;
const _si = window.setInterval, _ci = window.clearInterval;
window.setInterval = function () {
const id = _si.apply(window, arguments);
_viewIntervals.add(id);
return id;
};
window.clearInterval = function (id) {
_viewIntervals.delete(id);
return _ci.apply(window, arguments);
};
})();
function clearViewIntervals() {
/* `L.Poll.timer` is the id of LuCI's OWN 1 s tick, and it is private state — `add`/`remove`/
* `start`/`stop`/`active` are the documented surface, and upstream has already marked the whole
* `L.Poll` alias deprecated (`'require poll'` is its replacement, and neither 24.10 nor 25.12
* ships poll.js yet, so the alias is still the only way in). If that field ever goes, `keep`
* becomes null and this sweep would clear LuCI's tick along with the view's timers — every poll
* on every later page silently dead, from a rename we did not notice. So the missing field is
* not a null: it is a reason to do NOTHING, once, loudly. A view's leftover interval outliving
* its page costs a wasted RPC; killing the global tick costs the router's live data. */
/* Asked through the DOCUMENTED half first: active() says whether LuCI's tick is running at all,
* and `timer` is deleted by stop() — so an absent field is the ordinary "nothing to protect"
* case on a page with no pollers, not a sign that upstream moved it. The anomaly worth reporting
* is the pair disagreeing: a tick that is running while the id it runs on has no name we know.
* Then do NOTHING, once, loudly: a view's leftover interval outliving its page costs a wasted
* RPC, whereas clearing LuCI's own tick costs every live value on every later page. */
/* …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. */
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 '
+ 'updating for this luci-base.');
return;
}
const running = (typeof L.Poll.active === 'function') ? L.Poll.active() : (L.Poll.timer != null);
if (running && L.Poll.timer == null) {
warnPollUnreadable('footstrap: LuCI is polling but L.Poll.timer is not readable — leaving view '
+ 'intervals alone rather than risking its tick. fs-router.js needs updating for this '
+ 'luci-base.');
return;
}
const keep = running ? L.Poll.timer : null;
_viewIntervals.forEach((id) => { if (id !== keep) window.clearInterval(id); });
}
/* ONE line per document whatever went wrong: this runs on every navigation, and a router that
* cannot read L.Poll cannot read it on the next click either — a message per click would bury the
* console the user is reading it in. */
let _pollWarned = false;
function warnPollUnreadable(msg) {
if (_pollWarned) return;
_pollWarned = true;
console.error(msg);
}
/* --- uci cache teardown for SPA nav ---
* `uci.load()` does not answer "is this config present?" — it answers "which of these packages did
* THIS call fetch", skipping every package already in its document-scoped cache:
*
* for (…) if (!self.state.values[packages[i]]) { pkgs.push(…); tasks.push(…) }
* return Promise.all(tasks).then(() => pkgs); // uci.js, luci-base
*
* Four shipped views read that return value as an existence check and abort on an empty array —
* luci-app-banip and luci-app-adblock's overview, luci-app-travelmate's overview and stations:
*
* if (!result[3] || result[3].length === 0) { ui.addNotification(…, _('No banIP config found!')); return; }
*
* On a full load the cache is empty, so the first visit always gets its package name back and the
* check passes. Under SPA the document survives, so the SECOND visit gets `[]` and the page renders
* as "no config found" — reported against banip, where switching between its own tabs and coming
* back to Overview is the ordinary gesture, and unstickable by anything short of a reload.
*
* The apps' reading of `load()` is wrong, but the DIVERGENCE is ours: a cache that outlives the page
* that filled it is state a fresh load does not have, exactly like the poll queue and the view's
* intervals above. So drop it on navigation and let the incoming view fetch what it needs.
*
* `unload()` is upstream's own idiom for this, not a lever we found: `uci.save()` ends with
* `self.unload(pkgs); return self.load(pkgs)`. Pending local edits (creates/changes/deletes) go with
* it — as they do on a full load, which throws away the whole document. Saved changes are already
* on the server and the Unsaved-changes banner reads them from there, so it is unaffected.
*
* Read through `window.L.uci` rather than a `'require uci'` pragma, deliberately: the class attaches
* itself to L's prototype when the FIRST requirer compiles it, so this sees the instance the pages
* actually use, and a router that required it would both bind it to its own prototypal L (the two-L
* trap, docs/spa-router.md) and pull uci.js onto pages that never touch uci. No instance means no
* cache to flush. */
function flushUciCache() {
const uci = window.L ? window.L.uci : null;
if (!uci || typeof uci.unload !== 'function') return;
/* `state.values` and `loaded` are private, so the same rule as L.Poll.timer applies: a shape we
* do not recognise is a reason to do nothing, once, loudly — never to guess. The two hold
* different halves of the cache (a package whose load is still in flight is in `loaded` alone),
* and unload() clears both, so the names are taken from both. */
if (!uci.state || typeof uci.state.values !== 'object' || typeof uci.loaded !== 'object') {
if (!_uciCacheWarned) {
_uciCacheWarned = true;
console.error('footstrap: LuCI.uci keeps its cache somewhere this router does not know, so '
+ 'it is left alone. An app that reads uci.load()\'s return value as an existence check '
+ 'will report a missing config on the second SPA visit. fs-router.js needs updating for '
+ 'this luci-base.');
}
return;
}
const names = Object.keys(uci.state.values).concat(Object.keys(uci.loaded));
if (names.length) uci.unload(names);
/* `state.reorder` is the one half unload() does not clear, and it is deliberately left alone
* rather than reset by hand: with `values` gone, reorderSections() finds no sections to order,
* emits no call and clears the map itself on the next save. Writing to that field would be us
* editing another module's private state, for a difference nothing can observe. */
}
let _uciCacheWarned = false;
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. */
let _curPath = window.location.pathname;
/* nav generation token: two quick clicks race their async require()s, and without it the FIRST
* view could render into #view after the second, leaving stale content under the newer
* URL/title/chrome. A resolved require whose generation is stale renders nothing. */
let _navGen = 0;
/* ---- Back must restore the scroll of WHICHEVER element is the scroller ----
* The two layouts scroll different elements: the sidebar layout pins .fs-shell to 100dvh and gives
* overflow-y to .fs-main (#maincontent), the top layout lets the document scroll. A browser restores
* an INNER scrollable region only across full loads, never on a same-document traversal — measured
* (docs/spa-router.md, "Scroll"): Back opened the incoming page at 0, because the swap empties #view,
* scrollHeight collapses and the browser clamps scrollTop.
*
* The DOCUMENT scroller was left to the browser's own scrollRestoration ('auto') on the grounds that
* it is the case a UA does handle — and that is wrong for the same reason, measured on the stand: in
* the top layout, scroll Processes to 400, open System, press Back, and the page opens at 0 (the
* sidebar layout, restored here, comes back at 400). The UA restores at the traversal, i.e. BEFORE
* this handler swaps #view; the height then collapses under the restored offset and the clamp takes
* it back to 0, with nothing left to re-apply it. So record and replay BOTH offsets; the one that is
* not this layout's scroller is 0 and skipped.
*
* NOT by replaceState on scroll: Safari rate-limits history writes (100 per 30 s) and a scroll
* listener trips it. Each SPA entry instead carries a session-unique id (fsid) in history.state, and
* the offsets live in this in-memory Map — lost on a full load, which is exactly when the browser's
* own scrollable-region restoration takes over. The id is session-prefixed because a bare counter
* restarts with every document: an entry stamped by a PREVIOUS document of this tab would collide
* with a fresh one and restore another page's offset. */
const _scrollMem = new Map();
const _scrollSess = Date.now().toString(36) + Math.floor(Math.random() * 1e6).toString(36);
let _histN = 0;
let _curId = null;
/* …and it is BOUNDED, because this document outlives every page in it. One entry per history entry
* per session, never evicted, is the same shape as the leaks this file already fixes elsewhere (the
* action-column resize listener, the view pollers) — small enough that nobody would see it and
* unbounded all the same. A browser keeps ~50 entries per tab and the ones past that cannot be
* traversed back to, so remembering more offsets than that can never be read. Least-recently-SAVED
* 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;
function newEntryId() { return _scrollSess + ':' + (++_histN); }
/* adopt the entry we are standing on: reuse its fsid if it has one, stamp one otherwise (entries
* created by a full load carry state === null). replaceState keeps the entry's own scroll record. */
function adoptEntry() {
const st = history.state;
if (st && st.fsid) { _curId = st.fsid; return; }
_curId = newEntryId();
try { history.replaceState(Object.assign({}, st, { fsid: _curId }), '', window.location.href); } catch (e) {}
}
/* the outgoing DOM is still on screen at both call sites (the click, the popstate), so this must
* run BEFORE _curId moves on to the incoming entry */
function saveScroll() {
if (!_curId) return;
const sc = document.getElementById('maincontent');
_scrollMem.delete(_curId); /* re-insert, so this entry counts as the most recently saved */
_scrollMem.set(_curId, { win: Math.round(window.scrollY) || 0, main: sc ? sc.scrollTop : 0 });
while (_scrollMem.size > SCROLL_MEM_MAX)
_scrollMem.delete(_scrollMem.keys().next().value);
}
/* Put the scrollers back where the entry left them — but only once the incoming view has grown that
* much height (docs/spa-router.md, "Scroll": restoring before the content exists is clamped to 0 and reads as
* "worked"). The view renders behind an RPC, so poll by frame; a newer navigation cancels via the
* generation, and a page that never reaches the old height again is simply left at the top. Each
* offset is waited for on its OWN scroller, so a layout switched between the two entries restores
* whichever half it can rather than blocking on the half that no longer scrolls. */
function restoreScroll(pos, gen) {
if (!pos || (!pos.win && !pos.main)) return;
/* A DEADLINE, not a frame count: this was 300 frames called "~5 s", which holds only at 60 Hz —
* the same budget is 10 s on a 30 Hz panel. Frames stay the tick (they are when a paint could
* have changed the height); time decides when to stop waiting for an RPC that is not coming. */
const until = Date.now() + 5000;
/* THE USER OUTRANKS THE SAVED POSITION. Waiting up to five seconds for a slow view to grow means
* the reader may have started using the page in the meantime — and jumping them somewhere else
* two seconds after they began reading is worse than opening at the top, which is what a full
* load does anyway. So any sign that the scroll is THEIRS cancels the restore for good.
*
* Two kinds of sign, because neither covers the other. The three input events are intent even
* when nothing moves yet (a wheel tick on a page too short to scroll, a touch that becomes a
* drag); `scroll` is the catch-all for everything they cannot see — a scrollbar drag, a
* trackpad fling, Find-in-page, an anchor jump, assistive tech. `scroll` also fires for OUR
* OWN writes, asynchronously, so a flag around the write would still be false by the time it
* arrives: the position we last wrote is remembered instead, and a scroll that lands exactly
* there is ours. Landing there by hand is indistinguishable and costs nothing — it is the
* position we were restoring to.
*
* Passive listeners: this must never sit in front of the scroll it is watching for. */
let cancelled = false, wroteWin = -1, wroteMain = -1;
const stop = () => { cancelled = true; off(); };
const onScroll = (ev) => {
const t = ev.target;
const now = (t === document || t === document.documentElement || t === document.body)
? Math.round(window.scrollY) : (t && t.scrollTop);
if (now === wroteWin || now === wroteMain) return; /* our own write coming back */
stop();
};
/* the keys that scroll, and only those: typing in a field must not cancel anything */
const SCROLL_KEYS = new Set([ 'PageUp', 'PageDown', 'Home', 'End', 'ArrowUp', 'ArrowDown', ' ', 'Spacebar' ]);
const onKey = (ev) => { if (SCROLL_KEYS.has(ev.key)) stop(); };
const opts = { passive: true, capture: true };
function off() {
window.removeEventListener('wheel', stop, opts);
window.removeEventListener('touchstart', stop, opts);
window.removeEventListener('keydown', onKey, opts);
window.removeEventListener('scroll', onScroll, opts);
}
window.addEventListener('wheel', stop, opts);
window.addEventListener('touchstart', stop, opts);
window.addEventListener('keydown', onKey, opts);
/* capture, so the inner scroller (#maincontent in the sidebar layout) is seen too: `scroll`
* does not bubble from an element, but it does travel down the capture phase. */
window.addEventListener('scroll', onScroll, opts);
(function tick() {
if (cancelled) return;
if (gen !== _navGen || Date.now() > until) { off(); return; }
const de = document.documentElement;
const sc = document.getElementById('maincontent');
let pending = false;
if (pos.main) {
if (sc && sc.scrollHeight - sc.clientHeight >= pos.main) { wroteMain = pos.main; sc.scrollTop = pos.main; }
else pending = true;
}
if (pos.win) {
if (de.scrollHeight - de.clientHeight >= pos.win) { wroteWin = pos.win; window.scrollTo(0, pos.win); }
else pending = true;
}
if (pending) requestAnimationFrame(tick);
else off();
})();
}
/* ---- the host half of "<host> | <page>" is read ONCE ----
* head.ut stamps it and it cannot change within a document, but navigate() used to re-derive it from
* the LIVE document.title on every hop — so any page that renames the tab became the host for every
* page after it, until a full load. Third-party views do rename it (log viewers, dashboards), and it
* takes only one: measured on the stand, a view setting `document.title = 'ACME Dashboard'` left the
* next hops reading "ACME Dashboard | Routing", "ACME Dashboard | System". Captured at seed(), i.e.
* at chrome init on the full load that started the session; the lazy branch is for a document whose
* chrome came up without seed() having run. */
let _titleHost = null;
function titleHost() {
if (_titleHost === null)
_titleHost = (document.title.split('|')[0] || '').trim();
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(). */
function moduleUrl(className) {
const v = L.env.resource_version ? ('?v=' + L.env.resource_version) : '';
return (L.env.base_url || '') + '/' + className.replace(/\./g, '/') + '.js' + v;
}
/* ---- link prefetch: warm the module cache for a page the user is about to open ----
*
* A plain fetch(), NOT require(): require() instantiates, and a view's __init__ IS its render, so it
* would paint another page into #view. fetch() only fills the browser's HTTP cache, which the later
* require()'s XHR then hits. Deduped per class; failures are silent (a pure optimisation).
*
* TRANSITIVE, and that is where most of the win is: warming the view class alone leaves its own
* `require` pragmas one round-trip behind it. view/network/routes.js pulls tools/network.js (40.5 KB),
* and measured on the dev router at 120 ms RTT a first visit cost 418 ms with the view warmed against
* 296 ms with its deps warmed too — one RTT exactly. Over six pages, 1713 ms cold → 1184 warmed →
* 1052 warmed transitively. The bytes are in hand either way, so the scan is free.
*
* The scan MUST NOT be line-anchored. The shipped files are MINIFIED and every pragma sits on one
* line (`'use strict';'require view';'require fs';…`), so /^'require …'$/m matches nothing at all —
* silently, which is how the first attempt at this measured a win of zero. luci.js lexes the leading
* string literals; this reads the same head of the file with one regex. */
const PRAGMA_HEAD = 2000; /* bytes of leading literals to scan — luci.js stops at the first non-string token */
const PREFETCH_DEPTH = 3;
function pragmaDeps(src) {
const re = /(['"])require[ \t]+([^'"]+?)\1/g;
const head = src.slice(0, PRAGMA_HEAD);
const out = [];
let m;
while ((m = re.exec(head)))
out.push(m[2].split(/[ \t]+as[ \t]+/)[0]);
return out;
}
/* WHICH CLASS NAMES ARE WORTH A PREFETCH, and the answer is the DOTTED ones — that is the whole
* rule now, and it used to be a hardcoded list of the six names luci.js seeds its registry with
* (`baseclass`, `dom`, `poll`, `request`, `session`, `view`). Those have no file, so fetching one is
* a guaranteed 404 in the user's console — measured before the list existed: warming the recents at
* idle put 404s for view.js and poll.js into every page load, and the first hover added baseclass.js.
* The list worked, and it was a literal copied out of luci.js rather than a guess about it, but it
* was still a copy: a seventh built-in would cost one 404 per name per session until this file
* caught up with a release of somebody else's software.
*
* The shape of the names answers it without the copy. A LuCI class name is a path — `tools.widgets`
* is tools/widgets.js — so a name with no dot is either one of those six virtual classes or one of
* the flat libraries (`ui`, `form`, `network`, `uci`, `rpc`, `fs`, `validation`), and those are
* already loaded by the time any prefetch runs: this theme's own chrome requires `network` for the
* overview grid, which drags in firewall/uci/rpc/validation, and `ui` comes with the widgets.
* Measured on the stand from three different landing pages, including the lightest one there is
* (System -> Reboot): all eight flat libraries were already instances on arrival, and driving the
* prefetch walk over seven pages fetched 10 files, every one of them nested. So the flat half of the
* namespace is worth nothing to prefetch and can never be worth a 404 — declining it outright costs
* nothing measurable, needs no list, and a future built-in is covered before it ships.
*
* The dotted half is still asked properly: require() attaches a class at its path (`ptr[parts[i]] =
* instance`), so `tools.widgets` reads back as L.tools.widgets once some form page has pulled it,
* and a second navigation there spends no request at all. `instanceof L.Class` rather than a
* truthiness test: L.env, L.url and L.get are members too. */
function classLoaded(name) {
if (name.indexOf('.') < 0) return true;
try {
let ptr = window.L;
for (const part of name.split('.')) {
ptr = ptr[part];
if (ptr == null) return false;
}
return ptr instanceof window.L.Class;
}
catch (e) { return false; }
}
const _seen = new Set();
const _prefetched = new Set();
/* className -> the promise of ITS OWN body being in the HTTP cache; navigate() waits on that.
* Deliberately the body and not the subtree, see _committed below. */
const _warming = new Map();
/* Roots a navigation has taken over. Speculation below them STOPS: require() is now fetching the same
* graph and pipelines its parse and eval against those fetches, so descending would only race it —
* measured at 120 ms RTT, waiting for the whole subtree instead cost 658 ms against 525 ms racing,
* for the sake of a duplicate that stopping avoids outright. Deps have not been asked for yet when a
* click arrives (they start only once the root body lands), so there is nothing in flight to collide
* with: this leaves zero duplicated bytes AND require()'s pipelining. */
const _committed = new Set();
function warmClass(name, depth, root) {
if (_prefetched.has(name)) return;
_prefetched.add(name);
if (classLoaded(name)) return;
let req;
try { req = fetch(moduleUrl(name), { credentials: 'same-origin' }); }
catch (e) { return; }
const body = req.then((res) => (res.ok ? res.text() : '')).catch(() => '');
_warming.set(name, body.then(() => {}, () => {}));
/* The visited set is global and the depth capped, so the walk terminates regardless of what the
* pragmas say — require() raises DependencyError on a cycle, but only for classes it actually
* loads, and this walks files it may never hand to require() at all. */
if (depth < PREFETCH_DEPTH)
body.then((src) => {
if (_committed.has(root)) return;
for (const d of pragmaDeps(src)) warmClass(d, depth + 1, root);
});
}
/* Warm the view a menu path resolves to, plus its dependency tree. `segs` is the menu path
* (`admin/network/routes`), the shape fs-search stores its recents in. */
function prefetchSegs(segs) {
if (!Array.isArray(segs) || !segs.length) return;
const res = tree.resolveSegs(segs);
const className = tree.viewClassFor(res && res.node);
if (className) warmClass(className, 0, className);
}
function prefetchView(pathname) {
const segs = tree.segsFromPath(pathname);
if (segs) prefetchSegs(segs);
}
/* Wait for an in-flight prefetch of `className` instead of racing it — see the call site. Capped,
* because a wedged prefetch must never wedge a navigation: on a stalled connection require()'s own
* XHR and its error path are the better place to end up. */
const WARM_WAIT_MS = 5000;
function warmedThen(className) {
_committed.add(className);
const body = _warming.get(className);
if (!body) return Promise.resolve();
/* the loser of the race is cancelled: a prefetch that lands in 40 ms otherwise leaves a 5 s
* timer armed behind every single navigation, doing nothing but keeping its closure alive */
let t = 0;
return Promise.race([ body, new Promise((r) => { t = window.setTimeout(r, WARM_WAIT_MS); }) ])
.finally(() => window.clearTimeout(t));
}
/* The page we are standing on arrived as a full load, so LuCI has ALREADY required — hence
* instantiated and rendered — its view. Seed `_seen`, or the first SPA nav BACK to this page would
* take require()'s cached instance, skip the re-instantiation and render nothing at all. */
function seed() {
const here = tree.viewClassFor(tree.currentNode());
if (here)
_seen.add(here);
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();
}
/* Attempt an in-place navigation to `pathname`. Returns true if handled as a
* SPA nav (caller should preventDefault), false to let the browser do a normal
* full navigation. `push` adds a history entry (false when replaying popstate).
* `kbd` — the navigation was activated from the keyboard (see the focus block). */
function navigate(pathname, push, kbd) {
const segs = tree.segsFromPath(pathname);
if (!segs) return false;
/* The view on screen injected CSS that can repaint any page: this document is spent, and
* the only exit that leaves BOTH pages correct is a real navigation. See fs-sheets.js. */
if (sheets.documentPoisoned()) 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
* lands somewhere the click did not. */
const res = tree.resolveSegs(segs);
const node = res && res.node;
const className = tree.viewClassFor(node);
if (!className)
return false;
/* A PAGE WHOSE STYLESHEET ONLY THE SERVER CAN EMIT IS NOT OURS TO SWAP INTO.
*
* A menu.d node may name its own sheet (`"css": "view/foo/foo.css"`), and the server links it from
* <head> on a full load (partials/head.ut). Nothing here can: a swap replaces #view's children, it
* does not re-render a document, so reaching such a page by CLICK would show it with the app's CSS
* missing — while reaching the same page by URL or F5 showed it styled. One page, two appearances,
* decided by how the user got there.
*
* So decline, exactly as the poisoned-document bail above does: speed is traded for correctness,
* never the other way. The cost is one full load per ENTRY INTO A DOCUMENT that lacks the sheet,
* not one per page ever: head.ut emits the link for the DISPATCHED node only, so each full load
* starts a document carrying exactly one such sheet and discards what the previous one had
* gathered. Within that document the page is a swap from then on (fs-sheets.js owns the sheet and
* re-lights it per page) — but two `css`-bearing pages alternating are a full load every time, in
* both directions. That is the trade taken knowingly: an unstyled page is worse than a reload, and
* no in-tree node sets `css` today.
*
* Injecting the <link> here instead would work and is deliberately not done: it would put the theme
* in charge of fetching and ordering a foreign stylesheet, which is the job fs-sheets.js exists to
* keep out of the theme. The server already does it correctly.
*
* The QUESTION is fs-sheets.js's, not this module's: which sheets a document carries, and which of
* them a swap is about to delete, is the one thing that file knows — see documentCarries(). What is
* decided here is only what to do about the answer.
*
* `node.css` reaches the client because /admin/menu serves the dispatcher's own tree and ui.js's
* scrubMenu() only rewrites `satisfied`. On a luci-base that predates the `css` schema entry the
* property is dropped server-side, so this is simply never true there. */
if (typeof node.css === 'string' && node.css !== '' && !sheets.documentCarries(node.css))
return false;
const rsegs = res.segs;
/* from here on the navigation is committed */
const gen = ++_navGen;
_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);
}
}
/* 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) */
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
* its close-on-navigate. The seam is deliberately inverted: a registrant calls onNavigate()
* (below) and the router names nobody, so an optional module that is not installed is not a
* DependencyError that takes out the whole chrome.
*
* The RESOLVED segments are passed in, and that is not convenience: this runs BEFORE L.env is
* re-pointed a few lines down, so a callback reading L.env.dispatchpath for "where are we going"
* would silently record the page being left. */
/* Logged, not swallowed. A registrant that throws is still isolated — the loop must finish, or a
* broken registrant would take the palette's recents and its close-on-navigate with it — but the
* empty catch made a registrant that throws on EVERY navigation indistinguishable from one that
* was never registered, which is the failure mode the .catch at the bottom of this file was
* given a console.error for. */
for (const fn of _navCbs) {
try { fn(rsegs); }
catch (e) { console.error('footstrap: a navigation callback threw', e); }
}
/* ui hard-requires into this module and ui.js defines hideModal unconditionally, so there is no
* feature to test; what is caught is a modal's own teardown throwing, which must not take the
* navigation with it. Reported, not swallowed — the same rule as the loop above. */
try { ui.hideModal(); }
catch (e) { console.error('footstrap: hideModal threw during a navigation', e); }
/* point the runtime env at the new node so views, tabs and highlighting read the right
* path. For a fully-matched leaf, request == dispatch path. */
L.env.requestpath = rsegs.slice();
L.env.dispatchpath = rsegs.slice();
L.env.pathinfo = '/' + segs.join('/');
/* `readonly` is not decoration: luci.js implements hasViewPermission() as
* `!env.nodespec.readonly`, and views (network/interfaces, wireless, the package manager)
* plus luci.js's Save/Apply footer key their disabled state off it. Dropping it handed a
* read-only user LIVE Save/Apply buttons on an SPA nav, where a full load disabled them —
* and reading it off the LEAF was still dropping it for two thirds of the readonly pages,
* because the dispatcher folds the acls of the whole path into that one flag. See
* fs-menutree's readonlyForSegs(). */
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
* correctly returns — one dead Back press per stray click. A full load has no such trap. */
if (push) {
const same = pathname === window.location.pathname;
/* a NEW entry gets a new id; re-navigating in place keeps the entry and therefore its id
* (seed() adopted one before wire() made this function reachable, so there is always one) */
if (!same) _curId = newEntryId();
history[same ? 'replaceState' : 'pushState']({ fsnav: true, fsid: _curId }, '', pathname);
}
/* titles: <host> | <page> */
document.title = node.title ? (titleHost() + ' | ' + _(node.title)) : titleHost();
const tmain = document.querySelector('.fs-title-main');
if (tmain && node.title)
tmain.textContent = _(node.title);
chrome.renderChrome();
/* a full load starts at the top; the in-place swap must too, or navigating away from a long
* page opens the next one mid-scroll. In the desktop sidebar layout the window does NOT scroll —
* .fs-shell is exactly 100dvh with overflow:hidden and .fs-main owns overflow-y, so the sidebar
* can be static rather than a composited sticky layer (issue #7) — so reset it too; scrollTo on
* whichever of the two is not the scroller is a harmless no-op.
*
* A popstate replay resets nothing on purpose: BOTH scrollers are restored there from _scrollMem —
* see restoreScroll(). scrollRestoration is left at 'auto': the UA's own attempt lands before the
* swap and is undone by it (see restoreScroll above), so it neither helps nor hurts, and 'manual' would only take away
* the case that does work — a genuine full load. */
if (push) {
window.scrollTo(0, 0);
const sc = document.getElementById('maincontent');
if (sc) sc.scrollTo(0, 0);
}
/* ---- what a full load does for a keyboard/screen-reader user, and the SPA did not ----
* renderChrome() has just done `#topmenu.innerHTML = ''`, so the very <a> the user activated with
* Enter no longer exists: focus falls back to <body>, the next Tab restarts at the skip link, and
* nothing says the page changed — URL, title and #view all moved in silence. So do what a real
* navigation would, and where matters (Sutton's five-prototype study, docs/spa-router.md,
* "Accessibility of a route change"): a KEYBOARD
* activation (ev.detail === 0) moves focus to the skip link — a small target whose :focus overlay
* tells a sighted keyboard user where they are, with Enter jumping straight to the content; its
* text differs from the live region's announcement below, so the double announcement complements
* rather than repeats. A pointer activation (and a popstate replay, whose modality is unknowable)
* keeps the wrapper focus: focusing the skip link there would flash its overlay on every mouse
* click. <main> keeps tabindex="-1" and its outline-less :focus for exactly that path.
* preventScroll because the scroll position is decided just above — focus() would otherwise drag
* a popstate replay back to the top and undo the restoration. */
const skip = kbd ? document.querySelector('.fs-skip') : null;
const main = skip || document.getElementById('maincontent');
if (main) main.focus({ preventScroll: true });
const live = document.getElementById('fs-nav-status');
if (live) live.textContent = node.title ? _(node.title) : '';
/* Require through the runtime singleton `window.L`, NOT the bare `L` a module factory is handed:
* the dispatcher builds `window.L = new LuCI()` and `ui` augments THAT instance with
* itemlist/showModal/…, so a view required via the bare `L` throws "L.itemlist is not a
* function" mid-render (the two-L trap, docs/spa-router.md). require/instanceof errors fall back to a real
* navigation; render-time errors are handled inside LuCI.view, as on a full load.
*
* WHEN to re-instantiate is the subtle part. 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 the page, and a
* `new view.constructor()` after it painted a SECOND time — two renders, two pollers, double
* RPCs for as long as the user stayed. Only on a REVISIT does require() return the cached
* singleton whose __init__ already ran. `_seen` is that distinction, and it must be read BEFORE
* the require resolves, since the require is what fills LuCI's cache. */
/* Status→Overview needs the 3 template globals (progressbar/renderBox/renderBadge) an SPA
* arrival never defines. fs-overview.js defines them at its own module eval — which, as a
* chrome module, happens at chrome init, i.e. before any SPA navigation can occur. Not here:
* the router has no business owning luci-mod-status's globals. */
const RT = window.L;
const cached = _seen.has(className);
/* WAIT for an in-flight prefetch of this class rather than racing it. Two requests for the same
* URL do not coalesce, so a click landing before the prefetch does downloads the module TWICE,
* both at full latency, and gains nothing — measured at 120 ms RTT: the prefetch ran 2664→2788 ms
* and the require's XHR 2682→2788 ms for the same 8.6 KB. That is the NORMAL case on a touch
* device, where pointerover fires the same moment as the tap. Waiting costs nothing: the XHR
* would have waited for exactly those bytes.
*
* `_seen` is marked here and not before, because it means "this class has been through require()"
* 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. */
warmedThen(className).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. */
if (gen !== _navGen) return null;
_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. */
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;
}
}).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;
});
return true;
}
/* The same-origin nav URL an event's link points at, or null when the link is not ours to handle
* (new-tab target, download, bare #hash, cross-origin, unparsable). Shared by the click router and
* the hover prefetch, which used to carry drifting copies of this filter. */
function linkUrlFrom(ev) {
const a = ev.target.closest?.('a[href]');
if (!a || (a.target && a.target !== '_self') || a.hasAttribute('download'))
return null;
const raw = a.getAttribute('href');
if (!raw || raw.charAt(0) === '#') return null;
let url;
try { url = new URL(a.href, window.location.href); } catch (e) { return null; }
return url.origin === window.location.origin ? url : null;
}
/* Warm the view module behind an event's link — ONE filter for all three prefetch triggers, and the
* same one the click router applies two lines further down: navigate() pushes a bare path, so a link
* carrying ?query or #hash full-loads, and warming its module spends a request on a page the SPA
* path can never open. The click handler declined those from the start; the three triggers below had
* each grown a copy of the URL test without that half. */
function prefetchFrom(ev) {
const url = linkUrlFrom(ev);
if (url && !url.search && !url.hash)
prefetchView(url.pathname);
}
/* The last <a> a pointer crossed, kept only to stop `pointerover` re-firing per child span (see the
* listener). Cleared on every navigation: an element holds its parent, so retaining one anchor
* retains the whole detached tree the content swap has just thrown away — a small leak, but one that
* lasts until the pointer happens to cross some other link. */
let _lastHovered = null;
function wireRouter() {
if (_wired) return;
_wired = true;
document.addEventListener('click', (ev) => {
if (ev.defaultPrevented || ev.button !== 0 ||
ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)
return;
const url = linkUrlFrom(ev);
if (!url) return;
/* navigate() carries only the pathname: pushState-ing a bare path for a link that
* promised ?query= / #hash would strip both from the URL and from the view, which
* reads location.search. Let those links full-load. */
if (url.search || url.hash) return;
/* record the outgoing page's offset under the entry we are still ON; harmless when
* navigate() declines (a full load throws the whole Map away anyway) */
saveScroll();
if (navigate(url.pathname, true, ev.detail === 0))
ev.preventDefault();
}, false);
/* Warm the view module cache when the pointer enters a nav link. `pointerover` bubbles from
* EVERY element the pointer crosses — dragging across the process table fires it hundreds of
* times — so bail on the element first: the same <a> re-fires this for every child span it
* contains, and a non-link target is the overwhelmingly common case. */
document.addEventListener('pointerover', (ev) => {
const a = ev.target.closest?.('a[href]');
if (!a || a === _lastHovered) return;
_lastHovered = a;
prefetchFrom(ev);
}, { passive: true });
/* The pointer is not the only way a link gets chosen, and the other two ways got no prefetch at
* all. A KEYBOARD user Tabs to the link and presses Enter — no pointer event ever fires, so the
* whole optimisation was invisible to them; focusin is the keyboard's hover, and the Tab→Enter gap
* is human-scale, so the module is usually there by the time Enter lands. A TOUCH user gets a
* pointerover, but at the same moment as the tap, which is what the in-flight wait in navigate()
* is for rather than an earlier trigger. pointerdown adds the one pointer case pointerover cannot
* see: a link that scrolled UNDER a stationary pointer crosses no boundary and fires nothing.
* Neither needs the lastHovered guard above — they fire once per interaction, not per element
* crossed — and warmClass() dedupes per class anyway. */
document.addEventListener('focusin', prefetchFrom, { passive: true });
document.addEventListener('pointerdown', prefetchFrom, { passive: true });
window.addEventListener('popstate', () => {
/* an entry carrying a query belongs to a full load (we only ever push bare paths):
* replaying it as a bare-path SPA nav would drop the query the view expects */
if (window.location.search) {
window.location.reload();
return;
}
/* A FRAGMENT CHANGE IS NOT A NAVIGATION. Chrome fires `popstate` for a same-document
* fragment nav, so clicking an `<a href="#">` inside a view — a very common idiom for
* in-page controls — arrived here as if the user had pressed Back, and we re-ran navigate()
* for the path already on screen, RE-INSTANTIATING the view and wiping the state the click
* had just set (issue #3, "luci-app-filemanager does not work": its tab strip is four
* `<a href="#">` links whose handler does not preventDefault). The view changed only if the
* PATH changed; if just the fragment moved, the page owns it. */
if (window.location.pathname === _curPath)
return;
/* the outgoing DOM is still up: record its offset under the entry we are LEAVING, then
* 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))
window.location.reload();
else
restoreScroll(target, _navGen);
});
}
/* ---- the poll indicator must not outlive the poll ----
*
* LuCI shows the "Refreshing" pill on `poll-start`, flips it to "Paused" on `poll-stop`, and never
* hides it again (core calls ui.hideIndicator() only for `uci-changes`). Invisible on a full load,
* because Poll.start() dispatches `poll-start` only when the queue is non-empty — an unpolled page
* never grows a pill. But our router flushes the queue and calls stop() on every nav, and stop()
* DOES dispatch `poll-stop`, so walking from a polled page to an unpolled one left a "Paused" pill
* reporting on a poll that does not exist there. Rule: the pill exists iff there is something to
* poll. Registered at module eval, i.e. AFTER luci.js's own listener, so ours runs second and can
* take back what that one just painted. */
document.addEventListener('poll-stop', () => {
if (L.Poll && L.Poll.queue && L.Poll.queue.length === 0) {
try { ui.hideIndicator('poll-status'); }
catch (e) { console.error('footstrap: hideIndicator threw on poll-stop', e); }
}
});
/* 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
* one immediate step(), so data is fresh on refocus. A poller added while hidden will not auto-start
* (stop() deletes the tick) — start() picks it up on show: deferred, not lost. docs/spa-router.md. */
let _visWired = false;
function wireVisibility() {
if (_visWired) return;
_visWired = true;
/* respect a manual pause: the user can stop polling from the "Refreshing" indicator, and an
* unconditional start() on tab-show would silently undo it. Resume only what we paused. */
let wasActive = true;
document.addEventListener('visibilitychange', () => {
if (!L.Poll) return;
try {
if (document.hidden) {
wasActive = L.Poll.active();
if (wasActive) L.Poll.stop();
}
else if (wasActive) {
L.Poll.start();
}
} catch (e) { console.error('footstrap: the poll pause/resume threw', e); }
});
}
/* Callbacks to run on every SPA navigation, each handed the resolved segments of the INCOMING page
* (see navigate() — they run before L.env is re-pointed). The registry is INVERTED on purpose: a
* registrant calls in and the router names nobody, so it can never grow a static dependency on a
* module that may not be installed. Registrant today: fs-search's recent-pages record and its
* close-on-navigate. */
const _navCbs = [];
function onNavigate(fn) { if (typeof fn === 'function') _navCbs.push(fn); }
return baseclass.extend({
seed,
wire: wireRouter,
wireVisibility,
onNavigate,
/* fs-search warms the pages this admin actually uses (its recents) and the arrow-key-highlighted
* result, both of which the pointer/focus triggers above cannot see. The edge points that way
* round — search → router — because the router must keep no dependency on the palette. */
prefetchSegs
});