From 420412831d5ebc823e785137fa20a6dd9167ed86 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Fri, 12 Jun 2026 18:17:03 +0200 Subject: [PATCH] perf(feed): smoother scrolling for long feeds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three low-risk wins for large filtered feeds: - content-visibility:auto on cards (.cv-card/.cv-row) so the browser skips layout/paint for off-screen cards; contain-intrinsic-size keeps the scrollbar stable and is remembered per card after first render. - memo(VideoCard) + stable onState/onChannelFilter callbacks (onState reads the loaded list via a ref) so appending a page only renders the ~60 new cards instead of reconciling every card already on screen. - Prefetch the next page earlier (sentinel rootMargin 800px → 1500px) so the 'Loading more…' flash is far less likely during fast scrolling. --- frontend/src/components/Feed.tsx | 84 +++++++++++++++------------ frontend/src/components/VideoCard.tsx | 12 +++- frontend/src/index.css | 13 +++++ 3 files changed, 70 insertions(+), 39 deletions(-) diff --git a/frontend/src/components/Feed.tsx b/frontend/src/components/Feed.tsx index 2652be3..b7d7a35 100644 --- a/frontend/src/components/Feed.tsx +++ b/frontend/src/components/Feed.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-query"; import { api, type FeedFilters, type Video } from "../lib/api"; import { notify } from "../lib/notifications"; @@ -66,48 +66,60 @@ export default function Feed({ fetchNextPage(); } }, - { rootMargin: "800px" } + { rootMargin: "1500px" } // prefetch the next page well before the bottom is in view ); io.observe(el); return () => io.disconnect(); }, [hasNextPage, isFetchingNextPage, fetchNextPage]); - function onState(id: string, status: string) { - setOverrides((o) => ({ ...o, [id]: status })); - // Refetch once the server has the change so other views (e.g. Hidden) are in sync. - api - .setState(id, status) - .then(() => qc.invalidateQueries({ queryKey: ["feed"] })) - .catch(() => {}); - if (status === "hidden") { - const v = (query.data?.pages ?? []).flatMap((p) => p.items).find((x) => x.id === id); - notify({ - message: v?.title ? `Hidden “${v.title}”` : "Video hidden", - action: { label: "Undo", onClick: () => onState(id, "new") }, - meta: { - kind: "video-hidden", - videoId: id, - title: v?.title ?? "this video", - channelId: v?.channel_id ?? "", - channelName: v?.channel_title ?? "", - }, - }); - } else if (status === "watched") { - const v = (query.data?.pages ?? []).flatMap((p) => p.items).find((x) => x.id === id); - notify({ - message: v?.title ? `Marked watched “${v.title}”` : "Marked watched", - action: { label: "Unwatch", onClick: () => onState(id, "new") }, - meta: { kind: "video-watched", videoId: id, title: v?.title ?? "this video" }, - }); - } - } + // 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 + // onState lets memo(VideoCard) skip re-rendering existing cards on page append. + const loadedRef = useRef([]); - function onChannelFilter(channelId: string, channelName: string) { - setFilters({ ...filters, channelId, channelName }); - } + const onState = useCallback( + (id: string, status: string) => { + setOverrides((o) => ({ ...o, [id]: status })); + // Refetch once the server has the change so other views (e.g. Hidden) are in sync. + api + .setState(id, status) + .then(() => qc.invalidateQueries({ queryKey: ["feed"] })) + .catch(() => {}); + if (status === "hidden") { + const v = loadedRef.current.find((x) => x.id === id); + notify({ + message: v?.title ? `Hidden “${v.title}”` : "Video hidden", + action: { label: "Undo", onClick: () => onState(id, "new") }, + meta: { + kind: "video-hidden", + videoId: id, + title: v?.title ?? "this video", + channelId: v?.channel_id ?? "", + channelName: v?.channel_title ?? "", + }, + }); + } else if (status === "watched") { + const v = loadedRef.current.find((x) => x.id === id); + notify({ + message: v?.title ? `Marked watched “${v.title}”` : "Marked watched", + action: { label: "Unwatch", onClick: () => onState(id, "new") }, + meta: { kind: "video-watched", videoId: id, title: v?.title ?? "this video" }, + }); + } + }, + [qc] + ); - const items: Video[] = (query.data?.pages ?? []) - .flatMap((p) => p.items) + const onChannelFilter = useCallback( + (channelId: string, channelName: string) => { + setFilters({ ...filters, channelId, channelName }); + }, + [filters, setFilters] + ); + + const loaded: Video[] = (query.data?.pages ?? []).flatMap((p) => p.items); + loadedRef.current = loaded; + const items: Video[] = loaded .map((v) => (overrides[v.id] ? { ...v, status: overrides[v.id] } : v)) .filter((v) => matchesView(v.status, filters.show)); diff --git a/frontend/src/components/VideoCard.tsx b/frontend/src/components/VideoCard.tsx index 9adfdf2..62ddeb8 100644 --- a/frontend/src/components/VideoCard.tsx +++ b/frontend/src/components/VideoCard.tsx @@ -1,3 +1,4 @@ +import { memo } from "react"; import { Bookmark, Check, CheckCheck, Eye, EyeOff, ListFilter } from "lucide-react"; import Avatar from "./Avatar"; import clsx from "clsx"; @@ -136,7 +137,7 @@ function Thumb({ ); } -export default function VideoCard({ +function VideoCard({ video, view, onState, @@ -172,7 +173,7 @@ export default function VideoCard({ return (
@@ -198,7 +199,7 @@ export default function VideoCard({ return (
@@ -227,3 +228,8 @@ export default function VideoCard({
); } + +// Memoized so appending a feed page only renders the new cards, not every existing +// one (the callbacks from Feed are stable; non-overridden video objects keep their +// identity across renders). +export default memo(VideoCard); diff --git a/frontend/src/index.css b/frontend/src/index.css index ebea9ae..6eb89db 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -72,6 +72,19 @@ html[data-perf="1"] body { background: var(--bg); } +/* Skip layout/paint for off-screen feed cards so long lists stay smooth while + scrolling. contain-intrinsic-size reserves an approximate box so the scrollbar + stays stable; `auto` lets the browser remember each card's real size after it + has rendered once. */ +.cv-card { + content-visibility: auto; + contain-intrinsic-size: auto 320px; +} +.cv-row { + content-visibility: auto; + contain-intrinsic-size: auto 96px; +} + /* ===== Motion ===== */ @keyframes fadeIn { from { opacity: 0; }