fix: guard stale suggestion menu actions
This commit is contained in:
@@ -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')
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user