From e1cb4500325010f51c5fad61702055bd604afe04 Mon Sep 17 00:00:00 2001 From: Jonathan Sykes Date: Thu, 8 Oct 2026 00:33:07 +0800 Subject: [PATCH] Add ordered build-local loader and record staged apply decision --- frontend/lazy.js | 72 +++++++++++++++++++++++++++ frontend/lazy.test.js | 40 +++++++++++++++ plans/phase3-staged-apply-decision.md | 32 ++++++++++++ 3 files changed, 144 insertions(+) create mode 100644 frontend/lazy.js create mode 100644 frontend/lazy.test.js create mode 100644 plans/phase3-staged-apply-decision.md diff --git a/frontend/lazy.js b/frontend/lazy.js new file mode 100644 index 0000000..920a40b --- /dev/null +++ b/frontend/lazy.js @@ -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(''); + } +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/frontend/lazy.test.js b/frontend/lazy.test.js new file mode 100644 index 0000000..756ff75 --- /dev/null +++ b/frontend/lazy.test.js @@ -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,['']); +}); +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/); +}); diff --git a/plans/phase3-staged-apply-decision.md b/plans/phase3-staged-apply-decision.md new file mode 100644 index 0000000..dc48267 --- /dev/null +++ b/plans/phase3-staged-apply-decision.md @@ -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.