refactor(feed): extract virtualization engine into a generic VirtualGrid<T>
VirtualFeed becomes a thin wrapper that maps a FeedView to VirtualGrid's layout spec and renders a VideoCard per item; all measure/chunk/place/ infinite-scroll mechanics move to VirtualGrid so the channel manager (E4 S3) can reuse them. Behaviour-preserving: e2e 17/17, all render families smoked.
This commit is contained in:
@@ -1,14 +1,8 @@
|
|||||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
||||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
|
||||||
import type { Video } from "../lib/api";
|
import type { Video } from "../lib/api";
|
||||||
import { FEED_VIEW_SPEC, type FeedView } from "../lib/feedView";
|
import { FEED_VIEW_SPEC, type FeedView } from "../lib/feedView";
|
||||||
import VideoCard from "./VideoCard";
|
import VideoCard from "./VideoCard";
|
||||||
|
import VirtualGrid from "./VirtualGrid";
|
||||||
|
|
||||||
// Column sizing mirrors the CSS the feed used before virtualization
|
|
||||||
// (grid-cols-[repeat(auto-fill,minmax(260px,1fr))] with a gap-4 / 1rem gutter). The per-view
|
|
||||||
// minimum column width lives with the rest of the view matrix in lib/feedView.ts.
|
|
||||||
const GRID_GAP = 16;
|
|
||||||
const LIST_GAP = 4;
|
|
||||||
// Estimated row heights before measurement; real heights are measured per-row so these only affect
|
// 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. Taken from measuring each mode in the
|
// the very first paint and the scrollbar's initial guess. Taken from measuring each mode in the
|
||||||
// browser rather than guessed — a bad estimate makes the scrollbar jump as you scroll in.
|
// browser rather than guessed — a bad estimate makes the scrollbar jump as you scroll in.
|
||||||
@@ -24,35 +18,6 @@ const ROW_EST: Record<FeedView, number> = {
|
|||||||
tiles: 132,
|
tiles: 132,
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Columns that fit `width`, or 1 for the single-column views. */
|
|
||||||
function columnsFor(view: FeedView, width: number): number {
|
|
||||||
const { minCol } = FEED_VIEW_SPEC[view];
|
|
||||||
if (minCol == null) return 1;
|
|
||||||
return Math.max(1, Math.floor((width + GRID_GAP) / (minCol + GRID_GAP)));
|
|
||||||
}
|
|
||||||
// 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 {
|
export interface VirtualFeedProps {
|
||||||
items: Video[];
|
items: Video[];
|
||||||
view: FeedView;
|
view: FeedView;
|
||||||
@@ -65,6 +30,9 @@ export interface VirtualFeedProps {
|
|||||||
fetchNextPage: () => void;
|
fetchNextPage: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The feed's videos on the shared VirtualGrid engine — this wrapper only maps a FeedView to the
|
||||||
|
// grid's layout spec and renders a VideoCard per item. All the virtualization mechanics (measure,
|
||||||
|
// chunk, place, infinite-scroll) live in VirtualGrid.
|
||||||
export default function VirtualFeed({
|
export default function VirtualFeed({
|
||||||
items,
|
items,
|
||||||
view,
|
view,
|
||||||
@@ -76,123 +44,31 @@ export default function VirtualFeed({
|
|||||||
isFetchingNextPage,
|
isFetchingNextPage,
|
||||||
fetchNextPage,
|
fetchNextPage,
|
||||||
}: VirtualFeedProps) {
|
}: VirtualFeedProps) {
|
||||||
const listRef = useRef<HTMLDivElement>(null);
|
const { minCol, maxCol, thumb } = FEED_VIEW_SPEC[view];
|
||||||
const [scrollEl, setScrollEl] = useState<HTMLElement | null>(null);
|
|
||||||
const { minCol, maxCol } = FEED_VIEW_SPEC[view];
|
|
||||||
const singleCol = minCol == null;
|
|
||||||
const [colCount, setColCount] = useState(singleCol ? 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);
|
|
||||||
// How wide ONE row/card actually renders. The one-line row drops columns as this shrinks, and it
|
|
||||||
// has to be the real width, not the viewport: the nav rail (56 slim / ~230 pinned) and the filter
|
|
||||||
// panel (~80 tab / ~256 pinned) move it by ~430px between them. A viewport breakpoint got this
|
|
||||||
// wrong — at 1280 with both pinned every column still showed while the row had 762px for 766px of
|
|
||||||
// them, which squeezed the title to nothing. This is measured here anyway, for colCount.
|
|
||||||
const [itemWidth, setItemWidth] = 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);
|
|
||||||
}
|
|
||||||
const w = node.clientWidth;
|
|
||||||
const cols = columnsFor(view, w);
|
|
||||||
setColCount(cols);
|
|
||||||
const { minCol: min, maxCol: max } = FEED_VIEW_SPEC[view];
|
|
||||||
setItemWidth(
|
|
||||||
min == null ? Math.min(w, max ?? w) : Math.floor((w - GRID_GAP * (cols - 1)) / cols)
|
|
||||||
);
|
|
||||||
};
|
|
||||||
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: () => ROW_EST[view],
|
|
||||||
overscan: 4,
|
|
||||||
gap: singleCol ? LIST_GAP : GRID_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 (
|
return (
|
||||||
<div ref={listRef} style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
|
<VirtualGrid
|
||||||
{virtualRows.map((vr) => {
|
items={items}
|
||||||
const row = rows[vr.index];
|
getKey={(v) => v.id}
|
||||||
if (!row) return null;
|
minCol={minCol}
|
||||||
return (
|
maxCol={maxCol}
|
||||||
<div
|
rowEst={ROW_EST[view]}
|
||||||
key={vr.key}
|
hasNextPage={hasNextPage}
|
||||||
data-index={vr.index}
|
isFetchingNextPage={isFetchingNextPage}
|
||||||
ref={virtualizer.measureElement}
|
fetchNextPage={fetchNextPage}
|
||||||
style={{
|
renderItem={(v, itemWidth) => (
|
||||||
position: "absolute",
|
<VideoCard
|
||||||
top: 0,
|
video={v}
|
||||||
left: 0,
|
view={view}
|
||||||
width: "100%",
|
// Only the one-line row (thumb:false) reads its own width — it stands columns down as it
|
||||||
transform: `translateY(${vr.start - virtualizer.options.scrollMargin}px)`,
|
// narrows. Everything else takes a stable 0, so a resize (which changes itemWidth every
|
||||||
}}
|
// frame) doesn't blow past VideoCard's memo for views that wouldn't do anything with it.
|
||||||
>
|
width={thumb ? 0 : itemWidth}
|
||||||
{/* One branch for both shapes: the single-column views just chunk to colCount 1 and
|
onState={onState}
|
||||||
get their reading-width cap instead of grid columns. */}
|
onToggleSave={onToggleSave}
|
||||||
<div
|
onResetState={onResetState}
|
||||||
className={singleCol ? "mx-auto pb-1" : "grid gap-4"}
|
onOpen={onOpen}
|
||||||
style={
|
/>
|
||||||
singleCol
|
)}
|
||||||
? { maxWidth: maxCol ?? undefined }
|
/>
|
||||||
: { gridTemplateColumns: `repeat(${colCount}, minmax(0, 1fr))` }
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{row.map((v) => (
|
|
||||||
<VideoCard
|
|
||||||
key={v.id}
|
|
||||||
video={v}
|
|
||||||
view={view}
|
|
||||||
// Only the one-line row reads its own width (it stands columns down as it
|
|
||||||
// narrows). Everything else takes a stable 0, so a resize — which changes
|
|
||||||
// itemWidth every frame — doesn't blow past VideoCard's memo for views that
|
|
||||||
// wouldn't do anything with it.
|
|
||||||
width={FEED_VIEW_SPEC[view].thumb ? 0 : itemWidth}
|
|
||||||
onState={onState}
|
|
||||||
onToggleSave={onToggleSave}
|
|
||||||
onResetState={onResetState}
|
|
||||||
onOpen={onOpen}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import { Fragment, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||||
|
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||||
|
|
||||||
|
// The virtualization engine, lifted out of VirtualFeed so any long client- or server-side list can
|
||||||
|
// reuse it (the feed's video grid, the channel manager's card/row views). It owns ONLY the layout
|
||||||
|
// mechanics — measure the container, derive the column count, chunk into rows, virtualize, and place
|
||||||
|
// each row absolutely — and knows nothing about what an item is. The caller supplies `renderItem`.
|
||||||
|
//
|
||||||
|
// 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_GAP = 16;
|
||||||
|
const LIST_GAP = 4;
|
||||||
|
// 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;
|
||||||
|
|
||||||
|
/** Columns that fit `width`, or 1 for the single-column layout (`minCol == null`). */
|
||||||
|
function columnsFor(minCol: number | null, width: number): number {
|
||||||
|
if (minCol == null) return 1;
|
||||||
|
return Math.max(1, Math.floor((width + GRID_GAP) / (minCol + GRID_GAP)));
|
||||||
|
}
|
||||||
|
|
||||||
|
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 list scrolls inside an <main overflow-y-auto> (the page scroller), 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 VirtualGridProps<T> {
|
||||||
|
items: T[];
|
||||||
|
getKey: (item: T) => string;
|
||||||
|
/** Narrowest grid column in px, or null for ONE full-width column. The column count is derived
|
||||||
|
* from this and the container width, so every multi-column layout reflows for free. */
|
||||||
|
minCol: number | null;
|
||||||
|
/** Single-column layout only: the reading-width cap in px (null = fill). */
|
||||||
|
maxCol?: number | null;
|
||||||
|
/** Estimated row height before measurement; real heights are measured per-row, so this only
|
||||||
|
* affects the very first paint and the scrollbar's initial guess. Measure it, don't guess — a
|
||||||
|
* bad estimate makes the scrollbar jump as you scroll in. */
|
||||||
|
rowEst: number;
|
||||||
|
/** Renders one item. `itemWidth` is the measured px width of one cell — for width-derived
|
||||||
|
* layouts (e.g. a one-line row that drops columns as it narrows); ignore it otherwise, and to
|
||||||
|
* protect a memo'd item from re-rendering on every resize frame, pass a stable value down. */
|
||||||
|
renderItem: (item: T, itemWidth: number) => ReactNode;
|
||||||
|
/** Infinite scroll — omit all three for a finite client-side list. */
|
||||||
|
hasNextPage?: boolean;
|
||||||
|
isFetchingNextPage?: boolean;
|
||||||
|
fetchNextPage?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function VirtualGrid<T>({
|
||||||
|
items,
|
||||||
|
getKey,
|
||||||
|
minCol,
|
||||||
|
maxCol = null,
|
||||||
|
rowEst,
|
||||||
|
renderItem,
|
||||||
|
hasNextPage = false,
|
||||||
|
isFetchingNextPage = false,
|
||||||
|
fetchNextPage,
|
||||||
|
}: VirtualGridProps<T>) {
|
||||||
|
const listRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [scrollEl, setScrollEl] = useState<HTMLElement | null>(null);
|
||||||
|
const singleCol = minCol == null;
|
||||||
|
const [colCount, setColCount] = useState(singleCol ? 1 : 4);
|
||||||
|
// Distance from the scroll element's content top to where this list starts (a toolbar sits above
|
||||||
|
// it inside the same scroll area).
|
||||||
|
const [scrollMargin, setScrollMargin] = useState(0);
|
||||||
|
// How wide ONE row/card actually renders. It has to be the real container width, not the
|
||||||
|
// viewport: side rails/panels move it independently of the window, and a viewport breakpoint got
|
||||||
|
// this wrong before. This is measured here anyway, for colCount.
|
||||||
|
const [itemWidth, setItemWidth] = 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);
|
||||||
|
}
|
||||||
|
const w = node.clientWidth;
|
||||||
|
const cols = columnsFor(minCol, w);
|
||||||
|
setColCount(cols);
|
||||||
|
setItemWidth(
|
||||||
|
minCol == null ? Math.min(w, maxCol ?? w) : Math.floor((w - GRID_GAP * (cols - 1)) / cols)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
measure();
|
||||||
|
const ro = new ResizeObserver(measure);
|
||||||
|
ro.observe(node);
|
||||||
|
if (scrollEl) ro.observe(scrollEl);
|
||||||
|
return () => ro.disconnect();
|
||||||
|
}, [scrollEl, minCol, maxCol]);
|
||||||
|
|
||||||
|
const rows = useMemo(() => chunk(items, colCount), [items, colCount]);
|
||||||
|
|
||||||
|
const virtualizer = useVirtualizer({
|
||||||
|
count: rows.length,
|
||||||
|
getScrollElement: () => scrollEl,
|
||||||
|
estimateSize: () => rowEst,
|
||||||
|
overscan: 4,
|
||||||
|
gap: singleCol ? LIST_GAP : GRID_GAP,
|
||||||
|
scrollMargin,
|
||||||
|
});
|
||||||
|
|
||||||
|
const virtualRows = virtualizer.getVirtualItems();
|
||||||
|
|
||||||
|
// Infinite-scroll trigger: once the last rendered row is within PREFETCH_ROWS of the end, pull
|
||||||
|
// the next page. A no-op for finite lists (no fetchNextPage / hasNextPage false).
|
||||||
|
useEffect(() => {
|
||||||
|
if (!fetchNextPage) return;
|
||||||
|
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)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* One branch for both shapes: the single-column layout chunks to colCount 1 and gets
|
||||||
|
its reading-width cap instead of grid columns. */}
|
||||||
|
<div
|
||||||
|
className={singleCol ? "mx-auto pb-1" : "grid gap-4"}
|
||||||
|
style={
|
||||||
|
singleCol
|
||||||
|
? { maxWidth: maxCol ?? undefined }
|
||||||
|
: { gridTemplateColumns: `repeat(${colCount}, minmax(0, 1fr))` }
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{/* A transparent Fragment carries React's key so the rendered item stays the DIRECT
|
||||||
|
grid/column child — some cards rely on that (e.g. `h-full` stretch, actions pinned
|
||||||
|
to the bottom edge), which a wrapper element would silently break. */}
|
||||||
|
{row.map((item) => (
|
||||||
|
<Fragment key={getKey(item)}>{renderItem(item, itemWidth)}</Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user