import { Editor } from '@tiptap/core' import Document from '@tiptap/extension-document' import Paragraph from '@tiptap/extension-paragraph' import Text from '@tiptap/extension-text' import { PluginKey } from '@tiptap/pm/state' import { describe, expect, it, vi } from 'vite-plus/test' import { FloatingMenu } from './floating-menu.js' import { FloatingMenuView } from './floating-menu-plugin.js' function createEditor(content = '

Hello world

') { return new Editor({ extensions: [Document, Paragraph, Text], content, }) } function createFloatingMenuView( editor: Editor, overrides: Partial[0]> = {}, ) { return new FloatingMenuView({ editor, element: document.createElement('div'), view: editor.view, shouldShow: () => false, ...overrides, }) } describe('FloatingMenuView cross-contamination', () => { it('should only process updateOptions for its own pluginKey (string)', () => { const editor = createEditor() const view1 = createFloatingMenuView(editor, { pluginKey: 'floatingMenu1' }) const view2 = createFloatingMenuView(editor, { pluginKey: 'floatingMenu2' }) const spy1 = vi.spyOn(view1, 'updateOptions') const spy2 = vi.spyOn(view2, 'updateOptions') const newOptions = { updateDelay: 999 } editor.view.dispatch( editor.state.tr.setMeta('floatingMenu1', { type: 'updateOptions', options: newOptions, }), ) expect(spy1).toHaveBeenCalledTimes(1) expect(spy1).toHaveBeenCalledWith(newOptions) expect(spy2).not.toHaveBeenCalled() view1.destroy() view2.destroy() editor.destroy() }) it('should only process updateOptions for its own pluginKey (PluginKey instance)', () => { const editor = createEditor() const key1 = new PluginKey('customFloatingA') const key2 = new PluginKey('customFloatingB') const view1 = createFloatingMenuView(editor, { pluginKey: key1 }) const view2 = createFloatingMenuView(editor, { pluginKey: key2 }) const spy1 = vi.spyOn(view1, 'updateOptions') const spy2 = vi.spyOn(view2, 'updateOptions') const newOptions = { updateDelay: 500 } editor.view.dispatch( editor.state.tr.setMeta('customFloatingA$', { type: 'updateOptions', options: newOptions, }), ) expect(spy1).toHaveBeenCalledTimes(1) expect(spy1).toHaveBeenCalledWith(newOptions) expect(spy2).not.toHaveBeenCalled() view1.destroy() view2.destroy() editor.destroy() }) it('should only process updatePosition for its own pluginKey', () => { const editor = createEditor() const view1 = createFloatingMenuView(editor, { pluginKey: 'floatingMenu1' }) const view2 = createFloatingMenuView(editor, { pluginKey: 'floatingMenu2' }) const spy1 = vi.spyOn(view1, 'updatePosition') const spy2 = vi.spyOn(view2, 'updatePosition') editor.view.dispatch(editor.state.tr.setMeta('floatingMenu1', 'updatePosition')) expect(spy1).toHaveBeenCalledTimes(1) expect(spy2).not.toHaveBeenCalled() view1.destroy() view2.destroy() editor.destroy() }) it('should not cross-contaminate options between two instances', () => { const editor = createEditor() const view1 = createFloatingMenuView(editor, { pluginKey: 'floatingMenu1', updateDelay: 100, }) const view2 = createFloatingMenuView(editor, { pluginKey: 'floatingMenu2', updateDelay: 200, }) editor.view.dispatch( editor.state.tr.setMeta('floatingMenu1', { type: 'updateOptions', options: { updateDelay: 999 }, }), ) expect(view1.updateDelay).toBe(999) expect(view2.updateDelay).toBe(200) editor.view.dispatch( editor.state.tr.setMeta('floatingMenu2', { type: 'updateOptions', options: { updateDelay: 777 }, }), ) expect(view1.updateDelay).toBe(999) expect(view2.updateDelay).toBe(777) view1.destroy() view2.destroy() editor.destroy() }) it('should use pluginKey in updateFloatingMenuPosition command', () => { const customPluginKey = 'myFloatingMenu' const menuElement = document.createElement('div') const editor = new Editor({ extensions: [ Document, Paragraph, Text, FloatingMenu.configure({ element: menuElement, pluginKey: customPluginKey, shouldShow: () => false, }), ], content: '

Hello world

', }) const dispatchSpy = vi.spyOn(editor.view, 'dispatch') editor.commands.updateFloatingMenuPosition() const dispatchedTr = dispatchSpy.mock.calls[0][0] expect(dispatchedTr.getMeta(customPluginKey)).toBe('updatePosition') expect(dispatchedTr.getMeta('floatingMenu')).toBeUndefined() editor.destroy() }) it('should maintain backward compatibility with default "floatingMenu" pluginKey', () => { const editor = createEditor() const view = createFloatingMenuView(editor) const spy = vi.spyOn(view, 'updateOptions') const newOptions = { updateDelay: 500 } editor.view.dispatch( editor.state.tr.setMeta('floatingMenu', { type: 'updateOptions', options: newOptions, }), ) expect(spy).toHaveBeenCalledTimes(1) expect(spy).toHaveBeenCalledWith(newOptions) view.destroy() editor.destroy() }) it('should only process show for its own pluginKey', () => { const editor = createEditor() const view1 = createFloatingMenuView(editor, { pluginKey: 'floatingMenu1' }) const view2 = createFloatingMenuView(editor, { pluginKey: 'floatingMenu2' }) const spy1 = vi.spyOn(view1, 'show') const spy2 = vi.spyOn(view2, 'show') editor.view.dispatch(editor.state.tr.setMeta('floatingMenu1', 'show')) expect(spy1).toHaveBeenCalledTimes(1) expect(spy2).not.toHaveBeenCalled() view1.destroy() view2.destroy() editor.destroy() }) it('should only process hide for its own pluginKey', () => { const editor = createEditor() const view1 = createFloatingMenuView(editor, { pluginKey: 'floatingMenu1' }) const view2 = createFloatingMenuView(editor, { pluginKey: 'floatingMenu2' }) const spy1 = vi.spyOn(view1, 'hide') const spy2 = vi.spyOn(view2, 'hide') editor.view.dispatch(editor.state.tr.setMeta('floatingMenu1', 'hide')) expect(spy1).toHaveBeenCalledTimes(1) expect(spy2).not.toHaveBeenCalled() view1.destroy() view2.destroy() editor.destroy() }) }) describe('FloatingMenuView destroy safety', () => { it('updatePosition should not call coordsAtPos when the editor view is detached from the DOM', () => { const editor = createEditor() const view = createFloatingMenuView(editor) const coordsSpy = vi.spyOn(editor.view, 'coordsAtPos') try { // Simulate the real-world teardown race: the editor is destroyed while a // pending updatePosition call (debounced resize/scroll) is still in flight. // ProseMirror's destroy removes view.dom from its parent and nulls docView; // without a guard, posToDOMRect -> coordsAtPos throws on the null docView. editor.destroy() expect(() => view.updatePosition()).not.toThrow( /Cannot read properties of null \(reading 'domFromPos'\)/, ) expect(coordsSpy).not.toHaveBeenCalled() } finally { view.destroy() } }) })