7.5 KiB
MasonryGrid Virtualization Plan
Motivation
Long-scrolling infinite feeds currently mount every card from every page in the DOM. With staleTime: Infinity on the React Query client and no maxPages cap, a user scrolling 10 pages of ~50 items keeps 500 cards mounted simultaneously. The browser pays for each one:
- ~1-2 KB of DOM + React fiber + computed-style overhead per node. Mantine components typically wrap in 3-5 divs per logical card.
- Decoded image bitmap, which is usually the dominant per-card cost. An image displayed at 320×450 at 2× DPR holds a ~1.1 MB decoded RGBA surface in memory while mounted (width × height × DPR² × 4 bytes). At 320×450 × 1 DPR that's still ~575 KB.
At 500 cards mounted, the decoded-bitmap footprint alone can be 200–500 MB regardless of how small each network payload is. Reducing image request widths (450→320) helps bandwidth and decode work but does not reduce this if elements stay mounted — unmounting off-screen cards does.
Current state
There are two layout families in src/components/MasonryColumns/, with different virtualization stories:
Column-based (variable-height cards) — already handled
- MasonryColumns.tsx — non-virtualized.
- MasonryColumnsVirtual.tsx — tanstack-virtual-backed.
Used by image/post/as-posts feeds. The following call sites already use MasonryColumnsVirtual:
- pages/search/images.tsx
- components/Image/Infinite/ImagesInfinite.tsx
- components/Image/AsPosts/ImagesAsPostsInfinite.tsx
- components/Post/Infinite/PostsInfinite.tsx
- components/ImageGeneration/GenerationForm/ResourceSelectModal/ResourceHitList.tsx
Several components delegate to these and are already covered — e.g. UserMediaInfinite renders ImagesInfinite.
Remaining non-virtual MasonryColumns usages are moderator/admin pages and out of scope here.
Uniform-cell grid (square cards) — target of this work
- MasonryGrid.tsx — non-virtualized CSS grid; every card is
columnWidthsquare. - UniformGrid.tsx — similar layout, finite-size (used by home blocks with
maxRows). - No
MasonryGridVirtualexists yet.
The following 8 main card feeds use MasonryGrid and are not virtualized:
- Model/Infinite/ModelsInfinite.tsx — main
/modelsindex; single largest opportunity. - Article/Infinite/ArticlesInfinite.tsx
- Collections/Infinite/CollectionsInfinite.tsx
- Comics/ComicsInfinite.tsx
- Bounty/Infinite/BountiesInfinite.tsx
- Challenge/Infinite/ChallengesInfinite.tsx
- Club/Infinite/ClubsInfinite.tsx
- Tool/ToolsInfinite.tsx
Proposed component
Build src/components/MasonryColumns/MasonryGridVirtual.tsx as a drop-in replacement for MasonryGrid.
MasonryColumnsVirtual is not a drop-in for these feeds because it assumes variable-height items and takes imageDimensions/adjustHeight props. MasonryGrid cards are uniform (height = columnWidth), so the virtual version is actually simpler: a single useVirtualizer for rows, each virtual row rendering up to columnCount cards.
API surface
Match MasonryGrid exactly so migration is a one-line import swap per feed:
type Props<TData> = {
data: TData[];
render: React.ComponentType<MasonryRenderItemProps<TData>>;
itemId?: (data: TData) => string | number;
empty?: React.ReactNode;
withAds?: boolean;
overscan?: number; // new, optional — defaults to something sensible like 2
};
Shape
const { columnCount, columnWidth, columnGap, rowGap } = useMasonryContext();
const rowCount = Math.ceil(items.length / columnCount);
const rowVirtualizer = useVirtualizer({
count: rowCount,
getScrollElement: () => scrollAreaRef?.current ?? null,
estimateSize: () => columnWidth + rowGap,
overscan,
scrollMargin,
});
// each virtual row slices items[row * colCount, (row + 1) * colCount)
// into a flex/grid row of columnCount cards
Key considerations:
- Scroll container: use
useScrollAreaRef()the same wayMasonryColumnsVirtualdoes. The measuredscrollMarginviagetOffsetTopRelativeToAncestor(seeMasonryColumnsVirtual.tsx:103-107, :185-199) needs to be replicated. - Ad interleaving:
MasonryGridusesuseCreateAdFeedto weave{ type: 'ad', data: { AdUnit } }items into the data stream. Preserve this — ads can show up in any slot, so slice the merged feed the same way. The ad unit has its own height (300×250 per MasonryGrid.tsx:55-59), which might require a per-row height estimate if an ad lands in that row. Start by treating every row ascolumnWidthtall; revisit if ads visually break layout. - Single-column case:
MasonryGriduses a CSS grid withminmax(${columnWidth}px, ${maxSingleColumnWidth}px)on single column. The virtual version needs to honor that too — same row structure, just one card per row. emptystate: keep identical behavior — render the empty placeholder whenitems.length === 0.itemIdkeys: pass through for row+item keying to avoid remounts on reorder.
Non-goals for v1
- Variable-height cards (those belong on
MasonryColumnsVirtual). - Replacing
UniformGrid(finite lists, lower memory impact). - Built-in
content-visibilityorloading="lazy"wrapping (separate follow-up).
Migration plan
- Build
MasonryGridVirtual+ a minimal unit/integration test if the codebase has a pattern for this. - Migrate ModelsInfinite.tsx first as proof of concept — it's the highest-traffic and will surface any layout edge cases (ads, empty state, column-count transitions).
- Smoke test
/models, especially:- Scroll performance over 5+ pages.
- Ad slot rendering.
- Column-count transitions at breakpoint changes.
- The "back button" / scroll-position restoration behavior.
- Roll out to the remaining 7 feeds (articles, collections, comics, bounties, challenges, clubs, tools) — each should be a one-line import swap once v1 is proven.
Complementary follow-ups (out of scope for this branch)
- Set
gcTimeon the global QueryClient (utils/trpc.ts:69) andmaxPageson feed infinite queries — caps memory even before virtualization lands. - Add
loading="lazy"+content-visibility: autoto feed image cards as a baseline mitigation. - Broader audit items captured on the
feature/smaller-imagesbranch under docs/optimization/browser-memory-audit.md.