diff options
| author | Claude <noreply@anthropic.com> | 2026-02-17 16:35:56 +0000 |
|---|---|---|
| committer | Claude <noreply@anthropic.com> | 2026-02-17 16:35:56 +0000 |
| commit | 90177f1645bf886a2e4f84f4db287ba379f01773 (patch) | |
| tree | 3f19efc1c79acd3143730b9d677fc2d921e22644 /frontend-vanilla | |
| parent | 7f0b9ae0f53f26304d26a8d45191f268821425c8 (diff) | |
| download | neko-90177f1645bf886a2e4f84f4db287ba379f01773.tar.gz neko-90177f1645bf886a2e4f84f4db287ba379f01773.tar.bz2 neko-90177f1645bf886a2e4f84f4db287ba379f01773.zip | |
fix: store sentinel IntersectionObserver in module-level variable to prevent GC
The load-more sentinel observer was assigned to a local `const observer`
that fell out of scope after renderItems() returned. Without a persistent
JS reference, engines can garbage-collect the observer, silently breaking
infinite scroll (no more items loaded on scroll).
Fix: assign to the existing module-level `itemObserver` variable, which is
already disconnected/replaced at the top of each renderItems() call.
Add three regression tests in regression.test.ts that use a class-based
IntersectionObserver mock to capture the callback and verify:
- sentinel visible → loadMore fires
- sentinel visible while loading → loadMore suppressed
- hasMore=false → no sentinel rendered, no loadMore
https://claude.ai/code/session_01DpWhB9uGGMBnzqS28HxnuV
Diffstat (limited to 'frontend-vanilla')
| -rw-r--r-- | frontend-vanilla/src/main.ts | 6 | ||||
| -rw-r--r-- | frontend-vanilla/src/regression.test.ts | 95 |
2 files changed, 98 insertions, 3 deletions
diff --git a/frontend-vanilla/src/main.ts b/frontend-vanilla/src/main.ts index 5f8056c..bdd0e97 100644 --- a/frontend-vanilla/src/main.ts +++ b/frontend-vanilla/src/main.ts @@ -270,15 +270,15 @@ export function renderItems() { // Use the actual scroll container as IntersectionObserver root const scrollRoot = document.getElementById('main-content'); - // Setup infinite scroll + // 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) { - const observer = new IntersectionObserver((entries) => { + itemObserver = new IntersectionObserver((entries) => { if (entries[0].isIntersecting && !store.loading && store.hasMore) { loadMore(); } }, { root: scrollRoot, threshold: 0.1 }); - observer.observe(sentinel); + itemObserver.observe(sentinel); } // Scroll listener for reading items diff --git a/frontend-vanilla/src/regression.test.ts b/frontend-vanilla/src/regression.test.ts index 8529e20..97c601c 100644 --- a/frontend-vanilla/src/regression.test.ts +++ b/frontend-vanilla/src/regression.test.ts @@ -257,3 +257,98 @@ describe('NK-z1czaq: Sidebar overlays content, does not shift layout', () => { expect(mainContent!.parentElement?.classList.contains('layout')).toBe(true); }); }); + +// 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; + + 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, + json: async () => [], + } as Response); + }); + + it('should call loadMore (apiFetch /api/stream) when sentinel fires isIntersecting=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', + })); + // setItems emits items-updated → renderItems() sets up itemObserver + store.setItems(items as any); + + 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); + + expect(apiFetch).toHaveBeenCalledWith( + expect.stringContaining('/api/stream'), + ); + }); + + 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); // renderItems() called, capturedCallback set + vi.clearAllMocks(); // reset apiFetch call count + + // Directly mutate loading without emitting (avoids another renderItems cycle) + store.loading = true; + + capturedCallback!([{ isIntersecting: true }] as any, null as any); + + expect(apiFetch).not.toHaveBeenCalledWith( + expect.stringContaining('/api/stream'), + ); + }); + + it('should NOT render sentinel (or call loadMore) when hasMore is false', () => { + const items = Array.from({ length: 10 }, (_, i) => ({ + _id: i + 1, + title: `Item ${i + 1}`, + url: `http://example.com/${i + 1}`, + read: false, + publish_date: '2024-01-01', + })); + store.setHasMore(false); + 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'), + ); + }); +}); |
