From 7484e4ef84862530f5f37f431023dc6226031a68 Mon Sep 17 00:00:00 2001 From: mignot Date: Fri, 2 Oct 2026 03:23:50 +0200 Subject: [PATCH 1/2] fix(genre-tree): fail clearly when a full-list page returns no body Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 ++ .../src/genre-tree/useGenrePlaylist.test.ts | 46 +++++++++++++++++++ .../src/genre-tree/useGenrePlaylist.ts | 14 ++++-- 3 files changed, 60 insertions(+), 4 deletions(-) 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; From 62d6f38139ae81a190b3a3ea012d97c3d973f748 Mon Sep 17 00:00:00 2001 From: mignot Date: Fri, 2 Oct 2026 03:26:21 +0200 Subject: [PATCH 2/2] chore: release 8.4.2 --- CHANGELOG.md | 2 ++ packages/app-kit/package.json | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ce9843d..e05e6bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ easy to spot when bumping. ## [Unreleased] +## [8.4.2] - 2026-10-02 + ### 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`. diff --git a/packages/app-kit/package.json b/packages/app-kit/package.json index 1f0aa98..d295b40 100644 --- a/packages/app-kit/package.json +++ b/packages/app-kit/package.json @@ -1,6 +1,6 @@ { "name": "@behindthemusictree/app-kit", - "version": "8.4.1", + "version": "8.4.2", "description": "Shared transport, auth, popup, UI, player, and genre-tree plumbing for BehindTheMusicTree React apps", "repository": { "type": "git",