Files

1479 lines
79 KiB
JavaScript

'use strict';
'require baseclass';
'require ui';
'require rpc';
'require fs-menutree as tree';
'require fs-chrome as chrome';
'require fs-sheets as sheets';
'require fs-fit as fit';
/* ---- 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.
*
* `docs/…`, `tools/…` and `tests/…` in these comments name the theme's own repository
* (https://github.com/VizzleTF/luci-theme-footstrap), not the tree this file is read in: the
* package ships the runtime and nothing else. */
/* --- 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. Track view-set ids and clear them on navigation,
* keeping L.Poll's own 1 s tick (also a setInterval); L.Poll's queue is flushed in navigate().
* Hooked at module eval, before any view render can set a timer. */
const _viewIntervals = (window.__fsViewIntervals || (window.__fsViewIntervals = new Map()));
(function hookIntervals() {
if (window.__fsIntervalsHooked) return;
window.__fsIntervalsHooked = true;
const _si = window.setInterval, _ci = window.clearInterval;
/* The id the caller got is the id it keeps, whatever the pause below does underneath: the map is
* keyed by that first id and the entry carries `live`, the id armed right now (null while
* paused), so a view holding its handle can still stop its own poller after a trip through a
* hidden tab. The arguments are kept because a `setInterval` id carries none of them back. */
window.setInterval = function (fn, ms) {
const id = _si.apply(window, arguments);
_viewIntervals.set(id, { fn, ms, rest: Array.prototype.slice.call(arguments, 2), live: id });
return id;
};
window.clearInterval = function (id) {
const spec = _viewIntervals.get(id);
_viewIntervals.delete(id);
/* a paused timer is already disarmed and its number is the platform's to hand out again, so
* clearing it here would stop whatever timer holds it now. Untracked ids fall through: the
* hook stays a pass-through for everything it did not arm. */
if (spec) return (spec.live == null) ? undefined : _ci.call(window, spec.live);
return _ci.apply(window, arguments);
};
/* A hidden tab must not keep calling the router. `wireVisibility()` below stops LuCI's own poll,
* which is most of the traffic, but a view is free to run a plain `setInterval` of its own and
* those keep hammering ubus for as long as the tab stays open. The registry navigation already
* uses to clear them is enough to pause them: disarmed on hide, re-armed on show with the same
* callback and period.
*
* A paused timer stays IN the registry, armed on nothing. Held in a list beside it, the
* navigation sweep cannot see it — hide the tab while a navigation is in flight and coming back
* re-arms the timers of the page that navigation already replaced. */
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
/* LuCI's own tick is not ours to pause. `L.Poll.start()` arms it with a plain
* `setInterval`, so the hook above catches it like any other id — and pausing it here
* re-arms it under an id L.Poll knows nothing about, after which `start()` arms a
* second one because `active()` has nothing to see: two ticks per interval after one
* hide/show, three after two.
*
* So the tick is skipped here and wireVisibility() keeps both halves of it. When it
* cannot be told apart from a view's timer, NOTHING is paused: a view's poller in a
* hidden tab costs a wasted RPC, re-arming LuCI's tick behind its back costs a doubling
* that never stops. */
const keep = pollTickId();
if (keep === false) return;
for (const [ id, spec ] of _viewIntervals) {
if (id === keep || spec.live == null) continue;
_ci.call(window, spec.live);
spec.live = null;
}
}
else {
for (const spec of _viewIntervals.values()) {
if (spec.live != null) continue;
/* `_si`, not the hook: this timer is already in the registry under the id its
* caller holds, and re-registering it would key a second entry to a number nobody
* has. The fresh id lives in `spec.live` alone and is never a key. */
spec.live = _si.call(window, spec.fn, spec.ms, ...spec.rest);
}
}
});
})();
/* Which id is LuCI's own tick, asked in one place because both callers — the navigation sweep and
* the hidden-tab pause above — pay the same price for getting it wrong.
*
* `L.Poll.timer` is that id, and it is private state: `add`/`remove`/`start`/`stop`/`active` are the
* documented surface, and the whole `L.Poll` alias is already deprecated (`'require poll'` replaces
* it, but no supported release ships poll.js yet). Read blind, a renamed field would make LuCI's
* tick look like a view's — cleared on the next navigation, every poll on every later page silently
* dead. So a missing field is a reason to do nothing, once, loudly.
*
* Asked through the documented half first: `active()` says whether the tick is running, and `timer`
* is deleted by `stop()`, so an absent field is the ordinary "nothing to protect" case. The anomaly
* worth reporting is the pair disagreeing — a tick running while the id it runs on has no name we
* know.
*
* The alias itself is guarded for the same reason: the sweep runs inside the staged render, and a
* TypeError there would leave every click showing the previous page's content under the new page's
* title. */
/* -> the tick's id; null when LuCI is not polling; false when the two cannot be told apart, which
* every caller reads as "leave every interval alone" */
function pollTickId() {
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 false;
}
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 false;
}
return running ? L.Poll.timer : null;
}
function clearViewIntervals() {
const keep = pollTickId();
if (keep === false) return;
/* Map, not Set: the key is the timer id and the value is what it takes to re-arm it */
_viewIntervals.forEach((spec, id) => { if (id !== keep) window.clearInterval(id); });
}
/* one line per document: this runs on every navigation, and a router that cannot read L.Poll
* cannot read it on the next click either */
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. Several shipped
* views read that return value as an existence check and abort on an empty array, so under SPA the
* SECOND visit renders as "no config found", unstickable 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, like the poll queue and the intervals
* above. So drop it on navigation and let the incoming view fetch what it needs.
*
* `unload()` is upstream's own idiom for this — `uci.save()` ends with
* `self.unload(pkgs); return self.load(pkgs)`. Pending local edits go with it, as they do on a full
* load; saved changes are on the server and the Unsaved-changes banner reads them from there.
*
* Read through `window.L.uci` rather than a `'require uci'` pragma: the class attaches to L's
* prototype when the first requirer compiles it, so this sees the instance the pages use, while
* requiring it would bind it to our prototypal L (the two-L trap) and pull uci.js onto pages that
* never touch uci.
*
* Returns the refill below as a promise the caller must wait on, or null. It never rejects: a
* navigation is not the place to lose a page over a config the incoming view may not read. */
function flushUciCache() {
const uci = window.L ? window.L.uci : null;
if (!uci || typeof uci.unload !== 'function') return null;
/* `state.values` and `loaded` are private, so the L.Poll.timer rule applies: an unrecognised
* shape means do nothing, once, loudly. The two hold different halves of the cache (a package
* whose load is in flight is in `loaded` alone) and unload() clears 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 null;
}
const names = Object.keys(uci.state.values).concat(Object.keys(uci.loaded));
if (!names.length) return null;
uci.unload(names);
/* `state.reorder` is the half unload() does not clear, and is left alone: with `values` gone,
* reorderSections() finds no sections, emits no call and clears the map itself on the next
* save. */
/* …and put back the three packages luci-base's network.js reads but will never load again.
*
* initNetworkState() loads `network`, `wireless` and `luci` once and from then on answers every
* caller from its own `_state` — but what it answers WITH is the uci cache (`getWifiDevices()`
* is `uci.sections('wireless', 'wifi-device')`). So dropping those packages does not make
* network.js refetch them, it makes every consumer read an EMPTY config until the next full
* load: Channel Analysis with no band tabs, Network -> Switch with no VLAN sections, both
* correct again after F5. tools/spa-parity.mjs reproduces it; tools/upstream-contract.mjs
* notices if the list of three moves.
*
* navigate() waits for the refill, because a cached module resolves within a microtask and the
* view would read the cache we just emptied. Only when network.js is really in the document.
* The ubus half of `_state` stays as stale as upstream leaves it.
*
* The wait costs one uci `get` in front of the render on a warm navigation (136 -> 159 ms
* median). Leaving the three OUT of the unload instead is free and was rejected: a view calling
* `network.flushCache()` reloads its ubus half but calls `uci.load()` for the uci half, a no-op
* while the package is cached, so Interfaces and Wireless would render fresh device state over
* config values from whenever the document first touched them. */
if (!window.L.network) return null;
const refill = [ 'network', 'wireless', 'luci' ].filter((p) => names.indexOf(p) !== -1);
if (!refill.length) return null;
return uci.load(refill).catch((e) => {
console.error('footstrap: reloading uci ' + refill.join(', ') + ' after a navigation failed', e);
});
}
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 answers with the login form. Every
* navigation of ours does the opposite of both halves (`ui.hideModal()`, `L.Poll.stop()+start()`),
* so the first click after the session died dismissed that warning and carried on, leaving the page
* on "Loading view…" with every call behind it failing.
*
* 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, as the session did.
*
* The two signals are luci-base's own decision points (luci.js, `setupDOM`):
*
* 1. a `403` carrying `X-LuCI-Login-Required: yes` on any `L.Request`;
* 2. the `session.access` probe luci-base fires after a `-32002`, when that probe REJECTS.
*
* `access: false` is deliberately not one of them: the probe is declared `expect: { access: true }`,
* so rpc.js resolves it rather than rejecting, and it is an ACL answer — treating it as a dead
* session would drop a restricted user out of the SPA over a permission they do not have.
*
* The probe's rejection is read off the frame the interceptor is handed, since that is all an
* interceptor sees: `handleCallReply()` rejects on a frame that is not JSON-RPC 2.0 or on an `error`
* carrying both a code and a message. Somebody else's failing `session.access` call would also
* match, at a cost of one document of full loads.
*
* Neither interceptor may throw: luci-base runs both through `Promise.all(...).catch(req.reject)`,
* so an exception here would reject the caller's request. Hence the try/catch around each body. */
let _expired = false;
let _sessionWired = false;
function markExpired() {
if (_expired) return;
_expired = true;
/* once per document, at the least alarming level the house rules allow (eslint no-console
* permits warn and error only): nothing is broken, the session simply ended */
console.warn('footstrap: the LuCI session is gone — every navigation from here is a full load.');
}
/* The verdict is not a latch. An interceptor sees `msg` only once the transport succeeded and the
* body parsed, so a missing frame is not a network flap — but it is a captive portal's page, a
* proxy's error body, one truncated reply, any of which would take the router off for the rest of
* the document with the session alive throughout.
*
* A clean `session.access` is the same call the failing one was, so it is evidence the other way.
* If the session really has ended no clean one arrives, every ubus call carrying the same dead
* sid. */
function markAlive() {
if (!_expired) return;
_expired = false;
console.warn('footstrap: the LuCI session answers again — client navigation is back on.');
}
function sessionExpired() { return _expired; }
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') return;
/* an `error` carrying both a code and a message is what handleCallReply() rejects
* on, and a rejected session probe is the signal. A frame that is not JSON-RPC 2.0
* is rejected there too, but says nothing about the session. */
if (msg.error && msg.error.code && msg.error.message) { markExpired(); return; }
/* Only `access: true` says the session is there. A dead sid does not make this call
* fail — `session.access` answers `[0, {access:false}]` with HTTP 200 and no error
* frame, the `-32002` arriving on the ordinary call that made luci-base fire the
* probe — so "the reply parsed" would read as "the session is back" and clear the
* verdict a 403 just reached. `access:false` stays out of both answers, an ACL
* denial looking exactly the same. */
if (Array.isArray(msg.result) && msg.result[1] && msg.result[1].access === true)
markAlive();
}
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 only `dom.content()` ever deletes an entry. A plain `remove()`
* therefore leaves the entry — and through it the element and whatever class instance it held —
* reachable for the life of the document.
*
* `#view` is not affected, the incoming view's own `dom.content()` reaping 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.
*
* Nothing the sweeps remove carries a `data-idref` on the stands today, 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 measurable leak — it closes the class, for the coverage reason the table selector
* gives.
*
* 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. Public API only; no reaching into `dom.registry`. */
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 fragment change */
let _curPath = window.location.pathname;
/* nav generation token: two quick clicks race their async require()s, so 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 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.
*
* Both offsets are recorded and replayed, the document scroller included: the UA restores it at the
* traversal, i.e. BEFORE this handler swaps #view, so the height collapses under the restored
* offset and the clamp takes it back to 0 with nothing left to re-apply it. The offset 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 carries a session-unique id (fsid) in history.state and the
* offsets live in an in-memory Map, lost on a full load — which is exactly when the browser's own
* restoration takes over. The id is session-prefixed because a bare counter restarts with every
* document, and an entry stamped by a previous document of this tab would collide with a fresh
* one. */
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. A browser keeps ~50 entries
* per tab and the ones past that cannot be traversed back to, so remembering more offsets 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;
/* 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 earlier */
let _pendingRestore = null;
function newEntryId() { return _scrollSess + ':' + (++_histN); }
/* adopt the entry we are standing on: reuse its fsid, or stamp one (an entry created by a full load
* carries state === null) */
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);
_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: 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 is 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. */
function restoreScroll(pos, gen) {
if (!pos || (!pos.win && !pos.main)) return;
/* a deadline, not a frame count: 300 frames is 5 s at 60 Hz and 10 s on a 30 Hz panel. Frames
* stay the tick, being when a paint could have changed the height. */
const until = Date.now() + 5000;
/* The user outranks the saved position: waiting up to five seconds for a slow view means the
* reader may have started using the page, and jumping them somewhere else then is worse than
* opening at the top, which is what a full load does. 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, while `scroll` catches what they cannot see (a scrollbar drag,
* Find-in-page, an anchor jump, assistive tech). `scroll` also fires for our own writes,
* asynchronously, so a flag around the write would already be false — the position last written
* is remembered instead, and a scroll landing exactly there is ours.
*
* Passive listeners: this must never sit in front of the scroll it watches for.
*
* AND ONLY ON AN AXIS THIS `pos` ACTUALLY CARRIES. One `cancelled` flag serves both scrollers, so
* an event on the axis this layout does NOT use — a stray write to the other scroller, from
* anywhere in the document, while THIS one is still waiting for its content to grow tall enough —
* used to read as "the reader scrolled" and cancel the whole restore, killing the axis that WAS
* legitimately pending over one that was never being restored at all. Reproduced without a real
* router (`../tmp/task-back/repro2.mjs`): a bare `window.scrollTo(0, 111)` from an unrelated
* script while the sidebar layout's `#maincontent` restore was still pending left the reader at 0
* for the rest of the 5 s window instead of the parked 3000; scoping the check to the axis `pos`
* carries fixes it, samples unchanged (3000).
*
* A SAME-axis false alarm survives that fix: the browser's OWN traversal restore lands on the
* scroller BEFORE this handler swaps `#view` (see the comment above the function), and the swap
* that follows briefly leaves the incoming page shorter than the saved offset — `commitStage()`
* moves the outgoing page's nodes out before the incoming page's have finished growing under
* their own RPCs. The engine then clamps the scroller BACK to whatever height exists NOW, firing
* an ordinary `scroll` event that looks exactly like a reader's, and it lands before this tick has
* ever written anything (`wroteWin`/`wroteMain` still -1), so the "our own write coming back"
* check above cannot catch it either. Measured live (owrt2512b @1440, `/admin/status/overview` <-
* package-manager, Back): the UA restores `window.scrollY` to the parked 2684 in the same tick
* `popstate` fires, `commitStage()` leaves the document ~900px tall for one frame, and the next
* native `scroll` event reports `y=0` a whole 5 s before this function's own deadline — cancelling
* a restore that had not yet had the height to attempt. A clamp cannot land anywhere but the
* scroller's OWN current ceiling, and the reader cannot have scrolled PAST a height that does not
* exist yet, so a scroll landing exactly there while that ceiling is still short of the saved
* offset is the engine settling, not input — every real gesture that could produce it (a
* scrollbar drag past the same limit, in particular) already flows through the direct
* wheel/touchstart/keydown listeners below regardless of what onScroll decides. */
let cancelled = false, wroteWin = -1, wroteMain = -1;
const stop = () => { cancelled = true; off(); };
const onScroll = (ev) => {
const t = ev.target;
const isWin = (t === document || t === document.documentElement || t === document.body);
if (isWin) {
if (!pos.win) return;
if (Math.round(window.scrollY) === wroteWin) return; /* our own write coming back */
const de = document.documentElement;
const ceiling = Math.max(0, de.scrollHeight - de.clientHeight);
if (ceiling < pos.win && Math.round(window.scrollY) === ceiling) return; /* the page settling */
}
else {
if (!pos.main) return;
if (t && t.scrollTop === wroteMain) return; /* our own write coming back */
const ceiling = t ? Math.max(0, t.scrollHeight - t.clientHeight) : 0;
if (ceiling < pos.main && t && t.scrollTop === ceiling) return; /* the page settling */
}
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 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. Re-deriving it from the live
* document.title on every hop makes any page that renames the tab the host for every page after it
* — third-party log viewers and dashboards do rename it. Captured at seed(); 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 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 hits. Deduped per class; failures are silent.
*
* Transitive, which is where most of the win is: warming the view class alone leaves its own
* `require` pragmas one round trip behind it (418 -> 296 ms on a first visit at 120 ms RTT; 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, so /^'require …'$/m matches nothing at all, silently. 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: the DOTTED ones. A LuCI class name is a path
* (`tools.widgets` is tools/widgets.js), so a name with no dot is either one of the six virtual
* classes luci.js seeds its registry with — which have no file, so fetching one is a guaranteed 404
* in the user's console — or one of the flat libraries, which are already loaded by the time any
* prefetch runs (the chrome requires `network`, dragging in firewall/uci/rpc/validation, and `ui`
* comes with the widgets). Declining the flat half outright costs nothing measurable — on the
* stands every flat library was already an instance on arrival, and a seven-page prefetch walk
* fetched 10 files, every one of them nested — needs no list of built-ins to keep current, and
* covers a future one before it ships.
*
* The dotted half is asked properly: require() attaches a class at its path, so `tools.widgets`
* reads back as L.tools.widgets once some form page has pulled it. `instanceof L.Class` rather than
* a truthiness test, because 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 (658 ms waiting for the whole subtree against 525 ms racing, at 120 ms RTT). Deps have not been
* asked for when a click arrives, so there is nothing in flight to collide with. */
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 whatever the pragmas
* say: require() raises DependencyError on a cycle, but only for classes it actually loads */
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 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 would otherwise leave a
* 5 s timer armed behind every navigation, 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 takes
* require()'s cached instance, skips the re-instantiation and renders nothing. */
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();
/* Page-scoped CSS keys off `#view[data-page]` and `.fs-content[data-page]`, not `body`
* (commitStage) — but the server stamps only `body`, so a document that never took a client
* navigation would otherwise have neither. Copied, not recomputed: the server's value is
* already the resolved dispatch path (header.ut, `ctx.path`), and re-deriving it here from the
* tree could disagree with what the served markup actually carries. */
const curPage = document.body ? (document.body.getAttribute('data-page') || '') : '';
const contentHost = document.querySelector('.fs-content');
if (contentHost) contentHost.setAttribute('data-page', curPage);
/* 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 — and the first chain, `view.ut`'s inline
* `instantiateView()`, is subject to the same rule. Untracked, at 350 ms of latency a click 150 ms after
* DOMContentLoaded ends with the URL, title and menu on the new page and the old one painted
* over it.
*
* 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. The `.catch`
* keeps a document whose first view never renders from turning every later click into a
* rejected promise. */
const vp = document.getElementById('view');
if (vp) {
vp.setAttribute('data-page', curPage);
_inflight = renderedIn(vp).catch(() => {});
}
}
/* ---- the incoming page is rendered off screen and swapped in when it is ready ----
*
* Emptying `#view` and letting the incoming view render into the live page instead means the user
* watches an empty page for as long as the module and its data take (1800 ms on a first visit at
* 600 ms latency), and a superseded render cannot be stopped, so the damage has to be repaired
* afterwards — which took three mechanisms.
*
* `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, once in `View.__init__` for the spinner and again when the render resolves.
* So the incoming view writes into the stage while the page the user is reading stays on screen.
*
* Hidden but LAID OUT: `visibility: hidden; height: 0; overflow: clip`, never `display: none`.
* Several views size themselves from the element they render into (`view.offsetWidth - 2`), 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.
*
* The swap MOVES THE NODES rather than swapping the element: inserting the staged `#view` and
* deleting the old one would change the identity of `#view`, and fs-fit's content observer and
* fs-appearance's view observer are bound to the node that existed at chrome init — both would end
* up watching a detached node and the fitters would silently stop. So the live `#view` keeps its
* identity and its children are replaced through `dom.content()`, which also reaps the outgoing
* page's `data-idref` entries.
*
* Renders are SERIALIZED, and that is what retires the repair machinery. Neither an in-flight LuCI
* XHR nor a running `View.__init__` chain can be cancelled, and every chain resolves `#view` at
* paint time, so an older navigation's chain would paint into the newer one's stage. A navigation
* therefore waits for the previous one to finish; the older chain paints into its own stage, which
* is dropped unswapped. The cost is that a click during a slow first load waits for that load.
*
* Completion is observed, not assumed: `renderedIn()` resolves when a child that is not the spinner
* appears, or when a mutation leaves the stage empty, which is how a view that renders nothing
* finishes. A render that has not completed within RENDER_TIMEOUT is a FAILURE — 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 full-load fallback. */
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 */
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 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, a mutation that
* leaves no element children. No "has it started" flag is needed — `painted()` was
* asked before the observer existed, and an observer fires only on a mutation. */
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()`, drop the wrapper. */
function commitStage(stage, contentHost) {
/* the outgoing page is going off screen now, so the sheets it owned may be darkened — the half
* navigate() spared while it was still being read */
sheets.scopeToCurrentPage();
sweepAround(contentHost);
const live = liveView(contentHost, stage);
/* …and the page-scoped CSS identity moves forward with it. `stage.view` carries the incoming
* page's name from navigate() (spared there the same way the sheets above were); this is the
* commit that finally moves it onto the element the reader is about to see and onto
* `.fs-content`, which the same CSS uses for content that sits beside `#view` rather than
* inside it (the Overview's stray `<h2 name="content">`, styles/pages/20-overview.css). Until
* this line the LIVE `#view` and `.fs-content` still carried the OUTGOING page's name, so its
* own page-scoped rules kept matching for the whole staging window. */
const page = stage.view.getAttribute('data-page');
if (page != null) {
live.setAttribute('data-page', page);
if (contentHost) contentHost.setAttribute('data-page', page);
}
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 swap is a DOM move, and nothing wraps it ----
*
* 0.14.4 wrapped `commitStage()` in `document.startViewTransition()` to cross-fade the swap, on the
* reasoning that the commit is one synchronous DOM move and so the API would be animating a frame
* rather than a render. What that reasoning did not cover is the CAPTURE, which the engine performs
* before it enters the update callback and which the page cannot bound: measured on WebKit at
* 390px, a client navigation to /admin/system/system spent 2,143 ms in a single main-thread task
* with the transition and 213 ms without it, and the swap itself landed at 3,728 ms against 206 ms.
* The reader spends that time looking at the page they navigated away from, under the new URL —
* which is what a report of "the section is not where I expect it, F5 fixes it" looks like from the
* outside (issue #42), F5 being a full load and starting no transition.
*
* A deadline was tried and does not hold: `skipTransition()` runs the update callback at once
* (3-5 ms on WebKit, Chromium and Firefox alike), but it has to be called from a timer, and the
* capture is holding the thread that timer needs — the 150 ms deadline fired at 1,944 ms, after the
* callback it was there to pre-empt. Chromium captures the same navigations in 15-40 ms and pays
* none of this; an effect that is free on one engine and seconds on another, with no way to tell
* them apart before spending them, is not worth a reader's navigation. It can come back when a
* capture can be measured without one. */
/* The `#view` the document keeps between navigations, i.e. the one the observers are bound to:
* whichever `#view` is not the stage. A document that has none 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
* Overview template's `<h2 name="content">Status</h2>` is hidden by a `.fs-content[data-page=…]`
* rule (styles/pages/20-overview.css) and would show on every later page. Keep only the chrome
* that legitimately outlives a page (tabs, server notices, <noscript>) and the stage.
*
* …and the RUNTIME notifications, which live one level up: `ui.addNotification()` inserts into
* #maincontent while the sweep above reaches only children of .fs-content. A full load clears them,
* so without this every banner stacks up over each following page. The .fs-content banners kept
* above are the server's notices and do outlive a page. */
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 looks like a
* click that did nothing: the chrome switches instantly while the content does not move for as long
* as the module and its first RPC take.
*
* A hairline at the top of the content, shown only when the navigation outlives `PROGRESS_DELAY` —
* below that a bar flashes on and off on every warm click, which reads as a glitch. The counter is
* what makes overlapping navigations share one bar; reduced motion is handled in CSS. */
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';
/* the live region already announces the page, so a decorative bar in the accessibility tree
* would be 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 position it against the shell and clip
* it; inside `.fs-main`, a column flex container, its 2px shrink to zero */
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
* 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 leaving both pages correct is a real navigation (fs-sheets.js) */
if (sheets.documentPoisoned()) return false;
/* …and a document whose session has died is spent the same way: the only page it can render
* correctly is the login form, and only a real navigation gets there (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, 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"`), which the server links
* from <head> on a full load. A swap replaces #view's children rather than re-rendering a
* document, so reaching such a page by CLICK would show it with the app's CSS missing while a
* URL or F5 showed it styled.
*
* So decline, as the poisoned-document bail above does. The cost is one full load per ENTRY into
* a document that lacks the sheet: head.ut emits the link for the dispatched node only, so each
* full load starts a document carrying exactly one such sheet and the page is a swap from then
* on — but two `css`-bearing pages alternating are a full load in both directions. No in-tree
* node sets `css` today.
*
* Injecting the <link> here would work and is deliberately not done: it would put the theme in
* charge of fetching and ordering a foreign stylesheet, which the server already does correctly.
* Which sheets a document carries is fs-sheets.js's question (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`; a luci-base predating the `css` schema entry drops the
* property server-side, where this is simply never true. */
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;
/* the saved offset belongs to THIS navigation, so it leaves the module slot here rather than
* being read at the swap: left there, a Back offset outlives its own navigation and scrolls a
* page the user clicked instead. Clearing it on the superseded path is not the answer either —
* a second popstate may legitimately have put its offset there by then. */
const restoreTo = _pendingRestore;
_pendingRestore = null;
_curPath = pathname; /* what is on screen from now on — read by the popstate handler */
const contentHost = document.querySelector('.fs-content');
if (!contentHost) return false;
/* the page being left, captured before L.env is re-pointed below: the sheet scoping spares it
* until the swap takes it off screen */
const leaving = (L.env.dispatchpath || []).slice();
/* 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. The seam is inverted on purpose: a registrant calls
* onNavigate() and the router names nobody, so an optional module that is not installed cannot
* be a DependencyError taking out the whole chrome.
*
* The RESOLVED segments are passed in, because this runs before L.env is re-pointed below and a
* callback reading L.env.dispatchpath would record the page being left.
*
* A throwing registrant is isolated but logged: the loop must finish, and an empty catch makes
* a registrant that throws on every navigation indistinguishable from one never registered. */
for (const fn of _navCbs) {
try { fn(rsegs); }
catch (e) { console.error('footstrap: a navigation callback threw', e); }
}
/* 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 */
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 plus luci.js's Save/Apply footer key their disabled state
* off it, so dropping it hands a read-only user live Save/Apply buttons that a full load
* disables. It must come from the whole PATH, not the leaf — see readonlyForSegs(). */
L.env.nodespec = { satisfied: true, action: node.action, title: node.title,
depends: node.depends, readonly: tree.readonlyForSegs(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, the path is unchanged, and the fragment guard below correctly returns. */
if (push) {
const same = pathname === window.location.pathname;
/* a new entry gets a new id; re-navigating in place keeps the entry and its id (seed()
* adopted one before wire() made this function reachable) */
if (!same) _curId = newEntryId();
history[same ? 'replaceState' : 'pushState']({ fsnav: true, fsid: _curId }, '', pathname);
}
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 and the in-place swap must too, or navigating away from a long
* page opens the next one mid-scroll. Both scrollers are reset, since which one scrolls depends
* on the layout — in the sidebar layout the window does not scroll, `.fs-shell` being 100dvh
* with `.fs-main` owning overflow-y (issue #7) — and scrollTo on the other is a no-op.
*
* The WRITE is at commitStage now, not here — see there. `_rest` is forgotten here regardless:
* it is the reference fs-fit tells a reader-caused scroll from an engine's clamp with, and the
* reader is committed to leaving this page from this point on, so a mutation the outgoing page's
* own poller makes during the staging window (before clearViewIntervals, further down this
* chain) must not be read against a reference that belongs to a page about to go away.
*
* A popstate replay resets nothing: both scrollers are restored there from _scrollMem.
* scrollRestoration stays 'auto' — the UA's own attempt lands before the swap and is undone by
* it, so it neither helps nor hurts, while 'manual' would take away the genuine full load.
*
* `forgetRest()` was gated on `push` despite the comment above already claiming "regardless" — a
* Back replay is leaving this page exactly as much as a click does, so the outgoing page's stale
* anchoring reference survived a popstate and could still be read against the page being
* restored into once fs-fit's own mutation observer next fires on it. */
fit.forgetRest();
/* ---- what a full load does for a keyboard/screen-reader user, and the SPA does not ----
* renderChrome() has just emptied #topmenu, so the <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. 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 says where they are and whose Enter jumps to the content, with text that complements
* rather than repeats the live region below. A pointer activation — and a popstate replay,
* whose modality is unknowable — keeps the wrapper focus, since focusing the skip link there
* would flash its overlay on every mouse click. preventScroll, because the scroll position is
* the OUTGOING page's own — untouched until commitStage — and must stay that way here too. */
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, 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() 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 already painted, and a `new view.constructor()` after it paints a second time —
* two renders, two pollers. Only on a revisit does require() return a singleton whose __init__
* already ran, and `_seen` must be read BEFORE the require resolves, since the require is what
* fills LuCI's cache.
*
* The Overview's three template globals are defined in menu-footstrap-common.js, which every
* page evaluates before this router exists: 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 downloads the module twice, both
* at full latency, for nothing. That is the normal case on a touch device, where pointerover
* fires the same moment as the tap, and waiting costs nothing — the XHR would have waited for
* exactly those bytes.
*
* `_seen` is marked after the wait, not before, because it means "this class has been through
* require()" and the wait introduces a window in which we may never get there: marked up front,
* the next navigation takes the cached branch and renders twice.
*
* The previous render is waited for, not raced, for the reason the staging block above gives.
* The wait rides alongside the prefetch wait, both being 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 it would spend a module fetch and a round of RPCs on a page 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 up to a full `pollinterval` — Wireless drew its station list 4950 ms after
* arrival 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. stop()+start() on an empty
* queue leaves what a fresh document has, and the view's first poll.add() then starts it —
* upstream's own sequence. */
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, as a full load would; L.Poll's own
* tick survives */
clearViewIntervals();
/* and drop uci's document-scoped config cache, which a full load would not carry into the
* incoming page either (flushUciCache); what it hands back is the refill, awaited below */
const uciWarm = flushUciCache();
/* Keep <body data-page> in sync with the route: the server stamps the dispatch path on every
* full load, and fs-chrome/fs-fit/fs-overview/menu-footstrap-common read it off `body` as the
* ROUTE'S identity — a cache-invalidation key and a "did the page change" flag, never a CSS
* scope. `rsegs` is the resolved leaf, so a firstchild URL yields the same value however it
* is reached. It sits before the staged render because those modules' own fitting and module
* loading must react to the incoming route before that render runs, same as always.
*
* Page-scoped CSS no longer keys off `body[data-page]` — see the #view/`.fs-content` stamps
* below, which is where that identity now actually lives. Measured before the split: this
* write alone stopped 33 rules in styles/pages/20-overview.css from matching the OUTGOING
* page for the whole staging window (1407 ms on a cold require), growing the document 211px
* and moving the reader 140px — corrected here, not "visible today only as the Overview's
* stray heading" as this comment used to claim. docs/spa-router.md, "The staging window". */
document.body.setAttribute('data-page', rsegs.join('-'));
/* …and hand the new page to fs-sheets, which darkens every foreign sheet belonging to a
* different page and re-lights this page's own. That is what lets an invasive sheet stay in
* the document without spending it. After the stamp above and before the view renders, so
* nothing paints through a sheet that no longer owns the page.
*
* In two halves: enabling the incoming page's sheets is what the staged render needs, while
* disabling the outgoing page's would strip an app's stylesheet off content the user is
* still reading for the whole staging window. The page being left is swept at the swap
* instead (commitStage). */
sheets.scopeToCurrentPage(rsegs, leaving);
const stage = stageView(contentHost);
/* The stage's OWN identity, so the incoming render measures itself under its own page-scoped
* CSS (styles/pages/*.css keys off `#view[data-page]` now, not `body[data-page]`) while it is
* still hidden. The LIVE `#view` and `.fs-content` are not touched here — they keep the
* OUTGOING page's value, set at that page's own commitStage, until this navigation reaches
* its own commitStage and moves it forward. Same two-phase shape fs-sheets uses above:
* the incoming half is spared for the render, the outgoing half is swept at the swap. */
stage.view.setAttribute('data-page', rsegs.join('-'));
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. Without it fs-sheets credits such a sheet to whichever page was
* stamped when it landed and binds it there for the life of the document. A cached require
* injects nothing, so it has no business naming an owner. */
if (!cached) sheets.attributeTo(rsegs, gen);
/* `uciWarm` is awaited before the construct: 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, 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');
/* only a revisit has a singleton whose __init__ must be re-run; on a first visit the
* require has already painted into the stage. See the require block above. */
if (cached) new view.constructor();
return painted;
})
.then(() => {
/* superseded while rendering: the chain painted into its own stage, so drop it and
* leave the live page to the newer navigation */
if (gen !== _navGen) { dropStage(stage); return; }
commitStage(stage, contentHost);
/* Reset both scrollers to the top HERE, in the same synchronous turn as the content
* swap above: a full load starts a new page at the top, and doing it here — rather
* than at the click, where it used to sit — means the reader keeps reading the
* OUTGOING page from wherever they were for the whole staging window instead of
* being thrown to its top the moment they click. Measured with the require held open
* (1.2 s, ../tmp/task-navflash/navflash-slow.mjs): `y` used to hit 0 within 12 ms of
* the click and stay there through the swap; moved here it stays at the reader's own
* offset for the whole window and reaches 0 in the same frame the new page appears.
* docs/anchoring.md, "The scroll reset". */
if (push) {
window.scrollTo(0, 0);
const sc = document.getElementById('maincontent');
if (sc) sc.scrollTo(0, 0);
}
/* 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 makes every SPA-router
* regression look like a slow page */
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;
}
/* 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, so the filter cannot drift between them. */
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 below: 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. */
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. Cleared
* on every navigation: an element holds its parent, so retaining one anchor retains the whole
* detached tree the content swap just threw away. */
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 any `template` other than the Overview — is a page this
* theme did not build and cannot rebuild. Such a page may carry inline scripts and timers set
* before this module was evaluated, which the interval hook above never saw and no teardown of ours
* can retire; only the document's death does. So the first click away from one is a full load.
*
* Narrower than "did the current path resolve", deliberately: a path that resolves to nothing is a
* wildcard URL (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. */
function bootDocumentIsOurs() {
const node = tree.currentNode();
if (!node || !node.action)
return true; /* unknown to the tree: a wildcard page, where the router is right to run */
return tree.viewClassFor(node) != null;
}
/* ---- the boot contract: the luci-base surfaces this router calls, looked up before it wires ----
*
* Every module here is written against parts of somebody else's code that were never an API:
* `L.Poll` is a deprecated alias, `L.dom.content` and `ui.instantiateView` are what `view.ut`
* happens to use, `Request.addInterceptor` is how the session probe hears a 403.
* tools/upstream-contract.mjs asks whether they still BEHAVE as assumed, but only against the two
* userlands this repo owns. On a router carrying a luci-base that moved, the first anyone learns of
* it is a click that opens nothing.
*
* So existence is checked at boot, once, and a missing name turns the router OFF rather than
* on-and-broken: the page is then the server-dispatched MPA the theme was before the router
* existed, and the console names which surface is gone.
*
* Existence only: a probe that called these would have to run them for effect (there is no dry
* `instantiateView`), and a boot check that navigates is worse than the fault it looks for.
*
* The list is what THIS file calls. `uci` (flushUciCache) and `L.network` are read through their own
* guards at their use, being optional there. */
const CONTRACT_FNS = [
'L.require',
/* classLoaded() tests `instanceof L.Class` to tell a loaded module from L.env/L.url/L.get */
'L.Class',
'L.dom.content',
/* a slash in the leaf is several functions on one object: the pair is only ever there or gone
* together, so one name in the report is the whole finding */
'L.Poll.start/stop',
'L.Request.addInterceptor',
'rpc.addInterceptor',
'ui.instantiateView',
'ui.hideModal',
'ui.hideIndicator',
'ui.addNotification'
];
/* the roots are resolved per probe, not once: `window.L` is what the two-L trap makes load-bearing
* (docs/spa-router.md), and `ui`/`rpc` are this module's own requires */
function hasFns(path) {
const seg = path.split('.');
const leaf = seg.pop();
let node = { L: window.L, ui: ui, rpc: rpc }[seg.shift()];
for (const k of seg) node = node[k];
return leaf.split('/').every((n) => typeof node[n] === 'function');
}
/* the two surfaces that are not functions */
const CONTRACT_REST = [
/* the L.env keys navigate() re-points, plus the base_url moduleUrl() reads */
[ 'L.env.{base_url,dispatchpath,requestpath,pathinfo,nodespec}', () => {
const env = window.L.env;
return !!env && [ 'base_url', 'dispatchpath', 'requestpath', 'pathinfo', 'nodespec' ]
.every((k) => k in env);
} ],
[ 'L.Poll.queue', () => Array.isArray(window.L.Poll.queue) ]
];
/* -> the names that are not there, in list order; empty means the document can be navigated. A
* probe that throws counts as missing: `L` itself may be a shape nobody here expected. */
function contractBreaks() {
const gone = (probe) => {
try { return !probe(); }
catch (e) { return true; }
};
return CONTRACT_FNS.filter((path) => gone(() => hasFns(path)))
.concat(CONTRACT_REST.filter(([ , probe ]) => gone(probe)).map(([ name ]) => name));
}
function wireRouter() {
if (_wired) return;
_wired = true;
const broken = contractBreaks();
if (broken.length) {
/* the URL, not a repository path: this package also ships inside openwrt/luci, where no
* `docs/` directory exists, and this is the line a stranger is asked to quote */
console.error('footstrap: this luci-base has no ' + broken.join(', ') +
' — the client router stays off and every link is a full page load, which is what the ' +
'theme did before it existed. Please report this line: ' +
'https://github.com/VizzleTF/luci-theme-footstrap/blob/main/docs/spa-router.md');
return;
}
if (!bootDocumentIsOurs())
return;
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, so 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 */
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 throwing the whole Map away */
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 a table fires it hundreds of times — so
* bail on the element first: the same <a> re-fires for every child span, and a non-link target
* is the 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. A keyboard user Tabs to it and presses
* Enter, firing no pointer event at all: focusin is the keyboard's hover, and the Tab-to-Enter
* gap is human-scale. pointerdown adds the one pointer case pointerover cannot see — a link that
* scrolled under a stationary pointer crosses no boundary. (A touch user's pointerover fires at
* the same moment as the tap, which is what the in-flight wait in navigate() is for.) Neither
* needs the lastHovered guard: both fire once per interaction, and warmClass() dedupes. */
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, this router only ever pushing 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 an `<a href="#">` inside a view — a common idiom for in-page controls —
* arrives here as if Back had been pressed, and re-running navigate() re-instantiates the
* view and wipes the state the click just set (issue #3). The view changed only if the PATH
* changed. */
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();
/* Handed to navigate() rather than started here: 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 satisfying the test can be the old
* page's — restored at 386, then clamped to 197 when the swap brought in a shorter page.
* Started after the commit, there is only one height it can read. */
_pendingRestore = _scrollMem.get(_curId) || null;
if (!navigate(window.location.pathname, false)) {
_pendingRestore = null;
window.location.reload();
}
});
}
/* ---- 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`). That is invisible on a full
* load, since Poll.start() dispatches `poll-start` only for a non-empty queue — but this router
* flushes the queue and calls stop() on every navigation, so walking from a polled page to an
* unpolled one leaves a "Paused" pill reporting on a poll that does not exist. The pill exists iff
* there is something to poll. Registered at module eval, i.e. after luci.js's own listener, so this
* runs second. */
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); }
}
});
/* At module eval, like the listener above: the session can die during the first view's own data
* calls, before anything has called wire(), and an interceptor registered later never sees it. */
watchSession();
/* Pause LuCI's 1 s poll loop while the tab is hidden: LuCI has no visibilitychange handler, so an
* overview left open in a background tab hammers ubus around the clock, iwinfo getAssocList
* included. stop() only clearInterval()s and the queue survives; start() re-arms and runs one
* immediate step(), so data is fresh on refocus. A poller added while hidden does not auto-start
* (stop() deletes the tick) and start() picks it up on show. */
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 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();
}
/* …but never resume a poll the session can no longer answer: luci-base stopped it when
* it put its "Session expired" modal up, and restarting would spend a burst of failing
* calls behind a page the user cannot use */
else if (wasActive && !_expired) {
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
* (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 cannot grow a static dependency on a module that may not be
* installed. */
const _navCbs = [];
function onNavigate(fn) { if (typeof fn === 'function') _navCbs.push(fn); }
return baseclass.extend({
seed,
wire: wireRouter,
wireVisibility,
onNavigate,
/* exported for tests/router-contract.test.mjs (no tests ship in the package), where it is driven
* against a hand-broken `L`: the one way to see the off branch */
contractBreaks, /* fs:probe */
/* likewise out-of-package: interval-pause.test.mjs drives the navigation sweep around a
* visibilitychange and session-expiry.test.mjs reads the verdict the interceptors reached.
* navigate() is the real caller of the first and `_expired` gates the second. */
clearViewIntervals, /* fs:probe */
sessionExpired, /* fs:probe */
/* fs-search warms its recents and the arrow-key-highlighted result, neither of which the
* pointer/focus triggers above can see. The edge points search -> router, because the router
* must keep no dependency on the palette. */
prefetchSegs
});