diff options
| author | Claude <noreply@anthropic.com> | 2026-02-17 17:00:13 +0000 |
|---|---|---|
| committer | Claude <noreply@anthropic.com> | 2026-02-17 17:00:13 +0000 |
| commit | 8ac775d7ce97e31a9531572f6f116dfc8de25d35 (patch) | |
| tree | c429272b7cade26edd9f7a44ac02a9ff432b9b7a /frontend-vanilla/src/main.ts | |
| parent | 90177f1645bf886a2e4f84f4db287ba379f01773 (diff) | |
| download | neko-8ac775d7ce97e31a9531572f6f116dfc8de25d35.tar.gz neko-8ac775d7ce97e31a9531572f6f116dfc8de25d35.tar.bz2 neko-8ac775d7ce97e31a9531572f6f116dfc8de25d35.zip | |
fix: replace IntersectionObserver with scroll-position check for infinite scroll
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
Diffstat (limited to 'frontend-vanilla/src/main.ts')
| -rw-r--r-- | frontend-vanilla/src/main.ts | 50 |
1 files changed, 18 insertions, 32 deletions
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 ? '<div id="load-more-sentinel" class="loading-more">Loading more...</div>' : ''} `; - // 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; } } |
