diff --git a/web/src/hooks/queries/catalogRead.ts b/web/src/hooks/queries/catalogRead.ts index 4500d481..50c030db 100644 --- a/web/src/hooks/queries/catalogRead.ts +++ b/web/src/hooks/queries/catalogRead.ts @@ -1,4 +1,5 @@ -import { keepPreviousData, useQuery } from "@tanstack/react-query"; +import { keepPreviousData, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useCallback } from "react"; import { api } from "@/api/client"; import type { @@ -104,6 +105,28 @@ export function useCatalogItemEpisodes(id: string | undefined, libraryId?: numbe }); } +/** + * Returns a callback that warms the cache for a season detail page (item + * detail + episodes), so navigating there from a series page renders without + * a request waterfall. Prefetches are no-ops while the cached data is fresh. + */ +export function usePrefetchCatalogSeason(libraryId?: number) { + const queryClient = useQueryClient(); + return useCallback( + (seasonId: string) => { + void queryClient.prefetchQuery({ + queryKey: catalogKeys.itemDetail(seasonId, libraryId), + queryFn: () => fetchCatalogItemDetail(seasonId, libraryId), + }); + void queryClient.prefetchQuery({ + queryKey: catalogKeys.itemEpisodes(seasonId, libraryId), + queryFn: () => fetchCatalogItemEpisodes(seasonId, libraryId), + }); + }, + [queryClient, libraryId], + ); +} + export function useCatalogSeriesSeasons(seriesId: string | undefined, libraryId?: number) { return useQuery({ queryKey: catalogKeys.seriesSeasons(seriesId!, libraryId), diff --git a/web/src/pages/ItemDetail/SeasonCarousel.test.tsx b/web/src/pages/ItemDetail/SeasonCarousel.test.tsx index 7f6e8ce1..8a8c29cc 100644 --- a/web/src/pages/ItemDetail/SeasonCarousel.test.tsx +++ b/web/src/pages/ItemDetail/SeasonCarousel.test.tsx @@ -1,3 +1,4 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { renderToStaticMarkup } from "react-dom/server"; import { MemoryRouter } from "react-router"; import { describe, expect, it } from "vitest"; @@ -22,14 +23,16 @@ function makeSeason(overrides: Partial = {}): Season { describe("SeasonCarousel", () => { it("renders an Embla viewport and container for the season cards", () => { const markup = renderToStaticMarkup( - - - , + + + + + , ); expect(markup).toContain("embla__viewport"); diff --git a/web/src/pages/ItemDetail/SeasonCarousel.tsx b/web/src/pages/ItemDetail/SeasonCarousel.tsx index ccc8af0f..bb8c6e47 100644 --- a/web/src/pages/ItemDetail/SeasonCarousel.tsx +++ b/web/src/pages/ItemDetail/SeasonCarousel.tsx @@ -1,6 +1,7 @@ import { Link } from "react-router"; import { Check, ChevronLeft, ChevronRight } from "lucide-react"; import type { Season } from "@/api/types"; +import { usePrefetchCatalogSeason } from "@/hooks/queries/catalogRead"; import { useCarouselEmbla } from "@/hooks/useCarouselEmbla"; import { formatSeasonMeta, getSeasonDisplayTitle } from "./itemDetailLayout"; @@ -11,6 +12,7 @@ interface SeasonCarouselProps { export default function SeasonCarousel({ seasons }: SeasonCarouselProps) { const sorted = seasons.slice().sort((a, b) => a.season_number - b.season_number); const { emblaRef, canScrollPrev, canScrollNext, scrollPrev, scrollNext } = useCarouselEmbla(); + const prefetchSeason = usePrefetchCatalogSeason(); if (sorted.length === 0) { return null; @@ -54,6 +56,9 @@ export default function SeasonCarousel({ seasons }: SeasonCarouselProps) { prefetchSeason(season.content_id)} + onFocus={() => prefetchSeason(season.content_id)} + onTouchStart={() => prefetchSeason(season.content_id)} > {/* Poster */}