From 8ac775d7ce97e31a9531572f6f116dfc8de25d35 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 17 Feb 2026 17:00:13 +0000 Subject: fix: replace IntersectionObserver with scroll-position check for infinite scroll MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The IntersectionObserver approach for infinite scroll was unreliable — items would not load when scrolling to the bottom in v3, while v1's polling approach worked fine. The issue was that IntersectionObserver with a custom root element (main-content, whose height comes from flex align-items:stretch rather than an explicit height) didn't fire reliably, and renderItems() being called 3 times per fetch cycle (from both items-updated and loading-state-changed events) kept destroying and recreating the observer. Replace with a simple scroll-position check in the existing onscroll handler, matching v1's proven approach: when the user scrolls within 200px of the bottom of #main-content, trigger loadMore(). This runs on every scroll event (cheap arithmetic comparison) and only fires when content actually overflows the container. Remove the unused itemObserver module-level variable. Update regression tests to simulate scroll position instead of IntersectionObserver callbacks, with 4 cases: scroll near bottom triggers load, scroll far from bottom doesn't, loading=true blocks, and hasMore=false hides sentinel. https://claude.ai/code/session_01DpWhB9uGGMBnzqS28HxnuV --- frontend-vanilla/src/main.ts | 50 ++++++++++++++++---------------------------- 1 file changed, 18 insertions(+), 32 deletions(-) (limited to 'frontend-vanilla/src/main.ts') diff --git a/frontend-vanilla/src/main.ts b/frontend-vanilla/src/main.ts index bdd0e97..8d88470 100644 --- a/frontend-vanilla/src/main.ts +++ b/frontend-vanilla/src/main.ts @@ -18,7 +18,6 @@ let activeItemId: number | null = null; // Cache elements (initialized in renderLayout) let appEl: HTMLDivElement | null = null; -let itemObserver: IntersectionObserver | null = null; // Initial Layout (v2-style 2-pane) export function renderLayout() { @@ -243,10 +242,6 @@ export function renderFilters() { export function renderItems() { const { items, loading } = store; - if (itemObserver) { - itemObserver.disconnect(); - itemObserver = null; - } const contentArea = document.getElementById('content-area'); if (!contentArea || router.getCurrentRoute().path === '/settings') return; @@ -267,27 +262,26 @@ export function renderItems() { ${store.hasMore ? '
Loading more...
' : ''} `; - // Use the actual scroll container as IntersectionObserver root + // Scroll listener on the scrollable container (#main-content) handles both: + // 1. Infinite scroll — load more when near the bottom (like v1's proven approach) + // 2. Mark-as-read — mark items read when scrolled past + // Using onscroll assignment (not addEventListener) so each renderItems() call + // replaces the previous handler without accumulating listeners. const scrollRoot = document.getElementById('main-content'); - - // Setup infinite scroll — stored in itemObserver so it has a GC root and won't be collected - const sentinel = document.getElementById('load-more-sentinel'); - if (sentinel) { - itemObserver = new IntersectionObserver((entries) => { - if (entries[0].isIntersecting && !store.loading && store.hasMore) { - loadMore(); + if (scrollRoot) { + let readTimeoutId: number | null = null; + scrollRoot.onscroll = () => { + // Infinite scroll: check immediately on every scroll event (cheap comparison). + // Guard: only when content actually overflows the container (scrollHeight > clientHeight). + if (!store.loading && store.hasMore && scrollRoot.scrollHeight > scrollRoot.clientHeight) { + if (scrollRoot.scrollHeight - scrollRoot.scrollTop - scrollRoot.clientHeight < 200) { + loadMore(); + } } - }, { root: scrollRoot, threshold: 0.1 }); - itemObserver.observe(sentinel); - } - // Scroll listener for reading items - // We attach this to the scrollable container: #main-content - if (scrollRoot) { - let timeoutId: number | null = null; - const onScroll = () => { - if (timeoutId === null) { - timeoutId = window.setTimeout(() => { + // Mark-as-read: debounced to avoid excessive DOM queries + if (readTimeoutId === null) { + readTimeoutId = window.setTimeout(() => { const containerRect = scrollRoot.getBoundingClientRect(); store.items.forEach((item) => { @@ -302,18 +296,10 @@ export function renderItems() { } } }); - timeoutId = null; + readTimeoutId = null; }, 250); } }; - // Remove existing listener if any (simplistic approach, ideally we track and remove) - // Since renderItems is called multiple times, we might be adding multiple listeners? - // attachLayoutListeners is called once, but renderItems is called on updates. - // We should probably attaching the scroll listener in the layout setup, NOT here. - // But we need access to 'items' which is in store. - // Let's attach it here but be careful. - // Actually, attaching to 'onscroll' property handles replacement automatically. - scrollRoot.onscroll = onScroll; } } -- cgit v1.2.3