From 5968a7b59362c54263c4d8eaa56824647835f324 Mon Sep 17 00:00:00 2001 From: Masahiko AMANO Date: Thu, 11 Jun 2026 10:17:59 +0300 Subject: [PATCH] fix(frontend): drive infinite scroll from scroll position, not observer transitions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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
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 --- .../components/common/InfiniteScroll.svelte | 41 +++++++++++++------ 1 file changed, 29 insertions(+), 12 deletions(-) diff --git a/frontend/src/lib/components/common/InfiniteScroll.svelte b/frontend/src/lib/components/common/InfiniteScroll.svelte index 08c3d3c..53c7b39 100644 --- a/frontend/src/lib/components/common/InfiniteScroll.svelte +++ b/frontend/src/lib/components/common/InfiniteScroll.svelte @@ -31,22 +31,39 @@ 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
) as well as the document itself. rAF-throttled so it stays + // cheap (one getBoundingClientRect per frame at most). $effect(() => { - if (!sentinel) return; - const observer = new IntersectionObserver( - (entries) => { - if (entries[0].isIntersecting) maybeLoad(); - }, - { rootMargin: `${MARGIN}px` }, - ); - observer.observe(sentinel); - return () => observer.disconnect(); + let scheduled = false; + const onScroll = () => { + if (scheduled) return; + scheduled = true; + requestAnimationFrame(() => { + scheduled = false; + maybeLoad(); + }); + }; + 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 - // 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(() => { if (!loading) maybeLoad(); });