diff options
| author | Adam Soltys <[email protected]> | 2026-09-26 23:11:20 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-09-26 23:11:20 -0700 |
| commit | dbf755968827b0d75f5e30d35a6c90da3cdc1aa2 (patch) | |
| tree | 75ed7fa426994aec7407d7966e23eb353de17339 /src | |
| parent | b3df6e629a8d165ac2d6f9fb6c4962d47da0f0cf (diff) | |
| download | coinosv3-dbf755968827b0d75f5e30d35a6c90da3cdc1aa2.tar.gz coinosv3-dbf755968827b0d75f5e30d35a6c90da3cdc1aa2.zip | |
Feed scroll: stop re-parsing the whole messages state per row per repaint.
A CPU profile of a 44 s scroll on Adam's tab: 21 of 27 busy seconds in
loadFeatureState — every read parsed the multi-megabyte messages blob from
localStorage and re-hashed the seed for its key, and the mute/spam checks
read it for every row on every repaint. Now the cache key is hashed once
per identity, the messages state is parsed once per tick (a write refreshes
the copy; the copy is dropped on account switch), and the profile blob
stays parsed in memory and is written back once per burst instead of being
parsed and re-serialized for each of thirty profiles landing mid-scroll.
Videos preload metadata only: a window of autoplaying players was
streaming 20 MB during the same scroll.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01VhUKyXKtsJ67QAFfb7QR9D
Diffstat (limited to 'src')
| -rw-r--r-- | src/features/messages.js | 60 | ||||
| -rw-r--r-- | src/wallet.js | 7 |
2 files changed, 49 insertions, 18 deletions
diff --git a/src/features/messages.js b/src/features/messages.js index a7d22c0..b1c78d3 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -73,8 +73,17 @@ export function messagesFeature(ctx) { // ---- persisted state ---------------------------------------------------- + // The messages state is a large blob (chat caches, DMs, feeds) and reading + // it means parsing all of it. A repaint asks for it dozens of times (every + // row's mute and spam checks go through it), so within a tick every caller + // shares one parse; a write refreshes the copy. Another tab's write shows + // up on the next tick. + let stCache = null, stAt = 0; + const ST_MEMO_MS = 50; const st = () => { + if (stCache && Date.now() - stAt < ST_MEMO_MS) return stCache; const s = wallet.loadFeatureState('messages', {}); + stCache = s; stAt = Date.now(); s.joined ||= {}; // { [cid]: { [pubkey]: true } } s.cache ||= {}; // { [channelId]: [msgs] } s.communities ||= []; // join material beyond the built-in @@ -93,7 +102,7 @@ export function messagesFeature(ctx) { if (s.joined[k] === true) { (s.joined[COMMUNITY.community_id] ||= {})[k] = true; delete s.joined[k]; } return s; }; - const save = (s) => wallet.saveFeatureState('messages', s); + const save = (s) => { stCache = s; stAt = Date.now(); wallet.saveFeatureState('messages', s); }; const communities = () => [COMMUNITY, ...st().communities]; @@ -539,31 +548,31 @@ export function messagesFeature(ctx) { // warms again once they're there. if (!wallet.mnemonic && !wallet.xprv && !wallet.xpub) return; profilesWarmed = true; - const cached = wallet.loadFeatureState('profiles', {}); + const cached = profilesDisk(); for (const [pk, p] of Object.entries(cached)) if (!profiles.has(pk)) profiles.set(pk, p); } - function persistProfile(pk, p) { - // A faceless answer is not a fact worth writing down — persisting - // {name:null, picture:null} rows only evicts real faces from the cap and - // spreads a cold-relay miss across sessions. - if (!p || (!p.name && !p.picture)) return; - const s = wallet.loadFeatureState('profiles', {}); - s[pk] = { name: p.name || null, picture: p.picture || null, nip05: p.nip05 || null, lud16: p.lud16 || null, - about: p.about || null, banner: p.banner || null, eventAt: p.eventAt || 0, t: Date.now(), - ...(p.thumbFor === p.picture && p.thumb - ? { thumb: p.thumb, thumbFor: p.thumbFor, thumbPx: p.thumbPx || 0 } : {}), - ...(p.thumbFail ? { thumbFail: p.thumbFail, thumbFailAt: p.thumbFailAt || 0, thumbFails: p.thumbFails || 1, - thumbFailVersion: p.thumbFailVersion || 0 } : {}) }; + // The persisted profile blob (names, faces, thumbnails) lives parsed in + // memory and is written back once per burst: a batch of thirty profiles + // landing during a scroll used to parse and re-serialize the whole blob + // thirty times over, on the main thread, mid-scroll. + let profBlob = null, profFlush = null; + const profilesDisk = () => (profBlob ||= wallet.loadFeatureState('profiles', {})); + function flushProfiles() { + profFlush = null; + const s = profBlob; + if (!s) return; // Thumbnails are the bulk of this blob, so they live on a budget: the // least recently seen faces give theirs up first. The row itself stays — // that face just paints the way it used to. - if (JSON.stringify(s).length > THUMB_BUDGET) { + let size = JSON.stringify(s).length; + if (size > THUMB_BUDGET) { // never our own: the header face is the one that must paint offline const mine = new Set(myPubkeys()); const oldestFirst = Object.keys(s).filter((k) => s[k].thumb && !mine.has(k)).sort((a, b) => (s[a].t || 0) - (s[b].t || 0)); for (const k of oldestFirst) { + size -= (s[k].thumb || '').length; delete s[k].thumb; delete s[k].thumbFor; - if (JSON.stringify(s).length <= THUMB_BUDGET) break; + if (size <= THUMB_BUDGET) break; } } const keys = Object.keys(s); @@ -577,6 +586,20 @@ export function messagesFeature(ctx) { } wallet.saveFeatureState('profiles', s); } + function persistProfile(pk, p) { + // A faceless answer is not a fact worth writing down — persisting + // {name:null, picture:null} rows only evicts real faces from the cap and + // spreads a cold-relay miss across sessions. + if (!p || (!p.name && !p.picture)) return; + const s = profilesDisk(); + s[pk] = { name: p.name || null, picture: p.picture || null, nip05: p.nip05 || null, lud16: p.lud16 || null, + about: p.about || null, banner: p.banner || null, eventAt: p.eventAt || 0, t: Date.now(), + ...(p.thumbFor === p.picture && p.thumb + ? { thumb: p.thumb, thumbFor: p.thumbFor, thumbPx: p.thumbPx || 0 } : {}), + ...(p.thumbFail ? { thumbFail: p.thumbFail, thumbFailAt: p.thumbFailAt || 0, thumbFails: p.thumbFails || 1, + thumbFailVersion: p.thumbFailVersion || 0 } : {}) }; + if (!profFlush) profFlush = setTimeout(flushProfiles, 1500); + } // A punk picture is OUR OWN art. Every coinos user who keeps the default // publishes https://v3.coinos.io/punks/N.webp as their nostr picture, and // the legacy coinos.io copies 502 for a third of the set — so painting @@ -4645,7 +4668,7 @@ export function messagesFeature(ctx) { // on screen. Once it has played, the box is the viewer's (no morph). function videoNode(url, { stable = false } = {}) { const v = h('video', { src: url, class: 'note-video', controls: true, - preload: autoplayOk() ? 'auto' : 'metadata', playsinline: true, muted: true, + preload: 'metadata', playsinline: true, muted: true, // play() in view fetches; a window of videos must not all stream style: stable ? 'width:100%;aspect-ratio:16/9;object-fit:contain' : undefined, onError: (e) => { if (!stable) { const b = e.target.parentElement; if (b) b.style.display = 'none'; } } }); v.muted = true; // the property, not just the attribute: a script-made element autoplays only muted @@ -8507,6 +8530,9 @@ export function messagesFeature(ctx) { threadCache.clear(); follows = null; followsAt = 0; feed = null; feedAt = 0; relayLists = null; mutes = null; mutesAt = 0; + stCache = null; stAt = 0; // the next account's state, not this one's + if (profFlush) { clearTimeout(profFlush); flushProfiles(); } // this account's faces, written before the blob is dropped + profBlob = null; reacts.clear(); boosts.clear(); seenNoteEv.clear(); myReactEv.clear(); quoted.clear(); noteCountsReady.clear(); // what happened to THEIR posts stays with them — the next identity diff --git a/src/wallet.js b/src/wallet.js index ae9ac1b..9eb2b94 100644 --- a/src/wallet.js +++ b/src/wallet.js @@ -1785,8 +1785,13 @@ export class Wallet { _cacheKey() { let id = this.watchOnly ? this.xpub : this.xprv ? this.xprv : `${this.mnemonic}\n${this.passphrase}`; if (this.accountIndex) id += `\n#${this.accountIndex}`; // derived siblings get their own slots + // hashed once per identity: every feature-state read went through here, + // and a feed repaint reads state dozens of times + if (this._cacheKeyFor === id && this._cacheKeyVal) return this._cacheKeyVal; const bytes = new TextEncoder().encode(id); - return 'btc-wallet-cache:' + hex.encode(sha256(bytes)).slice(0, 32); + this._cacheKeyFor = id; + this._cacheKeyVal = 'btc-wallet-cache:' + hex.encode(sha256(bytes)).slice(0, 32); + return this._cacheKeyVal; } // The fresh receive index the "payment received" screen has been shown for. |
