diff options
| author | Adam Soltys <[email protected]> | 2026-10-11 19:51:45 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-10-11 19:51:45 -0700 |
| commit | fdaf7137139540d2f8de50b2a42d6ff3c036328b (patch) | |
| tree | 28577cec63e2ced3b2cdde83288e1b234bc2dde3 /tools | |
| parent | 491904e04fe3d69aa6b417c266aa8b550ffa0534 (diff) | |
| download | coinosv3-fdaf7137139540d2f8de50b2a42d6ff3c036328b.tar.gz coinosv3-fdaf7137139540d2f8de50b2a42d6ff3c036328b.zip | |
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.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Diffstat (limited to 'tools')
| -rw-r--r-- | tools/feed-scroll-jank.js | 222 |
1 files changed, 222 insertions, 0 deletions
diff --git a/tools/feed-scroll-jank.js b/tools/feed-scroll-jank.js new file mode 100644 index 0000000..3877bf3 --- /dev/null +++ b/tools/feed-scroll-jank.js @@ -0,0 +1,222 @@ +// How smooth is a wheel scroll through the live feed? Drives Adam's own +// Chrome (--remote-debugging-port=9222) with REAL mouse-wheel events through +// CDP, switches feeds while they are still loading (the moment that feels +// worst), and records what the page actually did: frame gaps from a rAF +// loop, long tasks, the wheel events' input delay, row churn in the feed, +// plus a CPU profile across the whole run so each long stretch is named. +// +// bun tools/feed-scroll-jank.js # Popular, Garden, Following +// bun tools/feed-scroll-jank.js --feeds Popular,Following --seconds 6 +// bun tools/feed-scroll-jank.js --trackpad # also the native (non-notch) wheel path +// bun tools/feed-scroll-jank.js --match v3.coinos.io # which tab (default: v3 if open, else localhost) +// bun tools/feed-scroll-jank.js --out dir # keep run.json + run.cpuprofile there +// +// Two kinds of scenario per feed: "switch" (click the chip, start turning the +// wheel at once while posts arrive) and "settled" (same feed after it has +// been quiet a few seconds) — the difference is the loading cost. The tab +// must be visible: a hidden tab gets no frames and the numbers mean nothing. +const args = Object.fromEntries(process.argv.slice(2).map((a, i, arr) => a.startsWith('--') ? [a.slice(2), arr[i + 1] && !arr[i + 1].startsWith('--') ? arr[i + 1] : true] : []).filter(Boolean)); +const port = args.port || 9222; +const seconds = Number(args.seconds || 5); +const feeds = String(args.feeds || 'Popular,Garden,Following').split(',').map((s) => s.trim()).filter(Boolean); +const notchMs = Number(args.cadence || 90); // ms between notches: a steady, fairly quick turn +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +if (args.analyze) { + const fs = await import('fs'); + const saved = JSON.parse(fs.readFileSync(`${args.analyze}/run.json`, 'utf8')); + for (const r of saved.results) console.log(` ${r.name.padEnd(26)} ${summarize(r)}`); + analyze(JSON.parse(fs.readFileSync(`${args.analyze}/run.cpuprofile`, 'utf8')), saved.results, saved.profT0); + process.exit(0); +} +// ---- the tab +const list = await (await fetch(`http://localhost:${port}/json/list`)).json(); +const pages = list.filter((t) => t.type === 'page'); +const page = args.match ? pages.find((t) => t.url.includes(args.match)) + : pages.find((t) => t.url.includes('v3.coinos.io')) || pages.find((t) => /localhost:5173/.test(t.url)); +if (!page) { console.error('no page tab; open ones:', pages.map((p) => p.url).join(' ')); process.exit(1); } +const ws = new WebSocket(page.webSocketDebuggerUrl); +let id = 0; const pending = new Map(); +ws.onmessage = (m) => { const d = JSON.parse(m.data); if (d.id && pending.has(d.id)) { const { res, rej } = pending.get(d.id); pending.delete(d.id); d.error ? rej(new Error(JSON.stringify(d.error))) : res(d.result); } }; +const send = (method, params = {}) => new Promise((res, rej) => { const i = ++id; pending.set(i, { res, rej }); ws.send(JSON.stringify({ id: i, method, params })); }); +await new Promise((r) => (ws.onopen = r)); +await send('Runtime.enable'); await send('Page.enable'); await send('Profiler.enable'); +const ev = async (expression) => { + const r = await send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true }); + if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description || r.exceptionDetails.text); + return r.result.value; +}; +const vis = await ev('document.visibilityState'); +if (vis !== 'visible') { console.error(`the tab is ${vis}; bring it to the front (a hidden tab gets no frames)`); process.exit(1); } +if (!(await ev(`!!document.querySelector('.feed-chips')`))) { + // not on the feed: the bottom-nav Feed button takes us there + await ev(`(() => { const b = [...document.querySelectorAll('.app-nav-button')].find((x) => /feed/i.test(x.textContent)); if (b) b.click(); return !!b; })()`); + await sleep(1500); + if (!(await ev(`!!document.querySelector('.feed-chips')`))) { console.error('could not reach the feed page'); process.exit(1); } +} + +// ---- the recorder, inside the page +await ev(`(() => { + const R = window.__jank = { frames: [], tasks: [], wheel: [], rows: { added: 0, removed: 0, batches: 0 }, live: false }; + R.start = () => { + R.frames = []; R.tasks = []; R.wheel = []; R.rows = { added: 0, removed: 0, batches: 0 }; R.live = true; + R.t0 = performance.now(); R.y0 = scrollY; + const tick = (t) => { if (!R.live) return; R.frames.push([t, scrollY]); requestAnimationFrame(tick); }; + requestAnimationFrame(tick); + R.lt = new PerformanceObserver((l) => { for (const e of l.getEntries()) R.tasks.push([e.startTime, e.duration]); }); + R.lt.observe({ type: 'longtask', buffered: false }); + try { + R.et = new PerformanceObserver((l) => { for (const e of l.getEntries()) if (e.name === 'wheel') R.wheel.push([e.startTime, e.processingStart - e.startTime, e.duration]); }); + R.et.observe({ type: 'event', durationThreshold: 16, buffered: false }); + } catch {} + const feed = document.querySelector('.notes-feed'); + R.mo = new MutationObserver((muts) => { let a = 0, r = 0; for (const m of muts) { a += m.addedNodes.length; r += m.removedNodes.length; } if (a || r) { R.rows.batches++; R.rows.added += a; R.rows.removed += r; } }); + if (feed) R.mo.observe(feed, { childList: true }); + }; + R.stop = () => { + R.live = false; R.lt?.disconnect(); R.et?.disconnect(); R.mo?.disconnect(); + return { t0: R.t0, t1: performance.now(), y0: R.y0, y1: scrollY, frames: R.frames, tasks: R.tasks, wheel: R.wheel, rows: R.rows, + timeOrigin: performance.timeOrigin, rowsNow: document.querySelectorAll('.notes-feed > .row').length, booting: !!document.querySelector('.notes-feed[data-booting]') }; + }; + return true; +})()`); + +// ---- real input +const feedRect = async () => ev(`(() => { const f = document.querySelector('.notes-feed') || document.querySelector('.feed-page'); const r = f.getBoundingClientRect(); return { x: r.x + r.width / 2, y: Math.max(120, Math.min(innerHeight - 80, r.y + r.height / 2)), w: innerWidth, h: innerHeight }; })()`); +const wheel = async (x, y, deltaY) => send('Input.dispatchMouseEvent', { type: 'mouseWheel', x, y, deltaX: 0, deltaY }); +const clickChip = async (label) => { + const r = await ev(`(() => { const b = [...document.querySelectorAll('.feed-chips .feed-chip')].find((x) => x.textContent.trim().toLowerCase() === ${JSON.stringify(label.toLowerCase())}); if (!b) return null; b.scrollIntoView({ inline: 'center', block: 'nearest' }); const q = b.getBoundingClientRect(); return { x: q.x + q.width / 2, y: q.y + q.height / 2 }; })()`); + if (!r) throw new Error(`no chip "${label}" (chips: ${await ev(`[...document.querySelectorAll('.feed-chips .feed-chip')].map((b) => b.textContent.trim()).join(' | ')`)})`); + await send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: r.x, y: r.y }); + await send('Input.dispatchMouseEvent', { type: 'mousePressed', x: r.x, y: r.y, button: 'left', clickCount: 1 }); + await send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: r.x, y: r.y, button: 'left', clickCount: 1 }); +}; +// A notch: Chrome gives a synthetic deltaY of 100 a wheelDeltaY of -120, which +// is what app.js treats as a mouse (it coasts the page itself). A trackpad +// delta (not a multiple of 120) leaves the scroll native. +const probe = await (async () => { + const r = await feedRect(); + await ev(`window.__probe = null; addEventListener('wheel', (e) => { window.__probe = { dy: e.deltaY, wdy: e.wheelDeltaY, mode: e.deltaMode }; e.preventDefault(); }, { once: true, passive: false })`); + await wheel(r.x, r.y, 100); await sleep(50); + return ev('window.__probe'); +})(); +console.log(`page=${page.url}\nwheel probe: deltaY=${probe?.dy} wheelDeltaY=${probe?.wdy} (${probe && probe.wdy % 120 === 0 ? 'notch → app coast' : 'NOT a notch'})`); + +// ---- scenarios +const results = []; +const delta = args.trackpad === true ? 100 : Number(args.delta || 100); +async function scenario(name, { feed, switchTo, dy = 100 }) { + const r = await feedRect(); + await send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: r.x, y: r.y }); + const tStart = Date.now(); + await ev('window.__jank.start()'); + if (switchTo) await clickChip(switchTo); + // turn the wheel steadily: down, then a stretch back up, then down again + const n = Math.round(seconds * 1000 / notchMs); + for (let i = 0; i < n; i++) { + const dir = i >= n * 0.55 && i < n * 0.75 ? -1 : 1; + await wheel(r.x, r.y, dir * dy); + await sleep(notchMs); + } + await sleep(600); // let the coast die + const rec = await ev('window.__jank.stop()'); + rec.name = name; rec.feed = feed; rec.wall = [tStart, Date.now()]; + results.push(rec); + console.log(` ${name.padEnd(26)} ${summarize(rec)}`); +} +function stats(gaps) { + const s = [...gaps].sort((a, b) => a - b); const q = (p) => s.length ? s[Math.min(s.length - 1, Math.floor(s.length * p))] : 0; + return { n: s.length, p50: q(0.5), p95: q(0.95), max: s.length ? s[s.length - 1] : 0 }; +} +function summarize(rec) { + const gaps = rec.frames.slice(1).map((f, i) => f[0] - rec.frames[i][0]); + const g = stats(gaps); + const over = (ms) => gaps.filter((x) => x > ms).length; + const lt = rec.tasks.reduce((a, t) => a + t[1], 0), ltMax = Math.max(0, ...rec.tasks.map((t) => t[1])); + const wd = stats(rec.wheel.map((w) => w[1])); + // a frozen frame while the page should be moving: nothing to show for >33 ms + const stalls = gaps.filter((x) => x > 33).reduce((a, x) => a + x, 0); + rec.s = { frames: g.n, p50: g.p50, p95: g.p95, max: g.max, over33: over(33), over50: over(50), over100: over(100), stalledMs: stalls, longTasks: rec.tasks.length, longTaskMs: lt, longTaskMax: ltMax, wheelSlow: rec.wheel.length, wheelDelayP95: wd.p95, moved: rec.y1 - rec.y0 }; + return `frames=${g.n} p50=${g.p50.toFixed(1)} p95=${g.p95.toFixed(0)} max=${g.max.toFixed(0)}ms | >33ms:${over(33)} >50:${over(50)} >100:${over(100)} (stalled ${stalls.toFixed(0)}ms of ${(rec.t1 - rec.t0).toFixed(0)}) | long tasks ${rec.tasks.length} = ${lt.toFixed(0)}ms max ${ltMax.toFixed(0)} | wheel>16ms ${rec.wheel.length} delay p95 ${wd.p95.toFixed(0)} | rows +${rec.rows.added} −${rec.rows.removed} in ${rec.rows.batches} batches | moved ${rec.y1 - rec.y0}px`; +} + +await send('Profiler.setSamplingInterval', { interval: Number(args.interval || 250) }); +await send('Profiler.start'); +const profT0 = await ev('performance.now()'); +// where are we now: settle, then start from another feed so every switch is a real one +const current = await ev(`(document.querySelector('.feed-chips .feed-chip.on') || {}).textContent?.trim()`); +console.log(`current feed: ${current}\n== mouse wheel (notch, app coast) ==`); +for (const f of feeds) { + if (current !== f || results.length) { await ev('window.scrollTo(0,0)'); await sleep(300); } + await scenario(`switch → ${f}`, { feed: f, switchTo: f, dy: delta }); + await sleep(4000); // let the feed settle + await ev('window.scrollTo(0,0)'); await sleep(400); + await scenario(`settled ${f}`, { feed: f, dy: delta }); +} +if (args.trackpad) { + console.log('== trackpad-like (native scroll) =='); + for (const f of feeds) { + await ev('window.scrollTo(0,0)'); await sleep(300); + await scenario(`switch → ${f} (native)`, { feed: f, switchTo: f, dy: 110 }); + await sleep(4000); await ev('window.scrollTo(0,0)'); await sleep(400); + await scenario(`settled ${f} (native)`, { feed: f, dy: 110 }); + } +} +const { profile } = await send('Profiler.stop'); +ws.close(); +if (args.out) { + const fs = await import('fs'); fs.mkdirSync(args.out, { recursive: true }); + fs.writeFileSync(`${args.out}/run.json`, JSON.stringify({ page: page.url, probe, results, profT0 }, null, 1)); + fs.writeFileSync(`${args.out}/run.cpuprofile`, JSON.stringify(profile)); +} +analyze(profile, results, profT0); + +// ---- who was running during the long frames +// Profile timestamps are µs on the same monotonic clock as performance.now() +// (startTime ≈ timeOrigin + now); align by the sample we took at start. +function analyze(profile, results, profT0) { +const nodes = new Map(profile.nodes.map((n) => [n.id, n])); +const fnKey = (n) => { const f = n.callFrame; return `${f.functionName || '(anon)'} ${f.url.replace(/^https?:\/\/[^/]+/, '').replace(/^\/assets\//, '')}:${f.lineNumber + 1}`; }; +const samples = []; let t = profile.startTime; +for (let i = 0; i < profile.samples.length; i++) { t += profile.timeDeltas[i]; samples.push([t, profile.samples[i], profile.timeDeltas[i + 1] || 0]); } +const offsetUs = profile.startTime - profT0 * 1000; // page-ms → profile-µs +const idleName = (n) => n.callFrame.functionName === '(idle)'; +// busy runs in the profile: stretches of non-idle samples longer than 50 ms +const profileParentMap = new Map(); for (const n of profile.nodes) for (const c of n.children || []) profileParentMap.set(c, n.id); +const runs = []; let run = null; +for (const [ts, nid, dt] of samples) { + const n = nodes.get(nid); + if (idleName(n)) { if (run) { runs.push(run); run = null; } continue; } + if (!run) run = { start: ts, end: ts + dt, self: new Map(), total: new Map() }; + run.end = ts + dt; + const k = fnKey(n); run.self.set(k, (run.self.get(k) || 0) + dt); + // inclusive: walk up + const seen = new Set(); let p = nid; const parent = profileParentMap; + while (p != null) { const kk = fnKey(nodes.get(p)); if (!seen.has(kk)) { seen.add(kk); run.total.set(kk, (run.total.get(kk) || 0) + dt); } p = parent.get(p); } +} +if (run) runs.push(run); +const long = runs.filter((r) => r.end - r.start > Number(args.busy || 30) * 1000).sort((a, b) => (b.end - b.start) - (a.end - a.start)); +const ms = (us) => (us / 1000).toFixed(0); +const scenarioAt = (us) => { const pm = (us - offsetUs) / 1000; return results.find((r) => pm >= r.t0 && pm <= r.t1)?.name || '(between)'; }; +console.log(`\n== busy stretches > ${args.busy || 30}ms (main thread never idle): ${long.length}, ${ms(long.reduce((a, r) => a + r.end - r.start, 0))}ms in all; the longest, who was inside ==`); +for (const r of long.slice(0, Number(args.top || 14))) { + const top = [...r.total].filter(([k]) => !/^\((root|program|garbage collector)\)/.test(k)).sort((a, b) => b[1] - a[1]).slice(0, 6).map(([k, v]) => `${k} ${ms(v)}`).join(' > '); + const self = [...r.self].filter(([k]) => !/^\((root|program)\)/.test(k)).sort((a, b) => b[1] - a[1]).slice(0, 4).map(([k, v]) => `${k} ${ms(v)}`).join(', '); + console.log(`${ms(r.end - r.start).padStart(5)}ms [${scenarioAt(r.start)}]\n inclusive: ${top}\n self: ${self}`); +} +// self time across all scenarios, by function +const selfAll = new Map(); let busyAll = 0, wallAll = 0; +for (const [ts, nid, dt] of samples) { wallAll += dt; const n = nodes.get(nid); if (idleName(n)) continue; busyAll += dt; const k = fnKey(n); selfAll.set(k, (selfAll.get(k) || 0) + dt); } +console.log(`\n== self time over the run (busy ${ms(busyAll)}ms of ${ms(wallAll)}ms, ${(100 * busyAll / wallAll).toFixed(0)}%) ==`); +for (const [k, v] of [...selfAll].sort((a, b) => b[1] - a[1]).slice(0, 18)) console.log(ms(v).padStart(7), k); + +// ---- the table +const busyBy = new Map(); for (const r of long) { const k = scenarioAt(r.start); busyBy.set(k, (busyBy.get(k) || 0) + (r.end - r.start)); } +console.log('\n== summary =='); +console.log('scenario'.padEnd(28) + 'p95 max >33 >50 >100 stalled ltasks ltMax busy>' + (args.busy || 30) + ' rows±'); +for (const r of results) { + const s = r.s; + console.log(r.name.padEnd(28) + `${s.p95.toFixed(0).padStart(3)} ${s.max.toFixed(0).padStart(4)} ${String(s.over33).padStart(4)} ${String(s.over50).padStart(4)} ${String(s.over100).padStart(5)} ${(s.stalledMs.toFixed(0) + 'ms').padStart(7)} ${String(s.longTasks).padStart(6)} ${s.longTaskMax.toFixed(0).padStart(5)} ${(ms(busyBy.get(r.name) || 0) + 'ms').padStart(8)} +${r.rows.added}/−${r.rows.removed} in ${r.rows.batches}`); +} +if (args.out) console.log(`\nraw: ${args.out}/run.json, ${args.out}/run.cpuprofile (tools/cdp-callers.js <profile> <fn> for callers; --analyze ${args.out} re-reads them)`); +} |
