fix(frontend): drive infinite scroll from scroll position, not observer transitions

The IntersectionObserver fired only on enter/leave transitions, so a scroll
that ended with the sentinel already in range (scrolling straight to the
bottom) produced no callback and nothing loaded — the user had to scroll up
and back down to force a fresh transition, loading one chunk per cycle.

Replace the observer with a capture-phase window scroll listener (capture is
required since scroll events don't bubble; it catches scrolls from the grid's
nested <main> as well as the document), rAF-throttled, re-checking the
sentinel's viewport position on every scroll. Keep the re-check on load
completion / mount for short pages and already-in-range first renders.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 10:17:59 +03:00
parent e801eec47d
commit 5968a7b593
@@ -31,22 +31,39 @@
if (nearViewport()) onLoadMore(); if (nearViewport()) onLoadMore();
} }
// Load on scroll: the observer notifies us when the sentinel nears the viewport. // Load on scroll. We watch the actual scroll position rather than relying on an
// IntersectionObserver, which fires only on enter/leave transitions: a scroll
// that *ends* with the sentinel already in range (e.g. scrolling straight to the
// bottom) produces no new observer callback, so nothing loads until the user
// scrolls back up and down to force a fresh transition. Re-checking the sentinel
// on every scroll is what reliably keeps the list growing.
//
// `capture: true` is required because scroll events don't bubble — capturing lets
// a single window listener catch scrolls from any nested scroll container (here
// the grid's <main>) as well as the document itself. rAF-throttled so it stays
// cheap (one getBoundingClientRect per frame at most).
$effect(() => { $effect(() => {
if (!sentinel) return; let scheduled = false;
const observer = new IntersectionObserver( const onScroll = () => {
(entries) => { if (scheduled) return;
if (entries[0].isIntersecting) maybeLoad(); scheduled = true;
}, requestAnimationFrame(() => {
{ rootMargin: `${MARGIN}px` }, scheduled = false;
); maybeLoad();
observer.observe(sentinel); });
return () => observer.disconnect(); };
window.addEventListener('scroll', onScroll, { passive: true, capture: true });
window.addEventListener('resize', onScroll, { passive: true });
return () => {
window.removeEventListener('scroll', onScroll, { capture: true });
window.removeEventListener('resize', onScroll);
};
}); });
// After each load settles (loading → false), re-check synchronously: if the // Re-check after mount and after each load settles (loading → false): if the
// freshly added content still didn't push the sentinel past the viewport, load // freshly added content still didn't push the sentinel past the viewport, load
// again. This fills short pages without the throttled observer lagging. // again. This fills short pages and covers the sentinel already being in range on
// first render, without waiting for a scroll.
$effect(() => { $effect(() => {
if (!loading) maybeLoad(); if (!loading) maybeLoad();
}); });