Add ordered build-local loader and record staged apply decision
This commit is contained in:
72
frontend/lazy.js
Normal file
72
frontend/lazy.js
Normal 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
40
frontend/lazy.test.js
Normal 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/);
|
||||
});
|
||||
Reference in New Issue
Block a user