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:
@@ -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)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
})
|
||||
)
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user