Add ordered build-local loader and record staged apply decision

This commit is contained in:
Jonathan Sykes
2026-10-08 00:33:07 +08:00
parent f804a23761
commit e1cb450032
3 changed files with 144 additions and 0 deletions

72
frontend/lazy.js Normal file
View File

@@ -0,0 +1,72 @@
/* Build-local classic-script loader. The inert manifest precedes this head script. */
(function (root) {
'use strict';
const doc = root.document;
let manifest;
try { manifest = JSON.parse(doc.getElementById('ytp-assets').textContent); }
catch { return; } // Native/static shells retain their eager script path.
const assets = new Map(), groups = new Map(), hooks = new Map();
const css = new Set([...doc.querySelectorAll('link[data-lazy-css]')].map(link => link.dataset.lazyCss));
const url = path => manifest.files[path]?.h ? path + '?v=' + manifest.files[path].h : path;
function inject(path) {
if (assets.has(path)) return assets.get(path);
const style = /\.css$/.test(path);
if (style && css.has(path)) return Promise.resolve();
if (!style && !/\.js$/.test(path)) return Promise.resolve(); // workers/imports/fonts are not page scripts
const task = new Promise((resolve, reject) => {
const node = doc.createElement(style ? 'link' : 'script');
if (style) { node.rel = 'stylesheet'; node.href = url(path); }
else { node.async = false; node.src = url(path); }
node.onload = resolve;
node.onerror = () => reject(new Error('Unable to load ' + path));
doc.head.append(node);
});
assets.set(path, task);
task.catch(() => assets.delete(path));
return task;
}
function load(name) {
if (groups.has(name)) return groups.get(name);
const group = manifest.groups[name];
if (!group) return Promise.reject(new Error('Unknown asset group: ' + name));
const task = (async () => {
for (const dependency of group.dependencies || []) await load(dependency);
for (const path of group.files) if (!/-worker\.js$/.test(path)) await inject(path);
for (const fn of hooks.get(name) || []) await fn();
group.executed = true;
})();
groups.set(name, task);
task.catch(error => {
groups.delete(name);
root.dispatchEvent?.(new root.CustomEvent('ytp-lazy-error', { detail: { group: name, error } }));
});
return task;
}
const api = {
load, prefetch: load, loaded: name => !!manifest.groups[name]?.executed, url,
manifest, layout: 'classic',
onLoad(name, fn) { const list = hooks.get(name) || []; list.push(fn); hooks.set(name, list); },
proxy(name, methods, globalName) {
return Object.fromEntries(methods.map(method => [method, async (...args) => {
await load(name);
const target = root[globalName || name.split(':')[1]];
if (typeof target?.[method] !== 'function') throw new Error('Missing lazy method: ' + method);
return target[method](...args);
}]));
},
intent(element, name) {
if (!element) return;
for (const type of ['pointerenter', 'focus', 'touchstart']) element.addEventListener(type, () => load(name).catch(() => {}), { once: true, passive: true });
},
};
root.Lazy = api;
try { api.layout = JSON.parse(root.localStorage.getItem('_ytpdata') || '{}').settings?.layout || 'classic'; } catch {}
doc.documentElement.dataset.layout = api.layout;
const layout = manifest.groups['layout:' + api.layout] || manifest.groups['layout:classic'];
if (doc.readyState === 'loading') for (const path of layout?.files || []) {
if (!/\.css$/.test(path) || css.has(path)) continue;
// Parser-inserted CSS blocks rendering; dynamic head links do not on all WebKit versions.
css.add(path);
doc.write('<link rel="stylesheet" href="' + url(path) + '" data-lazy-css="' + path + '">');
}
})(typeof window !== 'undefined' ? window : globalThis);

40
frontend/lazy.test.js Normal file
View File

