From a34769e3f60aa5b6920e4e2c9e0a9ee1013cacd1 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sun, 11 Oct 2026 20:56:32 -0700 Subject: Feed: the wheel coast no longer stalls or reverses as pictures land. A row that painted before its picture's size was known (a cached row on a feed just opened, a link card, a clip learning its shape) grew after it was measured, and the stale height is what the spacer took when the row left the window: the page shrank by the difference, the browser's scroll anchoring pulled the reader back by as much, and a coasting scroll lost ground (or ran backwards) right as the media appeared — on a feed opened fresh, 18 such frames in four seconds. Rows are now watched by a ResizeObserver (after layout, nothing forced) so the heights stay live; originals decode async so a big one never holds a paint. Tried and dropped: shrinking no-CORS originals through createImageBitmap — it re-decodes on the main thread. tools/feed-scroll-jank.js grows a hands-off coast scenario (--coast: quick-notch bursts, then let go; dips/jumps in the coast's own travel next to what landed: media loads, layout shifts, row batches, page height, scrollTo/scrollBy callers), --trace (a Chrome trace: the browser's work inside each long frame — decode, layout, paint, GC — and the compositor's dropped-frame count), --url for a never-seen topic feed, --only settled|coast, --start px. Measured: the fresh-feed coast went from 18 dips to 8 small ones; a fresh topic feed coasts with one 33 ms frame in five seconds. Still open: a 9528×6328 original from a host without CORS (primal's) once froze a scroll step for 400 ms on its texture upload — only a resizing proxy fixes that one. feed-stability's "a small feed query starts before opening the feed" and "an unavailable image remains a usable link" fail on 491904e 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 | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) (limited to 'src') diff --git a/src/features/messages.js b/src/features/messages.js index 278e66f..bb2d788 100644 --- a/src/features/messages.js +++ b/src/features/messages.js @@ -7098,17 +7098,37 @@ export function messagesFeature(ctx) { if (changed && repaint && c.win && (c.win.top || c.win.bottom)) holdScroll(render); }); } + // A row changes height after it was measured — a picture lands in a row + // that painted before its size was known, a clip learns its shape, a link + // card arrives. The stale number is what the spacer uses when the row + // leaves the window: the page then shrinks or grows by the difference, + // the browser's scroll anchoring pulls the reader back by as much, and a + // coasting wheel scroll stalled or even reversed right as the media + // appeared. Keep the measurements live instead (no layout forced: the + // observer runs after layout). + const rowSizer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver((entries) => { + for (const en of entries) { + const r = en.target, c = r.parentElement && r.parentElement._winC; + if (!c || !r.isConnected) continue; + const hgt = r.getBoundingClientRect().height, key = r.getAttribute('data-key'); + if (hgt > 0 && key) (c.heights || (c.heights = new Map())).set(key, hgt); + } + }); function measureNow(c) { const list = document.querySelector('.notes-feed'); if (!list) return false; c.listTop = list.getBoundingClientRect().top + (window.scrollY || 0); + list._winC = c; const heights = c.heights || (c.heights = new Map()); let changed = false; + const seen = new Set(); for (const r of list.querySelectorAll(':scope > .row[data-key]')) { const hgt = r.getBoundingClientRect().height; const key = r.getAttribute('data-key'); if (hgt > 0 && Math.abs((heights.get(key) || 0) - hgt) > 0.5) { heights.set(key, hgt); changed = true; } + if (rowSizer) { seen.add(r); if (!(list._sized ||= new Set()).has(r)) { list._sized.add(r); rowSizer.observe(r); } } } + if (rowSizer && list._sized) for (const r of list._sized) if (!seen.has(r)) { list._sized.delete(r); rowSizer.unobserve(r); } return changed; } // On scroll: replenish the buffer before the reader reaches its edge, @@ -7897,7 +7917,8 @@ export function messagesFeature(ctx) { const known = size || mediaReady.get(url); return h('img', { src: mediaSrc(url, known), class: 'note-img clickable', loading: feedPaint || known ? 'eager' : 'lazy', alt: label || '', - decoding: known && known.thumb ? 'sync' : undefined, // a few-ms decode: paint it with the page + // a few-ms decode of the copy paints with the page; an original never holds the page for its decode + decoding: known && known.thumb ? 'sync' : 'async', width: size?.width, height: size?.height, style: size ? 'height:auto;aspect-ratio:' + size.width + '/' + size.height + ';--ar:' + (size.width / size.height).toFixed(4) : undefined, onClick: (e) => { if (!onPicture(e)) return; e.stopPropagation(); ctx.openImage && ctx.openImage(url); }, -- cgit v1.2.3