Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
39 changes: 30 additions & 9 deletions packages/app-kit/src/genre-tree/useGenrePlaylist.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ describe("useGenrePlaylist", () => {
previous: null,
results: [{ uuid: "gp1" }],
page: 1,
pageSize: 1000,
pageSize: 100,
totalPages: 1,
});
renderHook(() => useListFullGenrePlaylists("reference", getBackendBaseUrl));
Expand All @@ -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 () => {
Expand All @@ -102,7 +102,7 @@ describe("useGenrePlaylist", () => {
previous: null,
results: [{ uuid: "gp1" }],
page: 1,
pageSize: 1000,
pageSize: 100,
totalPages: 1,
});
renderHook(() => useListFullGenrePlaylists("me", getBackendBaseUrl));
Expand All @@ -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", () => {
Expand All @@ -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,
Expand Down Expand Up @@ -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);
});

Expand Down
28 changes: 13 additions & 15 deletions packages/app-kit/src/genre-tree/useGenrePlaylist.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<unknown>): Promise<RawPaginatedResponse> => {
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) => {
Expand Down
Loading