aboutsummaryrefslogtreecommitdiffstats
path: root/frontend-vanilla
diff options
context:
space:
mode:
Diffstat (limited to 'frontend-vanilla')
-rw-r--r--frontend-vanilla/src/main.ts50
-rw-r--r--frontend-vanilla/src/regression.test.ts83
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'),
- );
});
});