From 540c661595dcd6d03629918343c9450a9b7adfa6 Mon Sep 17 00:00:00 2001 From: ocavue Date: Mon, 6 Jul 2026 23:14:31 +1000 Subject: [PATCH] fix(core): report mutations that remove the `contentDOM` to ProseMirror --- .changeset/tidy-donuts-smile.md | 5 +++ e2e/src/react/components/CodeBlock.tsx | 6 ++++ e2e/src/react/components/Editor.tsx | 5 +++ e2e/src/shared/createEditorView.ts | 37 +++++++++++++++++++-- e2e/tests/node-view.spec.ts | 36 ++++++++++++++++++++ e2e/tests/plugin-view.spec.ts | 6 ++-- packages/core/src/is-content-dom-removal.ts | 20 +++++++++++ packages/core/src/markView/CoreMarkView.ts | 3 ++ packages/core/src/nodeView/CoreNodeView.ts | 3 ++ 9 files changed, 115 insertions(+), 6 deletions(-) create mode 100644 .changeset/tidy-donuts-smile.md create mode 100644 e2e/src/react/components/CodeBlock.tsx create mode 100644 packages/core/src/is-content-dom-removal.ts diff --git a/.changeset/tidy-donuts-smile.md b/.changeset/tidy-donuts-smile.md new file mode 100644 index 00000000..c4070a76 --- /dev/null +++ b/.changeset/tidy-donuts-smile.md @@ -0,0 +1,5 @@ +--- +'@prosemirror-adapter/core': patch +--- + +Stop ignoring DOM mutations that remove the `contentDOM` element, so ProseMirror stays in sync when the browser's native editing replaces a fully selected node view or mark view content. diff --git a/e2e/src/react/components/CodeBlock.tsx b/e2e/src/react/components/CodeBlock.tsx new file mode 100644 index 00000000..a0e64328 --- /dev/null +++ b/e2e/src/react/components/CodeBlock.tsx @@ -0,0 +1,6 @@ +import { useNodeViewContext } from '@prosemirror-adapter/react' + +export function CodeBlock() { + const { contentRef } = useNodeViewContext() + return
+}
diff --git a/e2e/src/react/components/Editor.tsx b/e2e/src/react/components/Editor.tsx
index 49326307..6f1d6f4b 100644
--- a/e2e/src/react/components/Editor.tsx
+++ b/e2e/src/react/components/Editor.tsx
@@ -14,6 +14,7 @@ import { useEffect, useRef } from 'react'
 
 import { createEditorView } from '../../shared/createEditorView'
 
+import { CodeBlock } from './CodeBlock'
 import { Hashes } from './Hashes'
 import { Heading } from './Heading'
 import { Link } from './Link'
@@ -51,6 +52,10 @@ export const Editor: FC = () => {
         heading: nodeViewFactory({
           component: Heading,
         }),
+        code_block: nodeViewFactory({
+          component: CodeBlock,
+          contentAs: 'code',
+        }),
       },
       {
         link: markViewFactory({
diff --git a/e2e/src/shared/createEditorView.ts b/e2e/src/shared/createEditorView.ts
index 438be77a..307bf79c 100644
--- a/e2e/src/shared/createEditorView.ts
+++ b/e2e/src/shared/createEditorView.ts
@@ -5,10 +5,9 @@ import 'prosemirror-menu/style/menu.css'
 import { exampleSetup } from 'prosemirror-example-setup'
 import { keymap } from 'prosemirror-keymap'
 import { schema } from 'prosemirror-schema-basic'
-import type { Plugin } from 'prosemirror-state'
-import { EditorState } from 'prosemirror-state'
+import { EditorState, Plugin } from 'prosemirror-state'
 import type { MarkViewConstructor, NodeViewConstructor } from 'prosemirror-view'
-import { EditorView } from 'prosemirror-view'
+import { Decoration, DecorationSet, EditorView } from 'prosemirror-view'
 
 const defaultDoc = {
   type: 'doc',
@@ -134,9 +133,40 @@ const defaultDoc = {
         },
       ],
     },
+    {
+      type: 'code_block',
+      content: [
+        {
+          type: 'text',
+          text: 'const greeting = "hello"',
+        },
+      ],
+    },
   ],
 }
 
+// Mimics an inline syntax highlighter: wraps every word inside a code block
+// in a styled span, like prosemirror-highlight and similar plugins do.
+function createCodeHighlightPlugin(): Plugin {
+  return new Plugin({
+    props: {
+      decorations(state) {
+        const decorations: Decoration[] = []
+        state.doc.descendants((node, pos) => {
+          if (node.type.name !== 'code_block') return true
+          const pattern = /\S+/g
+          for (const match of node.textContent.matchAll(pattern)) {
+            const from = pos + 1 + match.index
+            decorations.push(Decoration.inline(from, from + match[0].length, { style: 'color: rgb(207, 34, 46)' }))
+          }
+          return false
+        })
+        return DecorationSet.create(state.doc, decorations)
+      },
+    },
+  })
+}
+
 export function createEditorView(
   element: HTMLElement | ShadowRoot,
   nodeViews: Record,
@@ -168,6 +198,7 @@ export function createEditorView(
             return true
           },
         }),
+        createCodeHighlightPlugin(),
         ...plugins,
       ],
     }),
diff --git a/e2e/tests/node-view.spec.ts b/e2e/tests/node-view.spec.ts
index 403736d9..5d706533 100644
--- a/e2e/tests/node-view.spec.ts
+++ b/e2e/tests/node-view.spec.ts
@@ -41,3 +41,39 @@ testAll(() => {
     await expect(h5).toBeVisible()
   })
 })
