fix(web): load more titles in Explore rows and show status in Discover

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
jakeandClaude Opus 5.5 committed 2026-10-05 03:23:27 -04:00
1 parent 9aebca7121
commit 7269209d43
6 files changed
+176 -49

No files matched your search

@@ -11,6 +11,7 @@ interface TitleGridItem {
firstAirDate?: string | null;
voteAverage?: number | null;
userStatus?: "in_watchlist" | "watching" | "caught_up" | "completed" | null;
episodeProgress?: { watched: number; total: number } | null;
}
export function TitleGridSectionSkeleton() {
@@ -49,6 +50,7 @@ export function TitleGrid({ items }: { items: TitleGridItem[] }) {
releaseDate={t.releaseDate ?? t.firstAirDate}
voteAverage={t.voteAverage}
userStatus={t.userStatus}
episodeProgress={t.episodeProgress}
/>
</div>
))}
@@ -97,7 +97,25 @@ export function DiscoverSection() {
isFetchingNextPage,
});
const items = useMemo(() => data?.pages.flatMap((p) => p.items) ?? [], [data?.pages]);
const items = useMemo(() => {
const pages = data?.pages ?? [];
const statuses = Object.assign({}, ...pages.map((p) => p.userStatuses)) as Record<
string,
"in_watchlist" | "watching" | "caught_up" | "completed"
>;
const progress = Object.assign({}, ...pages.map((p) => p.episodeProgress)) as Record<
string,
{ watched: number; total: number }
>;
return pages
.flatMap((p) => p.items)
.map((item) =>
Object.assign({}, item, {
userStatus: statuses[item.id] ?? null,
episodeProgress: progress[item.id] ?? null,
}),
);
}, [data?.pages]);
const genres = genreData?.genres ?? [];
const providers = providerData?.platforms ?? [];
@@ -5,7 +5,7 @@ import { useMemo, useRef, useState } from "react";
import { TitleCard, TitleCardSkeleton } from "@/components/title-card";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { hasReachedHorizontalEnd } from "@/hooks/use-infinite-scroll";
import { useInfiniteScroll } from "@/hooks/use-infinite-scroll";
import { orpc } from "@/lib/orpc/client";
interface Genre {
@@ -92,6 +92,14 @@ export function FilterableTitleRow({
[discoverData?.pages],
);
const sentinelRef = useInfiniteScroll({
fetchNextPage,
hasNextPage: selectedGenre !== null && hasNextPage,
isFetchingNextPage,
rootRef: scrollRef,
rootMargin: "0px 400px 0px 0px",
});
const isLoading = selectedGenre !== null && isPending;
const items = selectedGenre === null ? defaultItems : discoverItems;
const userStatuses = selectedGenre === null ? initialStatuses : discoverStatuses;
@@ -155,21 +163,6 @@ export function FilterableTitleRow({
hideScrollbar
className="-mx-6 sm:-mx-2"
scrollRef={scrollRef}
onScrollEnd={() => {
const viewport = scrollRef.current;
if (
selectedGenre === null ||
!viewport ||
!hasNextPage ||
isFetchingNextPage ||
!hasReachedHorizontalEnd(viewport)
) {
return;
}
fetchNextPage();
}}
>
<div className="flex gap-4 px-6 py-2 sm:px-2">
{items.map((item: TitleRowItem, i: number) => (
@@ -197,6 +190,7 @@ export function FilterableTitleRow({
<div className="border-primary size-5 animate-spin rounded-full border-2 border-t-transparent" />
</div>
)}
<div ref={sentinelRef} className="w-px shrink-0" aria-hidden />
</div>
</ScrollArea>
)}
+12 -22
View File
@@ -2,7 +2,9 @@ import { useRef } from "react";
import { TitleCard } from "@/components/title-card";
import { ScrollArea } from "@/components/ui/scroll-area";
import { hasReachedHorizontalEnd } from "@/hooks/use-infinite-scroll";
import { useInfiniteScroll } from "@/hooks/use-infinite-scroll";
const noop = () => {};
interface TitleRowItem {
id: string;
@@ -37,6 +39,13 @@ export function TitleRow({
isFetchingNextPage = false,
}: TitleRowProps) {
const scrollRef = useRef<HTMLDivElement>(null);
const sentinelRef = useInfiniteScroll({
fetchNextPage: onEndReached ?? noop,
hasNextPage: !!onEndReached && hasNextPage,
isFetchingNextPage,
rootRef: scrollRef,
rootMargin: "0px 400px 0px 0px",
});
if (items.length === 0) return null;
@@ -46,27 +55,7 @@ export function TitleRow({
{icon}
<h2 className="font-display text-xl tracking-tight text-balance">{heading}</h2>
</div>
<ScrollArea
scrollFade
hideScrollbar
className="-mx-6 sm:-mx-2"
scrollRef={scrollRef}
onScrollEnd={() => {
const viewport = scrollRef.current;
if (
!viewport ||
!onEndReached ||
!hasNextPage ||
isFetchingNextPage ||
!hasReachedHorizontalEnd(viewport)
) {
return;
}
onEndReached();
}}
>
<ScrollArea scrollFade hideScrollbar className="-mx-6 sm:-mx-2" scrollRef={scrollRef}>
<div className="flex gap-4 px-6 py-2 sm:px-2">
{items.map((item, i) => (
<div key={item.id} className="w-[140px] shrink-0 sm:w-[160px]">
@@ -93,6 +82,7 @@ export function TitleRow({
<div className="border-primary size-5 animate-spin rounded-full border-2 border-t-transparent" />
</div>
)}
<div ref={sentinelRef} className="w-px shrink-0" aria-hidden />
</div>
</ScrollArea>
</section>
@@ -0,0 +1,126 @@
import { act, useRef } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, describe, expect, test, vi } from "vitest";
import { useInfiniteScroll } from "./use-infinite-scroll";
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
function HorizontalRow({
fetchNextPage,
hasNextPage = true,
isFetchingNextPage = false,
rootMargin = "0px",
}: {
fetchNextPage: () => void;
hasNextPage?: boolean;
isFetchingNextPage?: boolean;
rootMargin?: string;
}) {
const scrollRef = useRef<HTMLDivElement>(null);
const sentinelRef = useInfiniteScroll({
fetchNextPage,
hasNextPage,
isFetchingNextPage,
rootRef: scrollRef,
rootMargin,
});
return (
<div
ref={scrollRef}
data-testid="scroller"
style={{ width: 200, height: 50, overflowX: "auto", overflowY: "hidden" }}
>
<div style={{ display: "flex", width: 2000, height: 40 }}>
<div style={{ flex: "1 1 auto" }} />
<div ref={sentinelRef} style={{ width: 1, flexShrink: 0 }} />
</div>
</div>
);
}
let root: Root | null = null;
let container: HTMLDivElement | null = null;
function mount(ui: React.ReactElement) {
// The host stays inside the document viewport, so only the scroll container clips the sentinel.
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
act(() => {
root?.render(ui);
});
return container;
}
function nextFrames(count = 3) {
return act(async () => {
for (let i = 0; i < count; i++) {
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
}
});
}
afterEach(() => {
act(() => {
root?.unmount();
});
container?.remove();
root = null;
container = null;
});
describe("useInfiniteScroll with rootRef", () => {
test("does not fetch while the sentinel is scrolled out of the container", async () => {
const fetchNextPage = vi.fn<() => void>();
mount(<HorizontalRow fetchNextPage={fetchNextPage} />);
await nextFrames();
expect(fetchNextPage).not.toHaveBeenCalled();
});
test("fetches once the container is scrolled to the sentinel", async () => {
const fetchNextPage = vi.fn<() => void>();
const host = mount(<HorizontalRow fetchNextPage={fetchNextPage} />);
const scroller = host.querySelector<HTMLElement>("[data-testid=scroller]");
expect(scroller).not.toBeNull();
await nextFrames();
expect(fetchNextPage).not.toHaveBeenCalled();
scroller!.scrollLeft = scroller!.scrollWidth;
await vi.waitFor(() => expect(fetchNextPage).toHaveBeenCalledTimes(1));
});
test("applies rootMargin relative to the scroll container, not the viewport", async () => {
const fetchNextPage = vi.fn<() => void>();
const host = mount(
<HorizontalRow fetchNextPage={fetchNextPage} rootMargin="0px 100px 0px 0px" />,
);
const scroller = host.querySelector<HTMLElement>("[data-testid=scroller]");
expect(scroller).not.toBeNull();
await nextFrames();
expect(fetchNextPage).not.toHaveBeenCalled();
// The container shows x = 1750..1950 and the sentinel sits at x = 1999: still clipped,
// but within the 100px margin. Only an observer rooted at the container fires here.
scroller!.scrollLeft = 1750;
await vi.waitFor(() => expect(fetchNextPage).toHaveBeenCalledTimes(1));
});
test("does not fetch when there is no next page", async () => {
const fetchNextPage = vi.fn<() => void>();
const host = mount(<HorizontalRow fetchNextPage={fetchNextPage} hasNextPage={false} />);
const scroller = host.querySelector<HTMLElement>("[data-testid=scroller]");
scroller!.scrollLeft = scroller!.scrollWidth;
await nextFrames();
expect(fetchNextPage).not.toHaveBeenCalled();
});
});
+7 -10
View File
@@ -1,21 +1,18 @@
import { useEffect, useRef } from "react";
export function hasReachedHorizontalEnd(element: HTMLElement, threshold = 24) {
if (element.scrollWidth <= element.clientWidth) {
return false;
}
return element.scrollLeft + element.clientWidth >= element.scrollWidth - threshold;
}
export function useInfiniteScroll({
fetchNextPage,
hasNextPage,
isFetchingNextPage,
rootRef,
rootMargin = "200px",
}: {
fetchNextPage: () => void;
hasNextPage: boolean;
isFetchingNextPage: boolean;
/** Scroll container to observe within. Defaults to the document viewport. */
rootRef?: React.RefObject<HTMLElement | null>;
rootMargin?: string;
}) {
const sentinelRef = useRef<HTMLDivElement>(null);
@@ -29,12 +26,12 @@ export function useInfiniteScroll({
fetchNextPage();
}
},
{ rootMargin: "200px" },
{ root: rootRef?.current ?? null, rootMargin },
);
observer.observe(el);
return () => observer.disconnect();
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
}, [hasNextPage, isFetchingNextPage, fetchNextPage, rootRef, rootMargin]);
return sentinelRef;
}