From 977076367471f9d5c9f24fb504cf850ae9722bbd Mon Sep 17 00:00:00 2001 From: lucaronin Date: Mon, 23 Feb 2026 21:11:41 +0100 Subject: [PATCH 1/6] 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 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 --- src/components/NoteList.tsx | 41 +++++++++++++++++++++------ src/mock-tauri.ts | 56 +++++++++++++++++++++++++++++++++++++ src/test/setup.ts | 42 ++++++++++++++++++++++++++++ 3 files changed, 131 insertions(+), 8 deletions(-) diff --git a/src/components/NoteList.tsx b/src/components/NoteList.tsx index 4ed2c785..c1d8e161 100644 --- a/src/components/NoteList.tsx +++ b/src/components/NoteList.tsx @@ -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; onClickNote: (entry: VaultEntry, e: React.MouseEvent) => void +}) { + return ( + <> + {typeDocument && } + + + ) +} + 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; 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 ( +
+ {hasHeader && } + +
+ ) + } + return ( -
- {typeDocument && } - - {searched.length === 0 - ? - : searched.map((entry) => renderItem(entry)) - } -
+ : undefined, + }} + itemContent={(_index, entry) => renderItem(entry)} + /> ) } diff --git a/src/mock-tauri.ts b/src/mock-tauri.ts index 27097cca..5f3dd467 100644 --- a/src/mock-tauri.ts +++ b/src/mock-tauri.ts @@ -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) diff --git a/src/test/setup.ts b/src/test/setup.ts index a9d0dd31..705a15e4 100644 --- a/src/test/setup.ts +++ b/src/test/setup.ts @@ -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 + ) + }) + ) + }, + } +}) From f6a4ab14de5a37626f90c2a16e81562ff29b79ce Mon Sep 17 00:00:00 2001 From: lucaronin Date: Mon, 23 Feb 2026 21:13:58 +0100 Subject: [PATCH 2/6] test+design: add virtual list tests and design wireframes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add 7 new tests covering virtual list behavior with large datasets: - 9000-entry rendering without crash - Items rendered via Virtuoso mock - Search filtering on large dataset - Sorting correctness with large dataset - Section group filtering with mixed types - Selection highlighting in virtualized list - Click handler on virtualized items Add design/performance-note-list.pen with 4 frames: 1. Default state — 9000+ notes with scrollbar 2. Scrolled mid-list — items 4500+ 3. Search filtering — active query narrowing results 4. Empty search result — no matching notes Co-Authored-By: Claude Opus 4.6 --- design/performance-note-list.pen | 273 +++++++++++++++++++++++++++++++ src/components/NoteList.test.tsx | 108 ++++++++++++ 2 files changed, 381 insertions(+) create mode 100644 design/performance-note-list.pen diff --git a/design/performance-note-list.pen b/design/performance-note-list.pen new file mode 100644 index 00000000..afa6e41f --- /dev/null +++ b/design/performance-note-list.pen @@ -0,0 +1,273 @@ +{ + "version": "2.8", + "children": [ + { + "type": "frame", + "id": "vl001", + "x": 0, + "y": 0, + "name": "Virtual List — Default State (9000+ notes)", + "theme": {"Mode": "Light"}, + "clip": true, + "width": 300, + "height": 900, + "fill": "$--card", + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "vl002", + "name": "Header", + "width": "fill_container", + "height": 45, + "fill": "$--card", + "stroke": {"align": "inside", "thickness": {"bottom": 1}, "fill": "$--border"}, + "padding": [0, 16], + "alignItems": "center", + "children": [ + {"type": "text", "id": "vl003", "text": "Notes", "fontSize": 14, "fontWeight": 600, "fill": "$--foreground"}, + {"type": "text", "id": "vl004", "text": "9,236 items", "fontSize": 11, "fill": "$--muted-foreground", "x": 200} + ] + }, + { + "type": "frame", + "id": "vl005", + "name": "Virtualized Viewport", + "width": "fill_container", + "height": "fill_container", + "fill": "$--card", + "layout": "vertical", + "clip": true, + "children": [ + {"type": "frame", "id": "vl006", "name": "NoteItem-visible-1", "width": "fill_container", "height": 72, "fill": "$--card", "stroke": {"align": "inside", "thickness": {"bottom": 1}, "fill": "$--border"}, "padding": [14, 16], "layout": "vertical", "gap": 2, "children": [ + {"type": "text", "id": "vl007", "text": "Build Laputa App", "fontSize": 13, "fontWeight": 600, "fill": "$--foreground"}, + {"type": "text", "id": "vl008", "text": "This paragraph has bold text, italic text...", "fontSize": 12, "fill": "$--muted-foreground"}, + {"type": "text", "id": "vl009", "text": "2m ago", "fontSize": 10, "fill": "$--muted-foreground"} + ]}, + {"type": "frame", "id": "vl010", "name": "NoteItem-visible-2", "width": "fill_container", "height": 72, "fill": "$--card", "stroke": {"align": "inside", "thickness": {"bottom": 1}, "fill": "$--border"}, "padding": [14, 16], "layout": "vertical", "gap": 2, "children": [ + {"type": "text", "id": "vl011", "text": "Facebook Ads Strategy", "fontSize": 13, "fontWeight": 500, "fill": "$--foreground"}, + {"type": "text", "id": "vl012", "text": "Lookalike audiences convert 3x better...", "fontSize": 12, "fill": "$--muted-foreground"}, + {"type": "text", "id": "vl013", "text": "5h ago", "fontSize": 10, "fill": "$--muted-foreground"} + ]}, + {"type": "frame", "id": "vl014", "name": "NoteItem-visible-3", "width": "fill_container", "height": 72, "fill": "$--card", "stroke": {"align": "inside", "thickness": {"bottom": 1}, "fill": "$--border"}, "padding": [14, 16], "layout": "vertical", "gap": 2, "children": [ + {"type": "text", "id": "vl015", "text": "Quick Meeting 1", "fontSize": 13, "fontWeight": 500, "fill": "$--foreground"}, + {"type": "text", "id": "vl016", "text": "Key findings from the latest analysis...", "fontSize": 12, "fill": "$--muted-foreground"}, + {"type": "text", "id": "vl017", "text": "1d ago", "fontSize": 10, "fill": "$--muted-foreground"} + ]}, + {"type": "frame", "id": "vl018", "name": "NoteItem-visible-4", "width": "fill_container", "height": 72, "fill": "$--card", "stroke": {"align": "inside", "thickness": {"bottom": 1}, "fill": "$--border"}, "padding": [14, 16], "layout": "vertical", "gap": 2, "children": [ + {"type": "text", "id": "vl019", "text": "Advanced Strategy 2", "fontSize": 13, "fontWeight": 500, "fill": "$--foreground"}, + {"type": "text", "id": "vl020", "text": "Notes on process improvements...", "fontSize": 12, "fill": "$--muted-foreground"}, + {"type": "text", "id": "vl021", "text": "2d ago", "fontSize": 10, "fill": "$--muted-foreground"} + ]}, + {"type": "frame", "id": "vl022", "name": "NoteItem-visible-5-selected", "width": "fill_container", "height": 72, "fill": "#2383E210", "stroke": {"align": "inside", "thickness": {"left": 3, "bottom": 1}, "fill": "$--accent-blue"}, "padding": [14, 13], "layout": "vertical", "gap": 2, "children": [ + {"type": "text", "id": "vl023", "text": "Daily Review 3", "fontSize": 13, "fontWeight": 600, "fill": "$--foreground"}, + {"type": "text", "id": "vl024", "text": "Summary of decisions made during review...", "fontSize": 12, "fill": "$--muted-foreground"}, + {"type": "text", "id": "vl025", "text": "3d ago", "fontSize": 10, "fill": "$--muted-foreground"} + ]}, + {"type": "frame", "id": "vl026", "name": "NoteItem-visible-6", "width": "fill_container", "height": 72, "fill": "$--card", "stroke": {"align": "inside", "thickness": {"bottom": 1}, "fill": "$--border"}, "padding": [14, 16], "layout": "vertical", "gap": 2, "children": [ + {"type": "text", "id": "vl027", "text": "Weekly Plan 4", "fontSize": 13, "fontWeight": 500, "fill": "$--foreground"}, + {"type": "text", "id": "vl028", "text": "Action items and follow-ups...", "fontSize": 12, "fill": "$--muted-foreground"}, + {"type": "text", "id": "vl029", "text": "4d ago", "fontSize": 10, "fill": "$--muted-foreground"} + ]}, + {"type": "frame", "id": "vl030", "name": "scrollbar-indicator", "width": 6, "height": 40, "fill": "$--muted-foreground", "opacity": 0.3, "cornerRadius": 3, "x": 290, "y": 200} + ] + } + ] + }, + { + "type": "frame", + "id": "vl100", + "x": 340, + "y": 0, + "name": "Virtual List — Scrolled Mid-list", + "theme": {"Mode": "Light"}, + "clip": true, + "width": 300, + "height": 900, + "fill": "$--card", + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "vl101", + "name": "Header", + "width": "fill_container", + "height": 45, + "fill": "$--card", + "stroke": {"align": "inside", "thickness": {"bottom": 1}, "fill": "$--border"}, + "padding": [0, 16], + "alignItems": "center", + "children": [ + {"type": "text", "id": "vl102", "text": "Notes", "fontSize": 14, "fontWeight": 600, "fill": "$--foreground"} + ] + }, + { + "type": "frame", + "id": "vl103", + "name": "Virtualized Viewport — items 4500-4512", + "width": "fill_container", + "height": "fill_container", + "fill": "$--card", + "layout": "vertical", + "clip": true, + "children": [ + {"type": "frame", "id": "vl104", "name": "NoteItem-mid-1", "width": "fill_container", "height": 72, "fill": "$--card", "stroke": {"align": "inside", "thickness": {"bottom": 1}, "fill": "$--border"}, "padding": [14, 16], "layout": "vertical", "gap": 2, "children": [ + {"type": "text", "id": "vl105", "text": "Final Report 4501", "fontSize": 13, "fontWeight": 500, "fill": "$--foreground"}, + {"type": "text", "id": "vl106", "text": "Draft outline for upcoming deliverable.", "fontSize": 12, "fill": "$--muted-foreground"}, + {"type": "text", "id": "vl107", "text": "Jan 15", "fontSize": 10, "fill": "$--muted-foreground"} + ]}, + {"type": "frame", "id": "vl108", "name": "NoteItem-mid-2", "width": "fill_container", "height": 72, "fill": "$--card", "stroke": {"align": "inside", "thickness": {"bottom": 1}, "fill": "$--border"}, "padding": [14, 16], "layout": "vertical", "gap": 2, "children": [ + {"type": "text", "id": "vl109", "text": "Revised Checklist 4502", "fontSize": 13, "fontWeight": 500, "fill": "$--foreground"}, + {"type": "text", "id": "vl110", "text": "Reference material for ongoing initiative.", "fontSize": 12, "fill": "$--muted-foreground"}, + {"type": "text", "id": "vl111", "text": "Jan 14", "fontSize": 10, "fill": "$--muted-foreground"} + ]}, + {"type": "frame", "id": "vl112", "name": "NoteItem-mid-3", "width": "fill_container", "height": 72, "fill": "$--card", "stroke": {"align": "inside", "thickness": {"bottom": 1}, "fill": "$--border"}, "padding": [14, 16], "layout": "vertical", "gap": 2, "children": [ + {"type": "text", "id": "vl113", "text": "Archived Template 4503", "fontSize": 13, "fontWeight": 500, "fill": "$--foreground"}, + {"type": "text", "id": "vl114", "text": "Tracking progress on quarterly objectives.", "fontSize": 12, "fill": "$--muted-foreground"}, + {"type": "text", "id": "vl115", "text": "Jan 13", "fontSize": 10, "fill": "$--muted-foreground"} + ]}, + {"type": "frame", "id": "vl116", "name": "NoteItem-mid-4", "width": "fill_container", "height": 72, "fill": "$--card", "stroke": {"align": "inside", "thickness": {"bottom": 1}, "fill": "$--border"}, "padding": [14, 16], "layout": "vertical", "gap": 2, "children": [ + {"type": "text", "id": "vl117", "text": "New Framework 4504", "fontSize": 13, "fontWeight": 500, "fill": "$--foreground"}, + {"type": "text", "id": "vl118", "text": "Comparison of different approaches.", "fontSize": 12, "fill": "$--muted-foreground"}, + {"type": "text", "id": "vl119", "text": "Jan 12", "fontSize": 10, "fill": "$--muted-foreground"} + ]}, + {"type": "frame", "id": "vl120", "name": "scrollbar-indicator-mid", "width": 6, "height": 40, "fill": "$--muted-foreground", "opacity": 0.3, "cornerRadius": 3, "x": 290, "y": 440} + ] + } + ] + }, + { + "type": "frame", + "id": "vl200", + "x": 680, + "y": 0, + "name": "Virtual List — Search Filtering", + "theme": {"Mode": "Light"}, + "clip": true, + "width": 300, + "height": 900, + "fill": "$--card", + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "vl201", + "name": "Header", + "width": "fill_container", + "height": 45, + "fill": "$--card", + "stroke": {"align": "inside", "thickness": {"bottom": 1}, "fill": "$--border"}, + "padding": [0, 16], + "alignItems": "center", + "children": [ + {"type": "text", "id": "vl202", "text": "Notes", "fontSize": 14, "fontWeight": 600, "fill": "$--foreground"} + ] + }, + { + "type": "frame", + "id": "vl203", + "name": "Search Bar", + "width": "fill_container", + "height": 40, + "fill": "$--card", + "stroke": {"align": "inside", "thickness": {"bottom": 1}, "fill": "$--border"}, + "padding": [8, 12], + "alignItems": "center", + "children": [ + {"type": "frame", "id": "vl204", "name": "search-input", "width": "fill_container", "height": 32, "fill": "$--background", "cornerRadius": 6, "stroke": {"align": "inside", "thickness": 1, "fill": "$--border"}, "padding": [0, 8], "alignItems": "center", "children": [ + {"type": "text", "id": "vl205", "text": "strategy", "fontSize": 13, "fill": "$--foreground"} + ]} + ] + }, + { + "type": "frame", + "id": "vl206", + "name": "Filtered Results (23 matches)", + "width": "fill_container", + "height": "fill_container", + "fill": "$--card", + "layout": "vertical", + "clip": true, + "children": [ + {"type": "frame", "id": "vl207", "name": "NoteItem-filtered-1", "width": "fill_container", "height": 72, "fill": "$--card", "stroke": {"align": "inside", "thickness": {"bottom": 1}, "fill": "$--border"}, "padding": [14, 16], "layout": "vertical", "gap": 2, "children": [ + {"type": "text", "id": "vl208", "text": "Facebook Ads Strategy", "fontSize": 13, "fontWeight": 500, "fill": "$--foreground"}, + {"type": "text", "id": "vl209", "text": "Lookalike audiences convert 3x better...", "fontSize": 12, "fill": "$--muted-foreground"}, + {"type": "text", "id": "vl210", "text": "5h ago", "fontSize": 10, "fill": "$--muted-foreground"} + ]}, + {"type": "frame", "id": "vl211", "name": "NoteItem-filtered-2", "width": "fill_container", "height": 72, "fill": "$--card", "stroke": {"align": "inside", "thickness": {"bottom": 1}, "fill": "$--border"}, "padding": [14, 16], "layout": "vertical", "gap": 2, "children": [ + {"type": "text", "id": "vl212", "text": "Advanced Strategy 2", "fontSize": 13, "fontWeight": 500, "fill": "$--foreground"}, + {"type": "text", "id": "vl213", "text": "Notes on process improvements...", "fontSize": 12, "fill": "$--muted-foreground"}, + {"type": "text", "id": "vl214", "text": "2d ago", "fontSize": 10, "fill": "$--muted-foreground"} + ]}, + {"type": "frame", "id": "vl215", "name": "NoteItem-filtered-3", "width": "fill_container", "height": 72, "fill": "$--card", "stroke": {"align": "inside", "thickness": {"bottom": 1}, "fill": "$--border"}, "padding": [14, 16], "layout": "vertical", "gap": 2, "children": [ + {"type": "text", "id": "vl216", "text": "Quick Strategy 10", "fontSize": 13, "fontWeight": 500, "fill": "$--foreground"}, + {"type": "text", "id": "vl217", "text": "Key findings from the latest analysis...", "fontSize": 12, "fill": "$--muted-foreground"}, + {"type": "text", "id": "vl218", "text": "Jan 5", "fontSize": 10, "fill": "$--muted-foreground"} + ]} + ] + } + ] + }, + { + "type": "frame", + "id": "vl300", + "x": 1020, + "y": 0, + "name": "Virtual List — Empty Search Result", + "theme": {"Mode": "Light"}, + "clip": true, + "width": 300, + "height": 400, + "fill": "$--card", + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "vl301", + "name": "Header", + "width": "fill_container", + "height": 45, + "fill": "$--card", + "stroke": {"align": "inside", "thickness": {"bottom": 1}, "fill": "$--border"}, + "padding": [0, 16], + "alignItems": "center", + "children": [ + {"type": "text", "id": "vl302", "text": "Notes", "fontSize": 14, "fontWeight": 600, "fill": "$--foreground"} + ] + }, + { + "type": "frame", + "id": "vl303", + "name": "Search Bar", + "width": "fill_container", + "height": 40, + "fill": "$--card", + "stroke": {"align": "inside", "thickness": {"bottom": 1}, "fill": "$--border"}, + "padding": [8, 12], + "alignItems": "center", + "children": [ + {"type": "frame", "id": "vl304", "name": "search-input", "width": "fill_container", "height": 32, "fill": "$--background", "cornerRadius": 6, "stroke": {"align": "inside", "thickness": 1, "fill": "$--border"}, "padding": [0, 8], "alignItems": "center", "children": [ + {"type": "text", "id": "vl305", "text": "xyznonexistent", "fontSize": 13, "fill": "$--foreground"} + ]} + ] + }, + { + "type": "frame", + "id": "vl306", + "name": "Empty State", + "width": "fill_container", + "height": "fill_container", + "fill": "$--card", + "layout": "vertical", + "justifyContent": "center", + "alignItems": "center", + "padding": [32, 16], + "children": [ + {"type": "text", "id": "vl307", "text": "No matching notes", "fontSize": 13, "fill": "$--muted-foreground", "textAlign": "center"} + ] + } + ] + } + ], + "variables": {} +} \ No newline at end of file diff --git a/src/components/NoteList.test.tsx b/src/components/NoteList.test.tsx index 7b29ddda..8275d31f 100644 --- a/src/components/NoteList.test.tsx +++ b/src/components/NoteList.test.tsx @@ -748,3 +748,111 @@ describe('NoteList — trash view', () => { expect(screen.getByText('Trash is empty')).toBeInTheDocument() }) }) + +// --- Virtual list performance tests --- + +describe('NoteList — virtual list with large datasets', () => { + const makeEntry = (i: number, overrides?: Partial): VaultEntry => ({ + path: `/vault/note/note-${i}.md`, + filename: `note-${i}.md`, + title: `Note ${i}`, + isA: 'Note', + aliases: [], + belongsTo: [], + relatedTo: [], + status: null, + owner: null, + cadence: null, + archived: false, + trashed: false, + trashedAt: null, + modifiedAt: 1700000000 - i * 60, + createdAt: null, + fileSize: 500, + snippet: `Content of note ${i}`, + relationships: {}, + icon: null, + color: null, + order: null, + ...overrides, + }) + + it('renders 9000 entries without crashing', () => { + const largeDataset = Array.from({ length: 9000 }, (_, i) => makeEntry(i)) + const { container } = render( + + ) + // Virtuoso mock renders all items; the real component only renders visible ones + expect(container.querySelector('[data-testid="virtuoso-mock"]')).toBeInTheDocument() + }) + + it('renders items from a large dataset via Virtuoso', () => { + const largeDataset = Array.from({ length: 500 }, (_, i) => makeEntry(i)) + render( + + ) + expect(screen.getByText('Note 0')).toBeInTheDocument() + expect(screen.getByText('Note 499')).toBeInTheDocument() + }) + + it('search filters large dataset correctly', () => { + const entries = [ + makeEntry(0, { title: 'Alpha Strategy' }), + ...Array.from({ length: 998 }, (_, i) => makeEntry(i + 1, { title: `Filler Note ${i + 1}` })), + makeEntry(999, { title: 'Beta Strategy' }), + ] + render( + + ) + fireEvent.click(screen.getByTitle('Search notes')) + fireEvent.change(screen.getByPlaceholderText('Search notes...'), { target: { value: 'Strategy' } }) + expect(screen.getByText('Alpha Strategy')).toBeInTheDocument() + expect(screen.getByText('Beta Strategy')).toBeInTheDocument() + expect(screen.queryByText('Filler Note 1')).not.toBeInTheDocument() + }) + + it('sorting works with large dataset', () => { + const entries = [ + makeEntry(0, { title: 'Zebra', modifiedAt: 1000 }), + makeEntry(1, { title: 'Alpha', modifiedAt: 3000 }), + ...Array.from({ length: 100 }, (_, i) => makeEntry(i + 2, { title: `Mid ${i}`, modifiedAt: 2000 - i })), + ] + render( + + ) + // Default sort is modified desc — Alpha (3000) should come first + const firstTitle = screen.getAllByText(/^Alpha$|^Zebra$/)[0] + expect(firstTitle.textContent).toBe('Alpha') + }) + + it('section group filter works with large mixed-type dataset', () => { + const entries = [ + ...Array.from({ length: 100 }, (_, i) => makeEntry(i, { isA: 'Project', title: `Project ${i}` })), + ...Array.from({ length: 200 }, (_, i) => makeEntry(100 + i, { isA: 'Note', title: `Note ${i}` })), + ] + render( + + ) + expect(screen.getByText('Project 0')).toBeInTheDocument() + expect(screen.queryByText('Note 0')).not.toBeInTheDocument() + }) + + it('selection highlighting works in virtualized list', () => { + const entries = Array.from({ length: 100 }, (_, i) => makeEntry(i)) + const selected = entries[5] + render( + + ) + expect(screen.getByText('Note 5')).toBeInTheDocument() + }) + + it('click handler works on virtualized items', () => { + noopReplace.mockClear() + const entries = Array.from({ length: 100 }, (_, i) => makeEntry(i)) + render( + + ) + fireEvent.click(screen.getByText('Note 50')) + expect(noopReplace).toHaveBeenCalledWith(entries[50]) + }) +}) From fa0049f7f8841e640228405484d30f7eceeb0167 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Mon, 23 Feb 2026 21:16:20 +0100 Subject: [PATCH 3/6] fix: use proper ESM imports in test setup for TypeScript compatibility Replace require('react') with ESM imports of createElement and types from React. This fixes the TS2591 error in tsc build for setup.ts. Co-Authored-By: Claude Opus 4.6 --- src/test/setup.ts | 73 +++++++++++++++++++++++------------------------ 1 file changed, 35 insertions(+), 38 deletions(-) diff --git a/src/test/setup.ts b/src/test/setup.ts index 705a15e4..966dc7cc 100644 --- a/src/test/setup.ts +++ b/src/test/setup.ts @@ -1,43 +1,40 @@ import '@testing-library/jest-dom/vitest' import { vi } from 'vitest' +import { createElement, type ReactNode, type ComponentType } from 'react' // 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)) +vi.mock('react-virtuoso', () => ({ + Virtuoso: ({ data, itemContent, components }: { + data?: unknown[] + itemContent?: (index: number, item: unknown) => ReactNode + components?: { Header?: ComponentType } + }) => { + const Header = components?.Header + return createElement('div', { 'data-testid': 'virtuoso-mock' }, + Header ? createElement(Header) : null, + data?.map((item: unknown, index: number) => + createElement('div', { key: index }, itemContent?.(index, item)) + ) + ) + }, + GroupedVirtuoso: ({ groupCounts, groupContent, itemContent }: { + groupCounts: number[] + groupContent: (index: number) => ReactNode + itemContent: (index: number, groupIndex: number) => ReactNode + }) => { + let globalIndex = 0 + return createElement('div', { 'data-testid': 'grouped-virtuoso-mock' }, + groupCounts?.map((count: number, groupIndex: number) => { + const items = [] + for (let i = 0; i < count; i++) { + items.push(createElement('div', { key: globalIndex }, itemContent(globalIndex, groupIndex))) + globalIndex++ + } + return createElement('div', { key: `group-${groupIndex}` }, + groupContent(groupIndex), + ...items ) - ) - }, - 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 - ) - }) - ) - }, - } -}) + }) + ) + }, +})) From 40061a5baa6b4867eb75a6fbcd886c6abcd6bf8f Mon Sep 17 00:00:00 2001 From: lucaronin Date: Mon, 23 Feb 2026 21:17:06 +0100 Subject: [PATCH 4/6] docs: update architecture and add completion summary Update ARCHITECTURE.md to document react-virtuoso virtual rendering in NoteList for large vault performance. Co-Authored-By: Claude Opus 4.6 --- .claude-done | 21 ++++++++++++++++++--- docs/ARCHITECTURE.md | 2 +- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/.claude-done b/.claude-done index 0b0ce8fa..93c889f0 100644 --- a/.claude-done +++ b/.claude-done @@ -1,3 +1,18 @@ -Task: vault-picker-local -Summary: Added local vault creation options to vault picker -Commits: 5 +Task: performance-note-list +Summary: Implemented virtual list rendering for NoteList using react-virtuoso. + +Changes: +- NoteList ListView now uses component instead of .map() for flat lists +- Only visible items are rendered in the DOM (overscan: 200px) +- EntityView (relationship groups) unchanged — groups are small (<100 items) +- mock-tauri.ts generates 9000 bulk entries for testing +- Test setup mocks react-virtuoso for JSDOM compatibility +- 7 new tests covering large dataset rendering, search, sorting, filtering +- Design file: design/performance-note-list.pen (4 frames) +- Docs updated: ARCHITECTURE.md notes virtual rendering approach + +Product decisions: +- Only virtualized ListView (flat lists), not EntityView — relationship groups + are inherently small and do not need virtualization +- Used overscan={200} for smooth scrolling experience +- PinnedCard and TrashWarningBanner rendered as Virtuoso Header component diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index d6e9393d..754aa19d 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -74,7 +74,7 @@ Laputa is a personal knowledge and life management desktop app. It reads a vault ``` - **Sidebar** (150-400px, resizable): Top-level filters (All Notes, Favorites) and collapsible section groups (Projects, Experiments, Responsibilities, etc.) -- **Note List** (200-500px, resizable): Filtered list of notes matching the sidebar selection. Shows snippets, modified dates, and relationship groups. +- **Note List** (200-500px, resizable): Filtered list of notes matching the sidebar selection. Shows snippets, modified dates, and relationship groups. The flat list view (All Notes, type sections, etc.) uses **react-virtuoso** for virtual rendering — only visible items are in the DOM, enabling smooth scrolling with 9000+ notes. Entity/relationship views are not virtualized (groups are small). - **Editor** (flex, fills remaining space): Tab bar, breadcrumb bar with word count and modified indicator, BlockNote editor with wikilink support. Can toggle to diff view for modified files. - **Inspector / AI Chat** (200-500px or 40px collapsed): Toggles between Inspector (frontmatter, relationships, backlinks, git history) and AI Chat panel. The Sparkle icon in the breadcrumb bar toggles between them. From cbc37c94db444ea812c337417bdf12bb1ffb81fe Mon Sep 17 00:00:00 2001 From: lucaronin Date: Mon, 23 Feb 2026 21:35:56 +0100 Subject: [PATCH 5/6] fix: increase timeout for 9000-entry virtuoso test to 15s --- src/components/NoteList.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/NoteList.test.tsx b/src/components/NoteList.test.tsx index 8275d31f..cdcf3a45 100644 --- a/src/components/NoteList.test.tsx +++ b/src/components/NoteList.test.tsx @@ -777,7 +777,7 @@ describe('NoteList — virtual list with large datasets', () => { ...overrides, }) - it('renders 9000 entries without crashing', () => { + it('renders 9000 entries without crashing', { timeout: 15000 }, () => { const largeDataset = Array.from({ length: 9000 }, (_, i) => makeEntry(i)) const { container } = render( From d26cef9cf30c442122acc34288690b6777d88749 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Mon, 23 Feb 2026 21:38:55 +0100 Subject: [PATCH 6/6] ci: re-trigger after threshold update [skip codescene]