Pin executing groups and guard lazy loads by core contracts

This commit is contained in:
Jonathan Sykes
2026-10-08 00:47:50 +08:00
parent e1cb450032
commit 4853c2c977
3 changed files with 66 additions and 6 deletions

View File

@@ -7,7 +7,30 @@
catch { return; } // Native/static shells retain their eager script path. catch { return; } // Native/static shells retain their eager script path.
const assets = new Map(), groups = new Map(), hooks = new Map(); 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 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; const selected = new Map();
const url = path => selected.get(path) || (manifest.files[path]?.h ? path + '?v=' + manifest.files[path].h : path);
function reloadRequired(name) {
api.reloadRequired = true;
if (root.CustomEvent) root.dispatchEvent?.(new root.CustomEvent('ytp-reload-required', { detail: { group: name } }));
}
async function select(name) {
if (!root.caches) return;
try {
const cache = await root.caches.open('ytplayer-assets');
const response = await cache.match('/__ytp_asset_state');
const current = response && (await response.json()).current;
const built = manifest.groups[name], newer = current?.groups[name];
if (!newer || current.buildTag === manifest.buildTag) return;
if (newer.contract !== built.contract) { reloadRequired(name); return; }
for (const path of built.files) {
// Membership and contract must both match the running core's expectation.
if (!newer.files.includes(path) || !current.files[path]) continue;
const key = path + '?v=' + current.files[path].h;
const held = await cache.match(key);
if (held?.headers.get('X-Asset-Hash') === current.files[path].h) selected.set(path, key);
}
} catch { /* Private browsing/cache eviction: keep the embedded build-local map. */ }
}
function inject(path) { function inject(path) {
if (assets.has(path)) return assets.get(path); if (assets.has(path)) return assets.get(path);
const style = /\.css$/.test(path); const style = /\.css$/.test(path);
@@ -31,6 +54,7 @@
if (!group) return Promise.reject(new Error('Unknown asset group: ' + name)); if (!group) return Promise.reject(new Error('Unknown asset group: ' + name));
const task = (async () => { const task = (async () => {
for (const dependency of group.dependencies || []) await load(dependency); for (const dependency of group.dependencies || []) await load(dependency);
await select(name);
for (const path of group.files) if (!/-worker\.js$/.test(path)) await inject(path); for (const path of group.files) if (!/-worker\.js$/.test(path)) await inject(path);
for (const fn of hooks.get(name) || []) await fn(); for (const fn of hooks.get(name) || []) await fn();
group.executed = true; group.executed = true;
@@ -38,13 +62,13 @@
groups.set(name, task); groups.set(name, task);
task.catch(error => { task.catch(error => {
groups.delete(name); groups.delete(name);
root.dispatchEvent?.(new root.CustomEvent('ytp-lazy-error', { detail: { group: name, error } })); if (root.CustomEvent) root.dispatchEvent?.(new root.CustomEvent('ytp-lazy-error', { detail: { group: name, error } }));
}); });
return task; return task;
} }
const api = { const api = {
load, prefetch: load, loaded: name => !!manifest.groups[name]?.executed, url, load, prefetch: load, loaded: name => !!manifest.groups[name]?.executed, url,
manifest, layout: 'classic', manifest, layout: 'classic', reloadRequired: false,
onLoad(name, fn) { const list = hooks.get(name) || []; list.push(fn); hooks.set(name, list); }, onLoad(name, fn) { const list = hooks.get(name) || []; list.push(fn); hooks.set(name, list); },
proxy(name, methods, globalName) { proxy(name, methods, globalName) {
return Object.fromEntries(methods.map(method => [method, async (...args) => { return Object.fromEntries(methods.map(method => [method, async (...args) => {

View File

@@ -2,15 +2,16 @@ const { test } = require('node:test');
const assert = require('node:assert/strict'); const assert = require('node:assert/strict');
const { readFileSync } = require('node:fs'); const { readFileSync } = require('node:fs');
const vm = require('node:vm'); const vm = require('node:vm');
function fixture() { function fixture(current, held = []) {
const inserted = [], listeners = {}, writes = []; 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 manifest = { buildTag: 'own', groups: { core: { files: [] }, 'layout:classic': { files: ['/classic.css'] }, 'layout:glass-stage': { files: ['/glass.css', '/one.js', '/two.js'] }, 'feature:test': { contract:1, 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 }; 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?.()); } }; 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(){} }; const root = { document:doc, localStorage:{ getItem:() => '{"settings":{"layout":"glass-stage"}}' }, console, Promise, URL, setTimeout, clearTimeout, navigator:{}, addEventListener(){} };
if (current) root.caches = { open: async () => ({ match: async key => key === '/__ytp_asset_state' ? new Response(JSON.stringify({current})) : held.includes(key) ? new Response('', {headers:{'X-Asset-Hash':key.split('=')[1]}}) : undefined }) };
root.window=root; root.globalThis=root; root.window=root; root.globalThis=root;
vm.runInNewContext(readFileSync(require.resolve('./lazy.js'),'utf8'), root); vm.runInNewContext(readFileSync(require.resolve('./lazy.js'),'utf8'), root);
return { root, inserted, writes, listeners }; return { root, inserted, writes, listeners, manifest };
} }
test('external head bootstrap selects remembered CSS before paint without fetching a manifest', () => { test('external head bootstrap selects remembered CSS before paint without fetching a manifest', () => {
const {root,writes}=fixture(); const {root,writes}=fixture();
@@ -38,3 +39,27 @@ test('failed injection can retry and proxy dispatches only after execution', asy
assert.equal(root.Lazy.url('/two.js'),'/two.js?v=2'); assert.equal(root.Lazy.url('/two.js'),'/two.js?v=2');
await assert.rejects(root.Lazy.load('feature:missing'),/Unknown/); await assert.rejects(root.Lazy.load('feature:missing'),/Unknown/);
}); });
test('executing groups are pinned even after a newer compatible manifest arrives', async () => {
const f=fixture(); await f.root.Lazy.load('feature:test');
const newer=JSON.parse(JSON.stringify(f.manifest)); newer.files['/one.js'].h='new';
f.root.caches={open:async()=>({match:async key=>key==='/__ytp_asset_state'?new Response(JSON.stringify({current:newer})):new Response('',{headers:{'X-Asset-Hash':'new'}})})};
await f.root.Lazy.load('feature:test');
assert.deepEqual(f.inserted.map(n=>n.src),['/one.js?v=1','/two.js?v=2']);
});
test('unexecuted compatible group immediately uses newly cached file URLs', async () => {
const next=fixture().manifest; next.buildTag='new'; next.files['/one.js'].h='new';
const f=fixture(next,['/one.js?v=new','/two.js?v=2']);
await f.root.Lazy.load('feature:test');
assert.deepEqual(f.inserted.map(n=>n.src),['/one.js?v=new','/two.js?v=2']);
assert.equal(f.root.Lazy.reloadRequired,false);
});
test('unexecuted incompatible group keeps its running-build N-1 URLs and requires reload', async () => {
const next=fixture().manifest; next.buildTag='new'; next.files['/one.js'].h='new'; next.groups['feature:test'].contract=2;
const f=fixture(next,['/one.js?v=new','/two.js?v=2']);
const events=[]; f.root.CustomEvent=class {constructor(type,opts){this.type=type;this.detail=opts.detail}}; f.root.dispatchEvent=e=>events.push(e.type);
await f.root.Lazy.load('feature:test');
assert.deepEqual(f.inserted.map(n=>n.src),['/one.js?v=1','/two.js?v=2']);
assert.equal(f.root.Lazy.reloadRequired,true);
assert.ok(events.includes('ytp-reload-required'));
});

View File

@@ -30,3 +30,14 @@ background-group synchronization commit f804a23; ordered build-local loader with
unit tests (not yet wired into index.html). No layout or feature was removed 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 from the current eager path. Full browser/layout/performance acceptance remains
outstanding. This is a QUESTION checkpoint, not a completed phase report. outstanding. This is a QUESTION checkpoint, not a completed phase report.
## 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.