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:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user