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
@@ -0,0 +1,35 @@
<script lang="ts">
// Renders an invisible sentinel element near the bottom of a paged list.
// When the sentinel enters the viewport (within `rootMargin`), onIntersect
// fires — typically calling TanStack Query's fetchNextPage. The caller is
// responsible for setting `enabled = hasNextPage && !isFetchingNextPage`
// so the observer stays disconnected while a fetch is in flight; otherwise
// a tall list could fire onIntersect repeatedly during a single scroll.
let {
onIntersect,
enabled = true,
rootMargin = '300px'
}: {
onIntersect: () => void;
enabled?: boolean;
rootMargin?: string;
} = $props();
let sentinel: HTMLElement | undefined = $state();
$effect(() => {
if (!enabled || !sentinel) return;
if (typeof IntersectionObserver === 'undefined') return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting) onIntersect();
},
{ rootMargin }
);
observer.observe(sentinel);
return () => observer.disconnect();
});
</script>
<div bind:this={sentinel} aria-hidden="true" class="h-px"></div>
@@ -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<typeof vi.fn>;
disconnect: ReturnType<typeof vi.fn>;
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);
});
});
+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}