perf: lazy-load section feature modules

This commit is contained in:
mARTin-B78 2026-09-04 10:23:20 +02:00
parent 06763a4602
commit c43a11da68
12 changed files with 132 additions and 1006 deletions

View File

@ -8,6 +8,9 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
## [Unreleased] ## [Unreleased]
### Changed ### 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, - **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, while keeping true lazy feature loading, responsibility-level frontend extraction, provider-health consolidation,
full workload measurements, and browser image-cache eviction explicitly open. full workload measurements, and browser image-cache eviction explicitly open.

16
TODO.md
View File

@ -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. All P0 safety, security, data-integrity, and release-protection work is complete.
P1 implementation is active: pagination, shared voice caching, bounded decoded-audio P1 implementation is active: pagination, shared voice caching, bounded decoded-audio
caching, background jobs, canonical character identity, forms, and the frontend caching, background jobs, canonical character identity, forms, and the frontend
namespace/manifest foundations are working and regression-tested. Remaining P1 work namespace/manifest foundations and section-scoped lazy loading are working and
is intentionally open where it needs a real architecture change or workload regression-tested. Remaining P1 work is intentionally open where it needs a real
measurement: true feature-module lazy loading, the larger Audiobook/Rehearser/style architecture change or workload measurement: the larger Audiobook/Rehearser/style
split, centralized provider-health handling, full constrained-device workloads, and split, centralized provider-health handling, full constrained-device workloads, and
browser image/profile-cache eviction. 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, - [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 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. forms feed prompt generation and identity fingerprints.
- [ ] **L — Modularize the frontend.** The shared manifest and compatibility namespace - [ ] **L — Modularize the frontend.** The shared manifest, compatibility namespace
bridge are complete foundations; responsibility-level extraction, true feature-module bridge, and section-scoped lazy loading are complete foundations; responsibility-level
lazy loading, and the Audiobook/Rehearser/style split remain. extraction and the Audiobook/Rehearser/style split remain.
### P2 — after behavior and safety stabilize ### P2 — after behavior and safety stabilize
@ -132,7 +132,9 @@ browser image/profile-cache eviction.
## P1 — Frontend performance and memory safety ## 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 - [x] Paginate large character and voice tables; render approximately 25–50 visible rows, with a constrained-device
regression covering a 250-character production. regression covering a 250-character production.
- [x] Cache `/api/voices` with a shared 30-second session cache, one in-flight request, explicit refresh, and - [x] Cache `/api/voices` with a shared 30-second session cache, one in-flight request, explicit refresh, and

View File

@ -1 +1 @@
1.24.15 1.24.16

View File

@ -7,7 +7,8 @@ import { join } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
const root = join(fileURLToPath(new URL('.', import.meta.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 seenModules = new Set();
const declarations = new Map(); const declarations = new Map();
const declarationPattern = /^(?:async\s+)?(?:function|class|const|let|var)\s+([A-Za-z_$][\w$]*)/gm; const declarationPattern = /^(?:async\s+)?(?:function|class|const|let|var)\s+([A-Za-z_$][\w$]*)/gm;

View File

@ -18,8 +18,11 @@ const jsDir = join(root, 'static', 'js');
const outDir = join(root, 'static', 'dist'); const outDir = join(root, 'static', 'dist');
mkdirSync(outDir, { recursive: true }); mkdirSync(outDir, { recursive: true });
// Must match loader.js batch C order exactly. // Bundle only the foundation. Section-specific modules are loaded on demand by
const MODULES = JSON.parse(readFileSync(join(root, 'static', 'module-manifest.json'), 'utf8')); // 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 MAIN = MODULES.map(n => join(jsDir, n + '.js'));
const source = MAIN.map(f => `\n/* ==== ${f.split('/').pop()} ==== */\n` + readFileSync(f, 'utf8')).join('\n'); const source = MAIN.map(f => `\n/* ==== ${f.split('/').pop()} ==== */\n` + readFileSync(f, 'utf8')).join('\n');

File diff suppressed because one or more lines are too long

View File

@ -127,24 +127,51 @@
// C — feature modules: always try the bundle first (one request instead of 23). // 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. // Falls back to individual files if the bundle is missing or fails to load.
var _featureFiles = []; var _featureFiles = [];
var _lazyFeatureGroups = {};
try { try {
var _manifestResponse = await fetch('/static/module-manifest.json?v=' + _appVersion, { cache: 'no-store' }); var _manifestResponse = await fetch('/static/module-manifest.json?v=' + _appVersion, { cache: 'no-store' });
if (!_manifestResponse.ok) throw new Error('module manifest ' + _manifestResponse.status); if (!_manifestResponse.ok) throw new Error('module manifest ' + _manifestResponse.status);
var _manifest = await _manifestResponse.json(); var _manifest = await _manifestResponse.json();
if (!Array.isArray(_manifest) || !_manifest.length) throw new Error('empty module manifest'); if (!Array.isArray(_manifest.core) || !_manifest.core.length) throw new Error('empty core module manifest');
_featureFiles = _manifest.map(function (name) { return '/static/js/' + name + '.js'; }); _featureFiles = _manifest.core.map(function (name) { return '/static/js/' + name + '.js'; });
_lazyFeatureGroups = _manifest.lazy && typeof _manifest.lazy === 'object' ? _manifest.lazy : {};
} catch (_) { } catch (_) {
console.error('Unable to load the feature module manifest'); console.error('Unable to load the feature module manifest');
} }
var _bundleOk = false; var _bundleOk = false;
try { await _load('/static/dist/main.min.js'); _bundleOk = true; } try { await _load('/static/dist/main.min.js'); _bundleOk = true; }
catch (_) { _bundleOk = false; } 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 // D — navigation (must run before init/reveal so routing is interactive before
// async settings or voice-library refreshes can occupy the backend). // async settings or voice-library refreshes can occupy the backend).
try { await _load('/static/nav.js'); } catch (_) {} 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) // E — init (needs feature modules and nav globals to be defined)
try { await _load('/static/js/init.js'); } catch (_) {} try { await _load('/static/js/init.js'); } catch (_) {}

View File

@ -1,6 +1,12 @@
[ {
"voice-picker", "benchmark-voice-picker", "voice-inspector", "seed-finder", "voice-sources", "fishaudio-browser", "voice-cache", "jobs", "core": [
"integrations", "routing", "voice-clone", "voice-library", "tts-preview", "generation", "benchmark", "stt", "voice-picker", "benchmark-voice-picker", "voice-inspector", "seed-finder", "voice-sources", "fishaudio-browser", "voice-cache", "jobs",
"rehearser-parse", "rehearser", "reader", "audiobook", "audiobook/network", "character-sheets", "integrations", "routing", "voice-clone", "voice-library", "tts-preview", "generation", "benchmark", "stt", "namespace"
"characters-library", "sillytavern", "library", "character-columns", "library-characters", "studio", "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"]
}
}

View File

@ -216,18 +216,24 @@
// Preserve that early intent so startup cannot immediately switch back to // Preserve that early intent so startup cannot immediately switch back to
// the previously stored section. // the previously stored section.
if (SECTIONS.includes(sectionId)) window._navRequestedSection = sectionId; if (SECTIONS.includes(sectionId)) window._navRequestedSection = sectionId;
showSection(sectionId); var activate = function () {
var tabName = Object.keys(TAB_SECTION_MAP).find(function (k) { showSection(sectionId);
return TAB_SECTION_MAP[k] === 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 if (tabName) runSideEffects(tabName);
// plain sidebar navigation (not just the crumb's own buttons). // Keep the Reader/Casting/Characters/Rehearser breadcrumb in sync with
if (typeof window.refreshWorkflowCrumbs === 'function') { // plain sidebar navigation (not just the crumb's own buttons).
if (sectionId === 's-rehearser') window.refreshWorkflowCrumbs('rehearser'); if (typeof window.refreshWorkflowCrumbs === 'function') {
else if (sectionId === 's-reader') window.refreshWorkflowCrumbs('source'); if (sectionId === 's-rehearser') window.refreshWorkflowCrumbs('rehearser');
else if (sectionId === 's-library' && (window._libraryView || 'books') === 'characters') window.refreshWorkflowCrumbs('chars'); 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 // Pulsing dot on the Read Aloud nav item while casting is in progress
@ -246,9 +252,8 @@
window.switchTab = function (name) { window.switchTab = function (name) {
var sectionId = TAB_SECTION_MAP[name]; var sectionId = TAB_SECTION_MAP[name];
if (sectionId) showSection(sectionId); if (!sectionId) return false;
runSideEffects(name); return window.navTo(sectionId);
return true;
}; };
// ── Voice tree sub-navigation ───────────────────────────────────────────── // ── Voice tree sub-navigation ─────────────────────────────────────────────
@ -431,11 +436,16 @@
window._settingsSidebarCat = localStorage.getItem('ttsvc_settings_cat') || 'connections'; window._settingsSidebarCat = localStorage.getItem('ttsvc_settings_cat') || 'connections';
window._enginesSidebarCat = localStorage.getItem('ttsvc_engines_cat') || 'llm'; window._enginesSidebarCat = localStorage.getItem('ttsvc_engines_cat') || 'llm';
var _startSection = window._navRequestedSection || hashSection() || ((_savedSection && SECTIONS.includes(_savedSection)) ? _savedSection : 's-voices'); 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 () { window.addEventListener('hashchange', function () {
var section = hashSection(); 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'; var _startTab = Object.keys(TAB_SECTION_MAP).find(function (k) { return TAB_SECTION_MAP[k] === _startSection; }) || 'library';
runSideEffects(_startTab); runSideEffects(_startTab);

View File

@ -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.route('**/api/voice/meta', route => route.fulfill({ json: { ok: true } }));
await page.goto('/', { waitUntil: 'domcontentloaded' }); 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 loader injects inactive section HTML in the background; wait until
// the Library panel exists before switching its sub-view. // the Library panel exists before switching its sub-view.
await page.waitForSelector('#lib-chars-list', { state: 'attached', timeout: 15_000 }); 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.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 });
await page.evaluate(() => window.navTo('s-library')); 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 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 page.locator('.lib-tab[data-library-view="characters"]').evaluate(el => el.click());
await expect(page.locator('#s-library')).toHaveClass(/is-active/, { timeout: 15_000 }); 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.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.waitForFunction(() => typeof window._charAutoGenerateConsistentImages === 'function', null, { timeout: 15_000 });
await page.evaluate(() => { await page.evaluate(() => {
window.__cancelRun = window._charAutoGenerateConsistentImages({ 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/settings*', route => route.fulfill({ json: {} }));
await page.route('**/api/book-profile*', route => route.fulfill({ json: { profile: {} } })); await page.route('**/api/book-profile*', route => route.fulfill({ json: { profile: {} } }));
await page.goto('/', { waitUntil: 'domcontentloaded' }); 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.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.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 });
await page.locator('#nav-library-head').evaluate(el => el.click()); await page.locator('#nav-library-head').evaluate(el => el.click());
await page.locator('.lib-tab[data-library-view="characters"]').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 }) => { 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.route('**/api/book-profile*', route => route.fulfill({ json: { profile: {} } }));
await page.goto('/', { waitUntil: 'domcontentloaded' }); 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.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({ await page.evaluate(() => window._charDetailPage({
id: 'c-forms', book: 'Smoke Book', name: 'Zerwas', voice: 'de_bob', id: 'c-forms', book: 'Smoke Book', name: 'Zerwas', voice: 'de_bob',
image: '/human.png', sheet: { 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 }) => { test('profile completeness counter distinguishes empty, partial, and full sheets', async ({ page }) => {
await page.goto('/', { waitUntil: 'domcontentloaded' }); 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 }); await page.waitForFunction(() => typeof window._charProfileCompleteness === 'function', null, { timeout: 15_000 });
const counts = await page.evaluate(() => ({ const counts = await page.evaluate(() => ({
empty: window._charProfileCompleteness({}), empty: window._charProfileCompleteness({}),

View File

@ -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/voices*', route => route.fulfill({ json: [] }));
await page.route('**/api/settings*', route => route.fulfill({ json: {} })); await page.route('**/api/settings*', route => route.fulfill({ json: {} }));
await page.goto('/', { waitUntil: 'domcontentloaded' }); 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.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.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 });
await page.evaluate(() => window.navTo('s-library')); await page.evaluate(() => window.navTo('s-library'));
await page.locator('.lib-tab[data-library-view="characters"]').click(); 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 }) => { test('Rehearser audio cache stays bounded with LRU eviction', async ({ page }) => {
await page.goto('/', { waitUntil: 'domcontentloaded' }); 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 }); await page.waitForFunction(() => window.rehState?.synthCache, null, { timeout: 15_000 });
const size = await page.evaluate(() => { const size = await page.evaluate(() => {
const cache = window.rehState.synthCache; const cache = window.rehState.synthCache;

View 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);
});