feat(feed): virtualize the feed list and consume the keyset cursor
Render only the visible rows via @tanstack/react-virtual (useVirtualizer against the app's <main> scroll container, per-row dynamic measurement), so the DOM no longer accumulates every loaded card. A ResizeObserver keeps the responsive grid column count in sync and chunks cards into virtualized rows; the list view virtualizes single-card rows. Infinite scroll now triggers from the virtual range and pages via next_cursor instead of offset; feed/count drops its unused paging args.
This commit is contained in:
parent
424b19f3b6
commit
0058ba7ccf
5 changed files with 228 additions and 55 deletions
|
|
@ -5,7 +5,7 @@ import { ArrowDown, ArrowUp, RefreshCw } from "lucide-react";
|
|||
import { api, type FeedFilters, type Video } from "../lib/api";
|
||||
import i18n from "../i18n";
|
||||
import { notify, resolveVideo } from "../lib/notifications";
|
||||
import VideoCard from "./VideoCard";
|
||||
import VirtualFeed from "./VirtualFeed";
|
||||
import PlayerModal from "./PlayerModal";
|
||||
|
||||
const PAGE = 60;
|
||||
|
|
@ -94,9 +94,9 @@ export default function Feed({
|
|||
|
||||
const query = useInfiniteQuery({
|
||||
queryKey: ["feed", filters],
|
||||
queryFn: ({ pageParam }) => api.feed(filters, pageParam as number, PAGE),
|
||||
initialPageParam: 0,
|
||||
getNextPageParam: (last) => (last.has_more ? last.offset + last.limit : undefined),
|
||||
queryFn: ({ pageParam }) => api.feed(filters, pageParam as string | null, PAGE),
|
||||
initialPageParam: null as string | null,
|
||||
getNextPageParam: (last) => last.next_cursor ?? undefined,
|
||||
});
|
||||
|
||||
// Drop optimistic status overrides when filters change OR fresh server data
|
||||
|
|
@ -118,22 +118,7 @@ export default function Feed({
|
|||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
const sentinel = useRef<HTMLDivElement | null>(null);
|
||||
const { hasNextPage, isFetchingNextPage, fetchNextPage } = query;
|
||||
useEffect(() => {
|
||||
const el = sentinel.current;
|
||||
if (!el) return;
|
||||
const io = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
|
||||
fetchNextPage();
|
||||
}
|
||||
},
|
||||
{ rootMargin: "1500px" } // prefetch the next page well before the bottom is in view
|
||||
);
|
||||
io.observe(el);
|
||||
return () => io.disconnect();
|
||||
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||
|
||||
// Keep the loaded videos in a ref so onState can stay referentially stable
|
||||
// (it needs the list only to look up a title for the notification). A stable
|
||||
|
|
@ -388,36 +373,19 @@ export default function Feed({
|
|||
{toolbar}
|
||||
{items.length === 0 ? (
|
||||
<div className="py-16 text-center text-muted">{t("feed.noMatches")}</div>
|
||||
) : view === "grid" ? (
|
||||
<div className="grid gap-4 grid-cols-[repeat(auto-fill,minmax(260px,1fr))]">
|
||||
{items.map((v) => (
|
||||
<VideoCard
|
||||
key={v.id}
|
||||
video={v}
|
||||
view="grid"
|
||||
onState={onState}
|
||||
onToggleSave={onToggleSave}
|
||||
onChannelFilter={onChannelFilter}
|
||||
onResetState={onResetState}
|
||||
onOpen={openVideo}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-w-4xl mx-auto flex flex-col gap-1">
|
||||
{items.map((v) => (
|
||||
<VideoCard
|
||||
key={v.id}
|
||||
video={v}
|
||||
view="list"
|
||||
onState={onState}
|
||||
onToggleSave={onToggleSave}
|
||||
onChannelFilter={onChannelFilter}
|
||||
onResetState={onResetState}
|
||||
onOpen={openVideo}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<VirtualFeed
|
||||
items={items}
|
||||
view={view}
|
||||
onState={onState}
|
||||
onToggleSave={onToggleSave}
|
||||
onChannelFilter={onChannelFilter}
|
||||
onResetState={onResetState}
|
||||
onOpen={openVideo}
|
||||
hasNextPage={!!hasNextPage}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
fetchNextPage={fetchNextPage}
|
||||
/>
|
||||
)}
|
||||
{activeVideo && (
|
||||
<PlayerModal
|
||||
|
|
@ -427,7 +395,6 @@ export default function Feed({
|
|||
onState={onState}
|
||||
/>
|
||||
)}
|
||||
<div ref={sentinel} className="h-10" />
|
||||
{isFetchingNextPage && (
|
||||
<div className="text-center text-muted py-4">{t("feed.loadingMore")}</div>
|
||||
)}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue