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]) + }) +})