luci-theme-footstrap: full load after a page litters body

The client router drops its fallbacks for luci-base older than 24.10. An
inline script at the top of <body> records nodes a page appends to
<body>; when such nodes remain, the next client-side navigation is a
full page load. The Refreshing pill also stays clickable after
navigation.

Signed-off-by: Ivan Kvashonkin <vizzlef@gmail.com>
This commit is contained in:
Ivan Kvashonkin authored and Hannu Nyman committed 2026-10-10 16:02:37 +03:00
1 parent 83b2216a96
commit 36720dd716
4 files changed
+143 -45

No files matched your search

@@ -56,7 +56,10 @@ function probeColor(expr) {
* shared with `luci-app-*`, and an app's unlayered `span { color: … !important }` outranks
* a layer and a plain inline style alike. A probe that loses its own colour reports the
* app's, which then becomes the admin's saved axis on the next confirm. */
_probe = E('span', { 'aria-hidden': 'true' });
/* the id is this probe's escape from fs-router.js's strayBodyNode(): it is
* appended once and never removed, so without an fs-* id it would read as body litter and
* force every navigation away from this page into a full load. */
_probe = E('span', { 'id': 'fs-appearance-probe', 'aria-hidden': 'true' });
_probe.style.cssText = 'position:fixed!important;left:-9999px!important;top:0!important;'
+ 'width:0!important;height:0!important;overflow:hidden!important;'
+ 'pointer-events:none!important;';
@@ -110,6 +110,12 @@ let _probe = null;
function resolveLen(token, dflt) {
if (!_probe) {
_probe = document.createElement('div');
/* the id is fs-router.js's strayBodyNode() reading an fs-* id as "ours", which is
* a different question from the chrome mark above: this stays unmarked (no data-fs-chrome,
* so the fence still does not spare it from a hostile !important) but still needs SOME fs-*
* name, or an element appended once and never removed would read as body litter and force
* every navigation off this page into a full load. */
_probe.id = 'fs-chrome-geom-probe';
_probe.setAttribute('aria-hidden', 'true');
/* out of flow, no box, no ink: it must never affect layout, scroll extent or hit-testing */
_probe.style.cssText = 'position:absolute!important;visibility:hidden!important;' +
@@ -43,9 +43,9 @@ const _viewIntervals = (window.__fsViewIntervals || (window.__fsViewIntervals =
* 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) {
window.setInterval = function (fn, ms, ...rest) {
const id = _si.apply(window, arguments);
_viewIntervals.set(id, { fn, ms, rest: Array.prototype.slice.call(arguments, 2), live: id });
_viewIntervals.set(id, { fn, ms, rest, live: id });
return id;
};
window.clearInterval = function (id) {
@@ -102,28 +102,19 @@ const _viewIntervals = (window.__fsViewIntervals || (window.__fsViewIntervals =
*
* `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. */
* it, but no supported release ships poll.js yet). `active()` below is called unguarded: this
* caller runs from the visibilitychange listener at module eval (:69-81), before CONTRACT_FNS ever
* checks anything, so what backs it is the floor, not the boot contract — `L.Poll.active` is
* unconditional in every 24.10+ luci.js. Read blind, a renamed `timer` 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: `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 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);
const running = L.Poll.active();
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 '
@@ -138,6 +129,13 @@ function clearViewIntervals() {
/* 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); });
}
/* The "Refreshing"/"Paused" pill must not outlive the poll it reports on — see the `poll-stop`
* listener below, which is its only caller. */
function hidePollIndicatorIfEmpty() {
if (!(L.Poll && L.Poll.queue && L.Poll.queue.length === 0)) return;
try { ui.hideIndicator('poll-status'); }
catch (e) { console.error('footstrap: hideIndicator threw on the poll-status teardown', e); }
}
/* 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;
@@ -327,17 +325,69 @@ function watchSession() {
* 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);
window.L.dom.content(bin, null);
}
catch (e) {
el.remove();
}
}
/* ---- a page that parks nodes on <body> makes the next navigation a full load ----
*
* Some apps' render() appends tooltip/modal nodes to document.body on every visit (one leaves 7)
* and nothing here sweeps them, so the document is spent, as with an invasive foreign sheet. The
* recorder is header.ut's first <body> <script>, filling window.__fsBodyAdds; this file only reads it. */
/* -> true if `el` is a node the theme must treat as stray body litter: not the theme's own chrome,
* not something stock LuCI parks there itself, not a node type that can never paint. Pure: no DOM
* writes, no window.__fsBodyAdds read. */
function strayBodyNode(el) {
if (!el || el.nodeType !== 1) return false;
/* never rendered on their own, wherever a script parks them */
switch (el.nodeName) {
case 'SCRIPT': case 'STYLE': case 'LINK': case 'TEMPLATE': case 'NOSCRIPT': case 'META':
return false;
}
/* ours: the chrome mark, or an fs-* id/class. All of these
* parent straight to <body> and must never be read as litter: this file's own #fs-nav-progress,
* fs-search.js's #fs-search-ov (which also carries the mark), and the geometry/colour probes
* fs-chrome.js and fs-appearance.js each park on <body> once and never remove — an fs-* id is
* what tells them from a foreign app's own unmarked nodes below. */
/* read through `dataset`, not a literal `hasAttribute()` call: the build counts every
* single-quoted spelling of the chrome mark's name as a JS-built chrome root, a false positive
* for code that only READS the mark on somebody else's node. camelCase avoids that spelling. */
if (el.dataset && el.dataset.fsChrome !== undefined) return false;
if (el.id && el.id.indexOf('fs-') === 0) return false;
for (const c of el.classList) if (c.indexOf('fs-') === 0) return false;
/* stock LuCI's own ui.js parents these to <body> at its own __init__ and keeps them for the life
* of the document: #modal_overlay (showModal's host) and the shared .cbi-tooltip */
if (el.id === 'modal_overlay') return false;
if (el.classList.contains('cbi-tooltip')) return false;
/* ui.js's handleDownload() appends a hidden `<a download>`, clicks it once and revokes its object
* URL, but never removes the element itself. Every app that downloads the same way (wireguard,
* filemanager, snmpd, banip/adblock's feed export) calls a.remove() of its own accord and is
* pruned by bodyLittered() below before ever reaching this function. */
if (el.nodeName === 'A' && el.hasAttribute('download') && el.style.display === 'none') return false;
return true;
}
/* -> true iff the document holds a stray body node right now. A recording only ever grows — the
* recorder cannot know a node was later removed or moved inside #view — so this is also the one
* place that prunes it: an entry whose element is no longer a direct child of <body> is dropped for
* good, or the list would grow for the life of the document and re-judge nodes nobody can reach. */
function bodyLittered() {
const adds = window.__fsBodyAdds;
if (!adds || !adds.length) return false;
let littered = false;
for (let i = adds.length - 1; i >= 0; i--) {
if (!document.body || adds[i].parentNode !== document.body) { adds.splice(i, 1); continue; }
if (strayBodyNode(adds[i])) littered = true;
}
return littered;
}
let _wired = false;
/* the pathname whose view is currently rendered; popstate compares against it to tell a real
* navigation from a fragment change */
@@ -468,7 +518,7 @@ function restoreScroll(pos, gen) {
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 SCROLL_KEYS = new Set([ 'PageUp', 'PageDown', 'Home', 'End', 'ArrowUp', 'ArrowDown', ' ' ]);
const onKey = (ev) => { if (SCROLL_KEYS.has(ev.key)) stop(); };
const opts = { passive: true, capture: true };
function off() {
@@ -773,11 +823,7 @@ function commitStage(stage, contentHost) {
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);
window.L.dom.content(live, nodes);
dropStage(stage);
}
@@ -891,6 +937,10 @@ function navigate(pathname, push, kbd) {
* only exit leaving both pages correct is a real navigation (fs-sheets.js) */
if (sheets.documentPoisoned()) return false;
/* …and a page that parked its own nodes on <body> is spent the same way: see
* strayBodyNode()/bodyLittered() above. */
if (bodyLittered()) 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;
@@ -1094,6 +1144,11 @@ function navigate(pathname, push, kbd) {
if (L.Poll && L.Poll.queue) {
L.Poll.queue.length = 0;
L.Poll.stop();
/* The "Refreshing"/"Paused" pill's own teardown rides this `stop()`'s `poll-stop` event —
* see the listener below, not a call here. A page can also empty the queue on its own, with
* no navigation at all (`L.Poll.remove()`, e.g. `luci-mod-status`'s graphs.js on unload,
* `luci-app-banip`'s log view), and `stop()` dispatches the same event for that call too;
* a call placed only here would miss it. */
L.Poll.start();
}
/* kill the outgoing view's plain setInterval pollers too, as a full load would; L.Poll's own
@@ -1399,24 +1454,21 @@ function wireRouter() {
});
}
/* ---- the poll indicator must not outlive the poll ----
/* ---- the poll-status pill must not outlive the poll it reports on ----
*
* 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. */
* LuCI shows the pill on `poll-start`, flips it to "Paused" on `poll-stop` and never hides it. The
* queue is emptied by this router's navigate() teardown and also by a view calling `L.Poll.remove()`
* mid-page; both reach `L.Poll.stop()`, so one `poll-stop` listener covers both. The hide runs one
* microtask later: luci.js registers its own `poll-stop` listener (which shows "Paused" with no
* click handler) after an async chain while this one registers at eval, so the order is not
* deterministic, and hiding inline could remove the span before luci.js re-created it. The queue
* length is read at that later moment, so a queue the incoming page has already refilled is left alone. */
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); }
}
queueMicrotask(hidePollIndicatorIfEmpty);
});
/* 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. */
/* At module eval: 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
@@ -85,6 +85,43 @@
#}
{% const fs_page = entityencode(join('-', length(ctx.path) ? ctx.path : ctx.request_path), true) %}
<body class="lang_{{ dispatcher.lang }}" data-page="{{ fs_page }}">
<script>
/* Records every node a script parks directly on <body>: a page that does so makes the next
* navigation a full load, because the document is spent. fs-router.js's
* strayBodyNode()/bodyLittered() read window.__fsBodyAdds. First byte of <body>, before
* any chrome element or the dispatched page's own <div id="view">. Own property of this
* <body> instance, never Node.prototype. */
(() => {
const body = document.body;
if (!body || body.__fsBodyHooked) return;
body.__fsBodyHooked = true;
const adds = window.__fsBodyAdds || (window.__fsBodyAdds = []);
const names = [ 'appendChild', 'insertBefore', 'replaceChild', 'append', 'prepend',
'insertAdjacentElement', 'insertAdjacentHTML' ];
names.forEach((name) => {
const orig = body[name];
if (typeof orig !== 'function') return;
try {
Object.defineProperty(body, name, {
configurable: true,
writable: true,
value() {
const before = Array.prototype.slice.call(this.children);
const ret = orig.apply(this, arguments);
const after = this.children;
for (let i = 0; i < after.length; i++) {
if (before.indexOf(after[i]) === -1) adds.push(after[i]);
}
return ret;
}
});
}
/* a host that refuses to redefine this one name leaves it unwrapped; the rest
* still are */
catch (e) {}
});
})();
</script>
{#
The wallpaper pattern's paint surface: 15-wallpaper.css masks this element so an
uploaded SVG takes the theme's ink. A mask applies to an element's whole rendering,