fix: guard stale suggestion menu actions

This commit is contained in:
lucaronin
2026-04-28 23:29:09 +02:00
parent e601a42f41
commit f34fdb0289
2 changed files with 149 additions and 17 deletions

View File

@@ -14,7 +14,9 @@ const state = vi.hoisted(() => ({
hoverGuardMock: vi.fn(),
imageDropState: { isDragOver: false },
linkActivationMock: vi.fn(),
personMentionCandidates: [] as Record<string, unknown>[],
wikilinkEntriesRef: { current: [] as VaultEntry[] },
wikilinkCandidates: [] as Record<string, unknown>[],
}))
vi.mock('@blocknote/react', () => ({
@@ -145,12 +147,12 @@ vi.mock('../utils/typeColors', () => ({
vi.mock('../utils/wikilinkSuggestions', () => ({
MIN_QUERY_LENGTH: 2,
deduplicateByPath: <T,>(items: T[]) => items,
preFilterWikilinks: () => [],
preFilterWikilinks: () => state.wikilinkCandidates,
}))
vi.mock('../utils/personMentionSuggestions', () => ({
PERSON_MENTION_MIN_QUERY: 1,
filterPersonMentions: () => [],
filterPersonMentions: () => state.personMentionCandidates,
}))
vi.mock('../utils/suggestionEnrichment', () => ({
@@ -241,6 +243,7 @@ function createEditor() {
{ id: 'heading-block', type: 'heading', content: [], children: [] },
cursorBlock,
],
domElement: undefined as HTMLElement | undefined,
tryParseMarkdownToBlocks: vi.fn(async () => [
{ type: 'table', content: { type: 'tableContent' } },
]),
@@ -308,7 +311,9 @@ describe('SingleEditorView', () => {
state.capturedBlockNoteOnChange = null
state.capturedMantineGetStyleNonce = null
state.imageDropState.isDragOver = false
state.personMentionCandidates = []
state.wikilinkEntriesRef.current = []
state.wikilinkCandidates = []
mockOpenExternalUrl.mockClear()
document.documentElement.removeAttribute('data-theme')
document.documentElement.classList.remove('dark')
@@ -435,6 +440,60 @@ describe('SingleEditorView', () => {
expect(onMentionItemClick).toHaveBeenCalledOnce()
})
it('ignores stale suggestion item clicks after the editor DOM disconnects', () => {
const editor = createEditor()
editor.domElement = document.createElement('div')
render(
<SingleEditorView
editor={editor as never}
entries={[makeEntry()]}
onNavigateWikilink={vi.fn()}
/>,
)
const staleItemClick = vi.fn(() => {
throw new TypeError('Cannot read properties of undefined (reading isConnected)')
})
expect(() => {
;(state.capturedSuggestionProps['[['].onItemClick as (item: { onItemClick: () => void }) => void)({
onItemClick: staleItemClick,
})
}).not.toThrow()
expect(staleItemClick).not.toHaveBeenCalled()
})
it('guards stale click handlers stored on wikilink suggestion items', async () => {
const editor = createEditor()
editor.domElement = document.createElement('div')
const staleItemClick = vi.fn(() => {
throw new TypeError('Cannot read properties of undefined (reading isConnected)')
})
state.wikilinkCandidates = [{
title: 'Alpha',
path: '/vault/project/alpha.md',
onItemClick: staleItemClick,
}]
render(
<SingleEditorView
editor={editor as never}
entries={[makeEntry()]}
onNavigateWikilink={vi.fn()}
/>,
)
const getItems = state.capturedSuggestionProps['[['].getItems as (
query: string
) => Promise<Array<{ onItemClick: () => void }>>
const items = await getItems('al')
expect(items).toHaveLength(1)
expect(() => items[0].onItemClick()).not.toThrow()
expect(staleItemClick).not.toHaveBeenCalled()
})
it('passes the active document theme to BlockNote', () => {
document.documentElement.setAttribute('data-theme', 'dark')
document.documentElement.classList.add('dark')

View File

@@ -67,6 +67,53 @@ type TestTableBlock = {
type?: string
content?: { type?: string; columnWidths?: Array<number | null> }
}
type SuggestionAction = () => void
type SuggestionItemWithClick = { onItemClick?: SuggestionAction }
function isEditorReadyForSuggestionAction(
editor: ReturnType<typeof useCreateBlockNote>,
container: HTMLElement | null,
) {
if (!container?.isConnected) return false
const editorElement = editor.domElement
if (!(editorElement instanceof HTMLElement)) return true
return editorElement.isConnected && container.contains(editorElement)
}
function runSuggestionActionSafely({
action,
container,
editor,
}: {
action: SuggestionAction
container: HTMLElement | null
editor: ReturnType<typeof useCreateBlockNote>
}) {
if (!isEditorReadyForSuggestionAction(editor, container)) return
try {
action()
} catch (error) {
console.warn('[editor] Ignored stale suggestion menu action:', error)
}
}
function guardSuggestionMenuItems<T extends SuggestionItemWithClick>(
items: T[],
runEditorAction: (action: SuggestionAction) => void,
): T[] {
return items.map((item) => {
if (!item.onItemClick) return item
const onItemClick = item.onItemClick
return {
...item,
onItemClick: () => runEditorAction(onItemClick),
}
})
}
function SharedContextBlockNoteView(props: React.ComponentProps<typeof BlockNoteViewRaw>) {
const { children, className, theme, ...rest } = props
@@ -412,20 +459,26 @@ function buildBaseSuggestionItems(entries: VaultEntry[]) {
})))
}
function useInsertWikilink(editor: ReturnType<typeof useCreateBlockNote>) {
function useInsertWikilink(
editor: ReturnType<typeof useCreateBlockNote>,
runEditorAction: (action: SuggestionAction) => void,
) {
return useCallback((target: string) => {
editor.insertInlineContent([
{ type: 'wikilink' as const, props: { target } },
" ",
], { updateSelection: true })
trackEvent('wikilink_inserted')
}, [editor])
runEditorAction(() => {
editor.insertInlineContent([
{ type: 'wikilink' as const, props: { target } },
" ",
], { updateSelection: true })
trackEvent('wikilink_inserted')
})
}, [editor, runEditorAction])
}
function useSuggestionMenuItems(options: {
baseItems: ReturnType<typeof buildBaseSuggestionItems>
editor: ReturnType<typeof useCreateBlockNote>
insertWikilink: (target: string) => void
runEditorAction: (action: SuggestionAction) => void
typeEntryMap: Record<string, VaultEntry>
vaultPath?: string
}) {
@@ -433,6 +486,7 @@ function useSuggestionMenuItems(options: {
baseItems,
editor,
insertWikilink,
runEditorAction,
typeEntryMap,
vaultPath,
} = options
@@ -447,8 +501,11 @@ function useSuggestionMenuItems(options: {
: filterPersonMentions(baseItems, normalizedQuery)
const items = attachClickHandlers(candidates, insertWikilink, vaultPath ?? '')
return enrichSuggestionItems(items, normalizedQuery, typeEntryMap)
}, [baseItems, insertWikilink, typeEntryMap, vaultPath])
return guardSuggestionMenuItems(
enrichSuggestionItems(items, normalizedQuery, typeEntryMap),
runEditorAction,
)
}, [baseItems, insertWikilink, runEditorAction, typeEntryMap, vaultPath])
const getWikilinkItems = useCallback(async (query: string): Promise<WikilinkSuggestionItem[]> => (
buildItems(query, '[[') ?? []
@@ -458,9 +515,17 @@ function useSuggestionMenuItems(options: {
buildItems(query, '@') ?? []
), [buildItems])
const getSlashMenuItems = useCallback(async (query: string) => (
getTolariaSlashMenuItems(editor, query)
), [editor])
const getSlashMenuItems = useCallback(async (query: string) => {
try {
return guardSuggestionMenuItems(
await Promise.resolve(getTolariaSlashMenuItems(editor, query)),
runEditorAction,
)
} catch (error) {
console.warn('[editor] Ignored stale slash menu query:', error)
return []
}
}, [editor, runEditorAction])
return {
getWikilinkItems,
@@ -513,7 +578,14 @@ export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange
const typeEntryMap = useMemo(() => buildTypeEntryMap(entries), [entries])
const baseItems = useMemo(() => buildBaseSuggestionItems(entries), [entries])
const insertWikilink = useInsertWikilink(editor)
const runEditorAction = useCallback((action: SuggestionAction) => {
runSuggestionActionSafely({
action,
container: containerRef.current,
editor,
})
}, [editor])
const insertWikilink = useInsertWikilink(editor, runEditorAction)
const {
getWikilinkItems,
getPersonMentionItems,
@@ -522,6 +594,7 @@ export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange
baseItems,
editor,
insertWikilink,
runEditorAction,
typeEntryMap,
vaultPath,
})
@@ -568,13 +641,13 @@ export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange
triggerCharacter="[["
getItems={getWikilinkItems}
suggestionMenuComponent={WikilinkSuggestionMenu}
onItemClick={(item: WikilinkSuggestionItem) => item.onItemClick()}
onItemClick={(item: WikilinkSuggestionItem) => runEditorAction(item.onItemClick)}
/>
<SuggestionMenuController
triggerCharacter="@"
getItems={getPersonMentionItems}
suggestionMenuComponent={WikilinkSuggestionMenu}
onItemClick={(item: WikilinkSuggestionItem) => item.onItemClick()}
onItemClick={(item: WikilinkSuggestionItem) => runEditorAction(item.onItemClick)}
/>
</SharedContextBlockNoteView>
</div>