From 10a836c3e0f44afeabd76a58354bb5b91c4278a3 Mon Sep 17 00:00:00 2001 From: yousefed Date: Tue, 6 Jan 2026 16:01:18 +0100 Subject: [PATCH 1/3] wip: fix remount issue --- .../core/src/extensions/LinkToolbar/LinkToolbar.ts | 7 ++++++- .../DefaultButtons/CreateLinkButton.tsx | 5 +++-- .../LinkToolbar/LinkToolbarController.tsx | 14 +++++++++----- .../useGridSuggestionMenuKeyboardNavigation.ts | 9 +++------ .../hooks/useSuggestionMenuKeyboardNavigation.ts | 9 +++------ 5 files changed, 24 insertions(+), 20 deletions(-) diff --git a/packages/core/src/extensions/LinkToolbar/LinkToolbar.ts b/packages/core/src/extensions/LinkToolbar/LinkToolbar.ts index 1e6c71ee2e..3e3ef64e32 100644 --- a/packages/core/src/extensions/LinkToolbar/LinkToolbar.ts +++ b/packages/core/src/extensions/LinkToolbar/LinkToolbar.ts @@ -1,6 +1,6 @@ import { getMarkRange, posToDOMRect } from "@tiptap/core"; -import { createExtension } from "../../editor/BlockNoteExtension.js"; import { getPmSchema } from "../../api/pmUtil.js"; +import { createExtension } from "../../editor/BlockNoteExtension.js"; export const LinkToolbarExtension = createExtension(({ editor }) => { function getLinkElementAtPos(pos: number) { @@ -67,6 +67,11 @@ export const LinkToolbarExtension = createExtension(({ editor }) => { getLinkAtElement(element: HTMLElement) { return editor.transact(() => { + // Q4: posAtDOM can fail if the editor view is not available + // (e.g. if the editor is not mounted) + // a) Unfortunately, TS doesn't give an error about this. Can we make this type safe? + // b) Double check other references of editor.prosemirrorView + const posAtElement = editor.prosemirrorView.posAtDOM(element, 0) + 1; return getMarkAtPos(posAtElement, "link"); }); diff --git a/packages/react/src/components/FormattingToolbar/DefaultButtons/CreateLinkButton.tsx b/packages/react/src/components/FormattingToolbar/DefaultButtons/CreateLinkButton.tsx index b90ff25bad..f1dcceab4d 100644 --- a/packages/react/src/components/FormattingToolbar/DefaultButtons/CreateLinkButton.tsx +++ b/packages/react/src/components/FormattingToolbar/DefaultButtons/CreateLinkButton.tsx @@ -94,10 +94,11 @@ export const CreateLinkButton = () => { } }; - editor.domElement?.addEventListener("keydown", callback); + const domElement = editor.domElement; + domElement?.addEventListener("keydown", callback); return () => { - editor.domElement?.removeEventListener("keydown", callback); + domElement?.removeEventListener("keydown", callback); }; }, [editor.domElement]); diff --git a/packages/react/src/components/LinkToolbar/LinkToolbarController.tsx b/packages/react/src/components/LinkToolbar/LinkToolbarController.tsx index 9271dd7fca..dcf07a9f51 100644 --- a/packages/react/src/components/LinkToolbar/LinkToolbarController.tsx +++ b/packages/react/src/components/LinkToolbar/LinkToolbarController.tsx @@ -4,14 +4,14 @@ import { Range } from "@tiptap/core"; import { FC, useEffect, useMemo, useState } from "react"; import { useBlockNoteEditor } from "../../hooks/useBlockNoteEditor.js"; -import { LinkToolbar } from "./LinkToolbar.js"; -import { LinkToolbarProps } from "./LinkToolbarProps.js"; import { useExtension } from "../../hooks/useExtension.js"; import { FloatingUIOptions } from "../Popovers/FloatingUIOptions.js"; import { GenericPopover, GenericPopoverReference, } from "../Popovers/GenericPopover.js"; +import { LinkToolbar } from "./LinkToolbar.js"; +import { LinkToolbarProps } from "./LinkToolbarProps.js"; export const LinkToolbarController = (props: { linkToolbar?: FC; @@ -98,13 +98,16 @@ export const LinkToolbarController = (props: { const destroyOnSelectionChangeHandler = editor.onSelectionChange(textCursorCallback); - editor.domElement?.addEventListener("mouseover", mouseCursorCallback); + const domElement = editor.domElement; + + // Q 1: why can domElement be available when is rendered? + // Q 2: this useEffect will not necessarily run when editor.domElement changes + domElement?.addEventListener("mouseover", mouseCursorCallback); return () => { destroyOnChangeHandler(); destroyOnSelectionChangeHandler(); - - editor.domElement?.removeEventListener("mouseover", mouseCursorCallback); + domElement?.removeEventListener("mouseover", mouseCursorCallback); }; }, [editor, linkToolbar, link, toolbarPositionFrozen]); @@ -161,6 +164,7 @@ export const LinkToolbarController = (props: { [link?.element], ); + // Q3: similar to Q2; are we sure the component rerenders when editor.isEditable changes? if (!editor.isEditable) { return null; } diff --git a/packages/react/src/components/SuggestionMenu/GridSuggestionMenu/hooks/useGridSuggestionMenuKeyboardNavigation.ts b/packages/react/src/components/SuggestionMenu/GridSuggestionMenu/hooks/useGridSuggestionMenuKeyboardNavigation.ts index 9f435158a1..e2b27f60e3 100644 --- a/packages/react/src/components/SuggestionMenu/GridSuggestionMenu/hooks/useGridSuggestionMenuKeyboardNavigation.ts +++ b/packages/react/src/components/SuggestionMenu/GridSuggestionMenu/hooks/useGridSuggestionMenuKeyboardNavigation.ts @@ -66,14 +66,11 @@ export function useGridSuggestionMenuKeyboardNavigation( return false; }; - editor.domElement?.addEventListener( - "keydown", - handleMenuNavigationKeys, - true, - ); + const domElement = editor.domElement; + domElement?.addEventListener("keydown", handleMenuNavigationKeys, true); return () => { - editor.domElement?.removeEventListener( + domElement?.removeEventListener( "keydown", handleMenuNavigationKeys, true, diff --git a/packages/react/src/components/SuggestionMenu/hooks/useSuggestionMenuKeyboardNavigation.ts b/packages/react/src/components/SuggestionMenu/hooks/useSuggestionMenuKeyboardNavigation.ts index 99c72a8b6a..6972c67f1f 100644 --- a/packages/react/src/components/SuggestionMenu/hooks/useSuggestionMenuKeyboardNavigation.ts +++ b/packages/react/src/components/SuggestionMenu/hooks/useSuggestionMenuKeyboardNavigation.ts @@ -15,14 +15,11 @@ export function useSuggestionMenuKeyboardNavigation( useSuggestionMenuKeyboardHandler(items, onItemClick); useEffect(() => { - (element || editor.domElement)?.addEventListener("keydown", handler, true); + const el = element || editor.domElement; + el?.addEventListener("keydown", handler, true); return () => { - (element || editor.domElement)?.removeEventListener( - "keydown", - handler, - true, - ); + el?.removeEventListener("keydown", handler, true); }; }, [editor.domElement, items, selectedIndex, onItemClick, element, handler]); From 754bbbfbc0bc655e3d112ee7bfc85bc141fb3d74 Mon Sep 17 00:00:00 2001 From: yousefed Date: Tue, 6 Jan 2026 20:11:14 +0100 Subject: [PATCH 2/3] add test --- .../LinkToolbar/LinkToolbarController.tsx | 2 +- .../react/BlockNoteViewRemountHover.test.tsx | 83 +++++++++++++++++++ 2 files changed, 84 insertions(+), 1 deletion(-) create mode 100644 tests/src/unit/react/BlockNoteViewRemountHover.test.tsx diff --git a/packages/react/src/components/LinkToolbar/LinkToolbarController.tsx b/packages/react/src/components/LinkToolbar/LinkToolbarController.tsx index dcf07a9f51..20edd94985 100644 --- a/packages/react/src/components/LinkToolbar/LinkToolbarController.tsx +++ b/packages/react/src/components/LinkToolbar/LinkToolbarController.tsx @@ -109,7 +109,7 @@ export const LinkToolbarController = (props: { destroyOnSelectionChangeHandler(); domElement?.removeEventListener("mouseover", mouseCursorCallback); }; - }, [editor, linkToolbar, link, toolbarPositionFrozen]); + }, [editor, editor.domElement, linkToolbar, link, toolbarPositionFrozen]); const floatingUIOptions = useMemo( () => ({ diff --git a/tests/src/unit/react/BlockNoteViewRemountHover.test.tsx b/tests/src/unit/react/BlockNoteViewRemountHover.test.tsx new file mode 100644 index 0000000000..f778099389 --- /dev/null +++ b/tests/src/unit/react/BlockNoteViewRemountHover.test.tsx @@ -0,0 +1,83 @@ +import { BlockNoteEditor } from "@blocknote/core"; +import { BlockNoteView } from "@blocknote/mantine"; +import "@blocknote/mantine/style.css"; +import React from "react"; +import { flushSync } from "react-dom"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +// https://github.com/TypeCellOS/BlockNote/pull/2335 +describe("BlockNoteView new editor + hover", () => { + let div: HTMLDivElement; + + beforeEach(() => { + div = document.createElement("div"); + document.body.appendChild(div); + }); + + afterEach(() => { + document.body.removeChild(div); + }); + + it("should not throw error when replacing editor in same container and mouseovering", async () => { + // 1. Setup container + const editor1 = BlockNoteEditor.create(); + const root = createRoot(div); + + // 2. Render first editor twice + flushSync(() => { + root.render( + + + , + ); + }); + + await new Promise((resolve) => setTimeout(resolve, 0)); + flushSync(() => { + root.render( + + + , + ); + }); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + const editor1DomElement = editor1.domElement; + expect(editor1DomElement).toBeDefined(); + + // 3. Replace with new editor in same container + // This causes LinkToolbarController of editor1 to unmount and editor2 to mount + const editor2 = BlockNoteEditor.create(); + flushSync(() => { + root.render( + + + , + ); + }); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + const editor2DomElement = editor2.domElement; + expect(editor2DomElement).toBeDefined(); + + // 4. Simulate mouseover on the OLD element. + // If the listener was leaked (due to editor.domElement being null/changed at cleanup), + // this will fire the callback. callback uses editor1 which might be in bad state -> Crash. + + expect(() => { + editor1DomElement!.dispatchEvent( + new MouseEvent("mouseover", { bubbles: true }), + ); + }).not.toThrow(); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + // Cleanup + editor1._tiptapEditor.destroy(); + editor2._tiptapEditor.destroy(); + root.unmount(); + }); +}); From c4c43dac74d4f9ff5564cf5f829769aba491ff40 Mon Sep 17 00:00:00 2001 From: yousefed Date: Wed, 7 Jan 2026 13:25:43 +0100 Subject: [PATCH 3/3] remove comments --- packages/core/src/extensions/LinkToolbar/LinkToolbar.ts | 5 ----- .../src/components/LinkToolbar/LinkToolbarController.tsx | 4 +--- 2 files changed, 1 insertion(+), 8 deletions(-) diff --git a/packages/core/src/extensions/LinkToolbar/LinkToolbar.ts b/packages/core/src/extensions/LinkToolbar/LinkToolbar.ts index 3e3ef64e32..1a61d67d44 100644 --- a/packages/core/src/extensions/LinkToolbar/LinkToolbar.ts +++ b/packages/core/src/extensions/LinkToolbar/LinkToolbar.ts @@ -67,11 +67,6 @@ export const LinkToolbarExtension = createExtension(({ editor }) => { getLinkAtElement(element: HTMLElement) { return editor.transact(() => { - // Q4: posAtDOM can fail if the editor view is not available - // (e.g. if the editor is not mounted) - // a) Unfortunately, TS doesn't give an error about this. Can we make this type safe? - // b) Double check other references of editor.prosemirrorView - const posAtElement = editor.prosemirrorView.posAtDOM(element, 0) + 1; return getMarkAtPos(posAtElement, "link"); }); diff --git a/packages/react/src/components/LinkToolbar/LinkToolbarController.tsx b/packages/react/src/components/LinkToolbar/LinkToolbarController.tsx index 20edd94985..ed6cb6db7c 100644 --- a/packages/react/src/components/LinkToolbar/LinkToolbarController.tsx +++ b/packages/react/src/components/LinkToolbar/LinkToolbarController.tsx @@ -100,8 +100,6 @@ export const LinkToolbarController = (props: { const domElement = editor.domElement; - // Q 1: why can domElement be available when is rendered? - // Q 2: this useEffect will not necessarily run when editor.domElement changes domElement?.addEventListener("mouseover", mouseCursorCallback); return () => { @@ -164,7 +162,7 @@ export const LinkToolbarController = (props: { [link?.element], ); - // Q3: similar to Q2; are we sure the component rerenders when editor.isEditable changes? + // TODO: this should be a hook to be reactive if (!editor.isEditable) { return null; }