'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 " | " 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 `
` 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 *