Skip to content

React node views: caret does not move into new block on Enter since 3.28.0 (portal notification batching) #8117

Description

@SebiVPS

Affected Packages

@tiptap/react

Tiptap Version

3.28.0

Browser Used

Chrome

What happened?

Description

When the paragraph node (or any block node) is rendered through ReactNodeViewRenderer, pressing Enter splits the node correctly in the ProseMirror document, but the DOM selection does not follow into the newly created block. The caret visually stays at the end of the original paragraph and continued typing is inserted there instead of the new paragraph — e.g. Hello + Enter + typing Second produces a single paragraph HelloSecond. This makes editors with React-node-view block nodes unusable for normal multi-paragraph text entry.

Native (non-node-view) nodes are unaffected. Marks are unaffected.

Steps to reproduce

  1. Replace StarterKit's paragraph with a React node view:

    import Paragraph from "@tiptap/extension-paragraph";
    import { EditorContent, NodeViewContent, NodeViewWrapper, ReactNodeViewRenderer, useEditor } from "@tiptap/react";
    import StarterKit from "@tiptap/starter-kit";
    
    const ParagraphView = () => (
        <NodeViewWrapper as="p">
            <NodeViewContent />
        </NodeViewWrapper>
    );
    
    const NodeViewParagraph = Paragraph.extend({
        addNodeView() {
            return ReactNodeViewRenderer(ParagraphView);
        },
    });
    
    function App() {
        const editor = useEditor({
            extensions: [StarterKit.configure({ paragraph: false }), NodeViewParagraph],
            content: "<p>Hello</p>",
        });
        return <EditorContent editor={editor} />;
    }
  2. Click at the end of "Hello".

  3. Press Enter.

  4. Type anything.

Expected Behavior

Expected: a new empty paragraph is created, the caret moves into it, and the typed text lands in the new paragraph.

Actual: the document splits, but the caret stays in the old paragraph; the typed text is appended to "Hello" (the document collapses back to a single paragraph containing HelloSecond).

Immediately after step 3, window.getSelection() anchors on the node view's content element ([data-node-view-content-react]) at offset 0 with an empty client rect, instead of a renderable caret position inside the new block.

Root cause analysis

The regression is the "Batch portal store notifications during simultaneous mounts" change in 3.28.0. Diff of the portal store between 3.27.4 and 3.28.0:

// 3.27.4 — subscribers notified synchronously in setRenderer/removeRenderer
subscribers.forEach((subscriber) => subscriber());

// 3.28.0 — deferred to a microtask
const notifySubscribers = () => {
    if (isNotificationQueued || !subscribers.size) return;
    isNotificationQueued = true;
    queueMicrotask(() => {
        isNotificationQueued = false;
        subscribers.forEach((subscriber) => subscriber());
    });
};

Because the Portals component (useSyncExternalStore) is now notified in a microtask, a newly created ReactNodeView's component — and therefore its contentDOM — does not exist until after ProseMirror's current view update cycle has finished. When Enter splits a block, ProseMirror tries to map the selection into the new node view's contentDOM during that same update cycle, finds none, and the DOM selection is left behind in the old block. The browser then keeps inserting text at the stale caret position.

3.27.4 flushed the portal synchronously inside setRenderer, so contentDOM was available in time.

Environment

Workaround

Pin @tiptap/* to 3.27.4.

One note: I verified the caret desync end-to-end in the Comet setup (TextBlockStyleParagraph); the minimal snippet in the issue is the standard pattern distilled from it but wasn't run standalone — if you want, I can spin it up quickly (e.g. in the cms-admin Storybook stories, which already have a plain node-view story) before you submit, so the repro is guaranteed against the exact snippet.

Reproducible Example URL (Optional)

No response

Additional Context (Optional)

No response

Metadata

Metadata

Assignees

Labels

area: coreCore editor engine and ProseMirror integrationarea: editorEditor behavior, commands, and transactionsarea: reactReact integration and componentsstatus: triageNeeds initial review and categorization

Type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions