From fdaf7137139540d2f8de50b2a42d6ff3c036328b Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sun, 11 Oct 2026 19:51:45 -0700 Subject: Feed: a wheel scroll through a loading feed no longer stutters on a laptop. The phone was fine (its scroll is the compositor's) but the desktop's wheel coast is driven from the main thread, so every long task froze it, and a feed that was still loading ran dozens of them: each ready post went in through its own admitFeed → holdScroll → full render (200–450 ms apiece; a Popular switch did 49 in a few seconds, one of them 1.3 s). Inside those renders the window's list getter — run on every scroll frame too — filtered every note through the mute/spam/report checks, and each check re-derived the login identity (two parses of the stored state plus a bech32 encode): 11 s of a 2-minute profile. Now ready posts are admitted together, one render per 120 ms, and while the reader is moving the page the batch waits for a quiet moment (up to 1.5 s); nostrLoginIdentity keeps its answer for 250 ms (dropped by its own saves, a signer change or another wallet's key), npubOf is memoized, modOn reads the cached messages state, the foreign-language guess is remembered per post, and the window list is reused between renders. Measured on Adam's dev tab (tools/feed-scroll-jank.js: real CDP wheel notches, chip clicks mid-load, rAF frame gaps, long tasks, a CPU profile that names each busy stretch): stalled time during a switch 1.9–2.5 s → 0–83 ms, no frame over 50 ms on the wheel path, main thread busy 33% → 17%; settled scrolling was already clean. feed-streaming's "tap reveals the oldest ready new post" fails on the previous commit too. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- src/features/messages.js | 58 ++++++++++++++++++++++++++++++++++++++++++---- src/features/nostrlogin.js | 18 ++++++++++---- 2 files changed, 67 insertions(+), 9 deletions(-) (limited to 'src/features') diff --git a/src/features/messages.js b/src/features/messages.js index 94a5173..278e66f 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -4193,11 +4193,19 @@ export function messagesFeature(ctx) { // language); unknown is never translated unasked // A post that is only hashtags is judged by them: its tags in a script // the reader can't read are what wants translating (as glosses). + // Asked per row per render; the guess reads the whole text, and a post's + // text and the target language don't change, so remember it. + const foreignMemo = new Map(); const looksForeign = (ev) => { const to = transLang(); if (!translatable(ev) || !(deeplKey() || canTranslateTo(to))) return false; + const mk = transKey(ev) + '|' + to; + if (foreignMemo.has(mk)) return foreignMemo.get(mk); const text = noteText(ev), { prose, tags } = transParts(text, to); - return prose.length ? !inLanguage(text, to) : tags.length > 0; + const v = prose.length ? !inLanguage(text, to) : tags.length > 0; + if (foreignMemo.size > 3000) foreignMemo.clear(); + foreignMemo.set(mk, v); + return v; }; const langName = (code) => { try { return new Intl.DisplayNames([getLang()], { type: 'language' }).of(code) || code; } catch { return code; } }; // the translation to SHOW for this post now, or null @@ -4715,8 +4723,14 @@ export function messagesFeature(ctx) { // (a /username deep link over the unlock screen) scrolls too, and init // only runs once a wallet opens. let lastFeedY = 0, pillFrame = 0; + // When the reader last moved the page themselves (a wheel notch coasts for + // a while after the last one), and when it last scrolled at all: a batch of + // arriving posts waits for a quiet moment (see queueAdmit). + let lastInputAt = 0, lastScrollAt = 0; if (typeof window !== 'undefined') { + for (const n of ['wheel', 'touchstart', 'touchmove', 'keydown', 'pointerdown']) window.addEventListener(n, () => { lastInputAt = Date.now(); }, { passive: true, capture: true }); window.addEventListener('scroll', () => { + lastScrollAt = Date.now(); if (!ui.profilePk && (!(ui.chatOpen && ui.msgView === 'feed') || ui.noteThread)) return; windowScrolled(ui.profilePk ? notesCache.get(ui.profilePk) : feed); // back at the top by yourself: the new posts are under your eyes, so @@ -6707,12 +6721,37 @@ export function messagesFeature(ctx) { // Cached rows can still be warming. This new row is already prepared // and can take its stable presentation without waiting for that page. if (c.booting) { c.prepared ||= new Set(); c.prepared.add(e.id); } - admitFeed([...(c.deferred || []), e], c, false, opts.newerThan); - scheduleRepaint(); + queueAdmit(e, c, opts.newerThan); return true; })); return ready.some(Boolean); } + // Ready posts go in together: one admission — one render with the reader's + // row held still — per short interval, not one per post. A Popular switch + // used to run dozens of full renders in a few seconds, 200–450 ms each, + // and a wheel scroll through them stuttered on a laptop (the wheel coast + // is driven from the main thread, so every long render froze it). While + // the reader is moving the page the batch waits for a quiet moment, up to + // a cap, so nothing much changes under a coasting scroll. + const ADMIT_MS = 120, ADMIT_QUIET_MS = 160, ADMIT_INPUT_MS = 2500, ADMIT_MAX_WAIT_MS = 1500; + const admitQueues = new Map(); // c → { evs, newerThan, since, timer } + function queueAdmit(e, c, newerThan) { + let q = admitQueues.get(c); + if (!q) admitQueues.set(c, q = { evs: [], newerThan, since: Date.now(), timer: 0 }); + q.evs.push(e); + if (!q.timer) q.timer = setTimeout(() => flushAdmit(c), ADMIT_MS); + } + const readerMoving = () => Date.now() - lastScrollAt < ADMIT_QUIET_MS && Date.now() - lastInputAt < ADMIT_INPUT_MS; + function flushAdmit(c) { + const q = admitQueues.get(c); + if (!q) return; + q.timer = 0; + if (c === feed && readerMoving() && Date.now() - q.since < ADMIT_MAX_WAIT_MS) { q.timer = setTimeout(() => flushAdmit(c), ADMIT_QUIET_MS); return; } + admitQueues.delete(c); + if (c.stopped) return; + admitFeed([...(c.deferred || []), ...q.evs], c, false, q.newerThan); + scheduleRepaint(); + } // Never insert between the rows currently under the reader's eyes. Gap // fills wait until that part of the feed is offscreen; new posts above @@ -8706,7 +8745,10 @@ export function messagesFeature(ctx) { // rooms, DMs, quote cards. Two rules are Amethyst's, and are what makes // its feeds feel clean: repeated content from strangers is spam, and a // post your follows reported is folded (not hidden) behind who said so. - const modOn = (k) => (st().mod || {})[k] !== false; + // Per row per check; the cached copy serves (a Settings toggle writes + // through save(), which refreshes it) rather than re-parsing the blob + // every 50 ms through a long render. + const modOn = (k) => ((stCache || st()).mod || {})[k] !== false; // ---- sensitive posts (NIP-36 content warnings, NSFW accounts) ------------- // A post its author marked sensitive carries a content-warning tag — what a // Misskey artist's "sensitive" media becomes on the mostr bridge, and what @@ -11863,7 +11905,13 @@ export function messagesFeature(ctx) { // reconciles the list by post: a new one at the top is inserted as its // own node; the rest keep theirs. const list = visible.slice(0, c.shown || FEED_PAGE); - c.winList = () => c.notes.filter(shownHere).slice(0, c.shown || FEED_PAGE); + // The window's list getter runs on every scroll frame and inside + // holdScroll; filtering every note through the mute/spam/report checks + // each time was the main thread's single largest cost while a feed + // loaded. Anything that changes who is shown goes through render(), which + // lands here again; between renders the same notes give the same list. + const visNotes = c.notes, visShown = c.shown; + c.winList = () => (c.notes === visNotes && c.shown === visShown ? list : c.notes.filter(shownHere).slice(0, c.shown || FEED_PAGE)); const rows = windowedRows(c, list, (ev) => (c.booting && !c.prepared?.has(ev.id) ? keyed(noteRow(ev.pubkey, ev, displayName(ev.pubkey)), ev.id) : feedRow(c, ev))); // Posts that went in above you while you were reading. A floating pill diff --git a/src/features/nostrlogin.js b/src/features/nostrlogin.js index f18b075..f7f9e16 100644 --- a/src/features/nostrlogin.js +++ b/src/features/nostrlogin.js @@ -28,7 +28,13 @@ export function nostrLoginFeature(ctx) { const { h, ui, render, wallet, toast, copyBtn } = ctx; const load = () => wallet.loadFeatureState('nostrlogin', {}); - const save = (st) => wallet.saveFeatureState('nostrlogin', st); + const save = (st) => { idMemo = null; wallet.saveFeatureState('nostrlogin', st); }; + // nostrLoginIdentity() is asked per feed row per moderation check (isMe, + // mePk, trusted…): thousands of times per scroll frame. Each answer parsed + // the stored state twice and bech32-encoded the npub. Keep the last answer + // briefly; a save, a signer change or another wallet's key drops it. + let idMemo = null; + const ID_MEMO_MS = 250; // The signer that opened this session, kept so the names feature can claim // the user's real npub as their payment address (and nominate the wallet @@ -765,10 +771,14 @@ export function nostrLoginFeature(ctx) { // wallet key's face in the gap. // `via`: how the account came in, known before its signer wakes (an // Amber account is one whether or not Amber has been asked anything yet) + const key = wallet.featureStateKey('nostrlogin'); + if (idMemo && idMemo.live === live && idMemo.key === key && Date.now() - idMemo.at < ID_MEMO_MS) return idMemo.value; const st = load(); - if (liveSigner()) return { pubkey: live.pubkey, npub: npubOf(live.pubkey), signer: live, via: st.pubkey === live.pubkey ? reconnectViaOf(st) : viaOf(live) }; - if (!st.pubkey) return null; - return { pubkey: st.pubkey, npub: npubOf(st.pubkey), signer: live, via: reconnectViaOf(st) }; + let value = null; + if (liveSigner()) value = { pubkey: live.pubkey, npub: npubOf(live.pubkey), signer: live, via: st.pubkey === live.pubkey ? reconnectViaOf(st) : viaOf(live) }; + else if (st.pubkey) value = { pubkey: st.pubkey, npub: npubOf(st.pubkey), signer: live, via: reconnectViaOf(st) }; + idMemo = { at: Date.now(), live, key, value }; + return value; }, // A page reload loses the signer. A stored passkey key or an installed // extension reattaches without prompting, which is what lets the payment -- cgit v1.2.3