aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-09-26 23:11:20 -0700
committerAdam Soltys <[email protected]>2026-09-26 23:11:20 -0700
commitdbf755968827b0d75f5e30d35a6c90da3cdc1aa2 (patch)
tree75ed7fa426994aec7407d7966e23eb353de17339
parentb3df6e629a8d165ac2d6f9fb6c4962d47da0f0cf (diff)
downloadcoinosv3-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
-rw-r--r--src/features/messages.js60
-rw-r--r--src/wallet.js7
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.