diff --git a/CHANGELOG.md b/CHANGELOG.md index 95f2dba..ce9843d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ easy to spot when bumping. ## [Unreleased] +### Fixed + +- **genre-tree**: `useListFullGenrePlaylists` rejects with a clear `page N returned no body` error when a page comes back empty (e.g. a `handleError` callback swallowed the failure) instead of crashing with a `TypeError`. + ## [8.4.1] - 2026-10-02 ### Changed diff --git a/packages/app-kit/src/genre-tree/useGenrePlaylist.test.ts b/packages/app-kit/src/genre-tree/useGenrePlaylist.test.ts index bc8fdd9..6c7fa60 100644 --- a/packages/app-kit/src/genre-tree/useGenrePlaylist.test.ts +++ b/packages/app-kit/src/genre-tree/useGenrePlaylist.test.ts @@ -185,6 +185,52 @@ describe("useGenrePlaylist", () => { expect(result.overallTotal).toBe(250); }); + it("returns empty results with a single request when totalPages is 0", async () => { + fetchMock.mockResolvedValueOnce({ + overallTotal: 0, + next: null, + previous: null, + results: [], + page: 1, + pageSize: 100, + totalPages: 0, + }); + renderHook(() => useListFullGenrePlaylists("reference", getBackendBaseUrl)); + const { queryFn } = useQueryWithParseMock.mock.calls[0][0]; + + const result = await queryFn(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(result.results).toEqual([]); + }); + + it("rejects with the page number when a later page returns no body", async () => { + fetchMock + .mockResolvedValueOnce({ + overallTotal: 150, + next: "https://backend.example.com/genre-playlists/?page=2", + previous: null, + results: Array.from({ length: 100 }, (_, i) => ({ uuid: `gp${i}` })), + page: 1, + pageSize: 100, + totalPages: 2, + }) + .mockResolvedValueOnce(null); + renderHook(() => useListFullGenrePlaylists("reference", getBackendBaseUrl)); + const { queryFn } = useQueryWithParseMock.mock.calls[0][0]; + + await expect(queryFn()).rejects.toThrow("page 2 returned no body"); + }); + + it("rejects when the first page returns no body", async () => { + fetchMock.mockResolvedValueOnce(null); + renderHook(() => useListFullGenrePlaylists("reference", getBackendBaseUrl)); + const { queryFn } = useQueryWithParseMock.mock.calls[0][0]; + + await expect(queryFn()).rejects.toThrow("page 1 returned no body"); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + it("invalidateFullGenrePlaylists invalidates the scoped full query key", () => { const { result } = renderHook(() => useListFullGenrePlaylists("reference", getBackendBaseUrl)); diff --git a/packages/app-kit/src/genre-tree/useGenrePlaylist.ts b/packages/app-kit/src/genre-tree/useGenrePlaylist.ts index 07bee1a..d22cd69 100644 --- a/packages/app-kit/src/genre-tree/useGenrePlaylist.ts +++ b/packages/app-kit/src/genre-tree/useGenrePlaylist.ts @@ -32,10 +32,16 @@ type RawPaginatedResponse = { */ // ponytail: unbounded Promise.all; browsers cap per-host connections, add a limiter if pages reach the hundreds. const fetchAllPages = async (fetchPage: (page: number) => Promise): Promise => { - const first = (await fetchPage(1)) as RawPaginatedResponse; - const rest = (await Promise.all( - Array.from({ length: Math.max(first.totalPages - 1, 0) }, (_, i) => fetchPage(i + 2)), - )) as RawPaginatedResponse[]; + // fetch-wrapper resolves null (instead of throwing) when a handleError callback swallowed the failure. + const fetchRequiredPage = async (page: number) => { + const response = await fetchPage(page); + if (response == null) throw new Error(`Paginated list page ${page} returned no body`); + return response as RawPaginatedResponse; + }; + const first = await fetchRequiredPage(1); + const rest = await Promise.all( + Array.from({ length: Math.max(first.totalPages - 1, 0) }, (_, i) => fetchRequiredPage(i + 2)), + ); const results = [first, ...rest].flatMap((response) => response.results); const last = rest[rest.length - 1] ?? first;