@@ -0,0 +1,40 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { readFileSync } = require('node:fs');
const vm = require('node:vm');
function fixture() {
const inserted = [], listeners = {}, writes = [];
const manifest = { buildTag: 'own', groups: { core: { files: [] }, 'layout:classic': { files: ['/classic.css'] }, 'layout:glass-stage': { files: ['/glass.css', '/one.js', '/two.js'] }, 'feature:test': { files: ['/one.js', '/two.js'] } }, files: { '/classic.css': {h:'c'}, '/glass.css':{h:'g'}, '/one.js':{h:'1'}, '/two.js':{h:'2'} } };
const doc = { readyState:'loading', documentElement: { dataset:{} }, getElementById: () => ({ textContent:JSON.stringify(manifest) }), querySelectorAll: () => [], createElement: tag => ({ tagName:tag.toUpperCase() }), write: value => writes.push(value), addEventListener: (t,f) => listeners[t]=f };
doc.head = { append: node => { inserted.push(node); queueMicrotask(() => node.onload?.()); } };
const root = { document:doc, localStorage:{ getItem:() => '{"settings":{"layout":"glass-stage"}}' }, console, Promise, URL, setTimeout, clearTimeout, navigator:{}, addEventListener(){} };
root.window=root; root.globalThis=root;
vm.runInNewContext(readFileSync(require.resolve('./lazy.js'),'utf8'), root);
return { root, inserted, writes, listeners };
}
test('external head bootstrap selects remembered CSS before paint without fetching a manifest', () => {
const {root,writes}=fixture();
assert.equal(root.document.documentElement.dataset.layout,'glass-stage');
assert.deepEqual(writes,['<link rel="stylesheet" href="/glass.css?v=g" data-lazy-css="/glass.css">']);
});
test('lazy groups execute ordered classic scripts once, sharing concurrent calls and dependencies', async () => {
const {root,inserted}=fixture();
const one=root.Lazy.load('feature:test'), two=root.Lazy.load('feature:test');
await Promise.all([one,two]);
assert.deepEqual(inserted.map(n=>n.src),['/one.js?v=1','/two.js?v=2']);
assert.equal(root.Lazy.loaded('feature:test'),true);
await root.Lazy.load('layout:glass-stage');
assert.equal(inserted.filter(n=>n.tagName==='SCRIPT').length,2);
});
test('failed injection can retry and proxy dispatches only after execution', async () => {
const {root,inserted}=fixture();
const append=root.document.head.append;
root.document.head.append=n=>{inserted.push(n);queueMicrotask(()=>n.onerror?.());};
await assert.rejects(root.Lazy.load('feature:test'),/one.js/);
root.document.head.append=append;
root.TestApi={open:value=>value+1};
const proxy=root.Lazy.proxy('feature:test',['open'],'TestApi');
assert.equal(await proxy.open(4),5);
assert.equal(root.Lazy.url('/two.js'),'/two.js?v=2');
await assert.rejects(root.Lazy.load('feature:missing'),/Unknown/);
});

View File

@@ -0,0 +1,32 @@
# Phase 3 — decision required before live module replacement
COMMON.md says: "If you hit a decision that changes user-visible behaviour or
risks data loss that the documents do not answer, write QUESTION ... and end your
turn instead." This concerns §2c.5's instruction to switch stale modules when
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
waiting maps at module evaluation (lines 4–6). Its public API has no busy,
dispose or state handoff method. Re-evaluation replaces window.DirectMedia,
while WebRTC callbacks/workers still close over the old maps. Later messages
routed by app.js through the new DirectMedia.handle can no longer find those
in-flight transfers. p2p-client.js similarly replaces its public singleton but
retains old sockets, timers and its visibility listener; P2PTransfer exposes
start/download without disposal. Matching contract numbers do not establish
safe runtime state migration. Piano/MIDI/floating-window UI also retains closures.
Recommended owner decision: background-download and verify the new URLs
immediately, but pin an already-executing stale module until its feature session
closes and can be safely recreated. Keep P2P/direct instances until the next
explicit user-initiated, playback-guarded page reload. Groups never executed use
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
background-group synchronization commit f804a23; ordered build-local loader with
unit tests (not yet wired into index.html). No layout or feature was removed
from the current eager path. Full browser/layout/performance acceptance remains
outstanding. This is a QUESTION checkpoint, not a completed phase report.