From 560b82b0eff91436ab92351b7b35312a8b8115ed Mon Sep 17 00:00:00 2001 From: mARTin-B78 Date: Fri, 2 Oct 2026 23:13:19 +0200 Subject: [PATCH] perf(ui): lazy-render big sections to stay within the DOM node budget Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 3 +++ static/dist/main.min.js | 4 ++-- static/js/jobs.js | 6 ++++-- static/js/utils.js | 31 +++++++++++++++++++++++++------ static/loader.js | 36 ++++++++++++++++++++++++++++++++++-- static/nav.js | 1 + 6 files changed, 69 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c592173..713ac84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,9 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi - **Driving mode has a new layout that always keeps the controls on screen.** The overlay is now one full-height column: a top bar (share, a *Picture / Lyrics / Compact* switch, minimize, close), a content area that shrinks and scrolls inside itself, and a controls dock pinned to the bottom (position slider, time, previous / -15 / play / +15 / next, speed, text, images). On an iPhone SE (375x667) the controls used to fall below the screen, the share button covered the article title and the top of the title was cut off; now the dock is always fully visible from 320x568 up to tablets, in landscape the picture and the three lines sit side by side, and long titles wrap to two lines. *Picture* fills the area (double-tap, pinch, wheel, +/- keys or the zoom buttons to zoom, drag to pan, Escape or the reset button to reset); *Lyrics* shows the whole text, the spoken sentence large and centered, tap a sentence to jump there, and scrolling by hand pauses following until *Back to current*; *Compact* (default) is the picture plus previous / current / next line. The minimize button shrinks everything to just the current line. The choice is remembered. Transport buttons stay perfect circles. - **Driving mode shows three lines of text instead of the whole article.** The previous sentence (small), the current one (large, with the spoken word marked) and the next one (small) sit in a fixed-height box, so the controls below never jump. Lines fade in smoothly (no animation when your device asks for reduced motion), the text is not a live region so a screen reader does not read out every sentence, it is readable at 375 px, and tapping the previous or next line jumps to that sentence. Pictures work as before. +### Performance +- **Big sections render lazily, so the page stays inside its DOM-node budget.** The Magazine section's markup is now fetched and injected the first time you open it (or deep-link into it) instead of at startup; the Performance background-jobs history is fetched and drawn only while Performance is shown (it used to add about 470 nodes at startup); and the language pickers keep a single option in the hidden native ` holds only the current + // option (the searchable popup renders the full list on demand), and a + // `value` setter adds any option that is assigned later, so programmatic + // `sel.value = 'DE'` keeps working exactly as with a full list. + const full = LANG_CODE_OPTIONS.map(([c, n]) => ({ value: c, label: n })); + const labelOf = c => (full.find(o => o.value === c) || { label: c }).label; + const ensure = c => { + if (c != null && c !== '' && ![...sel.options].some(o => o.value === c) && full.some(o => o.value === c)) { + const opt = document.createElement('option'); + opt.value = c; opt.textContent = labelOf(c); + sel.appendChild(opt); + } + }; + sel.innerHTML = ''; + ensure(cur); + const native = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value'); + Object.defineProperty(sel, 'value', { + configurable: true, + get() { return native.get.call(this); }, + set(v) { ensure(v); native.set.call(this, v); }, + }); + sel.value = cur; + enhanceLangSelect(sel, full); }); scope.querySelectorAll('select[data-langsel-keep]').forEach(enhanceLangSelect); } diff --git a/static/loader.js b/static/loader.js index cb41fff..27fd9a4 100644 --- a/static/loader.js +++ b/static/loader.js @@ -56,6 +56,13 @@ // JS/CSS use the version string so the service worker cache stays valid across reloads. var _ts = Date.now(); + // Sections whose only consumer is a lazy feature group: their markup is not + // fetched/injected at startup (DOM-node budget, tests/constrained.spec.js); + // it is injected on first navigation, before that group's modules run. + var LAZY_HTML_SECTIONS = ['s-magazine']; + var _uiReady = false; + var _sectionHtml = {}; + function _inject(id, html) { var el = document.getElementById(id); if (el) el.innerHTML = html; @@ -80,7 +87,7 @@ // files are much smaller than JS, they finish before the feature-module batch // needs them. var _bgSections = Promise.all( - SECTIONS.filter(function (id) { return id !== _activeSection; }) + SECTIONS.filter(function (id) { return id !== _activeSection && LAZY_HTML_SECTIONS.indexOf(id) === -1; }) .map(async function (id) { try { var res = await fetch('/static/sections/' + id + '.html?v=' + _ts); @@ -203,7 +210,31 @@ // Public section loader used by nav.js. Modules are loaded in manifest order, // exactly once, and a failed group leaves the existing section visible rather // than turning navigation into a blank screen. + function _ensureSectionHtml(id) { + var el = document.getElementById(id); + if (LAZY_HTML_SECTIONS.indexOf(id) === -1 || !el || el.firstElementChild) return Promise.resolve(); + if (_sectionHtml[id]) return _sectionHtml[id]; + _sectionHtml[id] = fetch('/static/sections/' + id + '.html?v=' + _ts) + .then(function (res) { + if (!res.ok) throw new Error(res.status + ' ' + res.statusText); + return res.text(); + }) + .then(function (html) { + _inject(id, html); + if (_uiReady && window.applyI18n) window.applyI18n(el); + }) + .catch(function (e) { + delete _sectionHtml[id]; + el.innerHTML = '

Failed to load section ' + id + ': ' + e.message + '

'; + }); + return _sectionHtml[id]; + } + window.ensureFeatureModules = function (sectionId) { + return _ensureSectionHtml(sectionId).then(function () { return _ensureModules(sectionId); }); + }; + + function _ensureModules(sectionId) { var names = Array.isArray(_lazyFeatureGroups[sectionId]) ? _lazyFeatureGroups[sectionId] : []; var pending = names.map(function (name) { return '/static/js/' + name + '.js'; }) .filter(function (src) { return !_loadedFeatureModules.has(src); }); @@ -215,7 +246,7 @@ _notifyLoadFailure(); throw error; }); - }; + } // D — navigation (must run before init/reveal so routing is interactive before // async settings or voice-library refreshes can occupy the backend). @@ -251,6 +282,7 @@ if (window.initLangPicker) window.initLangPicker(); if (window.applyI18n) window.applyI18n(document); if (window.initAccessibilityEnhancements) window.initAccessibilityEnhancements(); + _uiReady = true; // E — post-init modules load in background AFTER the UI is already visible. // Engines, AI backends, and conversation load while the user browses other tabs. diff --git a/static/nav.js b/static/nav.js index 278e47f..c7436af 100644 --- a/static/nav.js +++ b/static/nav.js @@ -191,6 +191,7 @@ if (sectionId === 's-library' && typeof window.libraryRender === 'function') window.libraryRender(window._libraryView || 'books'); if (sectionId === 's-caststudio' && typeof window.studioOnShow === 'function') window.studioOnShow(); if (sectionId === 's-magazine' && typeof window.magOnShow === 'function') window.magOnShow(); + if (sectionId === 's-performance' && typeof window.jobsOnShow === 'function') window.jobsOnShow(); if (typeof window.initCollapsibleCards === 'function') window.initCollapsibleCards(); setStoredSection(sectionId); setSectionHash(sectionId);