From 2fab93e979a4b71f7e913430d3a8361125d09c6d Mon Sep 17 00:00:00 2001 From: chillerno1 Date: Mon, 31 Aug 2026 19:17:13 +1000 Subject: [PATCH 1/2] fix(core): validate splitBlock against the post-deleteSelection document splitBlock checked canSplit on the document as it was before deleteSelection ran, then executed the split on the document after the deletion. When deleting the selection restructures the document (for example a selection from the start of one block into another block, or across an isolating node), the validated position no longer exists and tr.split throws an uncaught TransformError: Inserted content deeper than insertion position. The split is now validated against the document as it will look after the deletion, by simulating deleteSelection on a scratch transaction. The shared transaction stays untouched until the split is known to be possible, because it is dispatched (and shared with chained commands) even when a command returns false. This restores the ordering used by prosemirror-commands splitBlockAs, where deleteSelection runs before canSplit. Fixes #7734 --- .../2026-08-31-splitblock-stale-cansplit.md | 5 + packages/core/__tests__/splitBlock.spec.ts | 98 +++++++++++++++++++ packages/core/src/commands/splitBlock.ts | 33 ++++--- 3 files changed, 124 insertions(+), 12 deletions(-) create mode 100644 .changeset/2026-08-31-splitblock-stale-cansplit.md create mode 100644 packages/core/__tests__/splitBlock.spec.ts diff --git a/.changeset/2026-08-31-splitblock-stale-cansplit.md b/.changeset/2026-08-31-splitblock-stale-cansplit.md new file mode 100644 index 0000000000..3cf028dcda --- /dev/null +++ b/.changeset/2026-08-31-splitblock-stale-cansplit.md @@ -0,0 +1,5 @@ +--- +'@tiptap/core': patch +--- + +`splitBlock` no longer throws `TransformError: Inserted content deeper than insertion position` when the selection spans block boundaries (for example from the start of one paragraph into another block, or across an isolating node). The command now returns `false` when the split is not possible. diff --git a/packages/core/__tests__/splitBlock.spec.ts b/packages/core/__tests__/splitBlock.spec.ts new file mode 100644 index 0000000000..34352c87e0 --- /dev/null +++ b/packages/core/__tests__/splitBlock.spec.ts @@ -0,0 +1,98 @@ +import { Editor, Node } from '@tiptap/core' +import Document from '@tiptap/extension-document' +import Paragraph from '@tiptap/extension-paragraph' +import Text from '@tiptap/extension-text' +import { describe, expect, it } from 'vite-plus/test' + +const Callout = Node.create({ + name: 'callout', + group: 'block', + content: 'paragraph+', + isolating: true, + renderHTML() { + return ['div', { 'data-callout': '' }, 0] + }, + parseHTML() { + return [ + { + tag: 'div[data-callout]', + }, + ] + }, +}) + +const createEditor = (content: string) => + new Editor({ + extensions: [Document, Paragraph, Text, Callout], + content, + }) + +describe('splitBlock', () => { + it('splits a paragraph at the cursor', () => { + const editor = createEditor('

onetwo

') + + editor.commands.setTextSelection(4) + + expect(editor.commands.splitBlock()).toBe(true) + expect(editor.getHTML()).toBe('

one

two

') + }) + + it('deletes the selection before splitting when the blocks can merge', () => { + const editor = createEditor('

one

two

') + + // from inside the first paragraph to inside the second one + editor.commands.setTextSelection({ from: 3, to: 7 }) + + expect(editor.commands.splitBlock()).toBe(true) + expect(editor.getHTML()).toBe('

on

wo

') + }) + + it('does not throw when the selection starts at the start of a block and ends in another block', () => { + const editor = createEditor('

one

two

') + + // start of the first paragraph to start of the second one: deleting this + // selection removes the first paragraph entirely, so the mapped split + // position resolves at depth 0 + editor.commands.setTextSelection({ from: 1, to: 6 }) + + expect(() => editor.commands.splitBlock()).not.toThrow() + }) + + it('returns false and leaves the document unchanged when the split position is invalid after deleting the selection', () => { + const editor = createEditor('

one

two

') + + editor.commands.setTextSelection({ from: 1, to: 6 }) + + expect(editor.commands.splitBlock()).toBe(false) + expect(editor.getHTML()).toBe('

one

two

') + }) + + it('does not throw when the selection spans from a paragraph into an isolating node', () => { + const editor = createEditor('

one

two

') + + // start of the paragraph to the start of the isolating node's content: + // deleting this selection removes the paragraph, and splitting at the + // stale position throws "Invalid content for node callout" (same root + // cause, different message) + editor.commands.setTextSelection({ from: 1, to: 6 }) + + expect(() => editor.commands.splitBlock()).not.toThrow() + }) + + it('does not throw when pressing Enter with a selection spanning block boundaries', () => { + const editor = createEditor('

one

two

') + + editor.commands.setTextSelection({ from: 1, to: 6 }) + + expect(() => editor.commands.keyboardShortcut('Enter')).not.toThrow() + }) + + it('can().splitBlock() returns false without mutating the document', () => { + const editor = createEditor('

one

two

') + + editor.commands.setTextSelection({ from: 1, to: 6 }) + + expect(editor.can().splitBlock()).toBe(false) + expect(editor.getHTML()).toBe('

one

two

