From 58457e2b0d10615218c8cf3f16dfbbaad7b803c8 Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Thu, 2 Apr 2026 14:18:02 +0200 Subject: [PATCH 1/3] Made editor re-render on mount --- packages/core/src/editor/BlockNoteEditor.ts | 4 ++-- packages/react/src/editor/BlockNoteView.tsx | 21 +++++++++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/packages/core/src/editor/BlockNoteEditor.ts b/packages/core/src/editor/BlockNoteEditor.ts index 2a6648f04b..b605ddbb18 100644 --- a/packages/core/src/editor/BlockNoteEditor.ts +++ b/packages/core/src/editor/BlockNoteEditor.ts @@ -1259,7 +1259,7 @@ export class BlockNoteEditor< editor: BlockNoteEditor; }) => void, ) { - this._eventManager.onMount(callback); + return this._eventManager.onMount(callback); } /** @@ -1275,7 +1275,7 @@ export class BlockNoteEditor< editor: BlockNoteEditor; }) => void, ) { - this._eventManager.onUnmount(callback); + return this._eventManager.onUnmount(callback); } /** diff --git a/packages/react/src/editor/BlockNoteView.tsx b/packages/react/src/editor/BlockNoteView.tsx index d810fafcbe..66cc4e2733 100644 --- a/packages/react/src/editor/BlockNoteView.tsx +++ b/packages/react/src/editor/BlockNoteView.tsx @@ -10,6 +10,7 @@ import React, { ReactNode, Ref, useCallback, + useEffect, useMemo, useState, } from "react"; @@ -192,6 +193,26 @@ function BlockNoteViewComponent< comments, ]); + // editor.domElement is gated behind TipTap's isInitialized flag, which is + // set in a setTimeout(0) after mount. Force a re-render once it becomes + // available so that all child components (toolbars, menus, etc.) that + // depend on editor.domElement see the real element. + const [, setMounted] = useState(false); + useEffect(() => { + if (editor.domElement) { + return; + } + // We listen to TipTap's "create" event directly because + // editor.onMount() is registered inside the "create" handler in + // EventManager, so it misses the first mount. The "create" event + // fires after isInitialized is set, so editor.domElement is ready. + const handler = () => setMounted(true); + editor._tiptapEditor.on("create", handler); + return () => { + editor._tiptapEditor.off("create", handler); + }; + }, [editor]); + return ( From 70b80754a2d8cb3562f57b66587ddf4a6f12736d Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Wed, 15 Apr 2026 10:23:27 +0200 Subject: [PATCH 2/3] Extracted editor DOM element re-render logic to separate hook. --- .../LinkToolbar/LinkToolbarController.tsx | 11 ++--- packages/react/src/editor/BlockNoteView.tsx | 20 --------- .../react/src/hooks/useEditorDomElement.ts | 43 +++++++++++++++++++ packages/react/src/index.ts | 1 + 4 files changed, 50 insertions(+), 25 deletions(-) create mode 100644 packages/react/src/hooks/useEditorDomElement.ts diff --git a/packages/react/src/components/LinkToolbar/LinkToolbarController.tsx b/packages/react/src/components/LinkToolbar/LinkToolbarController.tsx index ea876c24f8..9e7fe42d07 100644 --- a/packages/react/src/components/LinkToolbar/LinkToolbarController.tsx +++ b/packages/react/src/components/LinkToolbar/LinkToolbarController.tsx @@ -4,6 +4,7 @@ import { Range } from "@tiptap/core"; import { FC, useEffect, useMemo, useState } from "react"; import { useBlockNoteEditor } from "../../hooks/useBlockNoteEditor.js"; +import { useEditorDOMElement } from "../../hooks/useEditorDomElement.js"; import { useExtension } from "../../hooks/useExtension.js"; import { FloatingUIOptions } from "../Popovers/FloatingUIOptions.js"; import { @@ -22,6 +23,8 @@ export const LinkToolbarController = (props: { const [toolbarOpen, setToolbarOpen] = useState(false); const [toolbarPositionFrozen, setToolbarPositionFrozen] = useState(false); + const editorDOMElement = useEditorDOMElement(); + const linkToolbar = useExtension(LinkToolbarExtension); // Because the toolbar opens with a delay when a link is hovered by the mouse @@ -98,16 +101,14 @@ export const LinkToolbarController = (props: { const destroyOnSelectionChangeHandler = editor.onSelectionChange(textCursorCallback); - const domElement = editor.domElement; - - domElement?.addEventListener("mouseover", mouseCursorCallback); + editorDOMElement?.addEventListener("mouseover", mouseCursorCallback); return () => { destroyOnChangeHandler(); destroyOnSelectionChangeHandler(); - domElement?.removeEventListener("mouseover", mouseCursorCallback); + editorDOMElement?.removeEventListener("mouseover", mouseCursorCallback); }; - }, [editor, editor.domElement, linkToolbar, link, toolbarPositionFrozen]); + }, [editor, editorDOMElement, linkToolbar, link, toolbarPositionFrozen]); const floatingUIOptions = useMemo( () => ({ diff --git a/packages/react/src/editor/BlockNoteView.tsx b/packages/react/src/editor/BlockNoteView.tsx index 5641b3b0ae..a4679af611 100644 --- a/packages/react/src/editor/BlockNoteView.tsx +++ b/packages/react/src/editor/BlockNoteView.tsx @@ -211,26 +211,6 @@ function BlockNoteViewComponent< }; }, [autoFocus, contentEditableProps, editable, defaultUIProps]); - // editor.domElement is gated behind TipTap's isInitialized flag, which is - // set in a setTimeout(0) after mount. Force a re-render once it becomes - // available so that all child components (toolbars, menus, etc.) that - // depend on editor.domElement see the real element. - const [, setMounted] = useState(false); - useEffect(() => { - if (editor.domElement) { - return; - } - // We listen to TipTap's "create" event directly because - // editor.onMount() is registered inside the "create" handler in - // EventManager, so it misses the first mount. The "create" event - // fires after isInitialized is set, so editor.domElement is ready. - const handler = () => setMounted(true); - editor._tiptapEditor.on("create", handler); - return () => { - editor._tiptapEditor.off("create", handler); - }; - }, [editor]); - return ( diff --git a/packages/react/src/hooks/useEditorDomElement.ts b/packages/react/src/hooks/useEditorDomElement.ts new file mode 100644 index 0000000000..dde7cd2fce --- /dev/null +++ b/packages/react/src/hooks/useEditorDomElement.ts @@ -0,0 +1,43 @@ +import { useEffect, useState } from "react"; + +import { useBlockNoteEditor } from "./useBlockNoteEditor.js"; +import { useEditorState } from "./useEditorState.js"; + +/** + * Returns the editor's DOM element reactively. + * + * `editor.domElement` is gated behind TipTap's `isInitialized` flag, which is + * set in a `setTimeout(0)` after mount. A plain read of `editor.domElement` + * during the first render (or effect) will see `undefined`, and no transaction + * fires after `isInitialized` flips to notify subscribers. + * + * This hook uses `useEditorState` to pick up changes that coincide with + * transactions (e.g. remounts), and supplements it with a TipTap `create` + * event listener to handle the initial mount timing. + */ +export function useEditorDOMElement() { + const editor = useBlockNoteEditor(); + + // Handle initial mount timing. TipTap sets isInitialized synchronously + // right after emitting the "create" event, so by the time React processes + // this state update, editor.domElement is available. + const [, setInitialized] = useState(!editor.headless); + useEffect(() => { + if (!editor.headless) { + return; + } + const handler = () => setInitialized(true); + editor._tiptapEditor.on("create", handler); + return () => { + editor._tiptapEditor.off("create", handler); + }; + }, [editor]); + + // Re-evaluate editor.domElement on every render (including the one + // triggered by setInitialized above) and on every transaction. + return useEditorState({ + editor, + selector: (ctx) => ctx.editor?.domElement, + equalityFn: (a, b) => a === b, + }); +} diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index fdefed5e49..6ed745a789 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -117,6 +117,7 @@ export * from "./hooks/useActiveStyles.js"; export * from "./hooks/useBlockNoteEditor.js"; export * from "./hooks/useCreateBlockNote.js"; export * from "./hooks/useEditorChange.js"; +export * from "./hooks/useEditorDomElement.js"; export * from "./hooks/useEditorSelectionBoundingBox.js"; export * from "./hooks/useEditorSelectionChange.js"; export * from "./hooks/useFocusWithin.js"; From c20d1c16745412bbc354b44c06c84d759ada35ae Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Wed, 15 Apr 2026 10:35:17 +0200 Subject: [PATCH 3/3] Replaced all direct references to `editor.domElement` with hook in react package --- .../DefaultButtons/CreateLinkButton.tsx | 9 +++++---- .../react/src/components/Popovers/PositionPopover.tsx | 6 ++++-- .../GridSuggestionMenuController.tsx | 4 +++- .../hooks/useGridSuggestionMenuKeyboardNavigation.ts | 11 ++++++----- .../SuggestionMenu/SuggestionMenuController.tsx | 4 +++- .../hooks/useSuggestionMenuKeyboardNavigation.ts | 8 +++++--- 6 files changed, 26 insertions(+), 16 deletions(-) diff --git a/packages/react/src/components/FormattingToolbar/DefaultButtons/CreateLinkButton.tsx b/packages/react/src/components/FormattingToolbar/DefaultButtons/CreateLinkButton.tsx index 925c6b7d12..e3a7b736b1 100644 --- a/packages/react/src/components/FormattingToolbar/DefaultButtons/CreateLinkButton.tsx +++ b/packages/react/src/components/FormattingToolbar/DefaultButtons/CreateLinkButton.tsx @@ -15,6 +15,7 @@ import { import { useComponentsContext } from "../../../editor/ComponentsContext.js"; import { useBlockNoteEditor } from "../../../hooks/useBlockNoteEditor.js"; +import { useEditorDOMElement } from "../../../hooks/useEditorDomElement.js"; import { useEditorState } from "../../../hooks/useEditorState.js"; import { useExtension } from "../../../hooks/useExtension.js"; import { useDictionary } from "../../../i18n/dictionary.js"; @@ -41,6 +42,7 @@ function checkLinkInSchema( export const CreateLinkButton = () => { const editor = useBlockNoteEditor(); + const editorDOMElement = useEditorDOMElement(); const Components = useComponentsContext()!; const dict = useDictionary(); @@ -97,13 +99,12 @@ export const CreateLinkButton = () => { } }; - const domElement = editor.domElement; - domElement?.addEventListener("keydown", callback); + editorDOMElement?.addEventListener("keydown", callback); return () => { - domElement?.removeEventListener("keydown", callback); + editorDOMElement?.removeEventListener("keydown", callback); }; - }, [editor.domElement]); + }, [editorDOMElement]); if (state === undefined) { return null; diff --git a/packages/react/src/components/Popovers/PositionPopover.tsx b/packages/react/src/components/Popovers/PositionPopover.tsx index fafebd45b7..93ef837f61 100644 --- a/packages/react/src/components/Popovers/PositionPopover.tsx +++ b/packages/react/src/components/Popovers/PositionPopover.tsx @@ -2,6 +2,7 @@ import { posToDOMRect } from "@tiptap/core"; import { ReactNode, useMemo } from "react"; import { useBlockNoteEditor } from "../../hooks/useBlockNoteEditor.js"; +import { useEditorDOMElement } from "../../hooks/useEditorDomElement.js"; import { FloatingUIOptions } from "./FloatingUIOptions.js"; import { GenericPopover, GenericPopoverReference } from "./GenericPopover.js"; @@ -15,6 +16,7 @@ export const PositionPopover = ( const { from, to } = position || {}; const editor = useBlockNoteEditor(); + const editorDOMElement = useEditorDOMElement(); const reference = useMemo(() => { if (from === undefined || to === undefined) { @@ -25,11 +27,11 @@ export const PositionPopover = ( // Use first child as the editor DOM element may itself be scrollable. // For FloatingUI to auto-update the position during scrolling, the // `contextElement` must be a descendant of the scroll container. - element: editor.domElement?.firstElementChild || undefined, + element: editorDOMElement?.firstElementChild || undefined, getBoundingClientRect: () => posToDOMRect(editor.prosemirrorView, from, to ?? from), }; - }, [editor, from, to]); + }, [editor, editorDOMElement, from, to]); return ( diff --git a/packages/react/src/components/SuggestionMenu/GridSuggestionMenu/GridSuggestionMenuController.tsx b/packages/react/src/components/SuggestionMenu/GridSuggestionMenu/GridSuggestionMenuController.tsx index b2af2c55e5..a0fdcb61d4 100644 --- a/packages/react/src/components/SuggestionMenu/GridSuggestionMenu/GridSuggestionMenuController.tsx +++ b/packages/react/src/components/SuggestionMenu/GridSuggestionMenu/GridSuggestionMenuController.tsx @@ -7,6 +7,7 @@ import { autoPlacement, offset, shift, size } from "@floating-ui/react"; import { FC, useEffect, useMemo } from "react"; import { useBlockNoteEditor } from "../../../hooks/useBlockNoteEditor.js"; +import { useEditorDOMElement } from "../../../hooks/useEditorDomElement.js"; import { useExtension, useExtensionState, @@ -64,6 +65,7 @@ export function GridSuggestionMenuController< InlineContentSchema, StyleSchema >(); + const editorDOMElement = useEditorDOMElement(); const { triggerCharacter, @@ -108,7 +110,7 @@ export function GridSuggestionMenuController< // Use first child as the editor DOM element may itself be scrollable. // For FloatingUI to auto-update the position during scrolling, the // `contextElement` must be a descendant of the scroll container. - element: (editor.domElement?.firstChild || undefined) as + element: (editorDOMElement?.firstChild || undefined) as | Element | undefined, getBoundingClientRect: () => state?.referencePos || new DOMRect(), diff --git a/packages/react/src/components/SuggestionMenu/GridSuggestionMenu/hooks/useGridSuggestionMenuKeyboardNavigation.ts b/packages/react/src/components/SuggestionMenu/GridSuggestionMenu/hooks/useGridSuggestionMenuKeyboardNavigation.ts index e2b27f60e3..8dfaffc6ed 100644 --- a/packages/react/src/components/SuggestionMenu/GridSuggestionMenu/hooks/useGridSuggestionMenuKeyboardNavigation.ts +++ b/packages/react/src/components/SuggestionMenu/GridSuggestionMenu/hooks/useGridSuggestionMenuKeyboardNavigation.ts @@ -1,15 +1,17 @@ import { BlockNoteEditor } from "@blocknote/core"; import { useEffect, useState } from "react"; +import { useEditorDOMElement } from "../../../../hooks/useEditorDomElement.js"; // Hook which handles keyboard navigation of a grid suggestion menu. Arrow keys // are used to select a menu item, enter is used to execute it. export function useGridSuggestionMenuKeyboardNavigation( - editor: BlockNoteEditor, + _editor: BlockNoteEditor, query: string, items: Item[], columns: number, onItemClick?: (item: Item) => void, ) { + const editorDOMElement = useEditorDOMElement(); const [selectedIndex, setSelectedIndex] = useState(0); const isGrid = columns !== undefined && columns > 1; @@ -66,17 +68,16 @@ export function useGridSuggestionMenuKeyboardNavigation( return false; }; - const domElement = editor.domElement; - domElement?.addEventListener("keydown", handleMenuNavigationKeys, true); + editorDOMElement?.addEventListener("keydown", handleMenuNavigationKeys, true); return () => { - domElement?.removeEventListener( + editorDOMElement?.removeEventListener( "keydown", handleMenuNavigationKeys, true, ); }; - }, [editor.domElement, items, selectedIndex, onItemClick, columns, isGrid]); + }, [editorDOMElement, items, selectedIndex, onItemClick, columns, isGrid]); // Resets index when items change useEffect(() => { diff --git a/packages/react/src/components/SuggestionMenu/SuggestionMenuController.tsx b/packages/react/src/components/SuggestionMenu/SuggestionMenuController.tsx index 6efb18c845..8b4c81e6f7 100644 --- a/packages/react/src/components/SuggestionMenu/SuggestionMenuController.tsx +++ b/packages/react/src/components/SuggestionMenu/SuggestionMenuController.tsx @@ -8,6 +8,7 @@ import { autoPlacement, offset, shift, size } from "@floating-ui/react"; import { FC, useEffect, useMemo } from "react"; import { useBlockNoteEditor } from "../../hooks/useBlockNoteEditor.js"; +import { useEditorDOMElement } from "../../hooks/useEditorDomElement.js"; import { useExtension, useExtensionState } from "../../hooks/useExtension.js"; import { FloatingUIOptions } from "../Popovers/FloatingUIOptions.js"; import { @@ -58,6 +59,7 @@ export function SuggestionMenuController< InlineContentSchema, StyleSchema >(); + const editorDOMElement = useEditorDOMElement(); const { triggerCharacter, @@ -101,7 +103,7 @@ export function SuggestionMenuController< // Use first child as the editor DOM element may itself be scrollable. // For FloatingUI to auto-update the position during scrolling, the // `contextElement` must be a descendant of the scroll container. - element: (editor.domElement?.firstChild || undefined) as + element: (editorDOMElement?.firstChild || undefined) as | Element | undefined, getBoundingClientRect: () => state?.referencePos || new DOMRect(), diff --git a/packages/react/src/components/SuggestionMenu/hooks/useSuggestionMenuKeyboardNavigation.ts b/packages/react/src/components/SuggestionMenu/hooks/useSuggestionMenuKeyboardNavigation.ts index 6972c67f1f..8984dbb5bf 100644 --- a/packages/react/src/components/SuggestionMenu/hooks/useSuggestionMenuKeyboardNavigation.ts +++ b/packages/react/src/components/SuggestionMenu/hooks/useSuggestionMenuKeyboardNavigation.ts @@ -1,27 +1,29 @@ import { BlockNoteEditor } from "@blocknote/core"; import { useEffect } from "react"; +import { useEditorDOMElement } from "../../../hooks/useEditorDomElement.js"; import { useSuggestionMenuKeyboardHandler } from "./useSuggestionMenuKeyboardHandler.js"; // Hook which handles keyboard navigation of a suggestion menu. Up & down arrow // keys are used to select a menu item, enter is used to execute it. export function useSuggestionMenuKeyboardNavigation( - editor: BlockNoteEditor, + _editor: BlockNoteEditor, query: string, items: Item[], onItemClick?: (item: Item) => void, element?: HTMLElement, ) { + const editorDOMElement = useEditorDOMElement(); const { selectedIndex, setSelectedIndex, handler } = useSuggestionMenuKeyboardHandler(items, onItemClick); useEffect(() => { - const el = element || editor.domElement; + const el = element || editorDOMElement; el?.addEventListener("keydown", handler, true); return () => { el?.removeEventListener("keydown", handler, true); }; - }, [editor.domElement, items, selectedIndex, onItemClick, element, handler]); + }, [editorDOMElement, items, selectedIndex, onItemClick, element, handler]); // Resets index when items change useEffect(() => {