From 742f76969e613db3ccae48454c396b3365d3af06 Mon Sep 17 00:00:00 2001 From: rays Date: Sat, 7 Nov 2026 00:04:55 +0000 Subject: [PATCH] An open page takes up a new version by itself after a deploy (#388) The live stream already came back after a deploy and the lists caught up, but the page kept running the script it had loaded, in a tab or the home-screen app, until reloaded by hand. Back after a drop, the page now asks for / again without the cache; another app.js named there is a new version. With nothing to lose it reloads, and the item that was open, carried across in sessionStorage whole since a read one is off the Unread list, is opened again. While an episode plays, a dialog is open, a search is set or a field is typed in, it offers a reload in a toast. Co-Authored-By: Claude Opus 5.5 --- tests/ui/app.spec.js | 47 +++++++++++++++++++++++++++++++++++++++++++ web/CHANGELOG.md | 4 ++++ web/src/events.ts | 48 +++++++++++++++++++++++++++++++++++++++++++- web/src/items.ts | 4 ++++ 4 files changed, 102 insertions(+), 1 deletion(-) diff --git a/tests/ui/app.spec.js b/tests/ui/app.spec.js index afecf4a..ff994ff 100644 --- a/tests/ui/app.spec.js +++ b/tests/ui/app.spec.js @@ -629,6 +629,53 @@ test('marking an OPML subscription read covers the feeds inside it', async ({ pa await expect(folder.locator('.badge')).toHaveText('0'); }); +// A deploy of ipx-web: the page asks for itself again after the live stream comes back (#388). +// The new version is faked by answering / once with another app.js named in it. +async function newVersionOnce(page) { + await page.route(u => new URL(u).pathname === '/', async route => { + const r = await route.fetch(); + await route.fulfill({ response: r, body: (await r.text()).replace(/\/app\.js\?v=[0-9a-f]+/, '/app.js?v=0000000000ff') }); + }, { times: 1 }); +} + +test('a new version reloads the page and opens the item that was open, or offers a reload while a dialog is open', async ({ page }) => { + await page.locator('.feed', { hasText: 'Test Show' }).click(); + await page.locator('.tabs button', { hasText: 'Unread' }).first().click(); + const first = page.locator('.ep').first(); + if (!(await first.count())) { + // Earlier tests read everything; one unread item is enough. + await page.locator('.tabs button', { hasText: 'All' }).first().click(); + await page.locator('.ep').first().locator('[data-a="read"]').click(); + await page.locator('.tabs button', { hasText: 'Unread' }).first().click(); + } + await expect(first).toBeVisible({ timeout: 20_000 }); + const title = await first.locator('.t').textContent(); + await first.click(); + await expect(page.locator('#detail .dt')).toHaveText(title); + + // A dialog open: nothing is thrown away, a reload is offered. + await page.locator('#content .acts [data-a="settings"]').click(); + await expect(page.locator('#modal')).toHaveClass(/\bon\b/); + await newVersionOnce(page); + await page.evaluate(() => checkVersion()); + const offer = page.locator('.toast', { hasText: 'A new version of iPX is ready' }); + await expect(offer).toBeVisible(); + await page.evaluate(() => closeModal()); + + // Idle: it reloads by itself, and the item read, now off the Unread list, is open again. + await newVersionOnce(page); + const reloaded = page.waitForEvent('load'); + await page.evaluate(() => checkVersion()); + await reloaded; + await expect(page.locator('#detail .dt')).toHaveText(title, { timeout: 20_000 }); + await expect(page.locator('.toast', { hasText: 'A new version' })).toHaveCount(0); + + // The same version again: nothing happens. + await page.evaluate(() => checkVersion()); + await page.waitForTimeout(500); + await expect(page.locator('#detail .dt')).toHaveText(title); +}); + test.describe('on a phone', () => { test.use({ viewport: { width: 390, height: 844 } }); diff --git a/web/CHANGELOG.md b/web/CHANGELOG.md index 2058e3d..d98e129 100644 --- a/web/CHANGELOG.md +++ b/web/CHANGELOG.md @@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- An open page, or the home-screen app, takes up a new version of iPX by itself once it reconnects after a deploy, back on the item you had open; while something is playing, a dialog is open or a search is typed, it offers a reload instead (#388). + ### Fixed - Swiping, or j, through more than 50 items no longer stops at the 50th until a reload: the next ones are fetched as you near the end, and Load more on the Unread tab no longer skips as many unread items as you had read (#383). - On the Unread tab, a short list read through no longer asks the server for more that is not there, or shows a count of 0 items beside the items still on screen (#385). diff --git a/web/src/events.ts b/web/src/events.ts index 8626498..bf2801f 100644 --- a/web/src/events.ts +++ b/web/src/events.ts @@ -13,7 +13,7 @@ let dropped=false; function connect(){ sse=new EventSource('/api/events'); // Back after a drop: whatever was sent meanwhile went to nobody. - sse.onopen=()=>{ if(dropped){ dropped=false; catchUp(); } }; + sse.onopen=()=>{ if(dropped){ dropped=false; catchUp(); checkVersion(); } }; const soon=(fn: ()=>void,ms=500)=>{ let t: number; return ()=>{ clearTimeout(t); t=setTimeout(fn,ms); }; }; // Caught: a network gone when one fires (a laptop asleep) was an unhandled rejection (#219). const refreshEntries=soon(()=>{ if(S.feed) loadEntries(false,true).catch(()=>{}); }); @@ -84,5 +84,51 @@ function connect(){ }; sse.onerror=()=>{ sse.close(); dropped=true; setTimeout(connect,4000); }; } + +/// A deploy of ipx-web drops the stream and the page kept running the script it loaded, in an +/// open tab or the home-screen app, until reloaded by hand (#388). Back after a drop, the page +/// asks for itself again: a different app.js named there is a new version. +const runningScript=document.querySelector('script[src^="/app.js"]')?.getAttribute('src'); +async function checkVersion(){ + if(!runningScript) return; + let page: string; + // Mid-deploy, / can be a 502 or the sign-in page: no script named, nothing to compare. + try{ const r=await fetch('/',{cache:'no-store'}); if(!r.ok) return; page=await r.text(); }catch{ return; } + const live=page.match(/\/app\.js\?v=[0-9a-f]+/)?.[0]; + if(!live||live===runningScript) return; + if(reloadWouldLose()) offerReload(); + else reloadKeepingPlace(); +} +/// Something a reload throws away: an episode playing, a dialog open, a search or a field typed in. +function reloadWouldLose(){ + const typing=document.activeElement instanceof HTMLTextAreaElement + || (document.activeElement instanceof HTMLInputElement && document.activeElement.type!=='range' + && document.activeElement.type!=='checkbox' && document.activeElement.value!==''); + return !audio.paused || !!$('#modal')?.classList.contains('on') || !!S.q || typing; +} +/// The feed and tab come back from localStorage; the item open is carried across in the session, +/// whole: read, it is off the Unread list the server sends after the reload. +function reloadKeepingPlace(){ + try{ const e=cur(); if(e) sessionStorage.setItem('ipx.reopen',JSON.stringify(e)); }catch{} + location.reload(); +} +/// The item open before a reload for a new version, once, for showList to open again. +function takeReopen(): Entry|null{ + try{ + const j=sessionStorage.getItem('ipx.reopen'); sessionStorage.removeItem('ipx.reopen'); + return j ? JSON.parse(j) as Entry : null; + }catch{ return null; } +} +let offered=false; +function offerReload(){ + if(offered) return; + offered=true; + const t=document.createElement('div'), b=document.createElement('button'); + t.className='toast'; t.setAttribute('role','status'); t.textContent='A new version of iPX is ready. '; + b.type='button'; b.className='btn'; b.textContent='Reload'; + b.onclick=reloadKeepingPlace; + t.appendChild(b); + $('#toasts')?.appendChild(t); +} connect(); loadFeeds().then(preloadLists).catch(()=>{}); diff --git a/web/src/items.ts b/web/src/items.ts index 60b04d2..a0323f5 100644 --- a/web/src/items.ts +++ b/web/src/items.ts @@ -123,8 +123,12 @@ function showList(r: ListPage, append: boolean, asked: number){ const gone=S.entries.filter(e=>kept(e)&&!entries.some(n=>entryKey(n)===entryKey(e))); entries=[...gone,...entries]; } + // Reloaded for a new version (#388): the item that was open, open again, where it was in the list. + const back=append ? null : takeReopen(); + if(back && !entries.some(e=>entryKey(e)===entryKey(back))) entries=[back,...entries]; S.entries = entries; renderEntries(); + if(back){ const e=S.entries.find(x=>entryKey(x)===entryKey(back)); if(e) selectEntry(e); } } /// A list being waited for: said with a spinner, so the click is seen to have been heard. function listLoading(box=$('#eps')){