diff --git a/package-lock.json b/package-lock.json index 0f3871a..a11e4d4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 034a31c..7098fb5 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/feed/Feed.tsx b/src/components/feed/Feed.tsx index 6e61b66..e2d4cc0 100644 --- a/src/components/feed/Feed.tsx +++ b/src/components/feed/Feed.tsx @@ -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 elements alive = bounded memory. + const scrollRef = useRef(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 (
{/* Header */} @@ -208,14 +237,25 @@ export function Feed() { setFollowNotes((prev) => [event, ...prev]) : undefined} /> )} - {/* Feed */} -
- {error && !isFollowing && !isTrending && ( -
- {error} -
- )} + {/* 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 && ( +
+ {error} +
+ )} + {tab === "global" && pendingNotes.length > 0 && ( + + )} + + {/* Feed (virtualized scroll container) */} +
{isLoading && filteredNotes.length === 0 && ( )} @@ -264,22 +304,27 @@ export function Feed() {
)} - {/* New notes banner — only shown on global tab */} - {tab === "global" && pendingNotes.length > 0 && ( - - )} - - {filteredNotes.map((event, index) => - event.kind === 30023 ? ( - - ) : ( - - ) + {/* Virtualized list — only the visible window of cards stays in the DOM */} + {filteredNotes.length > 0 && ( +
+ {virtualizer.getVirtualItems().map((vi) => { + const event = filteredNotes[vi.index]; + return ( +
+ {event.kind === 30023 ? ( + + ) : ( + + )} +
+ ); + })} +
)}