aboutsummaryrefslogtreecommitdiffstats
path: root/frontend-vanilla/src/main.ts
diff options
context:
space:
mode:
authorClaude <noreply@anthropic.com>2026-02-17 17:00:13 +0000
committerClaude <noreply@anthropic.com>2026-02-17 17:00:13 +0000
commit8ac775d7ce97e31a9531572f6f116dfc8de25d35 (patch)
treec429272b7cade26edd9f7a44ac02a9ff432b9b7a /frontend-vanilla/src/main.ts
parent90177f1645bf886a2e4f84f4db287ba379f01773 (diff)
downloadneko-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.ts50
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;
}
}