perf(feed): smoother scrolling for long feeds

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.
This commit is contained in:
npeter83 2026-06-12 18:17:03 +02:00
parent 203624518d
commit a8e98d03ae
3 changed files with 70 additions and 39 deletions

View file

@ -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,13 +66,19 @@ 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) {
// 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<Video[]>([]);
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
@ -80,7 +86,7 @@ export default function Feed({
.then(() => qc.invalidateQueries({ queryKey: ["feed"] }))
.catch(() => {});
if (status === "hidden") {
const v = (query.data?.pages ?? []).flatMap((p) => p.items).find((x) => x.id === id);
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") },
@ -93,21 +99,27 @@ export default function Feed({
},
});
} else if (status === "watched") {
const v = (query.data?.pages ?? []).flatMap((p) => p.items).find((x) => x.id === id);
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]
);
function onChannelFilter(channelId: string, channelName: string) {
const onChannelFilter = useCallback(
(channelId: string, channelName: string) => {
setFilters({ ...filters, channelId, channelName });
}
},
[filters, setFilters]
);
const items: Video[] = (query.data?.pages ?? [])
.flatMap((p) => p.items)
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));

View file

@ -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 (
<div
className={clsx(
"group flex gap-3 p-2 rounded-xl hover:bg-card hover:shadow-lg transition",
"cv-row group flex gap-3 p-2 rounded-xl hover:bg-card hover:shadow-lg transition",
watched && "opacity-55"
)}
>
@ -198,7 +199,7 @@ export default function VideoCard({
return (
<div
className={clsx(
"group glass-card glass-hover rounded-2xl p-2.5 transition-all duration-150 hover:-translate-y-1",
"cv-card group glass-card glass-hover rounded-2xl p-2.5 transition-all duration-150 hover:-translate-y-1",
watched && "opacity-55"
)}
>
@ -227,3 +228,8 @@ export default function VideoCard({
</div>
);
}
// 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);

View file

@ -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; }