diff --git a/app.js b/app.js index 495c2f9..1fe5006 100644 --- a/app.js +++ b/app.js @@ -47,7 +47,21 @@ async function latestVersion() { } catch { return null; } } -async function refresh() { +// One refresh at a time. Signed in, a pass (session, then the vault index and the vault list, then the extension) +// takes longer than the 1.5 s poll, and each new pass used to make the one in flight bail out before rendering, so +// the vault stayed empty forever. Now a refresh asked for during another runs once more after it (actions, focus), +// and the poll simply skips while one is running. +let refreshing = null, refreshAgain = false; +function refresh() { + if (refreshing) { refreshAgain = true; return refreshing; } + refreshing = (async () => { + try { do { refreshAgain = false; await refreshOnce(); } while (refreshAgain); } + finally { refreshing = null; } + })(); + return refreshing; +} + +async function refreshOnce() { if (dragging || juiceBusy) return; const current = ++generation; const response = await fetch('/api/session', {credentials: 'same-origin', cache: 'no-store'}); @@ -550,5 +564,5 @@ window.addEventListener('pointerdown', event => { window.addEventListener('pointerup', () => { dragging = false; }, true); window.addEventListener('pageshow', () => refresh().catch(failed)); window.addEventListener('focus', () => refresh().catch(failed)); -setInterval(() => { if (!document.hidden && !$('siteModal')?.open) refresh().catch(() => {}); }, 1500); +setInterval(() => { if (!refreshing && !document.hidden && !$('siteModal')?.open) refresh().catch(() => {}); }, 1500); refresh().catch(failed); diff --git a/assets/app.js b/assets/app.js index 495c2f9..1fe5006 100644 --- a/assets/app.js +++ b/assets/app.js @@ -47,7 +47,21 @@ async function latestVersion() { } catch { return null; } } -async function refresh() { +// One refresh at a time. Signed in, a pass (session, then the vault index and the vault list, then the extension) +// takes longer than the 1.5 s poll, and each new pass used to make the one in flight bail out before rendering, so +// the vault stayed empty forever. Now a refresh asked for during another runs once more after it (actions, focus), +// and the poll simply skips while one is running. +let refreshing = null, refreshAgain = false; +function refresh() { + if (refreshing) { refreshAgain = true; return refreshing; } + refreshing = (async () => { + try { do { refreshAgain = false; await refreshOnce(); } while (refreshAgain); } + finally { refreshing = null; } + })(); + return refreshing; +} + +async function refreshOnce() { if (dragging || juiceBusy) return; const current = ++generation; const response = await fetch('/api/session', {credentials: 'same-origin', cache: 'no-store'}); @@ -550,5 +564,5 @@ window.addEventListener('pointerdown', event => { window.addEventListener('pointerup', () => { dragging = false; }, true); window.addEventListener('pageshow', () => refresh().catch(failed)); window.addEventListener('focus', () => refresh().catch(failed)); -setInterval(() => { if (!document.hidden && !$('siteModal')?.open) refresh().catch(() => {}); }, 1500); +setInterval(() => { if (!refreshing && !document.hidden && !$('siteModal')?.open) refresh().catch(() => {}); }, 1500); refresh().catch(failed); diff --git a/backend/web/app.js b/backend/web/app.js index 495c2f9..1fe5006 100644 --- a/backend/web/app.js +++ b/backend/web/app.js @@ -47,7 +47,21 @@ async function latestVersion() { } catch { return null; } } -async function refresh() { +// One refresh at a time. Signed in, a pass (session, then the vault index and the vault list, then the extension) +// takes longer than the 1.5 s poll, and each new pass used to make the one in flight bail out before rendering, so +// the vault stayed empty forever. Now a refresh asked for during another runs once more after it (actions, focus), +// and the poll simply skips while one is running. +let refreshing = null, refreshAgain = false; +function refresh() { + if (refreshing) { refreshAgain = true; return refreshing; } + refreshing = (async () => { + try { do { refreshAgain = false; await refreshOnce(); } while (refreshAgain); } + finally { refreshing = null; } + })(); + return refreshing; +} + +async function refreshOnce() { if (dragging || juiceBusy) return; const current = ++generation; const response = await fetch('/api/session', {credentials: 'same-origin', cache: 'no-store'}); @@ -550,5 +564,5 @@ window.addEventListener('pointerdown', event => { window.addEventListener('pointerup', () => { dragging = false; }, true); window.addEventListener('pageshow', () => refresh().catch(failed)); window.addEventListener('focus', () => refresh().catch(failed)); -setInterval(() => { if (!document.hidden && !$('siteModal')?.open) refresh().catch(() => {}); }, 1500); +setInterval(() => { if (!refreshing && !document.hidden && !$('siteModal')?.open) refresh().catch(() => {}); }, 1500); refresh().catch(failed); diff --git a/extension/tests/homepage-poll.test.mjs b/extension/tests/homepage-poll.test.mjs new file mode 100644 index 0000000..3152832 --- /dev/null +++ b/extension/tests/homepage-poll.test.mjs @@ -0,0 +1,39 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {readFile} from 'node:fs/promises'; + +// Signed in, one refresh pass outlasts the 1.5 s poll. The page must still render: a poll that fires while a pass +// is in flight may not cancel it (that left the Master vault empty forever, 2026-10-12). +test('a slow signed-in refresh still renders while the poll keeps firing', async () => { + const nodes = new Map(); + const node = id => { + if (!nodes.has(id)) nodes.set(id, {hidden:false, textContent:'', innerHTML:'', open:false, + addEventListener(){}, append(){}, appendChild(){}, replaceChildren(){}, style:{}}); + return nodes.get(id); + }; + globalThis.document = {hidden:false, getElementById:node, + createElement:tag => ({tagName:tag.toUpperCase(), hidden:false, textContent:'', innerHTML:'', style:{}, open:false, + setAttribute(){}, append(){}, appendChild(){}, replaceChildren(){}, showModal(){}, close(){}, addEventListener(){}, + querySelector(){ return {value:'', disabled:false}; }}), + body:{append(){}}}; + globalThis.window = {addEventListener(){}}; + let poll = null; + globalThis.setInterval = fn => { poll = fn; return 1; }; + globalThis.chrome = {runtime:{sendMessage:(id, message, callback) => callback({connected:true, initialized:true, unlocked:true, version:'0.4.4'}), lastError:null}}; + // Every request waits until the test lets it through, like a slow network. + const pending = []; + let requests = 0; + globalThis.fetch = url => { requests++; return new Promise(resolve => pending.push(() => resolve(new Response( + JSON.stringify(url === '/api/session' ? {owner:'fixture', login:'fixture'} : url === '/api/vault-index' ? {items:[]} : + url === '/api/vaults' ? {vaults:[]} : {version:'0.4.4'}), + {status:200, headers:{'content-type':'application/json'}})))); }; + const flush = async () => { for (const go of pending.splice(0)) go(); for (let i = 0; i < 20; i++) await new Promise(r => setImmediate(r)); }; + + const source = (await readFile(new URL('../../backend/web/app.js', import.meta.url), 'utf8')) + .replace('./version.mjs', new URL('../version.mjs', import.meta.url).href); + await import('data:text/javascript,' + encodeURIComponent(source)); + for (let tick = 0; tick < 4; tick++) { poll(); await flush(); } // the poll fires before every response + assert.equal(node('userIdentity').textContent, 'Signed in as fixture'); + assert.equal(node('vaultCount').textContent, '0 saved credentials'); + assert.ok(requests <= 12, `the poll must not stack passes (${requests} requests)`); +});