Merge: smoother long-feed scrolling

This commit is contained in:
npeter83 2026-06-12 18:17:03 +02:00
commit 9163a5f68e
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 { useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-query";
import { api, type FeedFilters, type Video } from "../lib/api"; import { api, type FeedFilters, type Video } from "../lib/api";
import { notify } from "../lib/notifications"; import { notify } from "../lib/notifications";
@ -66,13 +66,19 @@ export default function Feed({
fetchNextPage(); fetchNextPage();
} }
}, },
{ rootMargin: "800px" } { rootMargin: "1500px" } // prefetch the next page well before the bottom is in view
); );
io.observe(el); io.observe(el);
return () => io.disconnect(); return () => io.disconnect();
}, [hasNextPage, isFetchingNextPage, fetchNextPage]); }, [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 })); setOverrides((o) => ({ ...o, [id]: status }));
// Refetch once the server has the change so other views (e.g. Hidden) are in sync. // Refetch once the server has the change so other views (e.g. Hidden) are in sync.
api api
@ -80,7 +86,7 @@ export default function Feed({
.then(() => qc.invalidateQueries({ queryKey: ["feed"] })) .then(() => qc.invalidateQueries({ queryKey: ["feed"] }))
.catch(() => {}); .catch(() => {});
if (status === "hidden") { 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({ notify({
message: v?.title ? `Hidden “${v.title}` : "Video hidden", message: v?.title ? `Hidden “${v.title}` : "Video hidden",
action: { label: "Undo", onClick: () => onState(id, "new") }, action: { label: "Undo", onClick: () => onState(id, "new") },
@ -93,21 +99,27 @@ export default function Feed({
}, },
}); });
} else if (status === "watched") { } 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({ notify({
message: v?.title ? `Marked watched “${v.title}` : "Marked watched", message: v?.title ? `Marked watched “${v.title}` : "Marked watched",
action: { label: "Unwatch", onClick: () => onState(id, "new") }, action: { label: "Unwatch", onClick: () => onState(id, "new") },
meta: { kind: "video-watched", videoId: id, title: v?.title ?? "this video" }, 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 }); setFilters({ ...filters, channelId, channelName });
} },
[filters, setFilters]
);
const items: Video[] = (query.data?.pages ?? []) const loaded: Video[] = (query.data?.pages ?? []).flatMap((p) => p.items);
.flatMap((p) => p.items) loadedRef.current = loaded;
const items: Video[] = loaded
.map((v) => (overrides[v.id] ? { ...v, status: overrides[v.id] } : v)) .map((v) => (overrides[v.id] ? { ...v, status: overrides[v.id] } : v))
.filter((v) => matchesView(v.status, filters.show)); .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 { Bookmark, Check, CheckCheck, Eye, EyeOff, ListFilter } from "lucide-react";
import Avatar from "./Avatar"; import Avatar from "./Avatar";
import clsx from "clsx"; import clsx from "clsx";
@ -136,7 +137,7 @@ function Thumb({
); );
} }
export default function VideoCard({ function VideoCard({
video, video,
view, view,
onState, onState,
@ -172,7 +173,7 @@ export default function VideoCard({
return ( return (
<div <div
className={clsx( 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" watched && "opacity-55"
)} )}
> >
@ -198,7 +199,7 @@ export default function VideoCard({
return ( return (
<div <div
className={clsx( 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" watched && "opacity-55"
)} )}
> >
@ -227,3 +228,8 @@ export default function VideoCard({
</div> </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); 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 ===== */ /* ===== Motion ===== */
@keyframes fadeIn { @keyframes fadeIn {
from { opacity: 0; } from { opacity: 0; }