diff --git a/.changeset/angry-bees-prove.md b/.changeset/angry-bees-prove.md new file mode 100644 index 0000000000..b2d4293fc9 --- /dev/null +++ b/.changeset/angry-bees-prove.md @@ -0,0 +1,5 @@ +--- +'@tiptap/extension-floating-menu': patch +--- + +Added a safeguard to avoid `TypeError: Cannot read properties of null (reading 'domFromPos')` being thrown when the editor was being destroyed diff --git a/packages/extension-floating-menu/__tests__/floating-menu-plugin.spec.ts b/packages/extension-floating-menu/__tests__/floating-menu-plugin.spec.ts index 9d4c3a61ad..e7a97a9902 100644 --- a/packages/extension-floating-menu/__tests__/floating-menu-plugin.spec.ts +++ b/packages/extension-floating-menu/__tests__/floating-menu-plugin.spec.ts @@ -225,3 +225,24 @@ describe('FloatingMenuView cross-contamination', () => { 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() + } + }) +}) diff --git a/packages/extension-floating-menu/src/floating-menu-plugin.ts b/packages/extension-floating-menu/src/floating-menu-plugin.ts index e6ea4d41a4..e2a0b93475 100644 --- a/packages/extension-floating-menu/src/floating-menu-plugin.ts +++ b/packages/extension-floating-menu/src/floating-menu-plugin.ts @@ -432,6 +432,10 @@ export class FloatingMenuView { } updatePosition() { + if (!this.view?.dom?.parentNode) { + return + } + const { selection } = this.editor.state const domRect = posToDOMRect(this.view, selection.from, selection.to)