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));