aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-09-10 22:14:36 -0700
committerAdam Soltys <[email protected]>2026-09-10 22:14:36 -0700
commitb19cf9672532a8f6c347dc8fe99d83492bd478fd (patch)
tree740562a31a934415ba3c249b77e3eb1f23b0c05b
parentf0a5e0de3e68eee184b25ced2393329aec169f6d (diff)
downloadcoinosv3-b19cf9672532a8f6c347dc8fe99d83492bd478fd.tar.gz
coinosv3-b19cf9672532a8f6c347dc8fe99d83492bd478fd.zip
The wallet waits for its own state before painting, so nothing jumps.
The Spending balance and its history are read from the ark store, which is IndexedDB: synchronous to read, asynchronous to OPEN. Boot didn't wait for it, so every refresh painted a Spending wallet with nothing in it — no balance, no history — and filled it in a beat later. On a phone that second paint arrived as a change rather than an arrival: the balance counted itself up from 0 in green, wearing the animation a received payment gets. Boot now waits for the store (capped at 800ms — a store that won't open must not hold the app hostage), and the shell stays up a moment longer instead of a wrong balance going up. If the store does land past the cap, initArk retries on it — so a Spending wallet whose state arrived late also connects, which it previously didn't until something else happened to kick it — and that catch-up paint forgets the last amounts first, so it reads as state arriving rather than money. Also: the zap tally chip loses its underline. In chat the chip is a reaction chip too, and .chat-react.on still rings it; on a post row the underline just read as an artifact. tools/boot-balance-test.js pins the ordering, with IndexedDB slowed so it can't pass by luck. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_019mrt228Upn9nNCwGixzSLx
-rw-r--r--src/app.js14
-rw-r--r--src/features/ark.js25
-rw-r--r--src/style.css1
-rw-r--r--tools/boot-balance-test.js68
4 files changed, 107 insertions, 1 deletions
diff --git a/src/app.js b/src/app.js
index b6dd0dc..e5c3a06 100644
--- a/src/app.js
+++ b/src/app.js
@@ -4914,6 +4914,11 @@ async function importSnapshotFile(e) {
const ctx = {
h, ui, render, wallet, toast, copy, copyBtn, pasteBtn, blankSend, goBack, goHome, openExternal,
fmtAmount, unitLabel, unitTag, parseAmount, getUnit: () => unit, toggleUnit, download,
+ // "The next balance isn't a change, it's the first real value" — state
+ // arriving late (the ark store opening past boot's patience) must not read
+ // as money landing: without this the balance counts up from 0 in green,
+ // which is the celebration a received payment gets.
+ forgetAmountAnim: () => _amtLast.clear(),
// the one-tap zap amount, synced across devices with the rest of the state
zapDefaultSat: () => (wallet.loadFeatureState ? (wallet.loadFeatureState('prefs', {}).zapSat || 0) : 0),
setZapDefaultSat: (n) => {
@@ -5161,6 +5166,15 @@ loadLocale(getLang()).finally(async () => {
// the URL, or the claim would fall through to a normal boot and be lost.
if (/^\/(g|ag|lg)\//.test(location.pathname)) await _deferredReady;
if (featureHook('bootUrl')) return; // a feature consumed the URL (e.g. a gift claim)
+ // The Spending balance and its history are read from an IndexedDB-backed
+ // store, which is synchronous to read but asynchronous to OPEN. Opening a
+ // wallet before it lands paints a Spending card with nothing in it, and the
+ // real numbers arrive a beat later — a visible jump, and the balance
+ // counted itself up in green as if money had just come in. The shell is on
+ // screen either way; waiting shows it a moment longer rather than showing a
+ // wrong balance. The hook caps its own wait.
+ const storeReady = featureHook('arkStoreReady');
+ if (storeReady) await storeReady;
if (!restoreAccountsState()) render();
});
diff --git a/src/features/ark.js b/src/features/ark.js
index 8e74cc6..b70bafe 100644
--- a/src/features/ark.js
+++ b/src/features/ark.js
@@ -417,6 +417,16 @@ export function arkFeature(ctx) {
stopArk();
ui.arkError = '';
lastAutoInit = Date.now();
+ // The ark store is IndexedDB — asynchronous to OPEN, synchronous to read
+ // once it has. A boot that beats it open reads no state at all: no
+ // Spending balance, no Spending history, and nothing here that looks
+ // worth connecting for. Wait for it and run again, so what the store
+ // holds is on screen the moment it can be.
+ const store = wallet._arkStore;
+ if (store && !store.ready) {
+ store.open().then(() => { ctx.forgetAmountAnim && ctx.forgetAmountAnim(); render(); initArk(); }).catch(() => {});
+ return;
+ }
if (arkAvailable() && arkWanted()) connectArk().catch(() => {});
}
@@ -2998,6 +3008,21 @@ export function arkFeature(ctx) {
id: 'ark',
init() { _autoSelected = false; ui.accountUserChosen = false; initArk(); },
stop() { stopArk(); },
+ // Boot waits on this before opening a wallet: the Spending balance and
+ // its history live in the ark store, which is IndexedDB and therefore
+ // opens asynchronously. Painting first meant painting an empty Spending
+ // wallet and filling it in a beat later — a 0 that counted itself up,
+ // green, as if money had just arrived. Capped, because a store that
+ // won't open must not hold the whole app hostage; the retry in initArk
+ // catches up if it lands after the cap.
+ arkStoreReady() {
+ const store = wallet._arkStore;
+ if (!store || store.ready) return null;
+ return Promise.race([
+ store.open().catch(() => {}),
+ new Promise((r) => setTimeout(r, 800)),
+ ]);
+ },
screenView() { return ui.arkCoinsPage ? arkCoinsPage() : ui.arkExitPage ? arkExitPage() : null; },
receiveTakeover() {
const offboarded = arkOffboardedScreen();
diff --git a/src/style.css b/src/style.css
index 4028655..a83e4df 100644
--- a/src/style.css
+++ b/src/style.css
@@ -484,7 +484,6 @@ hr.divider { margin: 16px 0; }
.zap-tally { display: inline-flex; align-items: center; gap: 2px; font-size: 11px; font-weight: 650; color: #e6a800; white-space: nowrap; flex-shrink: 0; }
.zap-tally.chat-react { font-size: 12px; padding: 1px 7px 1px 5px; }
.zap-tally.clickable { cursor: pointer; }
-.zap-tally.on { text-decoration: underline; text-decoration-color: color-mix(in srgb, #e6a800 60%, transparent); text-underline-offset: 2px; }
/* A zap in flight: the chip lands the moment you tap and breathes until the
payment settles — the bolt carries the pulse, the number stays readable. */
.zap-tally.flying { animation: zap-breathe 1.15s ease-in-out infinite; }
diff --git a/tools/boot-balance-test.js b/tools/boot-balance-test.js
new file mode 100644
index 0000000..96f47a9
--- /dev/null
+++ b/tools/boot-balance-test.js
@@ -0,0 +1,68 @@
+// The wallet must not paint before the ark store has opened. The Spending
+// balance and its history are read from that store, which is IndexedDB:
+// synchronous to read, asynchronous to OPEN. Painting first showed an empty
+// Spending wallet for a beat and then filled it in — a layout shift on every
+// refresh, and a balance that counted itself up from 0 in green, wearing the
+// animation a received payment gets.
+//
+// IndexedDB is slowed here by a fixed delay, so the ordering is unambiguous:
+// we record when the ark store's open resolves and when the balance card
+// first appears, on a RELOAD (session restore — the case the user sees).
+//
+// Run: bun tools/boot-balance-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 DELAY = 400;
+const html = await buildHtml({ minify: true, pwa: false });
+const server = Bun.serve({ port: 5233, 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; };
+try {
+ await page.setViewport({ width: 390, height: 844 });
+ await page.goto('http://localhost:5233/', { 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);
+
+ // now the measured reload
+ await page.evaluateOnNewDocument((delay) => {
+ window.__idb = {};
+ const orig = indexedDB.open.bind(indexedDB);
+ indexedDB.open = (name, ...a) => {
+ const req = orig(name, ...a);
+ window.__idb[name] = { called: performance.now() };
+ let fn = null;
+ Object.defineProperty(req, 'onsuccess', { set(v) { fn = v; }, get() { return fn; }, configurable: true });
+ req.addEventListener('success', () => {
+ setTimeout(() => { window.__idb[name].done = performance.now(); if (fn) fn.call(req, new Event('success')); }, delay);
+ });
+ return req;
+ };
+ const mark = () => { if (!window.__balAt && document.querySelector('.balance')) window.__balAt = performance.now(); };
+ // documentElement doesn't exist yet at this point — poll on a frame loop,
+ // which resolves the first paint finely enough for this comparison.
+ const tick = () => { mark(); if (!window.__balAt) requestAnimationFrame(tick); };
+ requestAnimationFrame(tick);
+ }, DELAY);
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await sleep(3000);
+ const r = await page.evaluate(() => ({ idb: window.__idb, bal: window.__balAt, cls: [...document.querySelectorAll('#app *')].map((e) => e.className).filter((c) => typeof c === 'string' && c).slice(0, 25) }));
+ console.log('idb opens:', JSON.stringify(r.idb));
+ const ark = r.idb['coinos-ark'] || {};
+ console.log(`ark store: open called ${Math.round(ark.called)}ms, resolved ${Math.round(ark.done)}ms; balance card painted ${Math.round(r.bal)}ms`);
+ const ok = r.bal >= ark.done;
+ console.log(ok ? ' ✓ the wallet waits for the store' : ' ✗ the wallet paints first (empty Spending, then a jump)');
+ process.exitCode = ok ? 0 : 1;
+} finally { await browser.close(); server.stop(true); }
+process.exit(process.exitCode || 0);