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>