✨ Sync 2026-08-13 21:00:03

This commit is contained in:
github-actions[bot] committed 2026-08-13 21:00:03 +08:00
1 parent c57dcedc98
commit 29133c66a8
48 files changed
+1339 -1967

No files matched your search

@@ -684,9 +684,9 @@ function takeReturn() {
return true;
} catch (e) { return false; }
}
const MARK = 'fs-ap';
const MARK = 'fs-ap'; /* the built form's own class; also how mount() knows it is already there */
/* how long the stock view gets to render its tabs before a missing group counts as a failure */
const TAB_DEADLINE = 5000; /* the built form's own class; also how mount() knows it is already there */
const TAB_DEADLINE = 5000;
const TAB = 'fs-appearance'; /* the pane's data-tab, which ui.tabs' click handler matches on */
let _routeObserver = null, _viewObserver = null, _observedView = null, _building = false;
@@ -23,8 +23,18 @@ const _fitters = [];
let _rafPending = false;
let _ro = null, _mo = null;
/* one canvas for the whole document: wordFloor() measures text with it, and creating one per call
* is the expensive half of that measurement */
* is the expensive half of that measurement. `null` = not asked yet, `false` = asked and refused —
* the same three-state slot fs-widgets.js's rasterCtx() keeps, and for the same reason: a browser
* with canvas turned off (an anti-fingerprinting extension, a WebView, memory pressure) answers
* null, and retrying that on every fit pass is a cost with no chance of a different answer. */
let _cx = null;
function textCtx() {
if (_cx === null) {
try { _cx = document.createElement('canvas').getContext('2d') || false; }
catch (e) { _cx = false; }
}
return _cx;
}
const SPACE_RE = /\s+/;
/* Run every fitter NOW, synchronously. A fitter must be idempotent — this fires on every
@@ -155,7 +165,15 @@ return baseclass.extend({
wordFloor(t) {
const rows = t.querySelectorAll('.tr:not(.table-titles):not(.cbi-section-table-titles):not(.placeholder)');
if (!rows.length) return 0;
if (!_cx) _cx = document.createElement('canvas').getContext('2d');
/* No 2D context, no floor to report — and 0 is the honest answer, not a failure: it says
* "this test has nothing to add", leaving the other two stack tests (does it overflow, is a
* one-token column shredded) to decide. Throwing here would cost far more than the test is
* worth: fs-select's fitTables() strips `fs-stacked` BEFORE it measures (rule 1), and this is
* the last term of that `||`, so the throw would escape the row walk with the class already
* gone — every stackable table left un-stacked, every later table in the pass unfitted, once
* a second on a polled page. */
const cx = textCtx();
if (!cx) return 0;
const floors = [], longest = [];
let cols = null;
for (const row of rows) {
@@ -181,8 +199,8 @@ return baseclass.extend({
else for (const w of text.split(SPACE_RE)) if (w.length > word.length) word = w;
if (!(word.length > (longest[i] || 0))) continue;
longest[i] = word.length;
_cx.font = col.font;
const need = _cx.measureText(word).width + (col.tracking * word.length) + col.pad;
cx.font = col.font;
const need = cx.measureText(word).width + (col.tracking * word.length) + col.pad;
if (!(floors[i] >= need)) floors[i] = need;
}
}
@@ -61,20 +61,39 @@ function clearViewIntervals() {
* is the pair disagreeing: a tick that is running while the id it runs on has no name we know.
* Then do NOTHING, once, loudly: a view's leftover interval outliving its page costs a wasted
* RPC, whereas clearing LuCI's own tick costs every live value on every later page. */
/* …and the ALIAS ITSELF is the first thing that can go — it is the deprecation the paragraph
* above is about, so this function cannot be the one place that reads it blind. Every other
* `L.Poll` read in this file is guarded, and an unguarded one here throws where a throw costs
* most: navigate() calls this AFTER #view has been swapped for the "Loading view…" spinner and
* BEFORE data-page, pushState and the require, so a TypeError would strand every click on the
* spinner with the old URL and the old menu mark — far worse than the leaked interval this
* exists to sweep. No alias is the same answer as an unreadable timer: do NOTHING, once,
* loudly. */
if (!L.Poll) {
warnPollUnreadable('footstrap: L.Poll is gone from this luci-base, so LuCI\'s own tick cannot be '
+ 'told apart from a view\'s timers — leaving view intervals alone. fs-router.js needs '
+ 'updating for this luci-base.');
return;
}
const running = (typeof L.Poll.active === 'function') ? L.Poll.active() : (L.Poll.timer != null);
if (running && L.Poll.timer == null) {
if (!_pollTimerWarned) {
_pollTimerWarned = true;
console.error('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.');
}
warnPollUnreadable('footstrap: LuCI is polling but L.Poll.timer is not readable — leaving view '
+ 'intervals alone rather than risking its tick. fs-router.js needs updating for this '
+ 'luci-base.');
return;
}
const keep = running ? L.Poll.timer : null;
_viewIntervals.forEach((id) => { if (id !== keep) window.clearInterval(id); });
}
/* ONE line per document whatever went wrong: this runs on every navigation, and a router that
* cannot read L.Poll cannot read it on the next click either — a message per click would bury the
* console the user is reading it in. */
let _pollTimerWarned = false;
function warnPollUnreadable(msg) {
if (_pollTimerWarned) return;
_pollTimerWarned = 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
@@ -806,15 +825,23 @@ function navigate(pathname, push, kbd) {
* repairStaleRender() a mess that only exists because a require in flight cannot be stopped. */
if (gen !== _navGen) return null;
_seen.add(className);
/* NAME THE OWNER for the length of this require. On a first visit the require IS the render,
* so any <style> the module injects belongs to THIS page — even if a newer navigation has
* stamped data-page by the time it lands, which is exactly the case repairStaleRender() below
* exists for. Without it fs-sheets credited such a sheet to the page that superseded this one
* and bound it there for the life of the document (measured: luci-app-filemanager's
* `.cbi-button-save { display: none !important }` disabled on its own page and live on
* System -> System, across return visits). */
sheets.attributeTo(rsegs);
return RT.require(className).finally(() => sheets.attributeTo(null));
/* NAME THE OWNER for the length of this require, AND ONLY WHEN THE MODULE HAS YET TO BE
* EVALUATED. On a first visit the require IS the render, so any <style> the module injects
* belongs to THIS page — even if a newer navigation has stamped data-page by the time it
* lands, which is exactly the case repairStaleRender() below exists for. Without it fs-sheets
* credited such a sheet to the page that superseded this one and bound it there for the life
* of the document (measured: luci-app-filemanager's `.cbi-button-save { display: none
* !important }` disabled on its own page and live on System -> System, across return visits).
*
* `!cached` is what makes the hint survive long enough to be read, and its absence is why the
* measured case stopped being covered. A CACHED require injects nothing — the module was
* evaluated on the first visit and `require()` hands back the singleton — but it still ran
* this line, and its `.finally` fires within a microtask, so the navigation that superseded a
* cold require would set the hint to itself and clear it to null while that require was still
* in flight. The sheet then landed with no hint at all, i.e. credited to the superseding page,
* which is the exact bug. A require that cannot inject has no business naming an owner. */
if (!cached) sheets.attributeTo(rsegs, gen);
return RT.require(className).finally(() => { if (!cached) sheets.attributeTo(null, gen); });
}).then((view) => {
if (view == null) return;
if (!(view instanceof RT.view))
@@ -185,8 +185,21 @@ function enhance(sel) {
* openclash / justclash / ssclash / dashboard / statistics included, found ZERO. That is the point:
* every table anyone here emits already carries the class, so this changes nothing that can be
* measured and covers the one shape that cannot be (docs/conventions.md: coverage is a contract). */
const FOREIGN_TABLE = '#view .table:not(.cbi-section-table):not(.fs-dt), ' +
'#view table:not(.table):not(.cbi-section-table):not(.fs-dt)';
/* NO `:not(.fs-dt)`, and that is the difference between claiming a table once and KEEPING it. These
* tables are polled: L.ui.Table.update() and every hand-rolled equivalent replace the rows inside
* the element they already have. Excluding what we tagged meant the claim ran exactly once per
* element — the rows present at that moment were adopted and captioned, and every batch after it
* kept neither `.tr`/`.td` nor `data-title`, on a table that by then may carry `.fs-stacked`, where
* `#view .table.fs-dt.fs-stacked { overflow: hidden }` clips the lot with no scrollbar. That is the
* exact failure adoptMarkup() was written to prevent, arriving one poll later. LuCI's own tables
* were never exposed to it (ui.Table writes those class names and the caption itself), so this is
* third-party-only — which is the zone this whole selector exists for.
*
* Re-running is what the two functions below are already written for: both are additive, both skip
* what is already done, and neither re-decides anything (adoptMarkup() settles the "is this ours to
* rewrite?" question once, at claim time, and remembers the answer on the element). */
const FOREIGN_TABLE = '#view .table:not(.cbi-section-table), ' +
'#view table:not(.table):not(.cbi-section-table)';
/* The FOURTH header markup, and the one only a foreign table produces: `<table><tr><th>…`, with no
* `<thead>` for the parser to imply and none of LuCI's class names. It is the exact shape the phone
@@ -255,9 +268,10 @@ function tagDataTables() {
* names: a `<thead>` becomes `.thead` and a plain first row of `<th>` becomes `.tr.table-titles` —
* the two names theme/30-tables.css hides when stacked. */
function adoptMarkup(t, head) {
/* DECIDED ONCE, AT CLAIM TIME, and only for a table that speaks none of this vocabulary. Asking
* the question every pass instead would answer "already adopted" the moment we adopted it — and
* these tables are polled, so the fresh rows that arrive bare afterwards would never be taken.
/* DECIDED ONCE, AT CLAIM TIME, and only for a table that speaks none of this vocabulary — then
* READ on every pass, because the caller now revisits a table it has already claimed (see
* FOREIGN_TABLE). Asking the question afresh each pass instead would answer "already adopted"
* the moment we adopted it, and the fresh rows a poll brings in bare would never be taken.
* Asking it at all is what keeps the theme's hands off LuCI's own markup: the apk Software list
* heads its table with `.tr.cbi-section-table-titles`, and blindly adding `table-titles` to that
* would be the theme rewriting a class LuCI chose. */
@@ -676,11 +676,30 @@ function appKey(segs) {
* stand: the System page came back with no Save button, and stayed that way across return visits).
*
* So the router names the owner for the duration of such a require (see fs-router.js), and this is
* that hint. It is a plain value rather than a stack: two cold requires cannot overlap, because the
* second navigation supersedes the first before it starts one. */
* that hint.
*
* ONE SLOT, STAMPED WITH THE NAVIGATION THAT SET IT. The router only names an owner for a require
* that has yet to evaluate its module, so the common shape — a cold require in flight, the user
* clicking on to a page already in LuCI's class cache — no longer touches this slot at all. Two
* COLD requires can still overlap, and then the newer one wins the slot: it is the page the user is
* looking at, and crediting ITS sheet to the page it superseded would leave the visible page
* unpainted, which is the worse of the two errors. The generation stamp is what keeps that from
* getting worse still — the older require's `.finally` must not clear a slot the newer one now
* holds, or the newer page's own sheet lands unattributed.
*
* There is no way to do better from here: LuCI evaluates a view module inside `eval()` in its own
* `require()` (luci.js), so nothing observable says WHICH module is running when a <style> appears —
* `document.currentScript` is null and the injection is synchronous inside a promise chain we do
* not own. The remaining hole is therefore two cold requires overlapping, where the first one's
* sheet is credited to the second's page — the pre-existing behaviour, now narrowed to that one
* case instead of every navigation away from a cold require. */
let _ownerHint = null;
function attributeTo(segs) {
let _ownerGen = -1;
function attributeTo(segs, gen) {
/* a stale require letting go of a slot somebody else now holds: leave it alone */
if (segs == null && gen !== _ownerGen) return;
_ownerHint = (segs == null) ? null : appKey(segs);
_ownerGen = (segs == null) ? -1 : gen;
}
function ownerKey() {
@@ -723,6 +742,25 @@ function scopeToCurrentPage(segs) {
});
}
/* TAKE A SHEET, AND SCOPE IT IN THE SAME BREATH.
*
* scopeToCurrentPage() runs on NAVIGATION, so it only ever sees sheets that were already here. A
* sheet that arrives afterwards is scoped by nobody until the next click — and the one case where
* that matters is precisely the case the owner hint exists for: a cold require whose page the user
* has already left injects its <style> into a document showing somebody else's page, and the sheet
* paints there until the user navigates again. Measured with a view whose module appends
* `body { outline: 3px solid rgb(9,9,9) }`: correctly credited to its own page, and still painting
* the outline on the page that superseded it.
*
* So the stamp and the switch are one act. `ownerKey()` is the page the sheet BELONGS to and
* `currentKey()` the page on screen; on every ordinary arrival they are the same string and this is
* a no-op. */
function claimOwner(el) {
const key = ownerKey();
_owner.set(el, key);
if (outlivesPage(el)) setEnabled(el, key === currentKey());
}
function rehostIntoThemeLayer(el, universe) {
if (el.dataset.fsLayered) return;
@@ -735,7 +773,7 @@ function rehostIntoThemeLayer(el, universe) {
el.dataset.fsLayered = '1';
el.after(s); /* keep source order: ties inside the layer still resolve as they did */
silence(el);
_owner.set(s, ownerKey()); /* the shim paints; the original is silenced for good */
claimOwner(s); /* the shim paints; the original is silenced for good */
fenceImported(s, universe.names, Date.now() + 1000); /* a cache hit lands on the first frame */
return;
}
@@ -752,7 +790,6 @@ function rehostIntoThemeLayer(el, universe) {
* unpinned all over again and a second pass appends a second fence. The mark is the only thing
* that says the work is done, so it has to be set for every path below, wrapped or not. */
el.dataset.fsLayered = '1';
_owner.set(el, ownerKey()); /* a <style> is re-hosted IN PLACE, so it paints itself */
/* Wrap only if the text still IS the sheet (see textIsSheet). When it is not, the sheet stays
* unlayered — Zone 2 exactly where it already was, which is a trade — rather than lose rules,
@@ -769,6 +806,15 @@ function rehostIntoThemeLayer(el, universe) {
el.textContent = '@layer theme {\n' + el.textContent + '\n}';
}
try { if (el.sheet) fenceRules(el.sheet.cssRules, universe.names); } catch (e) { /* unfenced, not broken */ }
/* LAST, because the line above may have re-parsed the sheet. `el.disabled` is not a content
* attribute — it is the element's view of `el.sheet.disabled`, and assigning textContent throws
* the old CSSStyleSheet away and builds a new one, which comes back ENABLED. Claiming before the
* wrap therefore switched a sheet off and then switched it back on within the same call, which is
* how a sheet belonging to a page the user had left went on painting the page that superseded it
* (measured: `body { outline: 3px solid rgb(9,9,9) }` from a still-loading view, live on
* System -> System). Ownership is recorded from the same call either way — ownerKey() is read
* here, while the router's hint still names the page whose module is evaluating. */
claimOwner(el); /* a <style> is re-hosted IN PLACE, so it paints itself */
}
/* Re-hosting needs the theme's own selectors to tell an invasive sheet from an inert one. If