From 60f2a545f18fe1846b88d29fa4441799d2d2a252 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Tue, 17 Feb 2026 17:14:36 +0100 Subject: [PATCH] perf: virtualize NoteList with react-virtuoso and add useMemo Replace plain .map() rendering with Virtuoso for virtual scrolling of 9000+ notes. Add useMemo to all filtering/sorting chains (filtered, sorted, searched, typeCounts, displayed). Use entry.snippet directly instead of computing snippets from allContent. Wrap NoteList in React.memo to prevent unnecessary re-renders. Co-Authored-By: Claude Opus 4.6 --- package.json | 1 + pnpm-lock.yaml | 14 +++++ src/components/NoteList.tsx | 114 ++++++++++++++++++++---------------- 3 files changed, 78 insertions(+), 51 deletions(-) diff --git a/package.json b/package.json index 46673f7a..687e00b3 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "radix-ui": "^1.4.3", "react": "^19.2.0", "react-dom": "^19.2.0", + "react-virtuoso": "^4.18.1", "tailwind-merge": "^3.4.1", "tailwindcss": "^4.1.18", "tw-animate-css": "^1.4.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 21297b80..2c62c764 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -74,6 +74,9 @@ importers: react-dom: specifier: ^19.2.0 version: 19.2.4(react@19.2.4) + react-virtuoso: + specifier: ^4.18.1 + version: 4.18.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) tailwind-merge: specifier: ^3.4.1 version: 3.4.1 @@ -3017,6 +3020,12 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-virtuoso@4.18.1: + resolution: {integrity: sha512-KF474cDwaSb9+SJ380xruBB4P+yGWcVkcu26HtMqYNMTYlYbrNy8vqMkE+GpAApPPufJqgOLMoWMFG/3pJMXUA==} + peerDependencies: + react: '>=16 || >=17 || >= 18 || >= 19' + react-dom: '>=16 || >=17 || >= 18 || >=19' + react@19.2.4: resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} engines: {node: '>=0.10.0'} @@ -6658,6 +6667,11 @@ snapshots: transitivePeerDependencies: - '@types/react' + react-virtuoso@4.18.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + react@19.2.4: {} redent@3.0.0: diff --git a/src/components/NoteList.tsx b/src/components/NoteList.tsx index 6c0db12c..466f065f 100644 --- a/src/components/NoteList.tsx +++ b/src/components/NoteList.tsx @@ -1,4 +1,5 @@ -import { useState } from 'react' +import { useState, useMemo, useCallback, memo } from 'react' +import { Virtuoso } from 'react-virtuoso' import type { VaultEntry, SidebarSelection, ModifiedFile } from '../types' import { cn } from '@/lib/utils' import { Input } from '@/components/ui/input' @@ -19,18 +20,6 @@ interface RelationshipGroup { entries: VaultEntry[] } -/** Extract first ~80 chars of content after the title heading */ -function getSnippet(content: string | undefined): string { - if (!content) return '' - const withoutFm = content.replace(/^---[\s\S]*?---\s*/, '') - const withoutH1 = withoutFm.replace(/^#\s+.*\n+/, '') - const clean = withoutH1 - .replace(/[#*_`\[\]]/g, '') - .replace(/\n+/g, ' ') - .trim() - return clean.slice(0, 160) + (clean.length > 160 ? '...' : '') -} - function relativeDate(ts: number | null): string { if (!ts) return '' const now = Math.floor(Date.now() / 1000) @@ -159,51 +148,64 @@ const TYPE_PILLS = [ ] as const -export function NoteList({ entries, selection, selectedNote, allContent, modifiedFiles, onSelectNote, onCreateNote }: NoteListProps) { +function NoteListInner({ entries, selection, selectedNote, modifiedFiles, onSelectNote, onCreateNote }: NoteListProps) { const [search, setSearch] = useState('') const [searchVisible, setSearchVisible] = useState(false) const [typeFilter, setTypeFilter] = useState(null) const isEntityView = selection.kind === 'entity' - const entityGroups = isEntityView ? buildRelationshipGroups(selection.entry, entries) : [] - const modifiedStatusMap = new Map() - if (modifiedFiles) { - for (const f of modifiedFiles) { - modifiedStatusMap.set(f.path, f.status) - } - } - const filtered = isEntityView ? [] : filterEntries(entries, selection, modifiedFiles) - const sorted = isEntityView ? [] : [...filtered].sort(sortByModified) + const entityGroups = useMemo( + () => isEntityView ? buildRelationshipGroups(selection.entry, entries) : [], + [isEntityView, selection, entries] + ) + + const filtered = useMemo( + () => isEntityView ? [] : filterEntries(entries, selection, modifiedFiles), + [entries, selection, modifiedFiles, isEntityView] + ) + + const sorted = useMemo( + () => isEntityView ? [] : [...filtered].sort(sortByModified), + [filtered, isEntityView] + ) const query = search.trim().toLowerCase() - const searchedGroups = query - ? entityGroups - .map((g) => ({ - ...g, - entries: g.entries.filter((e) => e.title.toLowerCase().includes(query)), - })) - .filter((g) => g.entries.length > 0) - : entityGroups + const searched = useMemo( + () => query ? sorted.filter((e) => e.title.toLowerCase().includes(query)) : sorted, + [sorted, query] + ) - const searched = query - ? sorted.filter((e) => e.title.toLowerCase().includes(query)) - : sorted + const searchedGroups = useMemo( + () => query + ? entityGroups + .map((g) => ({ + ...g, + entries: g.entries.filter((e) => e.title.toLowerCase().includes(query)), + })) + .filter((g) => g.entries.length > 0) + : entityGroups, + [entityGroups, query] + ) - const typeCounts = new Map() - typeCounts.set(null, searched.length) - for (const entry of searched) { - if (entry.isA) { - typeCounts.set(entry.isA, (typeCounts.get(entry.isA) ?? 0) + 1) + const typeCounts = useMemo(() => { + const counts = new Map() + counts.set(null, searched.length) + for (const entry of searched) { + if (entry.isA) { + counts.set(entry.isA, (counts.get(entry.isA) ?? 0) + 1) + } } - } + return counts + }, [searched]) - const displayed = typeFilter - ? searched.filter((e) => e.isA === typeFilter) - : searched + const displayed = useMemo( + () => typeFilter ? searched.filter((e) => e.isA === typeFilter) : searched, + [searched, typeFilter] + ) - const renderItem = (entry: VaultEntry, isPinned = false) => { + const renderItem = useCallback((entry: VaultEntry, isPinned = false) => { const isSelected = selectedNote?.path === entry.path && !isPinned return (
- {getSnippet(allContent[entry.path])} + {entry.snippet}
) - } + }, [selectedNote?.path, onSelectNote]) return ( -
+
{/* Header */}

@@ -304,9 +306,9 @@ export function NoteList({ entries, selection, selectedNote, allContent, modifie )} {/* Items */} -
+
{isEntityView ? ( - <> +
{renderItem(selection.entry, true)} {searchedGroups.length === 0 ? (
@@ -325,15 +327,25 @@ export function NoteList({ entries, selection, selectedNote, allContent, modifie
)) )} - +
) : ( displayed.length === 0 ? (
No notes found
) : ( - displayed.map((entry) => renderItem(entry)) + { + const entry = displayed[index] + return entry ? renderItem(entry) : null + }} + /> ) )}
) } + +export const NoteList = memo(NoteListInner)