From 2d90b61a701b4c2d771242e9283a7444d7cc4130 Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 8 Apr 2026 09:18:59 +0200 Subject: [PATCH] fix: stabilize note list hotspots --- src/components/NoteList.tsx | 3 +- src/components/SearchPanel.test.tsx | 5 +- src/components/SearchPanel.tsx | 57 +++--- src/components/note-list/noteListHooks.ts | 3 +- src/test-utils/noteListTestUtils.tsx | 207 ++++++++-------------- src/test/setup.ts | 18 +- 6 files changed, 126 insertions(+), 167 deletions(-) diff --git a/src/components/NoteList.tsx b/src/components/NoteList.tsx index 6a647ad9..90374764 100644 --- a/src/components/NoteList.tsx +++ b/src/components/NoteList.tsx @@ -3,6 +3,7 @@ import type { VaultEntry, SidebarSelection, ModifiedFile, NoteStatus, InboxPerio import type { NoteListFilter } from '../utils/noteListHelpers' import { countByFilter, countAllByFilter } from '../utils/noteListHelpers' import { NoteItem } from './NoteItem' +import type { MultiSelectState } from '../hooks/useMultiSelect' import { BulkActionBar } from './BulkActionBar' import { NoteListHeader } from './note-list/NoteListHeader' import { FilterPills } from './note-list/FilterPills' @@ -37,7 +38,7 @@ function useViewFlags(selection: SidebarSelection) { } function useBulkActions( - multiSelect: ReturnType, + multiSelect: MultiSelectState, onBulkArchive: NoteListProps['onBulkArchive'], onBulkDeletePermanently: NoteListProps['onBulkDeletePermanently'], isArchivedView: boolean, diff --git a/src/components/SearchPanel.test.tsx b/src/components/SearchPanel.test.tsx index 867c4cc0..47524fa9 100644 --- a/src/components/SearchPanel.test.tsx +++ b/src/components/SearchPanel.test.tsx @@ -231,14 +231,15 @@ describe('SearchPanel', () => { , ) - fireEvent.change(screen.getByPlaceholderText('Search in all notes...'), { target: { value: 'api' } }) + const input = screen.getByPlaceholderText('Search in all notes...') + fireEvent.change(input, { target: { value: 'api' } }) await waitFor(() => { expect(screen.getByText('How to Design AI-first APIs')).toBeInTheDocument() }) await act(async () => { - fireEvent.keyDown(window, { key: 'Enter' }) + fireEvent.keyDown(input, { key: 'Enter' }) }) await waitFor(() => { diff --git a/src/components/SearchPanel.tsx b/src/components/SearchPanel.tsx index 03f55072..4a543cc2 100644 --- a/src/components/SearchPanel.tsx +++ b/src/components/SearchPanel.tsx @@ -1,4 +1,4 @@ -import { useRef, useEffect, useCallback } from 'react' +import { useRef, useEffect, useCallback, useLayoutEffect } from 'react' import { useMemo } from 'react' import { cn } from '@/lib/utils' import type { SearchResult, VaultEntry } from '../types' @@ -23,10 +23,8 @@ export function SearchPanel({ open, vaultPath, entries, onSelectNote, onClose }: const inputRef = useRef(null) const listRef = useRef(null) - - useEffect(() => { - if (open) setTimeout(() => inputRef.current?.focus(), 50) - }, [open]) + const resultsRef = useRef(results) + const selectedIndexRef = useRef(selectedIndex) useEffect(() => { if (!listRef.current) return @@ -42,26 +40,38 @@ export function SearchPanel({ open, vaultPath, entries, onSelectNote, onClose }: } }, [entries, onSelectNote, onClose]) + useLayoutEffect(() => { + resultsRef.current = results + selectedIndexRef.current = selectedIndex + }, [results, selectedIndex]) + + useEffect(() => { + if (open) setTimeout(() => inputRef.current?.focus(), 50) + }, [open]) + + const handleKeyDown = useCallback((e: { key: string; preventDefault: () => void }) => { + if (e.key === 'Escape') { + e.preventDefault() + onClose() + } else if (e.key === 'ArrowDown') { + e.preventDefault() + setSelectedIndex(i => Math.min(i + 1, resultsRef.current.length - 1)) + } else if (e.key === 'ArrowUp') { + e.preventDefault() + setSelectedIndex(i => Math.max(i - 1, 0)) + } else if (e.key === 'Enter') { + e.preventDefault() + const result = resultsRef.current[selectedIndexRef.current] + if (result) handleSelect(result) + } + }, [handleSelect, onClose, setSelectedIndex]) + useEffect(() => { if (!open) return - const handleKey = (e: KeyboardEvent) => { - if (e.key === 'Escape') { - e.preventDefault() - onClose() - } else if (e.key === 'ArrowDown') { - e.preventDefault() - setSelectedIndex(i => Math.min(i + 1, results.length - 1)) - } else if (e.key === 'ArrowUp') { - e.preventDefault() - setSelectedIndex(i => Math.max(i - 1, 0)) - } else if (e.key === 'Enter') { - e.preventDefault() - if (results[selectedIndex]) handleSelect(results[selectedIndex]) - } - } + const handleKey = (e: KeyboardEvent) => handleKeyDown(e) window.addEventListener('keydown', handleKey) return () => window.removeEventListener('keydown', handleKey) - }, [open, results, selectedIndex, handleSelect, setSelectedIndex, onClose]) + }, [open, handleKeyDown]) const typeEntryMap = useMemo(() => buildTypeEntryMap(entries), [entries]) const entryLookup = useMemo(() => { @@ -86,6 +96,7 @@ export function SearchPanel({ open, vaultPath, entries, onSelectNote, onClose }: query={query} loading={loading} onChange={setQuery} + onKeyDown={handleKeyDown} /> void + onKeyDown?: React.KeyboardEventHandler } const SearchInput = forwardRef( - function SearchInput({ query, loading, onChange }, ref) { + function SearchInput({ query, loading, onChange, onKeyDown }, ref) { return (
@@ -127,6 +139,7 @@ const SearchInput = forwardRef( placeholder="Search in all notes..." value={query} onChange={e => onChange(e.target.value)} + onKeyDown={onKeyDown} /> {loading && ( void onSelectNote: (entry: VaultEntry) => void - onOpenDeletedNote?: (entry: VaultEntry) => void + onOpenDeletedNote?: (entry: DeletedNoteEntry) => void onOpenInNewWindow?: (entry: VaultEntry) => void onAutoTriggerDiff?: () => void onDiscardFile?: (relativePath: string) => Promise diff --git a/src/test-utils/noteListTestUtils.tsx b/src/test-utils/noteListTestUtils.tsx index e0bbd7ba..51c67d65 100644 --- a/src/test-utils/noteListTestUtils.tsx +++ b/src/test-utils/noteListTestUtils.tsx @@ -9,144 +9,6 @@ type NoteListProps = ComponentProps export const allSelection: SidebarSelection = { kind: 'filter', filter: 'all' } -export const mockEntries: VaultEntry[] = [ - { - path: '/Users/luca/Laputa/project/26q1-laputa-app.md', - filename: '26q1-laputa-app.md', - title: 'Build Laputa App', - isA: 'Project', - aliases: [], - belongsTo: [], - relatedTo: ['[[topic/software-development]]'], - status: 'Active', - owner: 'Luca', - cadence: null, - archived: false, - modifiedAt: 1700000000, - createdAt: null, - fileSize: 1024, - snippet: 'Build a personal knowledge management app.', - wordCount: 0, - relationships: { - 'Related to': ['[[topic/software-development]]'], - }, - icon: null, - color: null, - order: null, - template: null, - sort: null, - outgoingLinks: [], - properties: {}, - }, - { - path: '/Users/luca/Laputa/note/facebook-ads-strategy.md', - filename: 'facebook-ads-strategy.md', - title: 'Facebook Ads Strategy', - isA: 'Note', - aliases: [], - belongsTo: ['[[project/26q1-laputa-app]]'], - relatedTo: ['[[topic/growth]]'], - status: null, - owner: null, - cadence: null, - archived: false, - modifiedAt: 1700000000, - createdAt: null, - fileSize: 847, - snippet: 'Lookalike audiences convert 3x better.', - wordCount: 0, - relationships: { - 'Belongs to': ['[[project/26q1-laputa-app]]'], - 'Related to': ['[[topic/growth]]'], - }, - icon: null, - color: null, - order: null, - template: null, - sort: null, - outgoingLinks: [], - properties: {}, - }, - { - path: '/Users/luca/Laputa/person/matteo-cellini.md', - filename: 'matteo-cellini.md', - title: 'Matteo Cellini', - isA: 'Person', - aliases: [], - belongsTo: [], - relatedTo: [], - status: null, - owner: null, - cadence: null, - archived: false, - modifiedAt: 1700000000, - createdAt: null, - fileSize: 320, - snippet: 'Sponsorship manager.', - wordCount: 0, - relationships: {}, - icon: null, - color: null, - order: null, - template: null, - sort: null, - outgoingLinks: [], - properties: {}, - }, - { - path: '/Users/luca/Laputa/event/2026-02-14-kickoff.md', - filename: '2026-02-14-kickoff.md', - title: 'Kickoff Meeting', - isA: 'Event', - aliases: [], - belongsTo: [], - relatedTo: [], - status: null, - owner: null, - cadence: null, - archived: false, - modifiedAt: 1700000000, - createdAt: null, - fileSize: 512, - snippet: 'Project kickoff meeting notes.', - wordCount: 0, - relationships: {}, - icon: null, - color: null, - order: null, - template: null, - sort: null, - outgoingLinks: [], - properties: {}, - }, - { - path: '/Users/luca/Laputa/topic/software-development.md', - filename: 'software-development.md', - title: 'Software Development', - isA: 'Topic', - aliases: [], - belongsTo: [], - relatedTo: [], - status: null, - owner: null, - cadence: null, - archived: false, - modifiedAt: 1700000000, - createdAt: null, - fileSize: 256, - snippet: 'Frontend, backend, and systems programming.', - wordCount: 0, - relationships: {}, - icon: null, - color: null, - order: null, - template: null, - sort: null, - outgoingLinks: [], - properties: {}, - }, -] - export const makeEntry = (overrides: Partial = {}): VaultEntry => ({ path: '/test.md', filename: 'test.md', @@ -171,11 +33,80 @@ export const makeEntry = (overrides: Partial = {}): VaultEntry => ({ sort: null, view: null, visible: null, + organized: false, + favorite: false, + favoriteIndex: null, + listPropertiesDisplay: [], outgoingLinks: [], properties: {}, + hasH1: false, ...overrides, }) +export const mockEntries: VaultEntry[] = [ + makeEntry({ + path: '/Users/luca/Laputa/project/26q1-laputa-app.md', + filename: '26q1-laputa-app.md', + title: 'Build Laputa App', + isA: 'Project', + relatedTo: ['[[topic/software-development]]'], + status: 'Active', + modifiedAt: 1700000000, + createdAt: null, + fileSize: 1024, + snippet: 'Build a personal knowledge management app.', + relationships: { + 'Related to': ['[[topic/software-development]]'], + }, + }), + makeEntry({ + path: '/Users/luca/Laputa/note/facebook-ads-strategy.md', + filename: 'facebook-ads-strategy.md', + title: 'Facebook Ads Strategy', + isA: 'Note', + belongsTo: ['[[project/26q1-laputa-app]]'], + relatedTo: ['[[topic/growth]]'], + modifiedAt: 1700000000, + createdAt: null, + fileSize: 847, + snippet: 'Lookalike audiences convert 3x better.', + relationships: { + 'Belongs to': ['[[project/26q1-laputa-app]]'], + 'Related to': ['[[topic/growth]]'], + }, + }), + makeEntry({ + path: '/Users/luca/Laputa/person/matteo-cellini.md', + filename: 'matteo-cellini.md', + title: 'Matteo Cellini', + isA: 'Person', + modifiedAt: 1700000000, + createdAt: null, + fileSize: 320, + snippet: 'Sponsorship manager.', + }), + makeEntry({ + path: '/Users/luca/Laputa/event/2026-02-14-kickoff.md', + filename: '2026-02-14-kickoff.md', + title: 'Kickoff Meeting', + isA: 'Event', + modifiedAt: 1700000000, + createdAt: null, + fileSize: 512, + snippet: 'Project kickoff meeting notes.', + }), + makeEntry({ + path: '/Users/luca/Laputa/topic/software-development.md', + filename: 'software-development.md', + title: 'Software Development', + isA: 'Topic', + modifiedAt: 1700000000, + createdAt: null, + fileSize: 256, + snippet: 'Frontend, backend, and systems programming.', + }), +] + export const makeIndexedEntry = (index: number, overrides?: Partial): VaultEntry => makeEntry({ path: `/vault/note/note-${index}.md`, diff --git a/src/test/setup.ts b/src/test/setup.ts index 4850672d..eafb98ca 100644 --- a/src/test/setup.ts +++ b/src/test/setup.ts @@ -63,7 +63,17 @@ vi.mock('react-day-picker', () => ({ getDefaultClassNames: () => ({}), })) -// Mock react-virtuoso: JSDOM has no real viewport, so render all items directly +function getVirtualizedIndexes(length: number): number[] { + if (length <= 200) return Array.from({ length }, (_, index) => index) + + const edgeSize = 50 + return [ + ...Array.from({ length: edgeSize }, (_, index) => index), + ...Array.from({ length: edgeSize }, (_, index) => length - edgeSize + index), + ] +} + +// Mock react-virtuoso: JSDOM has no real viewport, so render a representative window for large lists. vi.mock('react-virtuoso', () => ({ Virtuoso: ({ data, itemContent, components }: { data?: unknown[] @@ -71,10 +81,12 @@ vi.mock('react-virtuoso', () => ({ components?: { Header?: ComponentType } }) => { const Header = components?.Header + const resolvedData = data ?? [] + const renderedIndexes = getVirtualizedIndexes(resolvedData.length) return createElement('div', { 'data-testid': 'virtuoso-mock' }, Header ? createElement(Header) : null, - data?.map((item: unknown, index: number) => - createElement('div', { key: index }, itemContent?.(index, item)) + renderedIndexes.map((index) => + createElement('div', { key: index }, itemContent?.(index, resolvedData[index])) ) ) },