diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c17e45..bbd1c25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ easy to spot when bumping. ## [Unreleased] +### Changed + +- **genre-tree**: `useListFullGenrePlaylists` requests `pageSize=100` (grow-api's max) and fetches pages 2..`totalPages` in parallel instead of one after another, so the full canonical list loads in two round-trips instead of one per page. + ## [8.4.0] - 2026-10-02 ### Added diff --git a/packages/app-kit/src/genre-tree/useGenrePlaylist.test.ts b/packages/app-kit/src/genre-tree/useGenrePlaylist.test.ts index 1f9c3af..bc8fdd9 100644 --- a/packages/app-kit/src/genre-tree/useGenrePlaylist.test.ts +++ b/packages/app-kit/src/genre-tree/useGenrePlaylist.test.ts @@ -82,7 +82,7 @@ describe("useGenrePlaylist", () => { previous: null, results: [{ uuid: "gp1" }], page: 1, - pageSize: 1000, + pageSize: 100, totalPages: 1, }); renderHook(() => useListFullGenrePlaylists("reference", getBackendBaseUrl)); @@ -92,7 +92,7 @@ describe("useGenrePlaylist", () => { expect(enabled).toBe(true); await queryFn(); - expect(fetchMock).toHaveBeenCalledWith("genre-playlists/", true, false, {}, { page: 1, pageSize: 1000, treeName: "canonical" }); + expect(fetchMock).toHaveBeenCalledWith("genre-playlists/", true, false, {}, { page: 1, pageSize: 100, treeName: "canonical" }); }); it("queries the me full endpoint and gates on a restored session with a token", async () => { @@ -102,7 +102,7 @@ describe("useGenrePlaylist", () => { previous: null, results: [{ uuid: "gp1" }], page: 1, - pageSize: 1000, + pageSize: 100, totalPages: 1, }); renderHook(() => useListFullGenrePlaylists("me", getBackendBaseUrl)); @@ -112,7 +112,7 @@ describe("useGenrePlaylist", () => { expect(enabled).toBe(true); await queryFn(); - expect(fetchMock).toHaveBeenCalledWith("me/genre-playlists/", true, true, {}, { page: 1, pageSize: 1000, treeName: "canonical" }); + expect(fetchMock).toHaveBeenCalledWith("me/genre-playlists/", true, true, {}, { page: 1, pageSize: 100, treeName: "canonical" }); }); it("disables the me query until the session is restored", () => { @@ -122,7 +122,26 @@ describe("useGenrePlaylist", () => { expect(useQueryWithParseMock.mock.calls[0][0].enabled).toBe(false); }); - it("follows `next` and merges results when the backend clamps pageSize below overallTotal", async () => { + it("makes a single request when the first page is the only page", async () => { + fetchMock.mockResolvedValueOnce({ + overallTotal: 1, + next: null, + previous: null, + results: [{ uuid: "gp1" }], + page: 1, + pageSize: 100, + totalPages: 1, + }); + renderHook(() => useListFullGenrePlaylists("reference", getBackendBaseUrl)); + const { queryFn } = useQueryWithParseMock.mock.calls[0][0]; + + const result = await queryFn(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(result.results).toEqual([{ uuid: "gp1" }]); + }); + + it("fetches every page reported by totalPages and merges results in page order", async () => { fetchMock .mockResolvedValueOnce({ overallTotal: 250, @@ -157,10 +176,12 @@ describe("useGenrePlaylist", () => { const result = await queryFn(); expect(fetchMock).toHaveBeenCalledTimes(3); - expect(fetchMock).toHaveBeenNthCalledWith(1, "genre-playlists/", true, false, {}, { page: 1, pageSize: 1000, treeName: "canonical" }); - expect(fetchMock).toHaveBeenNthCalledWith(2, "genre-playlists/", true, false, {}, { page: 2, pageSize: 1000, treeName: "canonical" }); - expect(fetchMock).toHaveBeenNthCalledWith(3, "genre-playlists/", true, false, {}, { page: 3, pageSize: 1000, treeName: "canonical" }); - expect(result.results).toHaveLength(250); + expect(fetchMock).toHaveBeenNthCalledWith(1, "genre-playlists/", true, false, {}, { page: 1, pageSize: 100, treeName: "canonical" }); + expect(fetchMock).toHaveBeenNthCalledWith(2, "genre-playlists/", true, false, {}, { page: 2, pageSize: 100, treeName: "canonical" }); + expect(fetchMock).toHaveBeenNthCalledWith(3, "genre-playlists/", true, false, {}, { page: 3, pageSize: 100, treeName: "canonical" }); + expect(result.results.map((gp: { uuid: string }) => gp.uuid)).toEqual( + Array.from({ length: 250 }, (_, i) => `gp${i}`), + ); expect(result.overallTotal).toBe(250); }); diff --git a/packages/app-kit/src/genre-tree/useGenrePlaylist.ts b/packages/app-kit/src/genre-tree/useGenrePlaylist.ts index 48b61b0..07bee1a 100644 --- a/packages/app-kit/src/genre-tree/useGenrePlaylist.ts +++ b/packages/app-kit/src/genre-tree/useGenrePlaylist.ts @@ -12,7 +12,8 @@ import { PaginatedResponseSchema } from "../transport/lib/paginated-response"; import { genrePlaylistEndpoints, genrePlaylistQueryKeys } from "./api/genre-playlists"; import { Scope } from "../transport/lib/scope"; -const FULL_LIST_PAGE_SIZE = 1000; +// grow-api clamps pageSize to PAGINATION_PAGE_SIZE_MAX (100); asking for more only hides the real page count. +const FULL_LIST_PAGE_SIZE = 100; type RawPaginatedResponse = { overallTotal: number; @@ -26,22 +27,19 @@ type RawPaginatedResponse = { /** * Backends may clamp `pageSize` below what's requested (e.g. a server-side max page size), so a - * single request can silently return fewer results than `overallTotal`. Follows `next` until every - * result has been collected, so callers that need "the whole list" actually get it regardless of - * the effective page size the backend applies. + * single request can silently return fewer results than `overallTotal`. Reads `totalPages` from the + * first page, then fetches the rest in parallel so load time doesn't scale with the page count. */ +// 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 => { - let page = 1; - let response = (await fetchPage(page)) as RawPaginatedResponse; - const results = [...response.results]; - - while (results.length < response.overallTotal && response.next) { - page += 1; - response = (await fetchPage(page)) as RawPaginatedResponse; - results.push(...response.results); - } - - return { ...response, results, page: 1, pageSize: results.length, totalPages: 1 }; + 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[]; + const results = [first, ...rest].flatMap((response) => response.results); + const last = rest[rest.length - 1] ?? first; + + return { ...last, results, page: 1, pageSize: results.length, totalPages: 1 }; }; export const useListGenrePlaylists = (page = 1, pageSize: number | string = 50, getBackendBaseUrl: () => string) => {