diff options
| -rw-r--r-- | src/app.js | 19 | ||||
| -rw-r--r-- | tools/boot-signin-flash-test.js | 67 |
2 files changed, 85 insertions, 1 deletions
@@ -444,6 +444,16 @@ let _renderDeferred = false; // and every app render (feature init, emitter ticks) must stand down or the // onboarding screen paints over the chat moments after it mounts. let _publicChatMode = false; +// True until boot has decided whether a session is restoring. Anything that +// renders before then (a deferred feature chunk landing, an emitter tick) is +// rendering without knowing whether anyone is signed in — and the signed-out +// welcome screen is the one screen that must never be guessed at. The boot +// shell holds the frame for those few ms instead; see the dispatch below. +let _bootDeciding = true; +// Belt and braces: a boot path that returns early (or throws) must not leave +// the welcome screen held back forever. Nothing legitimate is still deciding +// four seconds in — the store wait alone caps at 800ms. +setTimeout(() => { if (_bootDeciding) { _bootDeciding = false; render(); } }, 4000); function render() { if (_publicChatMode) return; // A background render mid carousel-drag rebuilds the strip; the morph then @@ -529,7 +539,13 @@ function renderInner() { ? vaultScreen() : ui.screen === 'howItWorks' ? howItWorksScreen() - : shouldOnboard() ? onboardScreen() : unlockScreen()); + : _bootDeciding + // Not "nobody is signed in" — "we don't know yet". Painting + // Get started here is what flashed the sign-in page over a + // wallet that was about to restore. Every other screen is a + // deliberate choice and still paints; this one waits. + ? h('div', { class: 'col', style: 'gap:16px' }, brandHeader(false)) + : shouldOnboard() ? onboardScreen() : unlockScreen()); // Navigation animates; background repaints must not. The key is every // ui field that decides which page is on screen. const navKey = [ui.screen, ui.tab === 'settings', ui.chatOpen, ui.msgView, ui.msgPeer, ui.msgCommunity, @@ -5175,6 +5191,7 @@ loadLocale(getLang()).finally(async () => { // wrong balance. The hook caps its own wait. const storeReady = featureHook('arkStoreReady'); if (storeReady) await storeReady; + _bootDeciding = false; // from here on the screen is a decision, not a guess if (!restoreAccountsState()) render(); }); diff --git a/tools/boot-signin-flash-test.js b/tools/boot-signin-flash-test.js new file mode 100644 index 0000000..e46ee72 --- /dev/null +++ b/tools/boot-signin-flash-test.js @@ -0,0 +1,67 @@ +// A signed-in wallet must never flash the signed-out welcome screen. Anything +// that renders before boot has restored the session (a deferred feature chunk +// landing, an emitter tick) is rendering without knowing whether anyone is +// signed in, and "Get started" over a wallet that's about to open is the one +// wrong guess that's unmistakable. +// +// The race is widened here (the locale fetch is delayed, so the deferred +// chunks land in the gap) and every animation frame of the boot is checked +// for the welcome screen. +// +// Run: bun tools/boot-signin-flash-test.js +import puppeteer from 'puppeteer-core'; +import { generateMnemonic } from '@scure/bip39'; +import { wordlist } from '@scure/bip39/wordlists/english'; +import { buildHtml } from '../build.js'; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +const html = await buildHtml({ minify: true, pwa: false }); +const server = Bun.serve({ port: 5234, fetch: () => new Response(html, { headers: { 'content-type': 'text/html' } }) }); +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: 'new', args: ['--no-sandbox'] }); +const page = await browser.newPage(); +const click = (t) => page.evaluate((x) => { const e = [...document.querySelectorAll('button')].find((n) => n.textContent.trim().toLowerCase().includes(x)); if (e) { e.click(); return true; } return false; }, t); +const waitText = async (x, ms = 25000) => { for (let i = 0; i < ms / 250; i++) { if ((await page.evaluate(() => document.body.innerText)).toLowerCase().includes(x)) return true; await sleep(250); } return false; }; +let ok = true; +try { + await page.setViewport({ width: 390, height: 844 }); + await page.goto('http://localhost:5234/', { waitUntil: 'domcontentloaded' }); + await page.evaluate(() => localStorage.setItem('btc-wallet-network', 'regtest')); + await page.reload({ waitUntil: 'domcontentloaded' }); + await sleep(400); + await click('get started'); await sleep(500); + await click('import existing'); await sleep(400); + await page.waitForSelector('textarea'); + await page.type('textarea', generateMnemonic(wordlist)); + await click('open wallet'); + await waitText('receive', 20000); + + // Reload with the locale fetch held back, so whatever else renders during + // boot gets its chance, and watch every frame. + await page.evaluateOnNewDocument(() => { + const orig = window.fetch; + window.fetch = (...a) => { + const url = String(a[0] && a[0].url ? a[0].url : a[0]); + if (/locale|\.json$/i.test(url)) return sleepThen(600).then(() => orig(...a)); + return orig(...a); + }; + const sleepThen = (ms) => new Promise((r) => setTimeout(r, ms)); + window.__frames = []; + window.__sawSignin = null; + const tick = () => { + const txt = (document.body && document.body.innerText) || ''; + if (!window.__sawSignin && /get started|sign in with/i.test(txt)) window.__sawSignin = Math.round(performance.now()); + if (!window.__walletAt && document.querySelector('.balance')) window.__walletAt = Math.round(performance.now()); + if (!window.__walletAt) requestAnimationFrame(tick); + }; + requestAnimationFrame(tick); + }); + for (let i = 1; i <= 4; i++) { + await page.reload({ waitUntil: 'domcontentloaded' }); + await sleep(3000); + const r = await page.evaluate(() => ({ signin: window.__sawSignin, wallet: window.__walletAt })); + const clean = r.signin == null && r.wallet != null; + console.log(` ${clean ? '✓' : '✗'} reload ${i}: wallet at ${r.wallet}ms${r.signin != null ? `, sign-in page flashed at ${r.signin}ms` : ', no sign-in flash'}`); + if (!clean) ok = false; + } +} finally { await browser.close(); server.stop(true); } +console.log(ok ? '\n✅ no sign-in flash' : '\n❌ the sign-in page flashed'); +process.exit(ok ? 0 : 1); |
