diff --git a/src/components/SingleEditorView.test.tsx b/src/components/SingleEditorView.test.tsx index 764f9b37..c659e3aa 100644 --- a/src/components/SingleEditorView.test.tsx +++ b/src/components/SingleEditorView.test.tsx @@ -14,7 +14,9 @@ const state = vi.hoisted(() => ({ hoverGuardMock: vi.fn(), imageDropState: { isDragOver: false }, linkActivationMock: vi.fn(), + personMentionCandidates: [] as Record[], wikilinkEntriesRef: { current: [] as VaultEntry[] }, + wikilinkCandidates: [] as Record[], })) vi.mock('@blocknote/react', () => ({ @@ -145,12 +147,12 @@ vi.mock('../utils/typeColors', () => ({ vi.mock('../utils/wikilinkSuggestions', () => ({ MIN_QUERY_LENGTH: 2, deduplicateByPath: (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( + , + ) + + 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( + , + ) + + const getItems = state.capturedSuggestionProps['[['].getItems as ( + query: string + ) => Promise 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') diff --git a/src/components/SingleEditorView.tsx b/src/components/SingleEditorView.tsx index 3ad7cb30..0b118018 100644 --- a/src/components/SingleEditorView.tsx +++ b/src/components/SingleEditorView.tsx @@ -67,6 +67,53 @@ type TestTableBlock = { type?: string content?: { type?: string; columnWidths?: Array } } +type SuggestionAction = () => void +type SuggestionItemWithClick = { onItemClick?: SuggestionAction } + +function isEditorReadyForSuggestionAction( + editor: ReturnType, + 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 +}) { + if (!isEditorReadyForSuggestionAction(editor, container)) return + + try { + action() + } catch (error) { + console.warn('[editor] Ignored stale suggestion menu action:', error) + } +} + +function guardSuggestionMenuItems( + 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) { const { children, className, theme, ...rest } = props @@ -412,20 +459,26 @@ function buildBaseSuggestionItems(entries: VaultEntry[]) { }))) } -function useInsertWikilink(editor: ReturnType) { +function useInsertWikilink( + editor: ReturnType, + 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 editor: ReturnType insertWikilink: (target: string) => void + runEditorAction: (action: SuggestionAction) => void typeEntryMap: Record 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 => ( 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)} /> item.onItemClick()} + onItemClick={(item: WikilinkSuggestionItem) => runEditorAction(item.onItemClick)} />