diff --git a/packages/react/src/schema/ReactStyleSpec.browser.test.tsx b/packages/react/src/schema/ReactStyleSpec.browser.test.tsx
index e57f9b3abb..0d23f83f34 100644
--- a/packages/react/src/schema/ReactStyleSpec.browser.test.tsx
+++ b/packages/react/src/schema/ReactStyleSpec.browser.test.tsx
@@ -5,6 +5,7 @@ import {
} from "@blocknote/core";
import { flushSync } from "react-dom";
import { createRoot } from "react-dom/client";
+import { TextSelection } from "@tiptap/pm/state";
import { expect, it } from "vite-plus/test";
import { userEvent } from "vite-plus/test/browser";
@@ -19,6 +20,119 @@ const schema = BlockNoteSchema.create({
styleSpecs: { ...defaultStyleSpecs, highlight },
});
+it.each([
+ "cursor",
+ "forward selection",
+ "backward selection",
+ "mixed selection",
+] as const)(
+ "preserves the native selection when a React style mounts (%s)",
+ async (selectionMode) => {
+ const editor = BlockNoteEditor.create({
+ schema,
+ initialContent: [
+ {
+ type: "paragraph",
+ content:
+ selectionMode === "mixed selection"
+ ? [
+ { type: "text", text: "al", styles: {} },
+ { type: "text", text: "p", styles: { bold: true } },
+ { type: "text", text: "ha", styles: {} },
+ ]
+ : "alpha",
+ },
+ ],
+ });
+ const host = document.createElement("div");
+ document.body.append(host);
+ const root = createRoot(host);
+ try {
+ flushSync(() => root.render());
+ const view = editor.prosemirrorView;
+ const [anchor, head] =
+ selectionMode === "cursor"
+ ? [5, 5]
+ : selectionMode !== "backward selection"
+ ? [4, 7]
+ : [7, 4];
+ view.dispatch(
+ view.state.tr.setSelection(
+ TextSelection.create(view.state.doc, anchor, head),
+ ),
+ );
+ view.focus();
+ expect(document.getSelection()?.toString()).toBe(
+ selectionMode === "cursor" ? "" : "lph",
+ );
+ editor.addStyles({ highlight: true });
+
+ // Mounting a mark must preserve both endpoints, including a backwards
+ // selection, rather than merely retaining the selected text.
+ await expect
+ .poll(() => {
+ const selection = document.getSelection();
+ if (!selection?.anchorNode || !selection.focusNode) {
+ return undefined;
+ }
+ return [
+ view.posAtDOM(selection.anchorNode, selection.anchorOffset),
+ view.posAtDOM(selection.focusNode, selection.focusOffset),
+ ];
+ })
+ .toEqual([anchor, head]);
+
+ // The first character creates a mark view for a collapsed selection.
+ // React may commit it after ProseMirror has already placed the caret.
+ await userEvent.keyboard("X");
+ await userEvent.keyboard("Y");
+
+ expect(editor.prosemirrorState.doc.textContent).toBe(
+ selectionMode === "cursor" ? "alXYpha" : "aXYa",
+ );
+ expect(editor.getActiveStyles()).toMatchObject({ highlight: true });
+ } finally {
+ root.unmount();
+ editor._tiptapEditor.destroy();
+ host.remove();
+ }
+ },
+);
+
+it("does not refocus the editor after a React style mounts", async () => {
+ const editor = BlockNoteEditor.create({
+ schema,
+ initialContent: [{ type: "paragraph", content: "alpha" }],
+ });
+ const host = document.createElement("div");
+ const input = document.createElement("input");
+ document.body.append(host, input);
+ const root = createRoot(host);
+ try {
+ flushSync(() => root.render());
+ editor.prosemirrorView.focus();
+ flushSync(() => {
+ editor.updateBlock(editor.document[0], {
+ content: [
+ { type: "text", text: "styled", styles: { highlight: true } },
+ ],
+ });
+ });
+ // Focus can change between React's ref callback and reconciliation.
+ input.focus();
+ await userEvent.keyboard("X");
+
+ expect(document.activeElement).toBe(input);
+ expect(input.value).toBe("X");
+ expect(editor.prosemirrorState.doc.textContent).toBe("styled");
+ } finally {
+ root.unmount();
+ editor._tiptapEditor.destroy();
+ host.remove();
+ input.remove();
+ }
+});
+
it.each(["initial mount", "remount", "API remount"])(
"keeps typing after text inserted with a React style (%s)",
async (mountMode) => {
diff --git a/packages/react/src/schema/ReactStyleSpec.tsx b/packages/react/src/schema/ReactStyleSpec.tsx
index b780b2c9a9..7aafb2fafd 100644
--- a/packages/react/src/schema/ReactStyleSpec.tsx
+++ b/packages/react/src/schema/ReactStyleSpec.tsx
@@ -13,7 +13,7 @@ import {
ReactMarkViewContext,
ReactMarkViewRenderer,
} from "@tiptap/react";
-import { FC, useContext } from "react";
+import { FC, useCallback, useContext } from "react";
import { renderToDOMSpec } from "./@util/ReactRenderUtil.js";
// this file is mostly analogoues to `customBlocks.ts`, but for React blocks
@@ -88,44 +88,46 @@ export function createReactStyleSpec(
addMarkView() {
const editor: BlockNoteEditor = this.options.editor;
- return (markViewProps) => {
- const renderResult = ReactMarkViewRenderer((props) => {
- const ref = useContext(ReactMarkViewContext).markViewContentRef;
+ return ReactMarkViewRenderer((props) => {
+ const ref = useContext(ReactMarkViewContext).markViewContentRef;
- if (!ref) {
- throw new Error("markViewContentRef is not set");
- }
+ if (!ref) {
+ throw new Error("markViewContentRef is not set");
+ }
- const Content = styleImplementation.render;
- return (
- {
- ref(element);
- if (element) {
- element.dataset.markViewContent = "";
+ const view = props.view;
+ const contentRef = useCallback(
+ (element: HTMLElement | null) => {
+ ref(element);
+ if (element) {
+ element.dataset.markViewContent = "";
+ // React can attach this content after ProseMirror maps selection.
+ // EditorView.focus() resyncs it from the current editor state.
+ // Wait until the commit/transaction finishes, and only reconcile
+ // an already-focused editor.
+ queueMicrotask(() => {
+ if (!view.isDestroyed && view.hasFocus()) {
+ view.focus();
}
- }}
- value={
- styleConfig.propSchema === "boolean"
- ? undefined
- : props.mark.attrs.stringValue
- }
- />
- );
- })(markViewProps);
-
- /**
- * See https://github.com/ueberdosis/tiptap/blob/5ba480bcd97a0e92f889019f1a65fc67fd3506f9/packages/react/src/ReactMarkViewRenderer.tsx#L98-L103
- * If the `contentRef` is not set synchronously, the contentDOM will be null
- * This is a hack to make sure that it is always defined so that Prosemirror can insert the content into the correct position
- *
- * (This is only an issue with Firefox)
- */
- (renderResult as any).didMountContentDomElement = true;
+ });
+ }
+ },
+ [ref, view],
+ );
- return renderResult;
- };
+ const Content = styleImplementation.render;
+ return (
+
+ );
+ });
},
});
diff --git a/tests/src/unit/react/staleNodeViewPos.test.tsx b/tests/src/unit/react/staleNodeViewPos.test.tsx
index 2328f09ae0..bf6c69495c 100644
--- a/tests/src/unit/react/staleNodeViewPos.test.tsx
+++ b/tests/src/unit/react/staleNodeViewPos.test.tsx
@@ -9,6 +9,7 @@ import { useLayoutEffect } from "react";
import { flushSync } from "react-dom";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vite-plus/test";
+import * as editorContent from "../../../../packages/react/src/editor/EditorContent.js";
/**
* Regression tests for #2937 (and the same family: #2682, #2621).
@@ -111,6 +112,21 @@ function paragraphs(count: number, prefix: string) {
}
function mountEditor(initialContent: any[]): ReproEditor {
+ // Reproduce the synchronous node-view mounting used by older TipTap releases,
+ // independently of whether ReactRenderer itself still calls flushSync.
+ // Flush at BlockNote's portal registration boundary, while ProseMirror is
+ // still reconciling, rather than after the command has already completed.
+ const getContentComponent = editorContent.getContentComponent;
+ vi.spyOn(editorContent, "getContentComponent").mockImplementation(() => {
+ const contentComponent = getContentComponent();
+ return {
+ ...contentComponent,
+ setRenderer(...args) {
+ flushSync(() => contentComponent.setRenderer(...args));
+ },
+ };
+ });
+
div = document.createElement("div");
document.body.appendChild(div);
@@ -131,16 +147,10 @@ function mountEditor(initialContent: any[]): ReproEditor {
root!.render();
});
- // TipTap's `ReactRenderer` only renders a node view synchronously (via
- // `flushSync`) when `isEditorContentInitialized` is set; otherwise it defers
- // to a microtask. TipTap's own `PureEditorContent` sets it, but BlockNote
- // replaces that component with its own mounting (`BlockNoteView.tsx`) and
- // never does, so BlockNote currently gets the deferred path by accident.
- // Set it here so node views mount the way they do for every other TipTap
- // React user — and the way they do for anyone resolving `@tiptap/react`
- // below 3.22, where this flag was still the always-true `isInitialized`.
- // That is the configuration #2937 was reported against.
- (editor as any)._tiptapEditor.isEditorContentInitialized = true;
+ // Avoid ReactRenderer's initial microtask deferral (introduced in TipTap
+ // 3.22). This flag no longer guarantees a synchronous React commit in newer
+ // releases; the portal registration wrapper above supplies that separately.
+ Object.assign(editor._tiptapEditor, { isEditorContentInitialized: true });
return editor;
}
@@ -184,12 +194,18 @@ describe("stale node view getPos (#2937)", () => {
{ type: "repro", content: "existing repro block" },
...paragraphs(10, "tail"),
]);
+ let mounted = false;
+ onMount = (_id, text) => {
+ mounted ||= text === MARKER;
+ };
const errors = runCapturingErrors(() => {
ed.replaceBlocks(ed.document.slice(0, 28), [
{ type: "repro", content: MARKER } as any,
]);
});
+ // Even the non-reentrant control must actually mount during the command.
+ expect(mounted).toBe(true);
expect(errors).toEqual([]);
// The straightforward case must not go anywhere near the fallback.
expect(renderedFromFallbackCount).toBe(0);
@@ -207,8 +223,8 @@ describe("stale node view getPos (#2937)", () => {
return;
}
fired = true;
- // Runs inside React's commit phase, which is inside the `flushSync` in
- // TipTap's `ReactRenderer` constructor, which is inside ProseMirror's
+ // Runs inside React's commit phase, which is inside the test's
+ // synchronous portal registration, which is inside ProseMirror's
// `updateChildren`. Node views built or rendered from here see a
// `getPos()` computed against a half-updated view-desc tree.
ed.removeBlocks(ed.document.slice(-8));