aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-09-10 22:20:38 -0700
committerAdam Soltys <[email protected]>2026-09-10 22:20:38 -0700
commit4260b7469bd487e574f97bdf99db31abf2caaa67 (patch)
treedd86bab1f995da4cd8a6f51827a9d4370eb3273e
parentb19cf9672532a8f6c347dc8fe99d83492bd478fd (diff)
downloadcoinosv3-4260b7469bd487e574f97bdf99db31abf2caaa67.tar.gz
coinosv3-4260b7469bd487e574f97bdf99db31abf2caaa67.zip
Don't show "Get started" to someone who is signed in.
Boot decides whether a session restores, but plenty renders before it gets there — the deferred feature chunks land and repaint, an emitter ticks — and a render that early has no idea whether anyone is signed in. It painted the welcome screen anyway, so a signed-in wallet flashed the sign-in page for the ~75ms before boot restored it. Waiting for the ark store widened the gap; the race was already there. The signed-out screen is now the one screen boot won't guess at: until the session question is answered, the frame holds the boot shell (the brand row it's already showing). Every other screen is a deliberate choice — a gift claim, a locked vault, a chat — and still paints immediately. A release timer makes sure a boot path that returns early can't hold it back for good. tools/boot-signin-flash-test.js widens the race and checks every frame of four reloads; before this it caught the flash on every one. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_019mrt228Upn9nNCwGixzSLx
-rw-r--r--src/app.js19
-rw-r--r--tools/boot-signin-flash-test.js67
2 files changed, 85 insertions, 1 deletions
diff --git a/src/app.js b/src/app.js
index e5c3a06..f36c9bc 100644
--- a/src/app.js
+++ b/src/app.js
@@ -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);