homepage: one refresh at a time, so a slow signed-in pass still renders

Signed in, one refresh pass (session, then the vault index and the vault
list, then the extension) takes longer than the 1.5 s poll. Every poll
started a new pass, and the pass in flight saw the newer generation and
returned before rendering, so on persimmons.app the Master vault stayed an
empty box while every request returned 200 (Tea logs, 2026-10-12: session
every ~1.5 s, page-index and vaults ~1 s each, almost never a full pass).

refresh() now runs one pass at a time: the poll skips while one is in
flight, and a refresh asked for during one (an action, focus) runs once more
afterwards. The three identical copies (app.js, assets/app.js,
backend/web/app.js) change together.

New test extension/tests/homepage-poll.test.mjs holds every request until
after the next poll: it fails on the old code (nothing renders) and passes
now. Extension suite: 26/26.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wyb3QQDESFkosKRXzrXqwr
This commit is contained in:
claudeandClaude Opus 5.5 committed 2026-10-12 14:28:00 -07:00
1 parent 1aa3cffaf2
commit 97f4a739bb
4 files changed
+87 -6

No files matched your search

+39
View File
@@ -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)`);
});