From ec29df834d40482c6a9156a34e863a31cb5cd20d Mon Sep 17 00:00:00 2001 From: Jonas Date: Wed, 23 Sep 2026 09:46:17 +0200 Subject: [PATCH] fix(core): let ProseMirror handle selection changes inside leaf node views NodeView.ignoreMutation returned true for every mutation when the node view has no contentDOM, selection mutations included. When the browser places the caret inside such a node view, for example after ArrowUp in Firefox or a click on the node's text, ProseMirror never re-read the selection, the caret stayed inside the non-editable DOM and typed text was lost. Return false for selection mutations in that case, matching ProseMirror's own default for leaf nodes and the existing behaviour for node views with a contentDOM. Fixes: #8369 Signed-off-by: Jonas Assisted-by: ClaudeCode:claude-fable-5.1 --- .../nodeview-leaf-selection-mutations.md | 5 ++ packages/core/src/NodeView.test.ts | 53 +++++++++++++++++++ packages/core/src/NodeView.ts | 8 ++- 3 files changed, 65 insertions(+), 1 deletion(-) create mode 100644 .changeset/nodeview-leaf-selection-mutations.md diff --git a/.changeset/nodeview-leaf-selection-mutations.md b/.changeset/nodeview-leaf-selection-mutations.md new file mode 100644 index 0000000000..66ff42b6ef --- /dev/null +++ b/.changeset/nodeview-leaf-selection-mutations.md @@ -0,0 +1,5 @@ +--- +'@tiptap/core': patch +--- + +Node views without a `contentDOM` no longer ignore selection mutations, so ProseMirror moves the caret back to a valid position when the browser places it inside the node view. diff --git a/packages/core/src/NodeView.test.ts b/packages/core/src/NodeView.test.ts index 397f452802..35a9a17b75 100644 --- a/packages/core/src/NodeView.test.ts +++ b/packages/core/src/NodeView.test.ts @@ -1,3 +1,4 @@ +import type { ViewMutationRecord } from '@tiptap/pm/view' import { afterEach, describe, expect, it, vi } from 'vite-plus/test' import { createViewMutationTestContext } from '../tests/utils/createViewMutationTestContext.js' @@ -102,3 +103,55 @@ describe('NodeView ignoreMutation on iOS', () => { expect(nodeView.ignoreMutation(childListMutation(contentDOM, [edited]))).toBe(false) }) }) + +function createLeafNodeView() { + const wrapper = document.createElement('span') + + wrapper.contentEditable = 'false' + wrapper.append(document.createTextNode('@mention')) + document.body.append(wrapper) + + class LeafNodeView extends NodeView { + get dom() { + return wrapper + } + } + + const nodeView = new LeafNodeView(null, { + editor: {}, + extension: {}, + node: { isLeaf: true, isAtom: true }, + decorations: [], + innerDecorations: {}, + view: {}, + HTMLAttributes: {}, + getPos: () => 0, + } as unknown as NodeViewRendererProps) + + return { nodeView, wrapper } +} + +describe('NodeView ignoreMutation without contentDOM', () => { + afterEach(() => { + document.body.replaceChildren() + }) + + it('lets ProseMirror handle selection changes inside the node view', () => { + const { nodeView, wrapper } = createLeafNodeView() + const mutation = { type: 'selection', target: wrapper } as unknown as ViewMutationRecord + + expect(nodeView.ignoreMutation(mutation)).toBe(false) + }) + + it('still ignores DOM mutations inside the node view', () => { + const { nodeView, wrapper } = createLeafNodeView() + const mutation = { + type: 'childList', + target: wrapper, + addedNodes: [], + removedNodes: [], + } as unknown as ViewMutationRecord + + expect(nodeView.ignoreMutation(mutation)).toBe(true) + }) +}) diff --git a/packages/core/src/NodeView.ts b/packages/core/src/NodeView.ts index d63444b4ec..600dc274d2 100644 --- a/packages/core/src/NodeView.ts +++ b/packages/core/src/NodeView.ts @@ -269,10 +269,16 @@ export class NodeView< * @return `true` if it can safely be ignored. */ ignoreMutation(mutation: ViewMutationRecord) { - if (!this.dom || !this.contentDOM) { + if (!this.dom) { return true } + if (!this.contentDOM) { + // ProseMirror has to see selection changes inside a leaf node view, otherwise + // the browser caret can get stuck inside its non-editable DOM and typed text is lost + return mutation.type !== 'selection' + } + if (typeof this.options.ignoreMutation === 'function') { return this.options.ignoreMutation({ mutation }) }