aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-09 00:51:19 -0700
committerAdam Soltys <[email protected]>2026-10-09 00:51:19 -0700
commit2d13695021c80892af3604907a8501cc5d42e443 (patch)
tree8d0773e0bbf3390fcae6f39878af36ef11e3789c
parent806337899029f08bd71fc27be5537e7cc4d590ce (diff)
downloadcoinosv3-2d13695021c80892af3604907a8501cc5d42e443.tar.gz
coinosv3-2d13695021c80892af3604907a8501cc5d42e443.zip
Reuse feed overscan to reduce rendering during scroll coasts
-rw-r--r--src/features/messages.js21
-rw-r--r--tools/feed-window-coast-test.js63
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); }