Virtualize feed list with @tanstack/react-virtual

Stage 1 of the feed virtualization work. The feed now renders only the
visible window of note cards (~25-35 in the DOM) instead of all up to 200,
via @tanstack/react-virtual (pinned 3.13.24). This structurally caps the
WebKit decoded-bitmap accumulation that caused the v0.12.6-era OOM.

Error and 'new notes' banners moved above the scroll container so the
virtualizer's coordinate space always starts at scrollTop 0. Upward-scroll
flicker fixed with a scroll-direction-aware measureElement that reuses the
cached row height on backward scroll (TanStack/virtual#659).

Feed still capped at MAX_FEED_SIZE; infinite scroll is Stage 2.
This commit is contained in:
Jure
2026-05-16 19:02:18 +02:00
parent db81de9007
commit 73c1bd1ac9
3 changed files with 100 additions and 26 deletions
+30 -2
View File
@@ -1,15 +1,16 @@
{
"name": "vega",
"version": "0.12.11",
"version": "0.12.16",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "vega",
"version": "0.12.11",
"version": "0.12.16",
"dependencies": {
"@nostr-dev-kit/ndk": "^3.0.3",
"@tailwindcss/vite": "^4.2.1",
"@tanstack/react-virtual": "3.13.24",
"@tauri-apps/api": "^2.11.0",
"@tauri-apps/plugin-dialog": "^2.7.0",
"@tauri-apps/plugin-fs": "^2.5.0",
@@ -1901,6 +1902,33 @@
"vite": "^5.2.0 || ^6 || ^7"
}
},
"node_modules/@tanstack/react-virtual": {
"version": "3.13.24",
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.24.tgz",
"integrity": "sha512-aIJvz5OSkhNIhZIpYivrxrPTKYsjW9Uzy+sP/mx0S3sev2HyvPb7xmjbYvokzEpfgYHy/HjzJ2zFAETuUfgCpg==",
"license": "MIT",
"dependencies": {
"@tanstack/virtual-core": "3.14.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@tanstack/virtual-core": {
"version": "3.14.0",
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.14.0.tgz",
"integrity": "sha512-JLANqGy/D6k4Ujmh8Tr25lGimuOXNiaVyXaCAZS0W+1390sADdGnyUdSWNIfd49gebtIxGMij4IktRVzrdr12Q==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tauri-apps/api": {
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.11.0.tgz",
+1
View File
@@ -14,6 +14,7 @@
"dependencies": {
"@nostr-dev-kit/ndk": "^3.0.3",
"@tailwindcss/vite": "^4.2.1",
"@tanstack/react-virtual": "3.13.24",
"@tauri-apps/api": "^2.11.0",
"@tauri-apps/plugin-dialog": "^2.7.0",
"@tauri-apps/plugin-fs": "^2.5.0",
+69 -24
View File
@@ -1,4 +1,5 @@
import { useEffect, useState, useCallback } from "react";
import { useEffect, useState, useCallback, useRef } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
import { useFeedStore } from "../../stores/feed";
import { useUserStore } from "../../stores/user";
import { useMuteStore } from "../../stores/mute";
@@ -137,6 +138,34 @@ export function Feed() {
return true;
});
// Virtualized feed list: only ~25 note cards stay mounted in the DOM at any
// time regardless of feed length. This is the structural fix for the WebKit
// bitmap-accumulation OOM — fewer <img> elements alive = bounded memory.
const scrollRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: filteredNotes.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => 140,
overscan: 6,
// Re-measuring rows during upward scroll shifts scrollTop a frame late →
// visible flicker. On backward scroll, reuse the cached measurement instead.
// (TanStack/virtual#659; falls back to a real measure if never cached.)
measureElement: (element, _entry, instance) => {
if (instance.scrollDirection === "forward" || instance.scrollDirection === null) {
return element.getBoundingClientRect().height;
}
const index = Number(element.getAttribute("data-index"));
const cached = instance.getVirtualItems().find((v) => v.index === index)?.size;
return cached ?? element.getBoundingClientRect().height;
},
});
// Keyboard nav (j/k) moves focusedNoteIndex; virtualization unmounts off-screen
// rows, so bring the focused row into the rendered window when it changes.
useEffect(() => {
if (focusedNoteIndex >= 0) virtualizer.scrollToIndex(focusedNoteIndex, { align: "center" });
}, [focusedNoteIndex, virtualizer]);
return (
<div className="h-full flex flex-col">
{/* Header */}
@@ -208,14 +237,25 @@ export function Feed() {
<ComposeBox onPublished={isFollowing ? undefined : loadFeed} onNoteInjected={isFollowing ? (event) => setFollowNotes((prev) => [event, ...prev]) : undefined} />
)}
{/* Feed */}
<div className="flex-1 overflow-y-auto">
{error && !isFollowing && !isTrending && (
<div className="px-4 py-3 text-danger text-[12px] border-b border-border bg-danger/5">
{error}
</div>
)}
{/* Error + new-notes banners — pinned above the scroll area so they never
offset the virtualizer's coordinate space (its spacer must start at scrollTop 0) */}
{error && !isFollowing && !isTrending && (
<div className="px-4 py-3 text-danger text-[12px] border-b border-border bg-danger/5 shrink-0">
{error}
</div>
)}
{tab === "global" && pendingNotes.length > 0 && (
<button
onClick={flushPendingNotes}
className="w-full py-2 text-[12px] text-accent border-b border-accent/20 bg-accent/5 hover:bg-accent/10 transition-colors shrink-0"
>
{pendingNotes.length} new {pendingNotes.length === 1 ? "note" : "notes"} click to load
</button>
)}
{/* Feed (virtualized scroll container) */}
<div ref={scrollRef} className="flex-1 overflow-y-auto">
{isLoading && filteredNotes.length === 0 && (
<SkeletonNoteList count={6} />
)}
@@ -264,22 +304,27 @@ export function Feed() {
</div>
)}
{/* New notes banner — only shown on global tab */}
{tab === "global" && pendingNotes.length > 0 && (
<button
onClick={flushPendingNotes}
className="w-full py-2 text-[12px] text-accent border-b border-accent/20 bg-accent/5 hover:bg-accent/10 transition-colors"
>
{pendingNotes.length} new {pendingNotes.length === 1 ? "note" : "notes"} click to load
</button>
)}
{filteredNotes.map((event, index) =>
event.kind === 30023 ? (
<ArticleCard key={event.id} event={event} />
) : (
<NoteCard key={event.id} event={event} focused={focusedNoteIndex === index} />
)
{/* Virtualized list — only the visible window of cards stays in the DOM */}
{filteredNotes.length > 0 && (
<div style={{ height: `${virtualizer.getTotalSize()}px`, position: "relative", width: "100%" }}>
{virtualizer.getVirtualItems().map((vi) => {
const event = filteredNotes[vi.index];
return (
<div
key={event.id}
data-index={vi.index}
ref={virtualizer.measureElement}
style={{ position: "absolute", top: 0, left: 0, width: "100%", transform: `translateY(${vi.start}px)` }}
>
{event.kind === 30023 ? (
<ArticleCard event={event} />
) : (
<NoteCard event={event} focused={focusedNoteIndex === vi.index} />
)}
</div>
);
})}
</div>
)}
</div>
</div>