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:
@@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user