diff --git a/web/src/routes/admin/suspect-sources/+page.svelte b/web/src/routes/admin/suspect-sources/+page.svelte
index 1e143649..dc57a7df 100644
--- a/web/src/routes/admin/suspect-sources/+page.svelte
+++ b/web/src/routes/admin/suspect-sources/+page.svelte
@@ -4,7 +4,7 @@
import { createSuspectSourcesQuery } from '#lib/api/admin.js';
import { coverUrl } from '#lib/media/covers.js';
import { formatDuration } from '#lib/media/duration.js';
- import InfiniteScrollSentinel from '#lib/components/InfiniteScrollSentinel.svelte';
+ import ListContinuation from '#lib/components/ListContinuation.svelte';
import type { AdminSuspectGroup, AdminSuspectTrack } from '#lib/api/types.js';
// Files whose names say they were ripped from a video (#5410): "(Official
@@ -77,7 +77,7 @@
{#if query.isPending}
Reading file names…
- {:else if query.isError}
+ {:else if query.isError && !query.isFetchNextPageError}
diff --git a/web/src/routes/library/genres/+page.svelte b/web/src/routes/library/genres/+page.svelte
index 3a4ccfdf..e8adffb1 100644
--- a/web/src/routes/library/genres/+page.svelte
+++ b/web/src/routes/library/genres/+page.svelte
@@ -11,6 +11,7 @@
import AlbumCard from '#lib/components/AlbumCard.svelte';
import ApiErrorBanner from '#lib/components/ApiErrorBanner.svelte';
import EmptyState from '#lib/components/EmptyState.svelte';
+ import ListContinuation from '#lib/components/ListContinuation.svelte';
import QuickFilter from '#lib/components/QuickFilter.svelte';
import type { AlbumRef } from '#lib/api/types.js';
@@ -59,6 +60,9 @@
let total = $state(0);
let loading = $state(false);
let failed = $state(false);
+ // A later page failed: the albums already shown stay, and the list offers a
+ // retry at its end instead of loading again on every scroll.
+ let moreFailed = $state(false);
// Plain `let`, deliberately not $state: it's read inside the fetch path, and
// as reactive state that read would make this effect depend on its own
@@ -76,6 +80,7 @@
albums = [];
total = 0;
failed = false;
+ moreFailed = false;
if (!genre) return;
await fetchPage(genre, 0, requestToken);
}
@@ -88,13 +93,18 @@
albums = offset === 0 ? p.items : [...albums, ...p.items];
total = p.total;
} catch {
- if (token === requestToken) failed = true;
+ if (token === requestToken) {
+ if (offset === 0) failed = true;
+ else moreFailed = true;
+ }
} finally {
if (token === requestToken) loading = false;
}
}
function loadMore() {
+ if (loading) return;
+ moreFailed = false;
void fetchPage(selected, albums.length, requestToken);
}
@@ -154,21 +164,13 @@
{/each}
- {#if albums.length < total}
-
-
-
- {:else}
-
End of genre
- {/if}
+
{/if}
{:else}
diff --git a/web/src/routes/library/genres/genres.test.ts b/web/src/routes/library/genres/genres.test.ts
index f24be753..5e3b81ac 100644
--- a/web/src/routes/library/genres/genres.test.ts
+++ b/web/src/routes/library/genres/genres.test.ts
@@ -1,6 +1,7 @@
-import { afterEach, describe, expect, test, vi } from 'vitest';
+import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { render, screen, waitFor, fireEvent } from '@testing-library/svelte';
import { mockQuery } from '#test-utils/query.js';
+import { installIntersectionObserverMock } from '#test-utils/intersectionObserver.js';
import { pageUrlModule } from '#test-utils/mocks/appState.js';
import { apiClientMock } from '#test-utils/mocks/client.js';
import { emptyLikesMock } from '#test-utils/mocks/likes.js';
@@ -33,6 +34,11 @@ import { createGenresQuery, listAlbumsByGenre } from '#lib/api/browse.js';
const asMock = (fn: unknown) => fn as ReturnType;
+let io: ReturnType;
+beforeEach(() => {
+ io = installIntersectionObserverMock();
+});
+
function album(id: string, title: string): AlbumRef {
return {
id,
@@ -187,7 +193,7 @@ describe('/library/genres drill-down', () => {
expect(screen.getByRole('heading', { name: 'Rock/Pop' })).toBeInTheDocument();
});
- test('load more appends the next page and then reports the end', async () => {
+ test('scrolling to the end loads the next page, then reports the end', async () => {
pageState.pageUrl = new URL('http://localhost/library/genres?g=Rock');
asMock(createGenresQuery).mockReturnValue(mockQuery({ data: [] }));
asMock(listAlbumsByGenre)
@@ -201,10 +207,13 @@ describe('/library/genres drill-down', () => {
render(GenresPage);
await screen.findByText('One');
- const more = await screen.findByRole('button', { name: /Load more \(1 left\)/ });
- await fireEvent.click(more);
+ // No button (#5416): reaching the bottom of the grid loads the next page.
+ expect(screen.queryByRole('button', { name: /load more/i })).not.toBeInTheDocument();
+ await waitFor(() => {
+ io.scrollToEnd();
+ expect(listAlbumsByGenre).toHaveBeenLastCalledWith('Rock', 2, 2);
+ });
- await waitFor(() => expect(listAlbumsByGenre).toHaveBeenLastCalledWith('Rock', 2, 2));
expect(await screen.findByText('Three')).toBeInTheDocument();
// Earlier pages are appended, not replaced.
expect(screen.getByText('One')).toBeInTheDocument();
@@ -219,4 +228,27 @@ describe('/library/genres drill-down', () => {
expect(await screen.findByText(/Couldn't load albums for this genre/i)).toBeInTheDocument();
});
+
+ // A failed later page keeps what is shown and offers a retry at the end,
+ // rather than replacing the grid or re-requesting on every scroll.
+ test('a failed later page keeps the albums and offers Try again', async () => {
+ pageState.pageUrl = new URL('http://localhost/library/genres?g=Rock');
+ asMock(createGenresQuery).mockReturnValue(mockQuery({ data: [] }));
+ asMock(listAlbumsByGenre)
+ .mockResolvedValueOnce({ items: [album('a1', 'One'), album('a2', 'Two')], total: 3, limit: 2, offset: 0 })
+ .mockRejectedValueOnce(new Error('nope'))
+ .mockResolvedValueOnce({ items: [album('a3', 'Three')], total: 3, limit: 2, offset: 2 });
+ render(GenresPage);
+
+ await screen.findByText('One');
+ await waitFor(() => {
+ io.scrollToEnd();
+ expect(screen.getByRole('alert')).toHaveTextContent("Couldn't load more.");
+ });
+ expect(screen.getByText('One')).toBeInTheDocument();
+ expect(screen.queryByText(/Couldn't load albums for this genre/i)).not.toBeInTheDocument();
+
+ await fireEvent.click(screen.getByRole('button', { name: 'Try again' }));
+ expect(await screen.findByText('Three')).toBeInTheDocument();
+ });
});
diff --git a/web/src/routes/library/history/+page.svelte b/web/src/routes/library/history/+page.svelte
index 44893c19..be509f10 100644
--- a/web/src/routes/library/history/+page.svelte
+++ b/web/src/routes/library/history/+page.svelte
@@ -5,7 +5,7 @@
import { groupByDay } from '#lib/utils/dayGroup.js';
import HistoryRow from '#lib/components/HistoryRow.svelte';
import TrackList from '#lib/components/TrackList.svelte';
- import InfiniteScrollSentinel from '#lib/components/InfiniteScrollSentinel.svelte';
+ import ListContinuation from '#lib/components/ListContinuation.svelte';
import LibrarySkeleton from '#lib/components/LibrarySkeleton.svelte';
import ApiErrorBanner from '#lib/components/ApiErrorBanner.svelte';
import QuickFilter from '#lib/components/QuickFilter.svelte';
@@ -40,7 +40,7 @@
{#if query.isPending}
- {:else if query.isError}
+ {:else if query.isError && !query.isFetchNextPageError}
{:else if flatEvents.length === 0}
{/each}
- {#if query.hasNextPage}
- query.fetchNextPage()}
- />
- {#if query.isFetchingNextPage}
-