import { Fragment, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode, } from "react"; import { useVirtualizer } from "@tanstack/react-virtual"; import { useListNav } from "../../lib/listNav"; import { scrollParent } from "../../lib/listPaging"; import { GRID_GAP, columnsFor, itemWidthFor } from "./gridColumns"; import { shouldPrefetchNext } from "./readAhead"; // 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`. // // ⚠️ Each row is placed with `position:absolute` + `transform` (below), which makes EVERY ROW ITS OWN // STACKING CONTEXT. A z-indexed child therefore cannot escape its row: a menu or popover rendered // inside an item draws UNDER the next row, whatever z-index it carries — the channel manager's tag // picker hit exactly this. Anything floating that an item renders must portal out (see Overlay). // // 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 LIST_GAP = 4; function chunk(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; } export interface VirtualGridProps { 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 (`minCol == null`): the reading-width cap in px (null = fill). */ colMaxWidth?: number | null; /** Multi-column layout only (`minCol != null`): the most columns to lay out, or null for as many * as fit. This is the UPPER end of the viewport contract (E10 S4): an auto-fill grid always settles * just above its minimum column, so on an ultrawide it answers "more room" with more, smaller * items forever — 3440px gave the feed 11 columns of 264px. With a cap the extra width goes into * the items instead (8 columns of ~370px), which is what a screen watched from a distance wants. * It only ever binds ABOVE the width where that many columns already fit, so the 1024px floor and * 1920 are untouched; at 2560 it depends on the caller's cap (see `feedView.maxCols`). */ maxCols?: 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. * `index` is the item's position in `items` — what `navItemAttrs` wants on the item's root. */ renderItem: (item: T, itemWidth: number, index: number) => ReactNode; /** Take part in page keyboard navigation (`lib/listNav`): `j`/`k`/arrows/Enter over the items. * The grid supplies the column count and scrolls an unmounted item's row in; the item must spread * `navItemAttrs(index)` on its root and mark its "open" element `data-nav-open`. */ keyboardNav?: boolean; /** Infinite scroll — omit all three for a finite client-side list. */ hasNextPage?: boolean; isFetchingNextPage?: boolean; fetchNextPage?: () => void; } export default function VirtualGrid({ items, getKey, minCol, colMaxWidth = null, maxCols = null, rowEst, renderItem, hasNextPage = false, isFetchingNextPage = false, fetchNextPage, keyboardNav = false, }: VirtualGridProps) { const listRef = useRef(null); const [scrollEl, setScrollEl] = useState(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); // The list scrolls inside an
(the page scroller), not the window, so the // virtualizer needs that element. The SAME walk lib/listNav measures its visible band against: the // virtualizer and the keyboard must agree on which element scrolls. useLayoutEffect(() => { if (listRef.current) setScrollEl(scrollParent(listRef.current) ?? document.documentElement); }, []); // 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, maxCols); setColCount(cols); setItemWidth(minCol == null ? Math.min(w, colMaxWidth ?? w) : itemWidthFor(w, cols)); }; measure(); const ro = new ResizeObserver(measure); ro.observe(node); if (scrollEl) ro.observe(scrollEl); return () => ro.disconnect(); }, [scrollEl, minCol, colMaxWidth, maxCols]); const rows = useMemo(() => chunk(items, colCount), [items, colCount]); // The React Compiler (E27 S1) SKIPS memoising this whole component, and says so: TanStack // Virtual's `useVirtualizer()` hands back functions that cannot be memoised without going stale, // so the compiler bails rather than risk a frozen viewport. That is the correct trade here — this // component's cost is the row window it mounts, not its render closures — and it is the ONE such // site in the app, which is why this is a targeted disable rather than the rule being turned off: // a second incompatible library anywhere else must still stop the gate. The caution in the rule's // own text applies, though: values taken from this API and handed to a memoised child can go // stale, so keep the virtualizer's output inside this component. // eslint-disable-next-line react-hooks/incompatible-library -- see above; @tanstack/react-virtual const virtualizer = useVirtualizer({ count: rows.length, getScrollElement: () => scrollEl, estimateSize: () => rowEst, overscan: 4, gap: singleCol ? LIST_GAP : GRID_GAP, scrollMargin, }); const virtualRows = virtualizer.getVirtualItems(); // Only ~a screen of rows (plus overscan) is mounted, so a key that moves further — or past the // overscan at the edge — has to scroll the row in before the item exists to be focused. useListNav(listRef, { enabled: keyboardNav, count: items.length, cols: colCount, reveal: (index) => virtualizer.scrollToIndex(Math.floor(index / colCount), { align: "auto" }), }); // Infinite-scroll read-ahead: pull the next page once the last rendered row is within the // read-ahead window of the loaded end (see readAhead.ts — the window scales with the rendered row // count, clamped so a tall window doesn't prefetch unboundedly). A no-op for finite lists (no // fetchNextPage / hasNextPage false). Because this effect re-runs when a page settles // (isFetchingNextPage flips false) it CHAINS — refilling the buffer as the user consumes it, so a // fast scroll reaches the loaded end far less often. useEffect(() => { if (!fetchNextPage || !hasNextPage || isFetchingNextPage) return; const last = virtualRows[virtualRows.length - 1]; if (!last) return; if (shouldPrefetchNext(last.index, rows.length, virtualRows.length)) { fetchNextPage(); } }, [virtualRows, rows.length, hasNextPage, isFetchingNextPage, fetchNextPage]); return (
{virtualRows.map((vr) => { const row = rows[vr.index]; if (!row) return null; const first = vr.index * colCount; return (
{/* One branch for both shapes: the single-column layout chunks to colCount 1 and gets its reading-width cap instead of grid columns. */}
{/* 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, i) => ( {renderItem(item, itemWidth, first + i)} ))}
); })}
); }