diff options
| author | Adam Mathes <adam@adammathes.com> | 2026-02-14 10:44:18 -0800 |
|---|---|---|
| committer | Adam Mathes <adam@adammathes.com> | 2026-02-14 10:44:18 -0800 |
| commit | 419d84d2a8e84d028b145df57d1584a3fe163b37 (patch) | |
| tree | 5fc890921db2799a977a918d3a7f9e6c14775ed8 | |
| parent | 00512c639841dac9ca8d1cff5c2532ce7584eb15 (diff) | |
| download | neko-419d84d2a8e84d028b145df57d1584a3fe163b37.tar.gz neko-419d84d2a8e84d028b145df57d1584a3fe163b37.tar.bz2 neko-419d84d2a8e84d028b145df57d1584a3fe163b37.zip | |
fix: make infinite scroll less aggressive by using threshold 1.0 for sentinel observer
| -rw-r--r-- | frontend/src/components/FeedItems.test.tsx | 33 | ||||
| -rw-r--r-- | frontend/src/components/FeedItems.tsx | 32 |
2 files changed, 42 insertions, 23 deletions
diff --git a/frontend/src/components/FeedItems.test.tsx b/frontend/src/components/FeedItems.test.tsx index ca0dc98..6ffd026 100644 --- a/frontend/src/components/FeedItems.test.tsx +++ b/frontend/src/components/FeedItems.test.tsx @@ -137,13 +137,13 @@ describe('FeedItems Component', () => { json: async () => mockItems, }); - // Capture the callback - let observerCallback: IntersectionObserverCallback = () => { }; + // Capture both callbacks + const observerCallbacks: IntersectionObserverCallback[] = []; - // Override the mock to capture callback + // Override the mock to capture both callbacks class MockIntersectionObserver { constructor(callback: IntersectionObserverCallback) { - observerCallback = callback; + observerCallbacks.push(callback); } observe = vi.fn(); unobserve = vi.fn(); @@ -161,6 +161,11 @@ describe('FeedItems Component', () => { expect(screen.getByText('Item 1')).toBeVisible(); }); + // Wait for observers to be created + await waitFor(() => { + expect(observerCallbacks.length).toBeGreaterThan(0); + }); + // Simulate item leaving viewport at the top // Element index is 0 const entry = { @@ -173,11 +178,10 @@ describe('FeedItems Component', () => { intersectionRect: {} as DOMRectReadOnly, } as IntersectionObserverEntry; - // Use vi.waitUntil to wait for callback to be assigned if needed, - // though strictly synchronous render + effect should do it. - // Direct call: + // Call the last itemObserver (second-to-last in the array, since sentinelObserver is last) act(() => { - observerCallback([entry], {} as IntersectionObserver); + const lastItemObserver = observerCallbacks[observerCallbacks.length - 2]; + lastItemObserver([entry], {} as IntersectionObserver); }); await waitFor(() => { @@ -199,10 +203,10 @@ describe('FeedItems Component', () => { .mockResolvedValueOnce({ ok: true, json: async () => initialItems }) .mockResolvedValueOnce({ ok: true, json: async () => moreItems }); - let observerCallback: IntersectionObserverCallback = () => { }; + const observerCallbacks: IntersectionObserverCallback[] = []; class MockIntersectionObserver { constructor(callback: IntersectionObserverCallback) { - observerCallback = callback; + observerCallbacks.push(callback); } observe = vi.fn(); unobserve = vi.fn(); @@ -220,6 +224,11 @@ describe('FeedItems Component', () => { expect(screen.getByText('Item 1')).toBeInTheDocument(); }); + // Wait for observers to be created (effect runs multiple times) + await waitFor(() => { + expect(observerCallbacks.length).toBeGreaterThan(0); + }); + // Simulate sentinel becoming visible const entry = { isIntersecting: true, @@ -231,8 +240,10 @@ describe('FeedItems Component', () => { intersectionRect: {} as DOMRectReadOnly, } as IntersectionObserverEntry; + // Call the last sentinelObserver (second of the last pair created) act(() => { - observerCallback([entry], {} as IntersectionObserver); + const lastSentinelObserver = observerCallbacks[observerCallbacks.length - 1]; + lastSentinelObserver([entry], {} as IntersectionObserver); }); await waitFor(() => { diff --git a/frontend/src/components/FeedItems.tsx b/frontend/src/components/FeedItems.tsx index b497e9d..a058b70 100644 --- a/frontend/src/components/FeedItems.tsx +++ b/frontend/src/components/FeedItems.tsx @@ -164,17 +164,10 @@ export default function FeedItems() { useEffect(() => { - const observer = new IntersectionObserver( + // Observer for marking items as read + const itemObserver = new IntersectionObserver( (entries) => { entries.forEach((entry) => { - // Infinity scroll sentinel - if (entry.target.id === 'load-more-sentinel') { - if (entry.isIntersecting && !loadingMore && hasMore && items.length > 0) { - fetchItems(String(items[items.length - 1]._id)); - } - return; - } - // If item is not intersecting and is above the viewport, it's been scrolled past if (!entry.isIntersecting && entry.boundingClientRect.top < 0) { const index = Number(entry.target.getAttribute('data-index')); @@ -190,15 +183,30 @@ export default function FeedItems() { { root: null, threshold: 0 } ); + // Observer for infinite scroll (less aggressive, must be fully visible) + const sentinelObserver = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting && !loadingMore && hasMore && items.length > 0) { + fetchItems(String(items[items.length - 1]._id)); + } + }); + }, + { root: null, threshold: 1.0 } + ); + items.forEach((_, index) => { const el = document.getElementById(`item-${index}`); - if (el) observer.observe(el); + if (el) itemObserver.observe(el); }); const sentinel = document.getElementById('load-more-sentinel'); - if (sentinel) observer.observe(sentinel); + if (sentinel) sentinelObserver.observe(sentinel); - return () => observer.disconnect(); + return () => { + itemObserver.disconnect(); + sentinelObserver.disconnect(); + }; }, [items, loadingMore, hasMore]); if (loading) return <div className="feed-items-loading">Loading items...</div>; |
