feat(web): infinite scroll on /library/artists and /library/albums

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) <noreply@anthropic.com>
This commit is contained in:
2026-05-01 22:30:24 -04:00
parent 22ac86f200
commit 45793779df
5 changed files with 136 additions and 16 deletions
+8 -8
View File
@@ -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 @@
</AlphabeticalGrid>
{#if query.hasNextPage}
<button
type="button"
class="w-full rounded bg-surface py-2 text-sm hover:bg-surface-hover disabled:opacity-60"
disabled={query.isFetchingNextPage}
onclick={() => query.fetchNextPage()}
>
{query.isFetchingNextPage ? 'Loading…' : 'Load more'}
</button>
<InfiniteScrollSentinel
enabled={!query.isFetchingNextPage}
onIntersect={() => query.fetchNextPage()}
/>
{#if query.isFetchingNextPage}
<p class="py-2 text-center text-sm text-text-secondary">Loading more…</p>
{/if}
{:else if albums.length > 0}
<p class="py-2 text-center text-sm text-text-secondary">End of library</p>
{/if}
@@ -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<string, unknown>;
return { ...actual, useQueryClient: () => ({}) };
});
import Page from './+page.svelte';
+8 -8
View File
@@ -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 @@
</AlphabeticalGrid>
{#if query.hasNextPage}
<button
type="button"
class="w-full rounded bg-surface py-2 text-sm hover:bg-surface-hover disabled:opacity-60"
disabled={query.isFetchingNextPage}
onclick={() => query.fetchNextPage()}
>
{query.isFetchingNextPage ? 'Loading…' : 'Load more'}
</button>
<InfiniteScrollSentinel
enabled={!query.isFetchingNextPage}
onIntersect={() => query.fetchNextPage()}
/>
{#if query.isFetchingNextPage}
<p class="py-2 text-center text-sm text-text-secondary">Loading more…</p>
{/if}
{:else if artists.length > 0}
<p class="py-2 text-center text-sm text-text-secondary">End of library</p>
{/if}