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>
|
||||
)}
|
||||
|
|
|
|||
176
frontend/src/components/VirtualFeed.tsx
Normal file
176
frontend/src/components/VirtualFeed.tsx
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import type { Video } from "../lib/api";
|
||||
import VideoCard from "./VideoCard";
|
||||
|
||||
// Grid column sizing mirrors the CSS the feed used before virtualization
|
||||
// (grid-cols-[repeat(auto-fill,minmax(260px,1fr))] with a gap-4 / 1rem gutter).
|
||||
const GRID_MIN_COL = 260;
|
||||
const GRID_GAP = 16;
|
||||
const LIST_GAP = 4;
|
||||
// Estimated row heights before measurement; real heights are measured per-row so
|
||||
// these only affect the very first paint and the scrollbar's initial guess.
|
||||
const GRID_ROW_EST = 340;
|
||||
const LIST_ROW_EST = 96;
|
||||
// Start fetching the next page this many rows before the end scrolls into view
|
||||
// (keeps the old IntersectionObserver's generous prefetch feel).
|
||||
const PREFETCH_ROWS = 4;
|
||||
|
||||
function chunk<T>(arr: T[], size: number): T[][] {
|
||||
if (size <= 1) return arr.map((x) => [x]);
|
||||
const out: T[][] = [];
|
||||
for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size));
|
||||
return out;
|
||||
}
|
||||
|
||||
// The feed scrolls inside the app's <main overflow-y-auto>, not the window, so the
|
||||
// virtualizer needs that element. Walk up to the nearest scrollable ancestor.
|
||||
function getScrollParent(node: HTMLElement | null): HTMLElement {
|
||||
let el = node?.parentElement ?? null;
|
||||
while (el) {
|
||||
const oy = getComputedStyle(el).overflowY;
|
||||
if (oy === "auto" || oy === "scroll") return el;
|
||||
el = el.parentElement;
|
||||
}
|
||||
return document.documentElement;
|
||||
}
|
||||
|
||||
export interface VirtualFeedProps {
|
||||
items: Video[];
|
||||
view: "grid" | "list";
|
||||
onState: (id: string, status: string) => void;
|
||||
onToggleSave: (id: string, saved: boolean) => void;
|
||||
onChannelFilter: (channelId: string, channelName: string) => void;
|
||||
onResetState: (id: string) => void;
|
||||
onOpen: (v: Video, startAt?: number | null) => void;
|
||||
hasNextPage: boolean;
|
||||
isFetchingNextPage: boolean;
|
||||
fetchNextPage: () => void;
|
||||
}
|
||||
|
||||
export default function VirtualFeed({
|
||||
items,
|
||||
view,
|
||||
onState,
|
||||
onToggleSave,
|
||||
onChannelFilter,
|
||||
onResetState,
|
||||
onOpen,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
fetchNextPage,
|
||||
}: VirtualFeedProps) {
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const [scrollEl, setScrollEl] = useState<HTMLElement | null>(null);
|
||||
const [colCount, setColCount] = useState(view === "list" ? 1 : 4);
|
||||
// Distance from the scroll element's content top to where this list starts (the
|
||||
// feed toolbar sits above it inside the same scroll area).
|
||||
const [scrollMargin, setScrollMargin] = useState(0);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (listRef.current) setScrollEl(getScrollParent(listRef.current));
|
||||
}, []);
|
||||
|
||||
// Re-measure the column count and start offset on container/scroll-area resize.
|
||||
useLayoutEffect(() => {
|
||||
const node = listRef.current;
|
||||
if (!node) return;
|
||||
const measure = () => {
|
||||
if (scrollEl) {
|
||||
const m =
|
||||
node.getBoundingClientRect().top -
|
||||
scrollEl.getBoundingClientRect().top +
|
||||
scrollEl.scrollTop;
|
||||
setScrollMargin(m);
|
||||
}
|
||||
setColCount(
|
||||
view === "list"
|
||||
? 1
|
||||
: Math.max(1, Math.floor((node.clientWidth + GRID_GAP) / (GRID_MIN_COL + GRID_GAP)))
|
||||
);
|
||||
};
|
||||
measure();
|
||||
const ro = new ResizeObserver(measure);
|
||||
ro.observe(node);
|
||||
if (scrollEl) ro.observe(scrollEl);
|
||||
return () => ro.disconnect();
|
||||
}, [scrollEl, view]);
|
||||
|
||||
const rows = useMemo(() => chunk(items, colCount), [items, colCount]);
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: rows.length,
|
||||
getScrollElement: () => scrollEl,
|
||||
estimateSize: () => (view === "grid" ? GRID_ROW_EST : LIST_ROW_EST),
|
||||
overscan: 4,
|
||||
gap: view === "grid" ? GRID_GAP : LIST_GAP,
|
||||
scrollMargin,
|
||||
});
|
||||
|
||||
const virtualRows = virtualizer.getVirtualItems();
|
||||
|
||||
// Infinite-scroll trigger: once the last rendered row is within PREFETCH_ROWS of
|
||||
// the end, pull the next keyset page.
|
||||
useEffect(() => {
|
||||
const last = virtualRows[virtualRows.length - 1];
|
||||
if (!last) return;
|
||||
if (last.index >= rows.length - 1 - PREFETCH_ROWS && hasNextPage && !isFetchingNextPage) {
|
||||
fetchNextPage();
|
||||
}
|
||||
}, [virtualRows, rows.length, hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||
|
||||
return (
|
||||
<div ref={listRef} style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
|
||||
{virtualRows.map((vr) => {
|
||||
const row = rows[vr.index];
|
||||
if (!row) return null;
|
||||
return (
|
||||
<div
|
||||
key={vr.key}
|
||||
data-index={vr.index}
|
||||
ref={virtualizer.measureElement}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
transform: `translateY(${vr.start - virtualizer.options.scrollMargin}px)`,
|
||||
}}
|
||||
>
|
||||
{view === "grid" ? (
|
||||
<div
|
||||
className="grid gap-4"
|
||||
style={{ gridTemplateColumns: `repeat(${colCount}, minmax(0, 1fr))` }}
|
||||
>
|
||||
{row.map((v) => (
|
||||
<VideoCard
|
||||
key={v.id}
|
||||
video={v}
|
||||
view="grid"
|
||||
onState={onState}
|
||||
onToggleSave={onToggleSave}
|
||||
onChannelFilter={onChannelFilter}
|
||||
onResetState={onResetState}
|
||||
onOpen={onOpen}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-w-4xl mx-auto pb-1">
|
||||
<VideoCard
|
||||
video={row[0]}
|
||||
view="list"
|
||||
onState={onState}
|
||||
onToggleSave={onToggleSave}
|
||||
onChannelFilter={onChannelFilter}
|
||||
onResetState={onResetState}
|
||||
onOpen={onOpen}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -79,7 +79,8 @@ export interface VideoDetail {
|
|||
export interface FeedResponse {
|
||||
items: Video[];
|
||||
has_more: boolean;
|
||||
offset: number;
|
||||
// Opaque keyset cursor for the next page, or null when this is the last page.
|
||||
next_cursor: string | null;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
|
|
@ -308,11 +309,11 @@ function filterParams(f: FeedFilters): URLSearchParams {
|
|||
return p;
|
||||
}
|
||||
|
||||
function feedQuery(f: FeedFilters, offset: number, limit: number): string {
|
||||
function feedQuery(f: FeedFilters, cursor: string | null, limit: number): string {
|
||||
const p = filterParams(f);
|
||||
p.set("sort", f.sort);
|
||||
if (f.sort === "shuffle" && f.seed) p.set("seed", String(f.seed));
|
||||
p.set("offset", String(offset));
|
||||
if (cursor) p.set("cursor", cursor);
|
||||
p.set("limit", String(limit));
|
||||
return p.toString();
|
||||
}
|
||||
|
|
@ -511,10 +512,10 @@ export const api = {
|
|||
version: (): Promise<VersionInfo> => req("/api/version"),
|
||||
tags: (): Promise<Tag[]> => req("/api/tags"),
|
||||
status: (): Promise<SyncStatus> => req("/api/sync/status"),
|
||||
feed: (f: FeedFilters, offset: number, limit: number): Promise<FeedResponse> =>
|
||||
req(`/api/feed?${feedQuery(f, offset, limit)}`),
|
||||
feed: (f: FeedFilters, cursor: string | null, limit: number): Promise<FeedResponse> =>
|
||||
req(`/api/feed?${feedQuery(f, cursor, limit)}`),
|
||||
feedCount: (f: FeedFilters): Promise<{ count: number }> =>
|
||||
req(`/api/feed/count?${feedQuery(f, 0, 0)}`),
|
||||
req(`/api/feed/count?${filterParams(f).toString()}`),
|
||||
facets: (f: FeedFilters): Promise<{ counts: Record<string, number> }> =>
|
||||
req(`/api/facets?${filterParams(f).toString()}`),
|
||||
// idempotent: setting a state / saving a progress checkpoint is safe to replay, so these
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue