Verify staged lazy loading and record Phase 3 measurements

This commit is contained in:
Jonathan Sykes
2026-10-08 04:19:02 +08:00
parent fab444b7b8
commit b84050578b
12 changed files with 5462 additions and 46 deletions

View File

@@ -209,6 +209,65 @@ inspect individual responses to distinguish those from app asset downloads.
Migration results are `phase2-migration-<profile>-2026-10-07.json`. Migration results are `phase2-migration-<profile>-2026-10-07.json`.
`ASSET_SYNC=0` selects the legacy path while preserving the persistent utility `ASSET_SYNC=0` selects the legacy path while preserving the persistent utility
cache. `ASSET_HASHING=0` also selects legacy sync because single-tag URLs cannot cache. `ASSET_HASHING=0` also selects legacy sync because single-tag URLs cannot
pass per-file hash checks. Current assets.json groups all block; idle loading and pass per-file hash checks. In Phase 2 all assets.json groups blocked; Phase 3 selects core plus the active
lazy parsing await Phase 3. Incremental mode now caches the manifested layout and warms background groups at idle. Incremental mode now caches the manifested
`piano-engine.mjs` even though it remains absent from the legacy SHELL. `piano-engine.mjs` even though it remains absent from the legacy SHELL.
## Phase 3 lazy loading and staged apply
```bash
node perf/lazy.mjs --browser all
node perf/migration.mjs --browser all --profile lossy
node perf/migration.mjs --browser all --profile unthrottled
node perf/baseline.mjs --runs 1 --browser all --profile lte --scenario all \
--out perf/results/phase3-final-2026-10-08.json \
--compare perf/results/after-phase0-2026-10-07.json
node perf/baseline.mjs --runs 1 --browser all --profile lte --scenario cold \
--frontend-commit 7bc5b4f8b50d78ece3cca5100d54a97fece2748a \
--out perf/results/phase3-phase0-initial-2026-10-08.json
ASSET_SYNC=0 node perf/baseline.mjs --runs 1 --browser all --profile lte \
--scenario offline --out perf/results/phase3-sync-rollback-2026-10-08.json
ASSET_HASHING=0 node perf/baseline.mjs --runs 1 --browser all --profile lte \
--scenario offline --out perf/results/phase3-hashing-rollback-2026-10-08.json
```
`initialJsCss` sums compressed Resource Timing bodies for page JS/CSS initiated
before DOMContentLoaded, excluding SW precache and later idle feature parsing.
The saved Phase 0 results lack that metric: `--frontend-commit` reconstructs its
frontend in scratch using the current delivery backend for a matched initial-byte
comparison. Historical boot/FCP comparisons still use `after-phase0`. Reduced
single-run measurements are directional; Phase 5 supplies repeated medians.
Update scenarios now wait for every build-N group to finish idle caching before
starting the counter, excluding unfinished initial downloads. Waiting-worker
bytes represent blocking readiness; an inactive layout's changed CSS downloads
later at idle after activation.
The browser harness checks pre-body layout selection, immediate offline reload,
four distinct Car mode controls, optional Settings search, all extension panels,
zero network for warmed first use/layout switches, contract guards and pinned
instances. Known WebKit disconnected API transport errors are recorded separately;
script errors still fail. Save-Data prevents automatic warming.
The index contains inert JSON and an external head bootstrap under unchanged
CSP. Source JSON mirrors assets.json for native/static shells; the server replaces
it with hashed build-local URLs. No boot-time manifest network request is added.
Shared layout visibility defaults remain eager in layout-base.css.
Pure EQ settings metadata and DeviceDB remain eager to preserve synchronous
player defaults and offline file bookkeeping. P2P/direct parse on actual use or
the existing eight-second sharing startup. Searching Settings explicitly loads
optional panels so their original labels remain searchable.
| JS-requested assets | Phase 3 URL treatment |
|---|---|
| opfs-worker.js, hash-worker.js, direct-recv-worker.js, p2p-recv-worker.js | Lazy.worker uses embedded per-file URL |
| Workers' sha256.js, resume-core.js, sha256-wasm.js | First postMessage carries originating build URLs; importScripts uses them |
| piano-engine.mjs | Dynamic import uses Lazy.url; incremental cache warms it |
| vendor/framework7-swipe.min.js | Ordered layout group and fallback use Lazy.url |
| loudness-wasm.js | Remains an eager stamped script; embedded binary needs no separate URL |
| fonts/fonts.css and font preloads; webmanifest/icons | Plain URLs stay aligned with CSS/manifest references; all belong to manifested cache groups |
Legacy pages cannot report their selected layout. Their one-time migration blocks
all layouts to preserve immediate offline selection; modern pages block only the
reported layout and its shared controls. Executing groups never re-evaluate live.
See plans/phase3-staged-apply-decision.md: Phase 4 must add disposal/state handoff.

View File

