mirror of
https://github.com/hoornet/vega.git
synced 2026-06-08 06:01:57 -07:00
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:
Generated
+30
-2
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user