Pin executing groups and guard lazy loads by core contracts
This commit is contained in:
@@ -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) => {
|
||||||
|
|||||||
@@ -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'));
|
||||||
|
});
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user