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}