From b3f0525a8e422b3415e93d857f045c5b3947fe7d Mon Sep 17 00:00:00 2001 From: Jonathan Sykes Date: Sat, 10 Oct 2026 18:40:32 +0800 Subject: [PATCH] Stage legacy asset migration before activation --- frontend/asset-worker.test.js | 25 +++++++++++++++++++++++++ frontend/sw.js | 29 ++++++++++++++++++++++++++--- 2 files changed, 51 insertions(+), 3 deletions(-) diff --git a/frontend/asset-worker.test.js b/frontend/asset-worker.test.js index d7d9978..3b5a866 100644 --- a/frontend/asset-worker.test.js +++ b/frontend/asset-worker.test.js @@ -57,6 +57,31 @@ test('waiting status does not start completion and activation delegates it to it assert.equal((await core.status(e.m,cache)).offlineReady,true); }); +test('legacy shell verification is staged during install so Refresh UI activation does no hashing',async()=>{ + const e=environment(),legacy=await e.storage.open('ytplayer-production-old');let digests=0; + await legacy.put('/app.js',new Response('legacy app bytes')); + const subtle=e.sandbox.crypto.subtle; + e.sandbox.crypto={subtle:{digest:async(...args)=>{digests++;return subtle.digest(...args);}}}; + await e.dispatch('install'); + const stagedDigests=digests,cache=await e.storage.open(core.CACHE),candidate=await(await cache.match('/__ytp_candidate/next')).json(); + assert.ok(stagedDigests>0,'install verified old cached bytes before offering Refresh UI'); + assert.deepEqual(candidate.legacyCacheNames,['ytplayer-production-old']); + assert.ok((await core.state(cache)).current.legacyURLs['/app.js']); + await e.dispatch('activate'); + assert.equal(digests,stagedDigests,'the user-triggered activation does not hash the old shell again'); + assert.equal((await e.storage.keys()).includes('ytplayer-production-old'),false,'old shell is deleted only after commit'); +}); + +test('offline opt-in is persisted and adds only declared optional groups to completion',async()=>{ + const e=environment();e.m.files['/large.wasm']={h:'wasm'};e.m.groups.dsp={files:['/large.wasm'],contract:1,background:true,offlineOptIn:'jamesDsp'}; + await e.dispatch('install');await e.dispatch('activate');await e.complete(); + const cache=await e.storage.open(core.CACHE);assert.equal(await cache.match('/large.wasm?v=wasm'),undefined); + await e.dispatch('message',{data:{type:'OFFLINE_OPT_INS',values:['jamesDsp','not-a-valid-opt-in']}}); + assert.deepEqual(await core.readOptIns(cache),['jamesDsp']); + assert.equal((await cache.match('/large.wasm?v=wasm')).headers.get('X-Asset-Hash'),'wasm'); + const status=await core.status(e.m,cache);assert.equal(status.offlineReady,true);assert.equal(status.total,3); +}); + test('page-captured app is reused on first install without a worker download',async()=>{ const e=environment(),cache=await e.storage.open(core.CACHE); await cache.put('/app.js?v=app',new Response('page captured app',{headers:{'X-Asset-Hash':'app'}})); diff --git a/frontend/sw.js b/frontend/sw.js index f953d5d..31af1af 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -305,13 +305,18 @@ self.addEventListener('fetch', (e) => { const playingClients = new Map(), deferredClients = new Set(); let activeLayout = 'classic', layoutReply; let completionTask; -function completeAssets(force = false) { +function completeAssets(force = false, requestedOptIns) { if (!ASSET_SYNC) return Promise.resolve(); + if (completionTask) { + if (!requestedOptIns) return completionTask; + return completionTask.finally(() => completeAssets(true, requestedOptIns)); + } completionTask ||= (async () => { const cache = await caches.open(AssetSyncCore.CACHE); const m = (await AssetSyncCore.state(cache))?.current; if (!m || m.buildTag !== VERSION) return; - const job = await AssetSyncCore.complete(m, { cache, fetchFn: fetch, force, + const optIns = requestedOptIns || await AssetSyncCore.readOptIns(cache); + const job = await AssetSyncCore.complete(m, { cache, fetchFn: fetch, force, optIns, notify: async status => { for (const client of await self.clients.matchAll({ type: 'window' })) client.postMessage({ type: 'OFFLINE_STATUS', ...status }); @@ -330,6 +335,16 @@ self.addEventListener('sync', e => { if (e.tag === 'ytp-offline-complete') e.waitUntil(completeAssets()); }); self.addEventListener('message', (e) => { + if (ASSET_SYNC && e.data?.type === 'OFFLINE_OPT_INS' && Array.isArray(e.data.values)) { + e.waitUntil((async () => { + const cache = await caches.open(AssetSyncCore.CACHE); + const current = (await AssetSyncCore.state(cache))?.current || await candidate(cache); + const allowed = new Set(Object.values(current?.groups || {}).map(group => group.offlineOptIn).filter(Boolean)); + const values = [...new Set(e.data.values.filter(value => typeof value === 'string' && allowed.has(value)))]; + await cache.put(AssetSyncCore.OPT_INS, Response.json(values)); + await completeAssets(true, values); + })()); + } if (ASSET_SYNC && e.data?.type === 'LAYOUT' && e.source && typeof e.data.value === 'string') { activeLayout = e.data.value; layoutReply?.(); @@ -536,6 +551,10 @@ async function installAssets() { for(const name of layouts)for(const dependency of m.groups[name].dependencies || [])layouts.add(dependency); await AssetSyncCore.syncAssets(m,{cache,fetchFn:fetchAsset,groups:[...layouts]}); } + // Copy and hash legacy shell entries before the candidate reaches `installed`. + // A legacy page's Refresh UI then activates quickly, before its four-second + // reload safety timer can send it back into the old shell. + m.legacyCacheNames = await migrateLegacy(cache,m); await cache.put(candidateKey, new Response(JSON.stringify(m))); } async function migrateLegacy(cache, m) { @@ -566,7 +585,11 @@ async function migrateLegacy(cache, m) { async function activateAssets() { const cache = await caches.open(AssetSyncCore.CACHE), m = await candidate(cache); if (!m) throw Error('Missing candidate manifest'); - const names = await migrateLegacy(cache,m), old = await AssetSyncCore.state(cache); + // Legacy migration is staged by installAssets. Keep the fallback for + // candidates created by an older worker version. + const names = m.legacyCacheNames || await migrateLegacy(cache,m); + delete m.legacyCacheNames; + const old = await AssetSyncCore.state(cache); const previousClients = (await self.clients.matchAll({type:'window'})).map(c=>c.id); await AssetSyncCore.commit(m,cache,{previousClients,activeLayout:m.activeLayout || activeLayout}); // Legacy deletion occurs strictly after verified commit.