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:
parent
203624518d
commit
a8e98d03ae
3 changed files with 70 additions and 39 deletions
|
|
@ -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<Video[]>([]);
|
||||
|
||||
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));
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue