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 | |
| 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')
| -rw-r--r-- | frontend-vanilla/src/main.ts | 50 | ||||
| -rw-r--r-- | frontend-vanilla/src/regression.test.ts | 83 |
2 files changed, 66 insertions, 67 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; } } diff --git a/frontend-vanilla/src/regression.test.ts b/frontend-vanilla/src/regression.test.ts index 97c601c..813e4bb 100644 --- a/frontend-vanilla/src/regression.test.ts +++ b/frontend-vanilla/src/regression.test.ts @@ -258,30 +258,16 @@ describe('NK-z1czaq: Sidebar overlays content, does not shift layout', () => { }); }); -// Infinite scroll: sentinel IntersectionObserver must be kept alive via a module-level -// variable so it isn't garbage-collected between renderItems() calls. -describe('Infinite scroll: sentinel triggers loadMore when scrolled into view', () => { - let capturedCallback: IntersectionObserverCallback | null = null; - +// Infinite scroll: uses scroll-position check (like v1) instead of IntersectionObserver. +// When the user scrolls within 200px of the bottom of #main-content, loadMore fires. +describe('Infinite scroll: scroll near bottom triggers loadMore', () => { beforeEach(() => { document.body.innerHTML = '<div id="app"><div id="main-content"><div id="content-area"></div></div></div>'; Element.prototype.scrollIntoView = vi.fn(); - capturedCallback = null; vi.clearAllMocks(); store.setItems([]); store.setHasMore(true); - // Override IntersectionObserver to capture the callback passed by renderItems. - // Must use a class (not an arrow function) because the code calls it with `new`. - vi.stubGlobal('IntersectionObserver', class { - constructor(cb: IntersectionObserverCallback) { - capturedCallback = cb; - } - observe = vi.fn(); - disconnect = vi.fn(); - unobserve = vi.fn(); - }); - vi.mocked(apiFetch).mockResolvedValue({ ok: true, status: 200, @@ -289,7 +275,24 @@ describe('Infinite scroll: sentinel triggers loadMore when scrolled into view', } as Response); }); - it('should call loadMore (apiFetch /api/stream) when sentinel fires isIntersecting=true', () => { + function simulateScrollNearBottom(mainContent: HTMLElement) { + // Simulate: scrollHeight=2000, clientHeight=800, scrollTop=1050 + // remaining = 2000 - 1050 - 800 = 150 < 200 → should trigger loadMore + Object.defineProperty(mainContent, 'scrollHeight', { value: 2000, configurable: true }); + Object.defineProperty(mainContent, 'clientHeight', { value: 800, configurable: true }); + mainContent.scrollTop = 1050; + mainContent.dispatchEvent(new Event('scroll')); + } + + function simulateScrollFarFromBottom(mainContent: HTMLElement) { + // remaining = 2000 - 200 - 800 = 1000 > 200 → should NOT trigger + Object.defineProperty(mainContent, 'scrollHeight', { value: 2000, configurable: true }); + Object.defineProperty(mainContent, 'clientHeight', { value: 800, configurable: true }); + mainContent.scrollTop = 200; + mainContent.dispatchEvent(new Event('scroll')); + } + + it('should call loadMore (apiFetch /api/stream) when scrolled near the bottom', () => { const items = Array.from({ length: 50 }, (_, i) => ({ _id: i + 1, title: `Item ${i + 1}`, @@ -297,21 +300,19 @@ describe('Infinite scroll: sentinel triggers loadMore when scrolled into view', read: false, publish_date: '2024-01-01', })); - // setItems emits items-updated → renderItems() sets up itemObserver store.setItems(items as any); + vi.clearAllMocks(); - expect(document.getElementById('load-more-sentinel')).not.toBeNull(); - expect(capturedCallback).not.toBeNull(); - - // Simulate the sentinel scrolling into the scroll container's viewport - capturedCallback!([{ isIntersecting: true }] as any, null as any); + const mainContent = document.getElementById('main-content')!; + // renderItems sets onscroll on main-content; fire the scroll event + simulateScrollNearBottom(mainContent); expect(apiFetch).toHaveBeenCalledWith( expect.stringContaining('/api/stream'), ); }); - it('should NOT call loadMore when store.loading is true', () => { + it('should NOT call loadMore when scrolled far from the bottom', () => { const items = Array.from({ length: 50 }, (_, i) => ({ _id: i + 1, title: `Item ${i + 1}`, @@ -319,13 +320,31 @@ describe('Infinite scroll: sentinel triggers loadMore when scrolled into view', read: false, publish_date: '2024-01-01', })); - store.setItems(items as any); // renderItems() called, capturedCallback set - vi.clearAllMocks(); // reset apiFetch call count + store.setItems(items as any); + vi.clearAllMocks(); + + const mainContent = document.getElementById('main-content')!; + simulateScrollFarFromBottom(mainContent); + + expect(apiFetch).not.toHaveBeenCalledWith( + expect.stringContaining('/api/stream'), + ); + }); - // Directly mutate loading without emitting (avoids another renderItems cycle) + it('should NOT call loadMore when store.loading is true', () => { + const items = Array.from({ length: 50 }, (_, i) => ({ + _id: i + 1, + title: `Item ${i + 1}`, + url: `http://example.com/${i + 1}`, + read: false, + publish_date: '2024-01-01', + })); + store.setItems(items as any); + vi.clearAllMocks(); store.loading = true; - capturedCallback!([{ isIntersecting: true }] as any, null as any); + const mainContent = document.getElementById('main-content')!; + simulateScrollNearBottom(mainContent); expect(apiFetch).not.toHaveBeenCalledWith( expect.stringContaining('/api/stream'), @@ -344,11 +363,5 @@ describe('Infinite scroll: sentinel triggers loadMore when scrolled into view', store.setItems(items as any); expect(document.getElementById('load-more-sentinel')).toBeNull(); - // No IntersectionObserver was set up for a sentinel that doesn't exist - // (capturedCallback may have been set for a previous render, not this one) - // Just verify nothing was loaded - expect(apiFetch).not.toHaveBeenCalledWith( - expect.stringContaining('/api/stream'), - ); }); }); |
