fix: deduplicate autocomplete suggestions and disambiguate same-title notes (#54)

BlockNote's SuggestionMenu uses item.title as React key, causing duplicate
rendering and broken arrow-key navigation when multiple notes share the
same title. Fix by:
- Adding path-based deduplication to filter out duplicate entries
- Disambiguating same-title notes with parent folder name (e.g. "Standup (work)")
- Deduplicating aliases within each item (filename vs entry.aliases overlap)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Luca Rossi
2026-02-24 23:12:35 +01:00
committed by GitHub
parent 2e2224865c
commit 3ea175eec5
4 changed files with 162 additions and 9 deletions

View File

@@ -360,4 +360,50 @@ describe('wikilink autocomplete', () => {
' ',
])
})
it('deduplicates entries with the same path', async () => {
const dupEntries: VaultEntry[] = [
{ ...mockEntry, title: 'Dup Note', filename: 'dup.md', path: '/vault/dup.md', aliases: [] },
{ ...mockEntry, title: 'Dup Note Copy', filename: 'dup.md', path: '/vault/dup.md', aliases: [] },
{ ...mockEntry, title: 'Other Note', filename: 'other.md', path: '/vault/other.md', aliases: [] },
]
capturedGetItems = null
mockFilterSuggestionItems.mockImplementation((items: unknown[]) => items)
render(
<Editor
{...defaultProps}
tabs={[mockTab]}
activeTabPath={mockEntry.path}
entries={dupEntries}
/>
)
const items = await capturedGetItems!('Note')
const paths = items.map((i: { path: string }) => i.path)
expect(new Set(paths).size).toBe(paths.length)
mockFilterSuggestionItems.mockImplementation((items: unknown[]) => items)
})
it('disambiguates entries with the same title by appending folder name', async () => {
const sameTitle: VaultEntry[] = [
{ ...mockEntry, title: 'Standup', filename: 'standup.md', path: '/vault/work/standup.md', aliases: [] },
{ ...mockEntry, title: 'Standup', filename: 'standup.md', path: '/vault/personal/standup.md', aliases: [] },
]
capturedGetItems = null
mockFilterSuggestionItems.mockImplementation((items: unknown[]) => items)
render(
<Editor
{...defaultProps}
tabs={[mockTab]}
activeTabPath={mockEntry.path}
entries={sameTitle}
/>
)
const items = await capturedGetItems!('Standup')
expect(items).toHaveLength(2)
const titles = items.map((i: { title: string }) => i.title)
expect(new Set(titles).size).toBe(2)
expect(titles).toContain('Standup (work)')
expect(titles).toContain('Standup (personal)')
mockFilterSuggestionItems.mockImplementation((items: unknown[]) => items)
})
})

View File

@@ -14,7 +14,7 @@ import { TabBar } from './TabBar'
import { BreadcrumbBar } from './BreadcrumbBar'
import { useEditorTheme } from '../hooks/useTheme'
import { splitFrontmatter, preProcessWikilinks, injectWikilinks, restoreWikilinksInBlocks, countWords } from '../utils/wikilinks'
import { preFilterWikilinks, MAX_RESULTS, MIN_QUERY_LENGTH } from '../utils/wikilinkSuggestions'
import { preFilterWikilinks, deduplicateByPath, disambiguateTitles, MAX_RESULTS, MIN_QUERY_LENGTH } from '../utils/wikilinkSuggestions'
import { resolveWikilinkColor as resolveColor } from '../utils/wikilinkColors'
import './Editor.css'
import './EditorTheme.css'
@@ -132,12 +132,13 @@ function SingleEditorView({ editor, entries, onNavigateWikilink, onChange }: { e
}, [editor])
const baseItems = useMemo(
() => entries.map(entry => ({
() => deduplicateByPath(entries.map(entry => ({
title: entry.title,
aliases: [entry.filename.replace(/\.md$/, ''), ...entry.aliases],
aliases: [...new Set([entry.filename.replace(/\.md$/, ''), ...entry.aliases])],
group: entry.isA || 'Note',
entryTitle: entry.title,
})),
path: entry.path,
}))),
[entries]
)
@@ -157,7 +158,8 @@ function SingleEditorView({ editor, entries, onNavigateWikilink, onChange }: { e
])
},
}))
return filterSuggestionItems(items, query).slice(0, MAX_RESULTS)
const filtered = filterSuggestionItems(items, query).slice(0, MAX_RESULTS)
return disambiguateTitles(deduplicateByPath(filtered))
}, [baseItems, editor])
return (