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 <noreply@anthropic.com>
This commit is contained in:
lucaronin
2026-02-17 17:14:36 +01:00
parent f613335f97
commit 60f2a545f1
3 changed files with 78 additions and 51 deletions

View File

@@ -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"

14
pnpm-lock.yaml generated
View File

@@ -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:

View File

@@ -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<string | null>(null)
const isEntityView = selection.kind === 'entity'
const entityGroups = isEntityView ? buildRelationshipGroups(selection.entry, entries) : []
const modifiedStatusMap = new Map<string, string>()
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<string | null, number>()
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<string | null, number>()
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 (
<div
@@ -231,14 +233,14 @@ export function NoteList({ entries, selection, selectedNote, allContent, modifie
</span>
</div>
<div className="mt-0.5 text-[12px] leading-[1.5] text-muted-foreground" style={{ display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
{getSnippet(allContent[entry.path])}
{entry.snippet}
</div>
</div>
)
}
}, [selectedNote?.path, onSelectNote])
return (
<div className="flex flex-col overflow-y-auto border-r border-border bg-card text-foreground">
<div className="flex flex-col overflow-hidden border-r border-border bg-card text-foreground" style={{ height: '100%' }}>
{/* Header */}
<div className="flex shrink-0 items-center justify-between border-b border-border px-4 py-3.5" data-tauri-drag-region style={{ WebkitAppRegion: 'drag' } as React.CSSProperties}>
<h3 className="m-0 min-w-0 flex-1 truncate text-[14px] font-semibold">
@@ -304,9 +306,9 @@ export function NoteList({ entries, selection, selectedNote, allContent, modifie
)}
{/* Items */}
<div className="flex-1 overflow-y-auto">
<div className="flex-1 overflow-hidden" style={{ minHeight: 0 }}>
{isEntityView ? (
<>
<div className="h-full overflow-y-auto">
{renderItem(selection.entry, true)}
{searchedGroups.length === 0 ? (
<div className="px-4 py-8 text-center text-[13px] text-muted-foreground">
@@ -325,15 +327,25 @@ export function NoteList({ entries, selection, selectedNote, allContent, modifie
</div>
))
)}
</>
</div>
) : (
displayed.length === 0 ? (
<div className="px-4 py-8 text-center text-[13px] text-muted-foreground">No notes found</div>
) : (
displayed.map((entry) => renderItem(entry))
<Virtuoso
style={{ height: '100%' }}
totalCount={displayed.length}
initialItemCount={Math.min(displayed.length, 30)}
itemContent={(index) => {
const entry = displayed[index]
return entry ? renderItem(entry) : null
}}
/>
)
)}
</div>
</div>
)
}
export const NoteList = memo(NoteListInner)