feat: virtualize NoteList with react-virtuoso for large vaults

Replace the direct .map() rendering in ListView with react-virtuoso's
Virtuoso component. This ensures only visible items are in the DOM,
making the list performant with 9000+ notes.

Key changes:
- ListView now uses <Virtuoso> with data prop and overscan={200}
- PinnedCard and TrashWarningBanner rendered as Virtuoso Header component
- Empty state still renders without virtualization (no items to virtualize)
- mock-tauri.ts now generates 9000 bulk entries for testing
- Test setup mocks react-virtuoso for JSDOM compatibility

Product decision: EntityView (relationship groups) is NOT virtualized
because relationship groups are typically small (<100 items). Only the
flat ListView needed virtualization since it can contain 9000+ items.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
lucaronin
2026-02-23 21:11:41 +01:00
parent e02e61ae01
commit d10fd6bb25
3 changed files with 131 additions and 8 deletions

View File

@@ -1,4 +1,5 @@
import { useState, useMemo, useCallback, memo } from 'react'
import { Virtuoso } from 'react-virtuoso'
import type { VaultEntry, SidebarSelection, ModifiedFile } from '../types'
import { Input } from '@/components/ui/input'
import {
@@ -133,6 +134,18 @@ function EntityView({ entity, groups, query, collapsedGroups, sortPrefs, onToggl
)
}
function ListViewHeader({ typeDocument, isTrashView, expiredTrashCount, typeEntryMap, onClickNote }: {
typeDocument: VaultEntry | null; isTrashView: boolean; expiredTrashCount: number
typeEntryMap: Record<string, VaultEntry>; onClickNote: (entry: VaultEntry, e: React.MouseEvent) => void
}) {
return (
<>
{typeDocument && <PinnedCard entry={typeDocument} typeEntryMap={typeEntryMap} onClickNote={onClickNote} />}
<TrashWarningBanner expiredCount={isTrashView ? expiredTrashCount : 0} />
</>
)
}
function ListView({ typeDocument, isTrashView, expiredTrashCount, searched, query, renderItem, typeEntryMap, onClickNote }: {
typeDocument: VaultEntry | null; isTrashView: boolean; expiredTrashCount: number
searched: VaultEntry[]; query: string
@@ -140,15 +153,27 @@ function ListView({ typeDocument, isTrashView, expiredTrashCount, searched, quer
typeEntryMap: Record<string, VaultEntry>; onClickNote: (entry: VaultEntry, e: React.MouseEvent) => void
}) {
const emptyText = isTrashView ? 'Trash is empty' : (query ? 'No matching notes' : 'No notes found')
const hasHeader = typeDocument || (isTrashView && expiredTrashCount > 0)
if (searched.length === 0) {
return (
<div className="h-full overflow-y-auto">
{hasHeader && <ListViewHeader typeDocument={typeDocument} isTrashView={isTrashView} expiredTrashCount={expiredTrashCount} typeEntryMap={typeEntryMap} onClickNote={onClickNote} />}
<EmptyMessage text={emptyText} />
</div>
)
}
return (
<div className="h-full overflow-y-auto">
{typeDocument && <PinnedCard entry={typeDocument} typeEntryMap={typeEntryMap} onClickNote={onClickNote} />}
<TrashWarningBanner expiredCount={isTrashView ? expiredTrashCount : 0} />
{searched.length === 0
? <EmptyMessage text={emptyText} />
: searched.map((entry) => renderItem(entry))
}
</div>
<Virtuoso
style={{ height: '100%' }}
data={searched}
overscan={200}
components={{
Header: hasHeader ? () => <ListViewHeader typeDocument={typeDocument} isTrashView={isTrashView} expiredTrashCount={expiredTrashCount} typeEntryMap={typeEntryMap} onClickNote={onClickNote} /> : undefined,
}}
itemContent={(_index, entry) => renderItem(entry)}
/>
)
}

View File

@@ -1550,6 +1550,62 @@ const MOCK_ENTRIES: VaultEntry[] = [
},
]
// --- Bulk entry generator for large vault testing ---
const BULK_TYPES = ['Note', 'Project', 'Experiment', 'Responsibility', 'Procedure', 'Person', 'Event', 'Essay', 'Topic']
const BULK_ADJECTIVES = ['Quick', 'Advanced', 'Daily', 'Weekly', 'Annual', 'Draft', 'Final', 'Revised', 'Archived', 'New']
const BULK_NOUNS = ['Meeting', 'Strategy', 'Review', 'Plan', 'Analysis', 'Summary', 'Report', 'Guide', 'Checklist', 'Template', 'Framework', 'Workflow', 'Retrospective', 'Brainstorm', 'Proposal']
const BULK_SNIPPETS = [
'Key findings from the latest analysis session.',
'Notes on process improvements and next steps.',
'Summary of decisions made during the review.',
'Action items and follow-ups from discussion.',
'Draft outline for upcoming deliverable.',
'Reference material for the ongoing initiative.',
'Tracking progress on quarterly objectives.',
'Comparison of different approaches considered.',
]
function generateBulkEntries(count: number): VaultEntry[] {
const now = Date.now() / 1000
const entries: VaultEntry[] = []
for (let i = 0; i < count; i++) {
const type = BULK_TYPES[i % BULK_TYPES.length]
const adj = BULK_ADJECTIVES[i % BULK_ADJECTIVES.length]
const noun = BULK_NOUNS[i % BULK_NOUNS.length]
const title = `${adj} ${noun} ${i + 1}`
const slug = title.toLowerCase().replace(/\s+/g, '-')
const folder = type.toLowerCase()
entries.push({
path: `/Users/luca/Laputa/${folder}/${slug}.md`,
filename: `${slug}.md`,
title,
isA: type,
aliases: [],
belongsTo: [],
relatedTo: [],
status: i % 4 === 0 ? 'Active' : i % 4 === 1 ? 'Paused' : i % 4 === 2 ? 'Done' : null,
owner: i % 3 === 0 ? 'Luca Rossi' : null,
cadence: null,
archived: false,
trashed: false,
trashedAt: null,
modifiedAt: now - i * 600,
createdAt: now - 86400 * 90 - i * 3600,
fileSize: 500 + (i % 2000),
snippet: BULK_SNIPPETS[i % BULK_SNIPPETS.length],
relationships: {},
icon: null,
color: null,
order: null,
})
}
return entries
}
// Append 9000 generated entries for realistic large-vault testing
MOCK_ENTRIES.push(...generateBulkEntries(9000))
function mockFileHistory(path: string): GitCommit[] {
const filename = path.split('/').pop()?.replace('.md', '') ?? 'unknown'
const now = Math.floor(Date.now() / 1000)

View File

@@ -1 +1,43 @@
import '@testing-library/jest-dom/vitest'
import { vi } from 'vitest'
// Mock react-virtuoso: JSDOM has no real viewport, so render all items directly
vi.mock('react-virtuoso', () => {
const React = require('react')
return {
Virtuoso: ({ data, itemContent, components }: {
data?: unknown[]
itemContent?: (index: number, item: unknown) => React.ReactNode
components?: { Header?: React.ComponentType }
}) => {
const Header = components?.Header
return React.createElement('div', { 'data-testid': 'virtuoso-mock' },
Header ? React.createElement(Header) : null,
data?.map((item: unknown, index: number) =>
React.createElement('div', { key: index }, itemContent?.(index, item))
)
)
},
GroupedVirtuoso: ({ groupCounts, groupContent, itemContent }: {
groupCounts: number[]
groupContent: (index: number) => React.ReactNode
itemContent: (index: number, groupIndex: number) => React.ReactNode
}) => {
const React = require('react')
let globalIndex = 0
return React.createElement('div', { 'data-testid': 'grouped-virtuoso-mock' },
groupCounts?.map((count: number, groupIndex: number) => {
const items = []
for (let i = 0; i < count; i++) {
items.push(React.createElement('div', { key: globalIndex }, itemContent(globalIndex, groupIndex)))
globalIndex++
}
return React.createElement('div', { key: `group-${groupIndex}` },
groupContent(groupIndex),
...items
)
})
)
},
}
})