perf: lazy-load section feature modules
This commit is contained in:
parent
06763a4602
commit
c43a11da68
@ -8,6 +8,9 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
|
||||
## [Unreleased]
|
||||
|
||||
### Changed
|
||||
- **Section-scoped feature loading.** The shared module manifest now builds a 17-module foundation bundle and loads
|
||||
Rehearser, Reader/Audiobook, Library/Characters, and Studio code only when their sections are opened. Repeated
|
||||
navigation reuses loaded modules, while deep links load their group before activation.
|
||||
- **P1 Roadmap status.** Documented the completed namespace/manifest foundations and durable background artifacts,
|
||||
while keeping true lazy feature loading, responsibility-level frontend extraction, provider-health consolidation,
|
||||
full workload measurements, and browser image-cache eviction explicitly open.
|
||||
|
||||
16
TODO.md
16
TODO.md
@ -20,9 +20,9 @@ the first block before investing in architecture or polish.
|
||||
All P0 safety, security, data-integrity, and release-protection work is complete.
|
||||
P1 implementation is active: pagination, shared voice caching, bounded decoded-audio
|
||||
caching, background jobs, canonical character identity, forms, and the frontend
|
||||
namespace/manifest foundations are working and regression-tested. Remaining P1 work
|
||||
is intentionally open where it needs a real architecture change or workload
|
||||
measurement: true feature-module lazy loading, the larger Audiobook/Rehearser/style
|
||||
namespace/manifest foundations and section-scoped lazy loading are working and
|
||||
regression-tested. Remaining P1 work is intentionally open where it needs a real
|
||||
architecture change or workload measurement: the larger Audiobook/Rehearser/style
|
||||
split, centralized provider-health handling, full constrained-device workloads, and
|
||||
browser image/profile-cache eviction.
|
||||
|
||||
@ -89,9 +89,9 @@ browser image/profile-cache eviction.
|
||||
- [x] **L — Add character forms inside one character record.** `sheet.forms[]` stores bounded alternate appearances,
|
||||
prompts, and per-form visual assets; the detail-page switcher preserves one character ID and voice, and selected
|
||||
forms feed prompt generation and identity fingerprints.
|
||||
- [ ] **L — Modularize the frontend.** The shared manifest and compatibility namespace
|
||||
bridge are complete foundations; responsibility-level extraction, true feature-module
|
||||
lazy loading, and the Audiobook/Rehearser/style split remain.
|
||||
- [ ] **L — Modularize the frontend.** The shared manifest, compatibility namespace
|
||||
bridge, and section-scoped lazy loading are complete foundations; responsibility-level
|
||||
extraction and the Audiobook/Rehearser/style split remain.
|
||||
|
||||
### P2 — after behavior and safety stabilize
|
||||
|
||||
@ -132,7 +132,9 @@ browser image/profile-cache eviction.
|
||||
|
||||
## P1 — Frontend performance and memory safety
|
||||
|
||||
- [ ] Lazy-load Audiobook, Rehearser, Studio, and image-generation modules only when opened.
|
||||
- [x] Lazy-load Audiobook, Rehearser, Studio, and image-generation modules only when opened. The shared manifest
|
||||
keeps the foundation in the core bundle and loads Reader/Audiobook, Rehearser, Library/Characters, and Studio
|
||||
groups once on first navigation; deep links load their group before activation.
|
||||
- [x] Paginate large character and voice tables; render approximately 25–50 visible rows, with a constrained-device
|
||||
regression covering a 250-character production.
|
||||
- [x] Cache `/api/voices` with a shared 30-second session cache, one in-flight request, explicit refresh, and
|
||||
|
||||
@ -7,7 +7,8 @@ import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const root = join(fileURLToPath(new URL('.', import.meta.url)), '..');
|
||||
const modules = JSON.parse(readFileSync(join(root, 'static', 'module-manifest.json'), 'utf8'));
|
||||
const manifest = JSON.parse(readFileSync(join(root, 'static', 'module-manifest.json'), 'utf8'));
|
||||
const modules = [...new Set([...(manifest.core || []), ...Object.values(manifest.lazy || {}).flat()])];
|
||||
const seenModules = new Set();
|
||||
const declarations = new Map();
|
||||
const declarationPattern = /^(?:async\s+)?(?:function|class|const|let|var)\s+([A-Za-z_$][\w$]*)/gm;
|
||||
|
||||
@ -18,8 +18,11 @@ const jsDir = join(root, 'static', 'js');
|
||||
const outDir = join(root, 'static', 'dist');
|
||||
mkdirSync(outDir, { recursive: true });
|
||||
|
||||
// Must match loader.js batch C order exactly.
|
||||
const MODULES = JSON.parse(readFileSync(join(root, 'static', 'module-manifest.json'), 'utf8'));
|
||||
// Bundle only the foundation. Section-specific modules are loaded on demand by
|
||||
// loader.js; keeping the same manifest as the runtime prevents drift.
|
||||
const MANIFEST = JSON.parse(readFileSync(join(root, 'static', 'module-manifest.json'), 'utf8'));
|
||||
const MODULES = MANIFEST.core;
|
||||
if (!Array.isArray(MODULES) || !MODULES.length) throw new Error('module manifest has no core modules');
|
||||
const MAIN = MODULES.map(n => join(jsDir, n + '.js'));
|
||||
|
||||
const source = MAIN.map(f => `\n/* ==== ${f.split('/').pop()} ==== */\n` + readFileSync(f, 'utf8')).join('\n');
|
||||
|
||||
967
static/dist/main.min.js
vendored
967
static/dist/main.min.js
vendored
File diff suppressed because one or more lines are too long
@ -127,24 +127,51 @@
|
||||
// C — feature modules: always try the bundle first (one request instead of 23).
|
||||
// Falls back to individual files if the bundle is missing or fails to load.
|
||||
var _featureFiles = [];
|
||||
var _lazyFeatureGroups = {};
|
||||
try {
|
||||
var _manifestResponse = await fetch('/static/module-manifest.json?v=' + _appVersion, { cache: 'no-store' });
|
||||
if (!_manifestResponse.ok) throw new Error('module manifest ' + _manifestResponse.status);
|
||||
var _manifest = await _manifestResponse.json();
|
||||
if (!Array.isArray(_manifest) || !_manifest.length) throw new Error('empty module manifest');
|
||||
_featureFiles = _manifest.map(function (name) { return '/static/js/' + name + '.js'; });
|
||||
if (!Array.isArray(_manifest.core) || !_manifest.core.length) throw new Error('empty core module manifest');
|
||||
_featureFiles = _manifest.core.map(function (name) { return '/static/js/' + name + '.js'; });
|
||||
_lazyFeatureGroups = _manifest.lazy && typeof _manifest.lazy === 'object' ? _manifest.lazy : {};
|
||||
} catch (_) {
|
||||
console.error('Unable to load the feature module manifest');
|
||||
}
|
||||
var _bundleOk = false;
|
||||
try { await _load('/static/dist/main.min.js'); _bundleOk = true; }
|
||||
catch (_) { _bundleOk = false; }
|
||||
if (!_bundleOk) await _loadBatch(_featureFiles);
|
||||
var _loadedFeatureModules = new Set();
|
||||
if (_bundleOk) _featureFiles.forEach(function (src) { _loadedFeatureModules.add(src); });
|
||||
if (!_bundleOk) {
|
||||
await _loadBatch(_featureFiles);
|
||||
_featureFiles.forEach(function (src) { _loadedFeatureModules.add(src); });
|
||||
}
|
||||
|
||||
// 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.
|
||||
window.ensureFeatureModules = function (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); });
|
||||
if (!pending.length) return Promise.resolve();
|
||||
pending.forEach(function (src) { _loadedFeatureModules.add(src); });
|
||||
return _loadBatch(pending).catch(function (error) {
|
||||
pending.forEach(function (src) { _loadedFeatureModules.delete(src); });
|
||||
console.error('Unable to load section modules for ' + sectionId, error);
|
||||
throw error;
|
||||
});
|
||||
};
|
||||
|
||||
// D — navigation (must run before init/reveal so routing is interactive before
|
||||
// async settings or voice-library refreshes can occupy the backend).
|
||||
try { await _load('/static/nav.js'); } catch (_) {}
|
||||
|
||||
// A deep link or restored section must have its feature code before init and
|
||||
// before the first visible section is activated.
|
||||
try { if (window.ensureFeatureModules) await window.ensureFeatureModules(_activeSection); } catch (_) {}
|
||||
|
||||
// E — init (needs feature modules and nav globals to be defined)
|
||||
try { await _load('/static/js/init.js'); } catch (_) {}
|
||||
|
||||
|
||||
@ -1,6 +1,12 @@
|
||||
[
|
||||
"voice-picker", "benchmark-voice-picker", "voice-inspector", "seed-finder", "voice-sources", "fishaudio-browser", "voice-cache", "jobs",
|
||||
"integrations", "routing", "voice-clone", "voice-library", "tts-preview", "generation", "benchmark", "stt",
|
||||
"rehearser-parse", "rehearser", "reader", "audiobook", "audiobook/network", "character-sheets",
|
||||
"characters-library", "sillytavern", "library", "character-columns", "library-characters", "studio", "namespace"
|
||||
]
|
||||
{
|
||||
"core": [
|
||||
"voice-picker", "benchmark-voice-picker", "voice-inspector", "seed-finder", "voice-sources", "fishaudio-browser", "voice-cache", "jobs",
|
||||
"integrations", "routing", "voice-clone", "voice-library", "tts-preview", "generation", "benchmark", "stt", "namespace"
|
||||
],
|
||||
"lazy": {
|
||||
"s-rehearser": ["rehearser-parse", "rehearser"],
|
||||
"s-reader": ["rehearser-parse", "rehearser", "characters-library", "reader", "audiobook/network", "audiobook", "character-sheets"],
|
||||
"s-library": ["rehearser-parse", "rehearser", "characters-library", "reader", "audiobook/network", "audiobook", "character-sheets", "library", "character-columns", "library-characters", "sillytavern"],
|
||||
"s-caststudio": ["rehearser-parse", "rehearser", "characters-library", "reader", "audiobook/network", "audiobook", "character-sheets", "library", "character-columns", "library-characters", "sillytavern", "studio"]
|
||||
}
|
||||
}
|
||||
|
||||
@ -216,18 +216,24 @@
|
||||
// Preserve that early intent so startup cannot immediately switch back to
|
||||
// the previously stored section.
|
||||
if (SECTIONS.includes(sectionId)) window._navRequestedSection = sectionId;
|
||||
showSection(sectionId);
|
||||
var tabName = Object.keys(TAB_SECTION_MAP).find(function (k) {
|
||||
return TAB_SECTION_MAP[k] === sectionId;
|
||||
});
|
||||
if (tabName) runSideEffects(tabName);
|
||||
// Keep the Reader/Casting/Characters/Rehearser breadcrumb in sync with
|
||||
// plain sidebar navigation (not just the crumb's own buttons).
|
||||
if (typeof window.refreshWorkflowCrumbs === 'function') {
|
||||
if (sectionId === 's-rehearser') window.refreshWorkflowCrumbs('rehearser');
|
||||
else if (sectionId === 's-reader') window.refreshWorkflowCrumbs('source');
|
||||
else if (sectionId === 's-library' && (window._libraryView || 'books') === 'characters') window.refreshWorkflowCrumbs('chars');
|
||||
var activate = function () {
|
||||
showSection(sectionId);
|
||||
var tabName = Object.keys(TAB_SECTION_MAP).find(function (k) {
|
||||
return TAB_SECTION_MAP[k] === sectionId;
|
||||
});
|
||||
if (tabName) runSideEffects(tabName);
|
||||
// Keep the Reader/Casting/Characters/Rehearser breadcrumb in sync with
|
||||
// plain sidebar navigation (not just the crumb's own buttons).
|
||||
if (typeof window.refreshWorkflowCrumbs === 'function') {
|
||||
if (sectionId === 's-rehearser') window.refreshWorkflowCrumbs('rehearser');
|
||||
else if (sectionId === 's-reader') window.refreshWorkflowCrumbs('source');
|
||||
else if (sectionId === 's-library' && (window._libraryView || 'books') === 'characters') window.refreshWorkflowCrumbs('chars');
|
||||
}
|
||||
};
|
||||
if (typeof window.ensureFeatureModules === 'function') {
|
||||
return window.ensureFeatureModules(sectionId).then(activate, function () { activate(); });
|
||||
}
|
||||
activate();
|
||||
};
|
||||
|
||||
// Pulsing dot on the Read Aloud nav item while casting is in progress
|
||||
@ -246,9 +252,8 @@
|
||||
|
||||
window.switchTab = function (name) {
|
||||
var sectionId = TAB_SECTION_MAP[name];
|
||||
if (sectionId) showSection(sectionId);
|
||||
runSideEffects(name);
|
||||
return true;
|
||||
if (!sectionId) return false;
|
||||
return window.navTo(sectionId);
|
||||
};
|
||||
|
||||
// ── Voice tree sub-navigation ─────────────────────────────────────────────
|
||||
@ -431,11 +436,16 @@
|
||||
window._settingsSidebarCat = localStorage.getItem('ttsvc_settings_cat') || 'connections';
|
||||
window._enginesSidebarCat = localStorage.getItem('ttsvc_engines_cat') || 'llm';
|
||||
var _startSection = window._navRequestedSection || hashSection() || ((_savedSection && SECTIONS.includes(_savedSection)) ? _savedSection : 's-voices');
|
||||
showSection(_startSection);
|
||||
if (typeof window.ensureFeatureModules === 'function') {
|
||||
window.ensureFeatureModules(_startSection).then(function () { showSection(_startSection); }, function () { showSection(_startSection); });
|
||||
} else showSection(_startSection);
|
||||
|
||||
window.addEventListener('hashchange', function () {
|
||||
var section = hashSection();
|
||||
if (section && section !== storedSection()) showSection(section);
|
||||
if (section && section !== storedSection()) {
|
||||
if (typeof window.ensureFeatureModules === 'function') window.ensureFeatureModules(section).then(function () { showSection(section); }, function () { showSection(section); });
|
||||
else showSection(section);
|
||||
}
|
||||
});
|
||||
var _startTab = Object.keys(TAB_SECTION_MAP).find(function (k) { return TAB_SECTION_MAP[k] === _startSection; }) || 'library';
|
||||
runSideEffects(_startTab);
|
||||
|
||||
@ -21,13 +21,13 @@ test('Characters / Cast supports profile sorting, column visibility, and voice r
|
||||
await page.route('**/api/voice/meta', route => route.fulfill({ json: { ok: true } }));
|
||||
|
||||
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => typeof window.navTo === 'function' && typeof window.navLibraryView === 'function', null, { timeout: 15_000 });
|
||||
await page.waitForFunction(() => typeof window.navTo === 'function', null, { timeout: 15_000 });
|
||||
// The loader injects inactive section HTML in the background; wait until
|
||||
// the Library panel exists before switching its sub-view.
|
||||
await page.waitForSelector('#lib-chars-list', { state: 'attached', timeout: 15_000 });
|
||||
await page.waitForFunction(() => typeof window.libraryRenderCharacters === 'function', null, { timeout: 15_000 });
|
||||
await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 });
|
||||
await page.evaluate(() => window.navTo('s-library'));
|
||||
await page.waitForFunction(() => typeof window.navLibraryView === 'function' && typeof window.libraryRenderCharacters === 'function', null, { timeout: 15_000 });
|
||||
await expect(page.locator('#s-library')).toHaveClass(/is-active/, { timeout: 15_000 });
|
||||
await page.locator('.lib-tab[data-library-view="characters"]').evaluate(el => el.click());
|
||||
await expect(page.locator('#s-library')).toHaveClass(/is-active/, { timeout: 15_000 });
|
||||
@ -82,6 +82,8 @@ test('consistent image generation can be cancelled from its progress panel', asy
|
||||
}));
|
||||
|
||||
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => typeof window.navTo === 'function', null, { timeout: 15_000 });
|
||||
await page.evaluate(() => window.navTo('s-library'));
|
||||
await page.waitForFunction(() => typeof window._charAutoGenerateConsistentImages === 'function', null, { timeout: 15_000 });
|
||||
await page.evaluate(() => {
|
||||
window.__cancelRun = window._charAutoGenerateConsistentImages({
|
||||
@ -110,8 +112,10 @@ test('current-version image pairs marked inconsistent show a review warning', as
|
||||
await page.route('**/api/settings*', route => route.fulfill({ json: {} }));
|
||||
await page.route('**/api/book-profile*', route => route.fulfill({ json: { profile: {} } }));
|
||||
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => typeof window.libraryRenderCharacters === 'function', null, { timeout: 15_000 });
|
||||
await page.waitForSelector('#lib-chars-list', { state: 'attached', timeout: 15_000 });
|
||||
await page.waitForFunction(() => typeof window.navTo === 'function', null, { timeout: 15_000 });
|
||||
await page.evaluate(() => window.navTo('s-library'));
|
||||
await page.waitForFunction(() => typeof window.libraryRenderCharacters === 'function', null, { timeout: 15_000 });
|
||||
await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 });
|
||||
await page.locator('#nav-library-head').evaluate(el => el.click());
|
||||
await page.locator('.lib-tab[data-library-view="characters"]').evaluate(el => el.click());
|
||||
@ -122,8 +126,10 @@ test('current-version image pairs marked inconsistent show a review warning', as
|
||||
test('character appearance forms stay in one record and switch the detail view', async ({ page }) => {
|
||||
await page.route('**/api/book-profile*', route => route.fulfill({ json: { profile: {} } }));
|
||||
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => typeof window._charDetailPage === 'function', null, { timeout: 15_000 });
|
||||
await page.waitForSelector('#lib-chars-list', { state: 'attached', timeout: 15_000 });
|
||||
await page.waitForFunction(() => typeof window.navTo === 'function', null, { timeout: 15_000 });
|
||||
await page.evaluate(() => window.navTo('s-library'));
|
||||
await page.waitForFunction(() => typeof window._charDetailPage === 'function', null, { timeout: 15_000 });
|
||||
await page.evaluate(() => window._charDetailPage({
|
||||
id: 'c-forms', book: 'Smoke Book', name: 'Zerwas', voice: 'de_bob',
|
||||
image: '/human.png', sheet: {
|
||||
@ -144,6 +150,8 @@ test('character appearance forms stay in one record and switch the detail view',
|
||||
|
||||
test('profile completeness counter distinguishes empty, partial, and full sheets', async ({ page }) => {
|
||||
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => typeof window.navTo === 'function', null, { timeout: 15_000 });
|
||||
await page.evaluate(() => window.navTo('s-library'));
|
||||
await page.waitForFunction(() => typeof window._charProfileCompleteness === 'function', null, { timeout: 15_000 });
|
||||
const counts = await page.evaluate(() => ({
|
||||
empty: window._charProfileCompleteness({}),
|
||||
|
||||
@ -30,8 +30,10 @@ test('large character productions keep the rendered DOM paginated', async ({ pag
|
||||
await page.route('**/api/voices*', route => route.fulfill({ json: [] }));
|
||||
await page.route('**/api/settings*', route => route.fulfill({ json: {} }));
|
||||
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => typeof window.libraryRenderCharacters === 'function', null, { timeout: 15_000 });
|
||||
await page.waitForSelector('#lib-chars-list', { state: 'attached', timeout: 15_000 });
|
||||
await page.waitForFunction(() => typeof window.navTo === 'function', null, { timeout: 15_000 });
|
||||
await page.evaluate(() => window.navTo('s-library'));
|
||||
await page.waitForFunction(() => typeof window.libraryRenderCharacters === 'function', null, { timeout: 15_000 });
|
||||
await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 });
|
||||
await page.evaluate(() => window.navTo('s-library'));
|
||||
await page.locator('.lib-tab[data-library-view="characters"]').click();
|
||||
@ -88,6 +90,8 @@ test('voice library cache reuses fresh data and honors explicit refresh', async
|
||||
|
||||
test('Rehearser audio cache stays bounded with LRU eviction', async ({ page }) => {
|
||||
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => typeof window.navTo === 'function', null, { timeout: 15_000 });
|
||||
await page.evaluate(() => window.navTo('s-rehearser'));
|
||||
await page.waitForFunction(() => window.rehState?.synthCache, null, { timeout: 15_000 });
|
||||
const size = await page.evaluate(() => {
|
||||
const cache = window.rehState.synthCache;
|
||||
|
||||
25
tests/lazy-loading.spec.js
Normal file
25
tests/lazy-loading.spec.js
Normal file
@ -0,0 +1,25 @@
|
||||
// @ts-check
|
||||
// Section-specific feature code should not be fetched until its section is opened.
|
||||
const { test, expect } = require('@playwright/test');
|
||||
|
||||
test('heavy feature modules load on demand', async ({ page }) => {
|
||||
const requests = [];
|
||||
page.on('request', request => {
|
||||
if (request.url().includes('/static/js/')) requests.push(request.url());
|
||||
});
|
||||
|
||||
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => typeof window.navTo === 'function', null, { timeout: 15_000 });
|
||||
await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 });
|
||||
expect(requests.some(url => url.includes('/rehearser.js'))).toBeFalsy();
|
||||
expect(requests.some(url => url.includes('/library-characters.js'))).toBeFalsy();
|
||||
|
||||
await page.evaluate(() => window.navTo('s-rehearser'));
|
||||
await page.waitForFunction(() => window.rehState?.synthCache, null, { timeout: 15_000 });
|
||||
expect(requests.some(url => url.includes('/rehearser.js'))).toBeTruthy();
|
||||
|
||||
const countBeforeRepeat = requests.filter(url => url.includes('/rehearser.js')).length;
|
||||
await page.evaluate(() => window.navTo('s-rehearser'));
|
||||
await page.waitForTimeout(100);
|
||||
expect(requests.filter(url => url.includes('/rehearser.js')).length).toBe(countBeforeRepeat);
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user