@@ -25,7 +25,7 @@
* --compare <json-path> Compare results against a previous baseline JSON * --compare <json-path> Compare results against a previous baseline JSON
*/ */
import { spawn, spawnSync } from 'node:child_process'; import { spawn, spawnSync, execFileSync } from 'node:child_process';
import { existsSync, mkdirSync, rmSync, cpSync, appendFileSync, statSync, writeFileSync, readFileSync, symlinkSync, unlinkSync } from 'node:fs'; import { existsSync, mkdirSync, rmSync, cpSync, appendFileSync, statSync, writeFileSync, readFileSync, symlinkSync, unlinkSync } from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
@@ -58,6 +58,7 @@ function parseArgs() {
scenario: 'all', // 'cold', 'warm', 'offline', 'update-js', 'update-css', or 'all' scenario: 'all', // 'cold', 'warm', 'offline', 'update-js', 'update-css', or 'all'
out: null, out: null,
compare: null, compare: null,
frontendCommit: null,
}; };
for (let i = 0; i < args.length; i++) { for (let i = 0; i < args.length; i++) {
@@ -67,6 +68,7 @@ function parseArgs() {
else if (a === '--profile' && i + 1 < args.length) options.profile = args[++i]; else if (a === '--profile' && i + 1 < args.length) options.profile = args[++i];
else if (a === '--scenario' && i + 1 < args.length) options.scenario = args[++i]; else if (a === '--scenario' && i + 1 < args.length) options.scenario = args[++i];
else if (a === '--out' && i + 1 < args.length) options.out = args[++i]; else if (a === '--out' && i + 1 < args.length) options.out = args[++i];
else if (a === '--frontend-commit' && i + 1 < args.length) options.frontendCommit = args[++i];
else if (a === '--compare' && i + 1 < args.length) options.compare = args[++i]; else if (a === '--compare' && i + 1 < args.length) options.compare = args[++i];
} }
@@ -365,6 +367,8 @@ async function runColdScenario({ browserType, server, proxy, profile }) {
const fcp = paint.find((p) => p.name === 'first-contentful-paint'); const fcp = paint.find((p) => p.name === 'first-contentful-paint');
return { return {
initialJsCss: performance.getEntriesByType('resource').filter(entry=>/\.(?:js|css)(?:[?#]|$)/.test(entry.name)&&entry.startTime <= nav.domContentLoadedEventEnd&&['script','link','css'].includes(entry.initiatorType)).reduce((sum,entry)=>sum+entry.encodedBodySize,0),
initialAssetRequests: performance.getEntriesByType('resource').filter(entry=>/\.(?:js|css)(?:[?#]|$)/.test(entry.name)&&entry.startTime <= nav.domContentLoadedEventEnd&&['script','link','css'].includes(entry.initiatorType)).map(entry=>({url:entry.name,bytes:entry.encodedBodySize})),
fcp: fcp ? Math.round(fcp.startTime) : null, fcp: fcp ? Math.round(fcp.startTime) : null,
lcp: window.__lcp ? Math.round(window.__lcp) : null, lcp: window.__lcp ? Math.round(window.__lcp) : null,
domContentLoaded: nav ? Math.round(nav.domContentLoadedEventEnd - nav.startTime) : null, domContentLoaded: nav ? Math.round(nav.domContentLoadedEventEnd - nav.startTime) : null,
@@ -393,6 +397,8 @@ async function runColdScenario({ browserType, server, proxy, profile }) {
bootDone: pageMetrics.bootDone, bootDone: pageMetrics.bootDone,
longTaskTotal: pageMetrics.longTaskTotal, longTaskTotal: pageMetrics.longTaskTotal,
requests: stats.requests, requests: stats.requests,
initialJsCss: pageMetrics.initialJsCss,
initialAssetRequests: pageMetrics.initialAssetRequests,
swInstallDuration, swInstallDuration,
swInstallBytes: stats.totalWireBytes, swInstallBytes: stats.totalWireBytes,
storageEstimate: storage, storageEstimate: storage,
@@ -465,6 +471,8 @@ async function runWarmScenario({ browserType, server, proxy, profile }) {
const fcp = paint.find((p) => p.name === 'first-contentful-paint'); const fcp = paint.find((p) => p.name === 'first-contentful-paint');
return { return {
initialJsCss: performance.getEntriesByType('resource').filter(entry=>/\.(?:js|css)(?:[?#]|$)/.test(entry.name)&&entry.startTime <= nav.domContentLoadedEventEnd&&['script','link','css'].includes(entry.initiatorType)).reduce((sum,entry)=>sum+entry.encodedBodySize,0),
initialAssetRequests: performance.getEntriesByType('resource').filter(entry=>/\.(?:js|css)(?:[?#]|$)/.test(entry.name)&&entry.startTime <= nav.domContentLoadedEventEnd&&['script','link','css'].includes(entry.initiatorType)).map(entry=>({url:entry.name,bytes:entry.encodedBodySize})),
fcp: fcp ? Math.round(fcp.startTime) : null, fcp: fcp ? Math.round(fcp.startTime) : null,
lcp: window.__lcp ? Math.round(window.__lcp) : null, lcp: window.__lcp ? Math.round(window.__lcp) : null,
domContentLoaded: nav ? Math.round(nav.domContentLoadedEventEnd - nav.startTime) : null, domContentLoaded: nav ? Math.round(nav.domContentLoadedEventEnd - nav.startTime) : null,
@@ -542,6 +550,8 @@ async function runOfflineScenario({ browserType, server, proxy, profile }) {
const fcp = paint.find((p) => p.name === 'first-contentful-paint'); const fcp = paint.find((p) => p.name === 'first-contentful-paint');
return { return {
initialJsCss: performance.getEntriesByType('resource').filter(entry=>/\.(?:js|css)(?:[?#]|$)/.test(entry.name)&&entry.startTime <= nav.domContentLoadedEventEnd&&['script','link','css'].includes(entry.initiatorType)).reduce((sum,entry)=>sum+entry.encodedBodySize,0),
initialAssetRequests: performance.getEntriesByType('resource').filter(entry=>/\.(?:js|css)(?:[?#]|$)/.test(entry.name)&&entry.startTime <= nav.domContentLoadedEventEnd&&['script','link','css'].includes(entry.initiatorType)).map(entry=>({url:entry.name,bytes:entry.encodedBodySize})),
fcp: fcp ? Math.round(fcp.startTime) : null, fcp: fcp ? Math.round(fcp.startTime) : null,
lcp: window.__lcp ? Math.round(window.__lcp) : null, lcp: window.__lcp ? Math.round(window.__lcp) : null,
domContentLoaded: nav ? Math.round(nav.domContentLoadedEventEnd - nav.startTime) : null, domContentLoaded: nav ? Math.round(nav.domContentLoadedEventEnd - nav.startTime) : null,
@@ -602,6 +612,27 @@ async function runUpdateScenario({ browserType, server, proxy, profile, updateKi
await sleep(50); await sleep(50);
} }
// Phase 3 activation precedes idle feature caching. Measure updates from a
// genuinely warm build, rather than counting unfinished first-install bytes.
if (await page.evaluate(()=>!!window.Lazy && !!window.Lazy.warm)) {
await page.evaluate(()=>Lazy.warm());
const end=Date.now()+90000;
while (true) {
const ready=await page.evaluate(async()=>{
const c=await caches.open('ytplayer-assets'),r=await c.match('/__ytp_asset_state');
if(!r)return false;
const m=(await r.json()).current;
return (await Promise.all(Object.values(m.groups).flatMap(g=>g.files).map(async p=>{
const response=await c.match(p+'?v='+m.files[p].h);
return response?.headers.get('X-Asset-Hash')===m.files[p].h;
}))).every(Boolean);
});
if(ready)break;
if(Date.now()>end)throw Error('Idle warm did not complete before update benchmark');
await sleep(100);
}
}
// Switch server to Tree N+1 (app.js or theme-glass.css modification) // Switch server to Tree N+1 (app.js or theme-glass.css modification)
const targetTree = updateKind === 'js' ? trees.treeUpdateJs : trees.treeUpdateCss; const targetTree = updateKind === 'js' ? trees.treeUpdateJs : trees.treeUpdateCss;
await server.restartWithPublic(targetTree); await server.restartWithPublic(targetTree);
@@ -666,9 +697,14 @@ async function main() {
const treeUpdateJs = path.join(tmpRoot, 'tree-update-js'); const treeUpdateJs = path.join(tmpRoot, 'tree-update-js');
const treeUpdateCss = path.join(tmpRoot, 'tree-update-css'); const treeUpdateCss = path.join(tmpRoot, 'tree-update-css');
cpSync(path.join(REPO_ROOT, 'frontend'), treeBase, { recursive: true }); let frontendSource=path.join(REPO_ROOT,'frontend');
cpSync(path.join(REPO_ROOT, 'frontend'), treeUpdateJs, { recursive: true }); if(options.frontendCommit) {
cpSync(path.join(REPO_ROOT, 'frontend'), treeUpdateCss, { recursive: true }); const archive=path.join(tmpRoot,'frontend.tar');
writeFileSync(archive,execFileSync('git',['archive',options.frontendCommit,'frontend'],{cwd:REPO_ROOT,maxBuffer:20*1024*1024}));
execFileSync('tar',['-xf',archive,'-C',tmpRoot]);
frontendSource=path.join(tmpRoot,'frontend');
}
for(const tree of [treeBase,treeUpdateJs,treeUpdateCss])cpSync(frontendSource,tree,{recursive:true});
// Append 1 comment line to app.js in treeUpdateJs // Append 1 comment line to app.js in treeUpdateJs
appendFileSync(path.join(treeUpdateJs, 'app.js'), '\n// baseline bench: 1-line app.js comment\n'); appendFileSync(path.join(treeUpdateJs, 'app.js'), '\n// baseline bench: 1-line app.js comment\n');
@@ -684,6 +720,7 @@ async function main() {
const results = { const results = {
date: new Date().toISOString(), date: new Date().toISOString(),
frontendCommit: options.frontendCommit,
commit: spawnSync('git', ['rev-parse', 'HEAD'], { cwd: REPO_ROOT }).stdout.toString().trim(), commit: spawnSync('git', ['rev-parse', 'HEAD'], { cwd: REPO_ROOT }).stdout.toString().trim(),
runsConfigured: options.runs, runsConfigured: options.runs,
browsers: {}, browsers: {},
@@ -729,6 +766,8 @@ async function main() {
autoplayBlocked: coldRuns.some((r) => r.autoplayBlocked), autoplayBlocked: coldRuns.some((r) => r.autoplayBlocked),
wireBytesByType: coldRuns[0]?.wireBytesByType || {}, wireBytesByType: coldRuns[0]?.wireBytesByType || {},
requests: coldRuns[0]?.requests || [], requests: coldRuns[0]?.requests || [],
initialJsCss: summarizeList(coldRuns.map(r=>r.initialJsCss)),
initialAssetRequests: coldRuns[0]?.initialAssetRequests || [],
}; };
} }

135
perf/lazy.mjs Normal file
View File

@@ -0,0 +1,135 @@
#!/usr/bin/env node
// Phase 3 checks against the real Bun server, a same-origin restart and real workers.
import assert from 'node:assert/strict';
import { spawn } from 'node:child_process';
import { mkdtempSync, cpSync, mkdirSync, symlinkSync, readFileSync, writeFileSync, rmSync } from 'node:fs';
import { resolve, join } from 'node:path';
import { createServer } from 'node:net';
import { chromium, webkit } from 'playwright';
import { createThrottleProxy } from './proxy.mjs';
const repo=resolve(new URL('..',import.meta.url).pathname), scratch=mkdtempSync(join(repo,'perf/.tmp/lazy-'));
const sleep=ms=>new Promise(r=>setTimeout(r,ms));
async function wait(page,fn,arg,timeout=90000){const end=Date.now()+timeout;while(Date.now()<end){if(await page.evaluate(fn,arg))return;await sleep(100);}throw Error('Timed out: '+fn.toString());}
async function checkCssOrder(page){
const changes=await page.evaluate(async()=>{
await Lazy.load('layout:'+Lazy.layout);
const settle=()=>new Promise(resolve=>setTimeout(resolve,300));await settle();
const nodes=[...document.querySelectorAll('body,body *')],props=['display','position','color','background-color','font-family','font-size','padding','margin','border-radius','--bg','--text','--radius'];
const snapshot=()=>nodes.map(n=>props.map(p=>getComputedStyle(n).getPropertyValue(p)));
const links=[...document.querySelectorAll('link[data-lazy-css]')],positions=links.map(n=>[n,n.nextSibling]),before=snapshot();
const loaded=()=>new Promise((resolve,reject)=>{let tries=0;function poll(){if(links.every(n=>n.sheet))return resolve();if(tries++>120)return reject(Error('Reordered CSS did not load'));requestAnimationFrame(poll);}poll();});
links.forEach(n=>document.head.append(n));await loaded();await settle();const after=snapshot();
for(const [n,next] of positions.reverse())document.head.insertBefore(n,next);
await loaded();await settle();
return before.flatMap((values,i)=>values.flatMap((v,j)=>v===after[i][j]?[]:[{element:nodes[i].id||nodes[i].className,property:props[j],before:v,after:after[i][j]}]));
});
assert.deepEqual(changes,[],'requesting scoped layout CSS earlier preserves its computed appearance');
}
const args=process.argv.slice(2), requested=args.includes('--browser')?args[args.indexOf('--browser')+1]:'all';
const results=[];
async function run(name){
const cwd=join(scratch,name);mkdirSync(cwd);cpSync(join(repo,'frontend'),join(cwd,'public'),{recursive:true});
const portServer=createServer();await new Promise(r=>portServer.listen(0,'127.0.0.1',r));const port=portServer.address().port;await new Promise(r=>portServer.close(r));
let child,browser,proxy,log='';
async function stop(){if(!child)return;const p=child;child=null;if(p.exitCode===null){p.kill('SIGTERM');await new Promise(r=>p.once('exit',r));}}
async function start(){log='';child=spawn('bun',[join(repo,'server/server.js')],{cwd,env:{...process.env,PORT:String(port),ASSET_HASHING:'1',ASSET_SYNC:'1',BUILD_TAG:'',DB_PATH:join(cwd,'db.sqlite'),DATA_DIR:join(cwd,'data'),MEDIA_DIR:join(cwd,'media'),UPLOAD_DIR:join(cwd,'uploads')},stdio:['ignore','pipe','pipe']});for(const stream of [child.stdout,child.stderr])stream.on('data',c=>log=(log+c).slice(-15000));for(let i=0;i<200;i++){if(child.exitCode!==null)throw Error(log);try{if((await fetch('http://127.0.0.1:'+port+'/api/version')).ok)return;}catch{}await sleep(100);}throw Error(log);}
try {
await start();proxy=createThrottleProxy({targetPort:port,profile:'unthrottled'});const origin='http://127.0.0.1:'+await proxy.listen();proxy.startTracking();
browser=await ({chromium,webkit}[name]).launch({headless:true});const context=await browser.newContext();let disconnected=false;
await context.route('**/*',r=>r.request().url().startsWith(origin)?r.continue():r.abort());
await context.addInitScript(()=>{window.__saveData=localStorage.getItem('perf-save-data')==='1';const observer=new MutationObserver(()=>{if(document.body){window.__firstBodyLayout=document.documentElement.dataset.layout;observer.disconnect();}});observer.observe(document,{childList:true,subtree:true});Object.defineProperty(navigator,'onLine',{configurable:true,get:()=>localStorage.getItem('perf-offline')!=='1'});Object.defineProperty(navigator,'connection',{configurable:true,value:{get saveData(){return window.__saveData;}}});});
const page=await context.newPage(),errors=[],failedRequests=[];page.on('requestfailed',request=>{if(disconnected)failedRequests.push(request.url());});page.on('pageerror',e=>errors.push({message:e.message,disconnected}));page.on('console',m=>{if(m.type()==='error')console.log(name,'console:',m.text());});
console.log(name,'initial boot');await page.goto(origin);await page.waitForFunction(()=>typeof data!=='undefined'&&document.querySelector('#carModeBtn'));
await wait(page,async()=>!!(await navigator.serviceWorker.getRegistration())?.active);
await page.evaluate(()=>Lazy.warm());
await wait(page,async()=>{const c=await caches.open('ytplayer-assets'),r=await c.match('/__ytp_asset_state');if(!r)return false;const s=await r.json();return !(await Promise.all(Object.values(s.current.groups).flatMap(g=>g.files).map(async p=>!!await c.match(p+'?v='+s.current.files[p].h)))).includes(false);});
console.log(name,'warm complete');assert.equal(await page.evaluate(()=>Lazy.loaded('feature:car-mode')),false);
assert.equal(await page.evaluate(()=>[...document.querySelectorAll('.stg-overlay,.deck-chips,.vol-pct,.mode-name,.stg-bot,.ch-badge,.upnext-clear,.list-sub,.p2p-strip,.sleep-status .sl-ic,.sleep-status .sl-sub,.mc-tune')].every(n=>getComputedStyle(n).display==='none')),true,'classic retains shared visibility defaults without loading Glass CSS');
await checkCssOrder(page);
assert.equal(await page.evaluate(()=>Lazy.loaded('feature:piano')),false);
assert.equal(await page.evaluate(()=>[typeof newPlaylist,typeof renderSettings,typeof enterServiceMode,typeof Notes.load,typeof LyricsCore.fromText].every(type=>type==='function')),true,'playlist, settings, service mode and lyric core remain in RAM');
// Profile/imported settings use applyAppearance, not the Settings select handler.
await page.evaluate(()=>{data.settings.layout='glass-stage';applyAppearance();});
await wait(page,()=>document.documentElement.dataset.layout==='glass-stage'&&Lazy.layout==='glass-stage');
await page.evaluate(()=>{data.settings.layout='classic';applyAppearance();});
await wait(page,()=>document.documentElement.dataset.layout==='classic'&&Lazy.layout==='classic');
// Eager application panels stay usable without executing optional settings modules.
await page.click('[data-view="settings"]');await page.click('[data-section="appearance"]');await page.waitForSelector('#setLayout');
assert.equal(await page.evaluate(()=>Lazy.loaded('feature:piano')),false);
await page.selectOption('#setLayout','glass-stage');await page.waitForFunction(()=>document.documentElement.dataset.layout==='glass-stage');
await page.evaluate(()=>{window.__saveData=true;localStorage.setItem('perf-save-data','1');localStorage.setItem('perf-offline','1');});
await stop();disconnected=true;await proxy.setOffline(true);
console.log(name,'offline reload');await page.reload();await page.waitForFunction(()=>typeof data!=='undefined'&&document.querySelector('#carModeBtn'));
assert.equal(await page.evaluate(()=>document.documentElement.dataset.layout),'glass-stage');
assert.equal(await page.evaluate(()=>window.__firstBodyLayout),'glass-stage','selected layout is established before body/first paint');
await checkCssOrder(page);
const before=proxy.getStats().requestCount;
await page.getByRole('button',{name:'Car mode',exact:true}).click();await page.waitForSelector('.car-mode');
assert.equal(await page.locator('.car-controls button').count(),4,'all transport controls survive lazy setup');
assert.equal(await page.locator('.car-controls #voiceSearchBtn').count(),0,'voice search stays outside transport');
await page.getByRole('button',{name:'Exit Car mode'}).click();
assert.equal(proxy.getStats().requestCount,before,'warmed feature first use reaches no network');
await page.click('[data-view="settings"]');await page.click('[data-section="appearance"]');await page.selectOption('#setLayout','bento-hub');await page.waitForFunction(()=>document.documentElement.dataset.layout==='bento-hub');
await page.selectOption('#setLayout','f7-swipe');await page.waitForFunction(()=>document.documentElement.dataset.layout==='f7-swipe'&&!!window.Framework7);
await page.selectOption('#setLayout','classic');await page.waitForFunction(()=>document.documentElement.dataset.layout==='classic');
assert.equal(proxy.getStats().requestCount,before,'offline layout changes reach no network');
await page.getByRole('searchbox',{name:'Search settings and controls'}).fill('Enable piano transcription');
await page.locator('.settings-results button').first().waitFor();
await page.getByRole('searchbox',{name:'Search settings and controls'}).fill('');
// Every extension panel renders from disk without executing other panels eagerly.
for(const id of ['piano','midi','floating-lyrics','vocal-reducer','equalizer','setlist-import','broadcast']) {
await page.evaluate(id=>document.querySelector('[data-section="'+id+'"]').click(),id);
await wait(page,async id=>{const section=SettingsSections.get(id);return !section.lazyGroup;},id);
}
await page.evaluate(async()=>{await Lazy.load('feature:export');await Lazy.load('feature:video-edit');});
assert.equal(proxy.getStats().requestCount,before,'all warmed extension assets stay offline');
await start();disconnected=false;await proxy.setOffline(false);await page.evaluate(()=>localStorage.removeItem('perf-offline'));
// Keep an old tab whose core and group contracts remain those of build N.
const old=await context.newPage();old.on('pageerror',e=>errors.push({message:e.message,disconnected}));await old.goto(origin);await old.waitForSelector('#carModeBtn');
await old.evaluate(async()=>{window.__saveData=true;await Lazy.load('feature:setlist-import');window.__pinned=window.SetlistImport;});
await page.evaluate(()=>window.__saveData=true);
const previous=await page.evaluate(()=>Lazy.manifest);
await stop();
for(const file of ['video-edit.js','setlist-import.js','car-mode.js'])writeFileSync(join(cwd,'public',file),readFileSync(join(cwd,'public',file),'utf8')+'\n// phase3 new file\n');
const defs=JSON.parse(readFileSync(join(cwd,'public/assets.json')));defs.groups['feature:car-mode'].contract=2;writeFileSync(join(cwd,'public/assets.json'),JSON.stringify(defs));
// Production stamping replaces the source-only native map with these updated groups.
await start();const target=await(await fetch(origin+'/api/manifest')).json();
await page.evaluate(async()=>{window.__saveData=true;const r=await navigator.serviceWorker.getRegistration();await r.update();});
await wait(page,async target=>{const r=await navigator.serviceWorker.getRegistration();if(!r?.waiting)return false;return(await SwUpdate.askCacheStatus(r.waiting))?.version===target;},target.buildTag);
// No automatic navigation; a real playing flag blocks the real apply flow.
let navs=0;page.on('framenavigated',f=>{if(f===page.mainFrame())navs++;});
await sleep(1000);assert.equal(navs,0);
await page.evaluate(()=>Player._wantsPlaying=true);
page.once('dialog',d=>d.dismiss());
const guard=await page.evaluate(async()=>{try{await SwUpdate.applyUpdate({getRegistration:()=>navigator.serviceWorker.getRegistration(),askStatus:SwUpdate.askCacheStatus,reload:()=>location.reload()});return false;}catch{return true;}});
assert.equal(guard,true);assert.equal(navs,0);
await page.evaluate(()=>Player._wantsPlaying=false);
// Explicit worker message, then explicit navigation into new stamped core.
await page.evaluate(async()=>{const r=await navigator.serviceWorker.getRegistration();r.waiting.postMessage({type:'SKIP_WAITING'});});
await wait(page,async tag=>{const c=await caches.open('ytplayer-assets');return(await(await c.match('/__ytp_asset_state')).json()).current.buildTag===tag;},target.buildTag);
await page.reload();await page.waitForSelector('#carModeBtn');assert.equal(await page.evaluate(()=>Lazy.manifest.buildTag),target.buildTag);
// No warm: the new page gets a same-contract N-1 file immediately from disk.
const cacheKey='/video-edit.js?v='+target.files['/video-edit.js'].h;
assert.equal(await page.evaluate(async key=>!!await(await caches.open('ytplayer-assets')).match(key),cacheKey),false);
const stale=await page.evaluate(async key=>{const r=await fetch(key);return {h:r.headers.get('X-Asset-Hash'),body:await r.text()};},cacheKey);
assert.equal(stale.h,previous.files['/video-edit.js'].h);assert.ok(!stale.body.includes('// phase3 new file'));
await page.evaluate(()=>Lazy.load('feature:video-edit'));const pinned=await page.evaluate(()=>{window.__videoPinned=VideoEdit;return Lazy.loaded('feature:video-edit');});assert.equal(pinned,true);
// Different contract is already downloaded before commit, but the old core must keep N-1.
const oldRequests=[];old.on('request',r=>oldRequests.push(r.url()));
await old.evaluate(()=>Lazy.load('feature:car-mode'));assert.equal(await old.evaluate(()=>Lazy.reloadRequired),true);
await old.getByRole('button',{name:'Refresh UI',exact:true}).waitFor();
assert.ok(oldRequests.some(u=>u.includes('/car-mode.js?v='+previous.files['/car-mode.js'].h)));
await old.evaluate(()=>Lazy.load('feature:setlist-import'));assert.equal(await old.evaluate(()=>window.SetlistImport===window.__pinned),true);
// Warm new files explicitly; compatible, unexecuted old-page group selects new cached URL.
await page.evaluate(async()=>{const r=await navigator.serviceWorker.getRegistration();r.active.postMessage({type:'WARM_ASSETS',saveData:false});});
await wait(page,async key=>!!await(await caches.open('ytplayer-assets')).match(key),cacheKey);
await old.evaluate(()=>Lazy.load('feature:video-edit'));assert.ok(oldRequests.some(u=>u.includes(cacheKey)));
await page.evaluate(()=>Lazy.load('feature:video-edit'));assert.equal(await page.evaluate(()=>window.VideoEdit===window.__videoPinned),true);
const offlineTransportErrors=errors.filter(error=>name==='webkit'&&error.disconnected&&(error.message==='TypeError: Load failed'||/\/api\/(?:recommendations\?[^\s]*|version)\.$/.test(error.message)));
assert.deepEqual(errors.filter(error=>!offlineTransportErrors.includes(error)),[]);
if(offlineTransportErrors.length)assert.ok(failedRequests.some(url=>/\/api\/(?:recommendations|version)/.test(url)));
results.push({browser:name,sourceBuildTag:previous.buildTag,classicSharedDefaults:true,cssOrderPreserved:true,settingsSearch:true,carControls:4,offlineFeature:true,firstUseNetwork:0,layoutsOffline:['glass-stage','bento-hub','f7-swipe','classic'],pinned:true,compatibleUnexecutedNewURL:true,incompatibleN1ReloadRequired:true,stagedN1:true,playbackGuard:true,prePaintLayout:true,offlineSettingsPanels:true,scriptErrors:[],offlineTransportErrors});
console.log(name,'PASS');
} finally {await browser?.close();await proxy?.close();await stop();}
}
try{for(const name of requested==='all'?['chromium','webkit']:[requested])await run(name);writeFileSync(join(repo,'perf/results/phase3-lazy-2026-10-08.json'),JSON.stringify({results},null,2)+'\n');}finally{rmSync(scratch,{recursive:true,force:true});}

View File

@@ -153,6 +153,9 @@ async function run(name) {
assert.equal(banners, 1, 'no banner after applying the current build'); assert.equal(banners, 1, 'no banner after applying the current build');
assert.equal(await oldTab.evaluate(async()=>await (await fetch('/app.js')).text()),oldBytes,'open legacy tab keeps N-1 plain assets'); assert.equal(await oldTab.evaluate(async()=>await (await fetch('/app.js')).text()),oldBytes,'open legacy tab keeps N-1 plain assets');
await oldTab.close(); await oldTab.close();
// Phase 3 commits core + layout first; idle warming must fill the retained full shell before offline feature checks.
await page.evaluate(()=>Lazy.warm());
await waitAsync(page,async files=>{const cache=await caches.open('ytplayer-assets');return (await Promise.all(Object.keys(files).map(async path=>{const response=await cache.match(path+'?v='+files[path].h);return !!response&&response.headers.get('X-Asset-Hash')===files[path].h;}))).every(Boolean);},Object.fromEntries(shell.map(path=>[path==='/'?'/index.html':path,manifest.files[path==='/'?'/index.html':path]])));
const cacheResult = await page.evaluate(async ({ tag, shell, files }) => { const cacheResult = await page.evaluate(async ({ tag, shell, files }) => {
const cache = await caches.open('ytplayer-assets'); const cache = await caches.open('ytplayer-assets');
const missing = [], incorrect = []; const missing = [], incorrect = [];
@@ -231,5 +234,5 @@ async function run(name) {
try { try {
const results = []; const results = [];
for (const name of browserName === 'all' ? ['chromium', 'webkit'] : [browserName]) results.push(await run(name)); for (const name of browserName === 'all' ? ['chromium', 'webkit'] : [browserName]) results.push(await run(name));
writeFileSync(join(repo, `perf/results/phase2-migration-${profile}-2026-10-07.json`), JSON.stringify({ commit, results }, null, 2) + '\n'); writeFileSync(join(repo, `perf/results/phase3-migration-${profile}-2026-10-08.json`), JSON.stringify({ commit, results }, null, 2) + '\n');
} finally { rmSync(scratch, { recursive: true, force: true }); } } finally { rmSync(scratch, { recursive: true, force: true }); }

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,132 @@
{
"date": "2026-10-07T20:10:25.561Z",
"frontendCommit": null,
"commit": "46ec9ab2ca0f1b5365b5b05b08052df967d6fcfc",
"runsConfigured": 1,
"browsers": {
"chromium": {
"lte": {
"offline": {
"requestCount": {
"median": 0,
"min": 0,
"max": 0,
"values": [
0
]
},
"wireBytes": {
"median": 0,
"min": 0,
"max": 0,
"values": [
0
]
},
"fcp": {
"median": 48,
"min": 48,
"max": 48,
"values": [
48
]
},
"lcp": {
"median": 48,
"min": 48,
"max": 48,
"values": [
48
]
},
"domContentLoaded": {
"median": 165,
"min": 165,
"max": 165,
"values": [
165
]
},
"bootDone": {
"median": 170,
"min": 170,
"max": 170,
"values": [
170
]
},
"reloadDuration": {
"median": 187,
"min": 187,
"max": 187,
"values": [
187
]
}
}
}
},
"webkit": {
"lte": {
"offline": {
"requestCount": {
"median": 0,
"min": 0,
"max": 0,
"values": [
0
]
},
"wireBytes": {
"median": 0,
"min": 0,
"max": 0,
"values": [
0
]
},
"fcp": {
"median": 80,
"min": 80,
"max": 80,
"values": [
80
]
},
"lcp": {
"median": 136,
"min": 136,
"max": 136,
"values": [
136
]
},
"domContentLoaded": {
"median": 134,
"min": 134,
"max": 134,
"values": [
134
]
},
"bootDone": {
"median": 140,
"min": 140,
"max": 140,
"values": [
140
]
},
"reloadDuration": {
"median": 163,
"min": 163,
"max": 163,
"values": [
163
]
}
}
}
}
}
}

View File

@@ -0,0 +1,79 @@
{
"results": [
{
"browser": "chromium",
"sourceBuildTag": "bea1f63eba85",
"classicSharedDefaults": true,
"cssOrderPreserved": true,
"settingsSearch": true,
"carControls": 4,
"offlineFeature": true,
"firstUseNetwork": 0,
"layoutsOffline": [
"glass-stage",
"bento-hub",
"f7-swipe",
"classic"
],
"pinned": true,
"compatibleUnexecutedNewURL": true,
"incompatibleN1ReloadRequired": true,
"stagedN1": true,
"playbackGuard": true,
"prePaintLayout": true,
"offlineSettingsPanels": true,
"scriptErrors": [],
"offlineTransportErrors": []
},
{
"browser": "webkit",
"sourceBuildTag": "bea1f63eba85",
"classicSharedDefaults": true,
"cssOrderPreserved": true,
"settingsSearch": true,
"carControls": 4,
"offlineFeature": true,
"firstUseNetwork": 0,
"layoutsOffline": [
"glass-stage",
"bento-hub",
"f7-swipe",
"classic"
],
"pinned": true,
"compatibleUnexecutedNewURL": true,
"incompatibleN1ReloadRequired": true,
"stagedN1": true,
"playbackGuard": true,
"prePaintLayout": true,
"offlineSettingsPanels": true,
"scriptErrors": [],
"offlineTransportErrors": [
{
"message": "TypeError: Load failed",
"disconnected": true
},
{
"message": "/127.0.0.1:38845/api/version.",
"disconnected": true
},
{
"message": "TypeError: Load failed",
"disconnected": true
},
{
"message": "/127.0.0.1:38845/api/recommendations?fp=163577c02a53bc40.",
"disconnected": true
},
{
"message": "TypeError: Load failed",
"disconnected": true
},
{
"message": "/127.0.0.1:38845/api/version.",
"disconnected": true
}
]
}
]
}

View File

@@ -0,0 +1,41 @@
{
"commit": "b77938a",
"results": [
{
"browser": "chromium",
"oldTag": "1175f1a1d2c1",
"newTag": "bea1f63eba85",
"banners": 1,
"updateReloads": 1,
"cachedFiles": 114,
"durationMs": 24356,
"wireBytes": 607853,
"offline": true,
"legacyURLs": 21,
"profile": "lossy",
"userInitiatedOnly": true,
"playbackGuard": true,
"evictionRepair": true,
"incrementalRefresh": true,
"offlineTransportErrors": 0
},
{
"browser": "webkit",
"oldTag": "1175f1a1d2c1",
"newTag": "bea1f63eba85",
"banners": 1,
"updateReloads": 1,
"cachedFiles": 114,
"durationMs": 20402,
"wireBytes": 729652,
"offline": true,
"legacyURLs": 21,
"profile": "lossy",
"userInitiatedOnly": true,
"playbackGuard": true,
"evictionRepair": true,
"incrementalRefresh": true,
"offlineTransportErrors": 4
}
]
}

View File

@@ -0,0 +1,41 @@
{
"commit": "b77938a",
"results": [
{
"browser": "chromium",
"oldTag": "1175f1a1d2c1",
"newTag": "bea1f63eba85",
"banners": 1,
"updateReloads": 1,
"cachedFiles": 114,
"durationMs": 10864,
"wireBytes": 625216,
"offline": true,
"legacyURLs": 21,
"profile": "unthrottled",
"userInitiatedOnly": true,
"playbackGuard": true,
"evictionRepair": true,
"incrementalRefresh": true,
"offlineTransportErrors": 0
},
{
"browser": "webkit",
"oldTag": "1175f1a1d2c1",
"newTag": "bea1f63eba85",
"banners": 1,
"updateReloads": 1,
"cachedFiles": 114,
"durationMs": 9584,
"wireBytes": 743322,
"offline": true,
"legacyURLs": 21,
"profile": "unthrottled",
"userInitiatedOnly": true,
"playbackGuard": true,
"evictionRepair": true,
"incrementalRefresh": true,
"offlineTransportErrors": 4
}
]
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,132 @@
{
"date": "2026-10-07T20:10:14.094Z",
"frontendCommit": null,
"commit": "46ec9ab2ca0f1b5365b5b05b08052df967d6fcfc",
"runsConfigured": 1,
"browsers": {
"chromium": {
"lte": {
"offline": {
"requestCount": {
"median": 0,
"min": 0,
"max": 0,
"values": [
0
]
},
"wireBytes": {
"median": 0,
"min": 0,
"max": 0,
"values": [
0
]
},
"fcp": {
"median": 44,
"min": 44,
"max": 44,
"values": [
44
]
},
"lcp": {
"median": 44,
"min": 44,
"max": 44,
"values": [
44
]
},
"domContentLoaded": {
"median": 135,
"min": 135,
"max": 135,
"values": [
135
]
},
"bootDone": {
"median": 140,
"min": 140,
"max": 140,
"values": [
140
]
},
"reloadDuration": {
"median": 158,
"min": 158,
"max": 158,
"values": [
158
]
}
}
}
},
"webkit": {
"lte": {
"offline": {
"requestCount": {
"median": 0,
"min": 0,
"max": 0,
"values": [
0
]
},
"wireBytes": {
"median": 0,
"min": 0,
"max": 0,
"values": [
0
]
},
"fcp": {
"median": 64,
"min": 64,
"max": 64,
"values": [
64
]
},
"lcp": {
"median": 105,
"min": 105,
"max": 105,
"values": [
105
]
},
"domContentLoaded": {
"median": 103,
"min": 103,
"max": 103,
"values": [
103
]
},
"bootDone": {
"median": 107,
"min": 107,
"max": 107,
"values": [
107
]
},
"reloadDuration": {
"median": 124,
"min": 124,
"max": 124,
"values": [
124
]
}
}
}
}
}
}

View File

@@ -1,43 +1,29 @@
# Phase 3 — decision required before live module replacement # Phase 3 staged apply — accepted owner decision
COMMON.md says: "If you hit a decision that changes user-visible behaviour or The owner accepted this decision on 2026-10-08. It refines master §2c.5:
risks data loss that the documents do not answer, write QUESTION ... and end your background downloading never implies that an executing classic script can be
turn instead." This concerns §2c.5's instruction to switch stale modules when safely replaced.
new bytes arrive, after a new build has booted using a same-contract fallback.
Concrete finding: direct-media.js creates private rooms, pending, transfers and 1. Already executing groups stay pinned until the session ends or the next
waiting maps at module evaluation (lines 4–6). Its public API has no busy, user-initiated, playback-guarded reload. P2P/direct and every stateful instance
dispose or state handoff method. Re-evaluation replaces window.DirectMedia, are never re-evaluated live. Phase 4 must provide disposal and state handoff
while WebRTC callbacks/workers still close over the old maps. Later messages before live replacement is allowed.
routed by app.js through the new DirectMedia.handle can no longer find those 2. A group that has not executed can immediately use newer verified cached URLs
in-flight transfers. p2p-client.js similarly replaces its public singleton but only when its contract and dependency contracts equal those expected by the
retains old sockets, timers and its visibility listener; P2PTransfer exposes running core's embedded manifest. For a different contract, keep the running
start/download without disposal. Matching contract numbers do not establish build's N-1 URLs, set `Lazy.reloadRequired`, and request the existing Refresh UI
safe runtime state migration. Piano/MIDI/floating-window UI also retains closures. banner through the unchanged meta-build versus `/api/version` comparison.
3. New core boot may receive a verified N-1 response for an uncached new URL only
when group contracts match. The response retains its actual hash; it is never
stored under the new hash. Contract changes join the blocking download set.
Recommended owner decision: background-download and verify the new URLs Reason: direct-media.js owns private room, pending transfer and waiting maps;
immediately, but pin an already-executing stale module until its feature session WebRTC callbacks retain these closures. Re-evaluating the singleton redirects
closes and can be safely recreated. Keep P2P/direct instances until the next messages away from those transfers. P2P sockets/timers and piano/MIDI/floating
explicit user-initiated, playback-guarded page reload. Groups never executed use windows have similar state. Contract equality alone does not migrate it.
the newest cached version immediately. In Phase 4, introduce explicit teardown
and state handoff seams before enabling live replacement of these instances.
This narrows "switch when the new one arrives" for stateful modules; it requires
owner approval rather than silently claiming that caching new bytes replaces
running code.
Completed safe prerequisites: plan commit 895f20a; pure contract fallback and Implemented in 4853c2c and 5d6a154. Node loader tests cover pinned execution,
background-group synchronization commit f804a23; ordered build-local loader with compatible unexecuted selection, incompatible N-1 plus reload-required, and
unit tests (not yet wired into index.html). No layout or feature was removed incompatible dependencies. Worker tests cover same-contract fallback without
from the current eager path. Full browser/layout/performance acceptance remains cache poisoning and changed-contract readiness before commit. `perf/lazy.mjs`
outstanding. This is a QUESTION checkpoint, not a completed phase report. checks these decisions with real workers on Chromium and WebKit.
## Accepted owner decision
The owner accepted pinning on 2026-10-08. Already executing stale groups remain
pinned until their session ends or the next explicit playback-guarded reload.
P2P/direct and other stateful instances are never re-evaluated live; Phase 4 must
add disposal/state handoff before permitting replacement. An unexecuted group
may use newly cached URLs only when its contract equals the running core's
embedded contract. A different contract keeps the running build's N-1 URLs,
marks reload required and prompts the existing build-tag-based Refresh UI banner.
Unit tests cover all three cases. Continue Phase 3 under these rules.