aboutsummaryrefslogtreecommitdiffstats
path: root/frontend-vanilla/src
diff options
context:
space:
mode:
authorClaude <noreply@anthropic.com>2026-02-17 16:35:56 +0000
committerClaude <noreply@anthropic.com>2026-02-17 16:35:56 +0000
commit90177f1645bf886a2e4f84f4db287ba379f01773 (patch)
tree3f19efc1c79acd3143730b9d677fc2d921e22644 /frontend-vanilla/src
parent7f0b9ae0f53f26304d26a8d45191f268821425c8 (diff)
downloadneko-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/src')
-rw-r--r--frontend-vanilla/src/main.ts6
-rw-r--r--frontend-vanilla/src/regression.test.ts95
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'),
+ );
+ });
+});