+
+testAll(() => {
+  test('code block node view survives typing over a fully selected code block', async ({ page }) => {
+    const content = page.locator('.editor [data-node-view-root="true"] pre code[data-node-view-content="true"]')
+    await expect(content).toBeVisible()
+    await expect(content).toContainText('const greeting')
+
+    await content.click()
+    // Select all of the code block text, crossing the highlight spans, like a
+    // user dragging from the first character to the last one.
+    await content.evaluate((element) => {
+      const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT)
+      const textNodes: Text[] = []
+      while (walker.nextNode()) textNodes.push(walker.currentNode as Text)
+      const firstText = textNodes.at(0)
+      const lastText = textNodes.at(-1)
+      const selection = window.getSelection()
+      if (!selection || !firstText || !lastText) throw new Error('found no text to select')
+      const range = document.createRange()
+      range.setStart(firstText, 0)
+      range.setEnd(lastText, lastText.length)
+      selection.removeAllRanges()
+      selection.addRange(range)
+    })
+    await page.keyboard.type('X')
+
+    // Chrome and Safari delete the whole contentDOM element when typing over
+    // a selection that covers all of its content. ProseMirror must not ignore
+    // that mutation: the contentDOM has to come back under its control and
+    // typing has to keep updating the document.
+    await expect(content).toBeAttached()
+
+    await page.keyboard.type('hello')
+    await expect(content).toContainText('hello')
+  })
+}, ['react'])
diff --git a/e2e/tests/plugin-view.spec.ts b/e2e/tests/plugin-view.spec.ts
index 55ac649d..1b688e59 100644
--- a/e2e/tests/plugin-view.spec.ts
+++ b/e2e/tests/plugin-view.spec.ts
@@ -8,14 +8,14 @@ testAll(() => {
     await expect(editor).toBeVisible()
 
     const locator = editor.locator("[data-test-id='size-view-plugin']")
-    await expect(locator).toContainText('Size for document: 523')
+    await expect(locator).toContainText('Size for document: 549')
 
     await editor.locator('p').first().click()
     await page.keyboard.type('OK')
-    await expect(locator).toContainText('Size for document: 525')
+    await expect(locator).toContainText('Size for document: 551')
 
     await page.keyboard.press('Backspace')
-    await expect(locator).toContainText('Size for document: 524')
+    await expect(locator).toContainText('Size for document: 550')
   })
 
   test('Render context from parent component', async ({ page }) => {
diff --git a/packages/core/src/is-content-dom-removal.ts b/packages/core/src/is-content-dom-removal.ts
new file mode 100644
index 00000000..a5ec5965
--- /dev/null
+++ b/packages/core/src/is-content-dom-removal.ts
@@ -0,0 +1,20 @@
+import type { ViewMutationRecord } from 'prosemirror-view'
+
+/**
+ * Whether the mutation removed `contentDOM` from its parent element.
+ *
+ * The browser's native editing can cause this: when the selection covers all
+ * of the contentDOM's text and the user types, Chrome and Safari delete the
+ * whole contentDOM element and insert the typed text into its parent.
+ * ProseMirror must see such a mutation, otherwise the view and the document
+ * state diverge silently.
+ */
+export function isContentDOMRemoval(mutation: ViewMutationRecord, contentDOM: HTMLElement): boolean {
+  if (mutation.type !== 'childList') return false
+
+  for (const removedNode of mutation.removedNodes) {
+    if (removedNode === contentDOM) return true
+  }
+
+  return false
+}
diff --git a/packages/core/src/markView/CoreMarkView.ts b/packages/core/src/markView/CoreMarkView.ts
index a19b0989..fe7c2202 100644
--- a/packages/core/src/markView/CoreMarkView.ts
+++ b/packages/core/src/markView/CoreMarkView.ts
@@ -3,6 +3,7 @@ import type { Mark } from 'prosemirror-model'
 import type { EditorView, MarkView, ViewMutationRecord } from 'prosemirror-view'
 
 import { createKey } from '../create-key'
+import { isContentDOMRemoval } from '../is-content-dom-removal'
 
 import type { CoreMarkViewSpec, CoreMarkViewUserOptions, MarkViewDOMSpec } from './CoreMarkViewOptions'
 
@@ -68,6 +69,8 @@ export class CoreMarkView implements MarkView {
 
     if (this.contentDOM.contains(mutation.target)) return false
 
+    if (isContentDOMRemoval(mutation, this.contentDOM)) return false
+
     return true
   }
 
diff --git a/packages/core/src/nodeView/CoreNodeView.ts b/packages/core/src/nodeView/CoreNodeView.ts
index 0d4454af..45f095c5 100644
--- a/packages/core/src/nodeView/CoreNodeView.ts
+++ b/packages/core/src/nodeView/CoreNodeView.ts
@@ -3,6 +3,7 @@ import type { Attrs, Node } from 'prosemirror-model'
 import type { Decoration, DecorationSource, EditorView, NodeView, ViewMutationRecord } from 'prosemirror-view'
 
 import { createKey } from '../create-key'
+import { isContentDOMRemoval } from '../is-content-dom-removal'
 
 import type { CoreNodeViewSpec, CoreNodeViewUserOptions, NodeViewDOMSpec } from './CoreNodeViewOptions'
 
@@ -114,6 +115,8 @@ export class CoreNodeView implements NodeView {
 
     if (this.contentDOM.contains(mutation.target)) return false
 
+    if (isContentDOMRemoval(mutation, this.contentDOM)) return false
+
     return true
   }