From b19cf9672532a8f6c347dc8fe99d83492bd478fd Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Thu, 10 Sep 2026 22:14:36 -0700 Subject: The wallet waits for its own state before painting, so nothing jumps. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_019mrt228Upn9nNCwGixzSLx --- src/app.js | 14 ++++++++++++++ src/features/ark.js | 25 +++++++++++++++++++++++++ src/style.css | 1 - 3 files changed, 39 insertions(+), 1 deletion(-) (limited to 'src') 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; } -- cgit v1.2.3