diff options
| author | Adam Soltys <[email protected]> | 2026-10-09 00:51:19 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-10-09 00:51:19 -0700 |
| commit | 2d13695021c80892af3604907a8501cc5d42e443 (patch) | |
| tree | 8d0773e0bbf3390fcae6f39878af36ef11e3789c | |
| parent | 806337899029f08bd71fc27be5537e7cc4d590ce (diff) | |
| download | coinosv3-2d13695021c80892af3604907a8501cc5d42e443.tar.gz coinosv3-2d13695021c80892af3604907a8501cc5d42e443.zip | |
Reuse feed overscan to reduce rendering during scroll coasts
| -rw-r--r-- | src/features/messages.js | 21 | ||||
| -rw-r--r-- | tools/feed-window-coast-test.js | 63 |
2 files changed, 78 insertions, 6 deletions
diff --git a/src/features/messages.js b/src/features/messages.js index 540f942..d3c8e87 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -6595,18 +6595,27 @@ export function messagesFeature(ctx) { } return changed; } - // On scroll: when the window should move, repaint with the reader's row - // held still. One frame at a time. + // On scroll: replenish the buffer before the reader reaches its edge, + // holding their row still. Coalesce scroll events into one check per frame. let windowRaf = 0; function windowScrolled(c) { if (!c || !c.win || windowRaf) return; windowRaf = requestAnimationFrame(() => { windowRaf = 0; - measureNow(c); const list = c.winList ? c.winList() : null; - if (!list) return; - const next = listWindow(c, list); - if (next.start !== c.win.start || next.end !== c.win.end) holdScroll(render); + if (!list || !c.win || (!c.win.start && c.win.end >= list.length)) return; + // Keep using the 2.5-screen overscan until only one screen remains. + // Recentring it every time an offscreen row crossed the ideal boundary + // rebuilt the whole pane repeatedly during a coast. Two edge reads + // suffice here; measure all rows only when the window actually moves. + const rows = document.querySelectorAll('.notes-feed > .row[data-key]'); + const first = rows[0], last = rows[rows.length - 1], H = window.innerHeight; + if (!first || !last) return; + const nearTop = c.win.start > 0 && first.getBoundingClientRect().top > -H; + const nearBottom = c.win.end < list.length && last.getBoundingClientRect().bottom < 2 * H; + if (!nearTop && !nearBottom) return; + measureNow(c); + holdScroll(render, c); }); } diff --git a/tools/feed-window-coast-test.js b/tools/feed-window-coast-test.js new file mode 100644 index 0000000..7c7cead --- /dev/null +++ b/tools/feed-window-coast-test.js @@ -0,0 +1,63 @@ +// A continuous coast reuses the mounted overscan instead of rebuilding it +// one row at a time. Runs the production window/measurement/anchor functions. +import assert from 'node:assert/strict'; +import puppeteer from './puppeteer.js'; +const src = await Bun.file('src/features/messages.js').text(); +const start = src.indexOf(' const WINDOW_AHEAD ='); +const end = src.indexOf(' // While the feed is', start); +assert(start > 0 && end > start); +const script = ` +const h = (tag, attrs = {}, ...kids) => { + const el = document.createElement(tag); + for (const [k,v] of Object.entries(attrs)) el.setAttribute(k,v); + for (const child of kids.flat()) el.append(child); + return el; +}; +const notes = Array.from({ length: 200 }, (_, i) => ({ id: String(i), height: 140 + (i % 4) * 60 })); +const feed = { heights: new Map(notes.map(e => [e.id, e.height])), winList: () => notes }; +let renders = 0, reads = 0; +const rect = Element.prototype.getBoundingClientRect; +Element.prototype.getBoundingClientRect = function () { if (this.matches('.row')) reads++; return rect.call(this); }; +${src.slice(start,end)} +function render() { + renders++; + document.querySelector('.notes-feed').replaceChildren(...windowedRows(feed, notes, + e => h('div', { class: 'row', 'data-key': e.id, style: 'height:'+e.height+'px' }, 'Post '+e.id))); +} +window.addEventListener('scroll', () => windowScrolled(feed)); +render(); +window.runCoast = async () => { + const frame = () => new Promise(requestAnimationFrame); + await frame(); await frame(); renders = reads = 0; + let gaps = 0; + for (const direction of [1, -1]) { + for (let i = 0; i < 180; i++) { + window.scrollBy(0, direction * 50); + await frame(); await frame(); + const rows = [...document.querySelectorAll('.row')]; + if (!rows.some(r => { const b = rect.call(r); return b.top <= 0 && b.bottom >= 0; }) + && !rows.some(r => { const b = rect.call(r); return b.top >= 0 && b.top <= 2; })) gaps++; + } + } + const result = { renders, reads, gaps, y: scrollY, mounted: document.querySelectorAll('.row').length }; + // Large jumps must still replace the window immediately. + scrollTo(0, 30000); await frame(); await frame(); await frame(); + const visible = [...document.querySelectorAll('.row')].some(r => { const b = rect.call(r); return b.bottom > 0 && b.top < innerHeight; }); + return { ...result, jumpCovered: visible }; +};`; +const html = `<style>body{margin:0}.notes-feed{overflow-anchor:none}</style><div class="notes-feed"></div><script>${script}</script>`; +const server = Bun.serve({ port: 0, fetch: () => new Response(html, { headers: { 'Content-Type': 'text/html' } }) }); +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: true, args: ['--no-sandbox'] }); +try { + const page = await browser.newPage(); + await page.setViewport({ width: 800, height: 800 }); + await page.goto(server.url.href); + const result = await page.evaluate(() => runCoast()); + console.log(result); + assert.equal(result.gaps, 0, 'mounted posts always cover the viewport'); + assert.equal(result.y, 0, 'no accumulated anchor drift'); + assert(result.jumpCovered, 'a scrollbar jump is covered'); + assert(result.mounted < 35, 'DOM remains bounded'); + assert(result.renders <= 20, 'reuse the overscan during a coast'); + assert(result.reads < 2200, 'do not measure every mounted post on every scroll frame'); +} finally { await browser.close(); server.stop(true); } |
