perf(web): prefetch season detail and episodes from series page

Season cards now warm the react-query cache for the season's item detail
and episodes on hover/focus/touch, so navigating from a series page to a
season renders the episode grid without a request waterfall.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Quick
2026-06-10 16:06:30 -04:00
co-authored by Claude Fable 5
parent e64b130bd3
commit 52dd5dff7f
3 changed files with 40 additions and 9 deletions
+24 -1
View File
@@ -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),
@@ -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> = {}): Season {
describe("SeasonCarousel", () => {
it("renders an Embla viewport and container for the season cards", () => {
const markup = renderToStaticMarkup(
<MemoryRouter initialEntries={["/item/series-1"]}>
<SeasonCarousel
seasons={[
makeSeason(),
makeSeason({ content_id: "season-2", season_number: 2, title: "Season 2" }),
]}
/>
</MemoryRouter>,
<QueryClientProvider client={new QueryClient()}>
<MemoryRouter initialEntries={["/item/series-1"]}>
<SeasonCarousel
seasons={[
makeSeason(),
makeSeason({ content_id: "season-2", season_number: 2, title: "Season 2" }),
]}
/>
</MemoryRouter>
</QueryClientProvider>,
);
expect(markup).toContain("embla__viewport");
@@ -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) {
<Link
to={`/item/${season.content_id}`}
className="group/season block w-[160px] sm:w-[170px]"
onMouseEnter={() => prefetchSeason(season.content_id)}
onFocus={() => prefetchSeason(season.content_id)}
onTouchStart={() => prefetchSeason(season.content_id)}
>
{/* Poster */}
<div className="media-card-image relative aspect-[2/3] overflow-hidden rounded-xl">