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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user