Files
siftlode/frontend/src/components/ui/VirtualGrid.tsx
T
peter 7d682c90d5 build(frontend): React 18 -> 19 with the React Compiler (E27 S1)
The compiler auto-memoises, which is the point: it infers what the app's
112 `useCallback` and 48 `useMemo` call sites were hand-written for and
applies the equivalent itself. It is on repo-wide with no opt-out list —
it bails on anything it cannot prove safe, and says so through the lint
rules. Verified in the built bundle rather than assumed: 369
`react.memo_cache_sentinel` markers across 35 of the 76 chunks.

ONE transform, shared by vite.config.ts and vitest.config.ts through
reactPlugin.ts. Not two `react()` calls: if the compiler ran only in the
production build, every vitest run would exercise code the user never
receives and a compiler-induced change would ship green — the same
gate-vs-image split 0.93.2 was released to fix. The plugin is imported
rather than named by the usual string, because a string is invisible to
knip and, worse, a typo in it is a silent no-op that ships an uncompiled
bundle, while a bad import is a tsc error.

eslint-plugin-react-hooks 5 -> 7 brings the whole compiler rule set: 283
findings, triaged rather than bulk-suppressed, because the two halves
needed different answers.

  react-hooks/refs (217) is OFF. 153 of those 217 are this app's popover
  architecture, not its history: 79 are @floating-ui/react's documented
  `pop.refs.setReference` / `getReferenceProps` callback-ref setters, and
  74 more are passing the popover handle down as a prop, which is what
  components/ui's popover engine is for. Suppressing them would not help
  the next popover anyone writes. The remaining ~64 are genuine
  render-phase `ref.current = x` writes and are real, but the rule cannot
  tell them apart here; they are E27 S2's work.

  The other 66 go into eslint-suppressions.json as a ratchet — new
  violations fail the gate, and `--prune-suppressions` makes a fixed one
  fail too until it is removed. 45 of them are `set-state-in-effect`,
  which IS E27 S2's subject, so that sprint now has a measured worklist.

react-i18next deliberately stays on 14. It never declared a React-19
peer — every version from 14 to 17 asks only for `react >= 16.8.0` — and
what the newer lines actually require is i18next 25/26 against our 23.
That is an i18n runtime migration wearing a React hat, and it buys this
sprint nothing.

Two React 19 type changes: `useRef<T>(null)` is now `RefObject<T | null>`
(the honest shape — the element is null until it mounts), so
PlexPlayerSettings' prop follows; and reactPlugin.ts joins
tsconfig.node.json's include.

The compiler skips memoising VirtualGrid and reports it: TanStack
Virtual's `useVirtualizer()` returns functions that cannot be memoised
without going stale. That is the right trade for a component whose cost
is its row window, and it is the one such site in the app, so it carries
a targeted disable with the reason — a second incompatible library
anywhere else must still stop the gate.

Nine lanes green, vitest 1558. Browser UAT on the dev server against the
still-running React 18 build as a control: boot, feed (virtualized grid,
popover menu, a filter change that moved the list 195,187 -> 64 items
with the URL mirrored, so no stale memoised UI), command palette, player
modal — no console errors on any of it.
2026-09-20 23:21:08 +02:00

216 lines
10 KiB
TypeScript

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<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;
}
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 (`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<T>({
items,
getKey,
minCol,
colMaxWidth = null,
maxCols = null,
rowEst,
renderItem,
hasNextPage = false,
isFetchingNextPage = false,
fetchNextPage,
keyboardNav = false,
}: 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);
// The list scrolls inside an <main overflow-y-auto> (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 (
<div ref={listRef} style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
{virtualRows.map((vr) => {
const row = rows[vr.index];
if (!row) return null;
const first = vr.index * colCount;
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: colMaxWidth ?? 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, i) => (
<Fragment key={getKey(item)}>{renderItem(item, itemWidth, first + i)}</Fragment>
))}
</div>
</div>
);
})}
</div>
);
}