') + }) +}) diff --git a/packages/core/src/commands/splitBlock.ts b/packages/core/src/commands/splitBlock.ts index 6f264da40f..10a9e70f64 100644 --- a/packages/core/src/commands/splitBlock.ts +++ b/packages/core/src/commands/splitBlock.ts @@ -1,5 +1,4 @@ -import type { EditorState } from '@tiptap/pm/state' -import { NodeSelection, TextSelection } from '@tiptap/pm/state' +import { EditorState, NodeSelection, TextSelection } from '@tiptap/pm/state' import { canSplit } from '@tiptap/pm/transform' import { defaultBlockAt } from '../helpers/defaultBlockAt.js' @@ -80,13 +79,21 @@ export const splitBlock: RawCommands['splitBlock'] = ] : undefined - let can = canSplit(tr.doc, tr.mapping.map($from.pos), 1, types) - - if ( - !types && - !can && - canSplit(tr.doc, tr.mapping.map($from.pos), 1, deflt ? [{ type: deflt }] : undefined) - ) { + // Deleting the selection can restructure the document, so the split has to + // be validated against the document as it will look after the deletion. + // Simulate the deletion on a scratch transaction instead of mutating `tr`: + // the shared transaction is dispatched even when a command returns `false`, + // so it must stay untouched until the split is known to be possible. + const willDeleteSelection = selection instanceof TextSelection && !selection.empty + const deletedTr = willDeleteSelection + ? EditorState.create({ doc: tr.doc, selection }).tr.deleteSelection() + : null + const splitDoc = deletedTr ? deletedTr.doc : tr.doc + const splitPos = deletedTr ? deletedTr.mapping.map($from.pos) : $from.pos + + let can = canSplit(splitDoc, splitPos, 1, types) + + if (!types && !can && canSplit(splitDoc, splitPos, 1, deflt ? [{ type: deflt }] : undefined)) { can = true types = deflt ? [ @@ -100,18 +107,20 @@ export const splitBlock: RawCommands['splitBlock'] = if (dispatch) { if (can) { + const mapFrom = tr.steps.length + if (selection instanceof TextSelection) { tr.deleteSelection() } - tr.split(tr.mapping.map($from.pos), 1, types) + tr.split(tr.mapping.slice(mapFrom).map($from.pos), 1, types) if (deflt && !atEnd && !$from.parentOffset && $from.parent.type !== deflt) { - const first = tr.mapping.map($from.before()) + const first = tr.mapping.slice(mapFrom).map($from.before()) const $first = tr.doc.resolve(first) if ($from.node(-1).canReplaceWith($first.index(), $first.index() + 1, deflt)) { - tr.setNodeMarkup(tr.mapping.map($from.before()), deflt) + tr.setNodeMarkup(tr.mapping.slice(mapFrom).map($from.before()), deflt) } } } From de130d95eb63329a7264676fe73a184e8f7459df Mon Sep 17 00:00:00 2001 From: ump45nose Date: Wed, 23 Sep 2026 18:47:36 +0800 Subject: [PATCH 2/2] fix: pin optional bubble/floating-menu deps with workspace:* --- .changeset/pin-react-vue-menu-deps.md | 6 ++++++ packages/react/package.json | 4 ++-- packages/vue/package.json | 4 ++-- pnpm-lock.yaml | 8 ++++---- 4 files changed, 14 insertions(+), 8 deletions(-) create mode 100644 .changeset/pin-react-vue-menu-deps.md diff --git a/.changeset/pin-react-vue-menu-deps.md b/.changeset/pin-react-vue-menu-deps.md new file mode 100644 index 0000000000..33dc143f66 --- /dev/null +++ b/.changeset/pin-react-vue-menu-deps.md @@ -0,0 +1,6 @@ +--- +'@tiptap/react': patch +'@tiptap/vue': patch +--- + +`@tiptap/react` and `@tiptap/vue` now pin their optional `@tiptap/extension-bubble-menu` and `@tiptap/extension-floating-menu` dependencies to the exact version they were released with, so installing a specific version gives you that version's menu extensions instead of the newest ones. diff --git a/packages/react/package.json b/packages/react/package.json index 4c0d1fe781..1350857481 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -74,7 +74,7 @@ "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalDependencies": { - "@tiptap/extension-bubble-menu": "workspace:^", - "@tiptap/extension-floating-menu": "workspace:^" + "@tiptap/extension-bubble-menu": "workspace:*", + "@tiptap/extension-floating-menu": "workspace:*" } } diff --git a/packages/vue/package.json b/packages/vue/package.json index 655dba1dc1..933f794eea 100644 --- a/packages/vue/package.json +++ b/packages/vue/package.json @@ -62,7 +62,7 @@ "vue": "^3.0.0" }, "optionalDependencies": { - "@tiptap/extension-bubble-menu": "workspace:^", - "@tiptap/extension-floating-menu": "workspace:^" + "@tiptap/extension-bubble-menu": "workspace:*", + "@tiptap/extension-floating-menu": "workspace:*" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a0e4a7c3b0..32fa9a2fe5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -974,10 +974,10 @@ importers: version: 0.3.0(@types/node@22.20.1)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@22.20.1)(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.50.0)(typescript@5.9.3))(happy-dom@20.11.2)(jiti@2.6.1)(sass-embedded@1.100.0)(sass@1.100.0)(svelte@5.56.8)(terser@5.50.0)(typescript@5.9.3) optionalDependencies: '@tiptap/extension-bubble-menu': - specifier: workspace:^ + specifier: workspace:* version: link:../extension-bubble-menu '@tiptap/extension-floating-menu': - specifier: workspace:^ + specifier: workspace:* version: link:../extension-floating-menu packages/starter-kit: @@ -1093,10 +1093,10 @@ importers: version: 3.5.41(typescript@5.9.3) optionalDependencies: '@tiptap/extension-bubble-menu': - specifier: workspace:^ + specifier: workspace:* version: link:../extension-bubble-menu '@tiptap/extension-floating-menu': - specifier: workspace:^ + specifier: workspace:* version: link:../extension-floating-menu packages: