From 45793779dffd67800a7af09026bcc26633754022 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 1 May 2026 22:30:24 -0400 Subject: [PATCH] feat(web): infinite scroll on /library/artists and /library/albums MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the explicit 'Load more' button on both library list pages with an IntersectionObserver-based sentinel that triggers fetchNextPage automatically as the operator scrolls near the bottom (300px rootMargin). New InfiniteScrollSentinel component: - Stays disabled while a fetch is in flight (prevents repeat firing on tall pages or fast scrolls). - Tears down its observer when hasNextPage flips false. - Defensive against environments without IntersectionObserver (jsdom); tests provide a synchronous mock. Also fixes a pre-existing test setup gap on the albums page test: AlbumCard renders LikeButton which calls useQueryClient(), and the page.test.ts wasn't wrapped in a QueryClientProvider — added the same useQueryClient mock the AlbumCard.test.ts already uses. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../components/InfiniteScrollSentinel.svelte | 35 +++++++++ .../components/InfiniteScrollSentinel.test.ts | 78 +++++++++++++++++++ web/src/routes/library/albums/+page.svelte | 16 ++-- web/src/routes/library/albums/page.test.ts | 7 ++ web/src/routes/library/artists/+page.svelte | 16 ++-- 5 files changed, 136 insertions(+), 16 deletions(-) create mode 100644 web/src/lib/components/InfiniteScrollSentinel.svelte create mode 100644 web/src/lib/components/InfiniteScrollSentinel.test.ts diff --git a/web/src/lib/components/InfiniteScrollSentinel.svelte b/web/src/lib/components/InfiniteScrollSentinel.svelte new file mode 100644 index 00000000..8423d21c --- /dev/null +++ b/web/src/lib/components/InfiniteScrollSentinel.svelte @@ -0,0 +1,35 @@ + + + diff --git a/web/src/lib/components/InfiniteScrollSentinel.test.ts b/web/src/lib/components/InfiniteScrollSentinel.test.ts new file mode 100644 index 00000000..53234f54 --- /dev/null +++ b/web/src/lib/components/InfiniteScrollSentinel.test.ts @@ -0,0 +1,78 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { render } from '@testing-library/svelte'; +import InfiniteScrollSentinel from './InfiniteScrollSentinel.svelte'; + +// jsdom doesn't ship IntersectionObserver. We install a minimal mock that +// captures the registered callback so tests can drive intersection events +// synchronously. +type IOInstance = { + observe: ReturnType; + disconnect: ReturnType; + fire: (isIntersecting: boolean) => void; +}; +const created: IOInstance[] = []; + +beforeEach(() => { + created.length = 0; + (globalThis as unknown as { IntersectionObserver: unknown }).IntersectionObserver = + class MockIO { + private cb: IntersectionObserverCallback; + observe = vi.fn(); + disconnect = vi.fn(); + constructor(cb: IntersectionObserverCallback) { + this.cb = cb; + created.push({ + observe: this.observe, + disconnect: this.disconnect, + fire: (isIntersecting: boolean) => { + this.cb( + [{ isIntersecting } as unknown as IntersectionObserverEntry], + this as unknown as IntersectionObserver + ); + } + }); + } + }; +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe('InfiniteScrollSentinel', () => { + test('observes the sentinel when enabled', () => { + const onIntersect = vi.fn(); + render(InfiniteScrollSentinel, { props: { onIntersect, enabled: true } }); + expect(created).toHaveLength(1); + expect(created[0].observe).toHaveBeenCalledTimes(1); + }); + + test('does not create an observer when enabled is false', () => { + const onIntersect = vi.fn(); + render(InfiniteScrollSentinel, { props: { onIntersect, enabled: false } }); + expect(created).toHaveLength(0); + }); + + test('fires onIntersect when the sentinel enters view', () => { + const onIntersect = vi.fn(); + render(InfiniteScrollSentinel, { props: { onIntersect, enabled: true } }); + created[0].fire(true); + expect(onIntersect).toHaveBeenCalledTimes(1); + }); + + test('does not fire onIntersect when the entry is not intersecting', () => { + const onIntersect = vi.fn(); + render(InfiniteScrollSentinel, { props: { onIntersect, enabled: true } }); + created[0].fire(false); + expect(onIntersect).not.toHaveBeenCalled(); + }); + + test('disconnects the observer when the component unmounts', () => { + const onIntersect = vi.fn(); + const { unmount } = render(InfiniteScrollSentinel, { + props: { onIntersect, enabled: true } + }); + unmount(); + expect(created[0].disconnect).toHaveBeenCalledTimes(1); + }); +}); diff --git a/web/src/routes/library/albums/+page.svelte b/web/src/routes/library/albums/+page.svelte index a8da430b..6a142009 100644 --- a/web/src/routes/library/albums/+page.svelte +++ b/web/src/routes/library/albums/+page.svelte @@ -3,6 +3,7 @@ import AlbumCard from '$lib/components/AlbumCard.svelte'; import AlphabeticalGrid from '$lib/components/AlphabeticalGrid.svelte'; import ApiErrorBanner from '$lib/components/ApiErrorBanner.svelte'; + import InfiniteScrollSentinel from '$lib/components/InfiniteScrollSentinel.svelte'; import { useDelayed } from '$lib/utils/useDelayed.svelte'; import type { AlbumRef } from '$lib/api/types'; @@ -40,14 +41,13 @@ {#if query.hasNextPage} - + query.fetchNextPage()} + /> + {#if query.isFetchingNextPage} +

Loading more…

+ {/if} {:else if albums.length > 0}

End of library

{/if} diff --git a/web/src/routes/library/albums/page.test.ts b/web/src/routes/library/albums/page.test.ts index 437cac0f..52f7394a 100644 --- a/web/src/routes/library/albums/page.test.ts +++ b/web/src/routes/library/albums/page.test.ts @@ -37,6 +37,13 @@ vi.mock('$lib/api/likes', () => ({ likeEntity: vi.fn(), unlikeEntity: vi.fn() })); +// AlbumCard renders LikeButton, which calls useQueryClient() to get the +// cache for invalidation. The page test isn't wrapped in a QueryClientProvider, +// so we stub useQueryClient with a noop client. +vi.mock('@tanstack/svelte-query', async (orig) => { + const actual = (await orig()) as Record; + return { ...actual, useQueryClient: () => ({}) }; +}); import Page from './+page.svelte'; diff --git a/web/src/routes/library/artists/+page.svelte b/web/src/routes/library/artists/+page.svelte index a388c7eb..65daf323 100644 --- a/web/src/routes/library/artists/+page.svelte +++ b/web/src/routes/library/artists/+page.svelte @@ -3,6 +3,7 @@ import ArtistCard from '$lib/components/ArtistCard.svelte'; import AlphabeticalGrid from '$lib/components/AlphabeticalGrid.svelte'; import ApiErrorBanner from '$lib/components/ApiErrorBanner.svelte'; + import InfiniteScrollSentinel from '$lib/components/InfiniteScrollSentinel.svelte'; import { useDelayed } from '$lib/utils/useDelayed.svelte'; import type { ArtistRef } from '$lib/api/types'; @@ -40,14 +41,13 @@ {#if query.hasNextPage} - + query.fetchNextPage()} + /> + {#if query.isFetchingNextPage} +

Loading more…

+ {/if} {:else if artists.length > 0}

End of library

{/if}