From dedd3be4d3e5390610034350e9c284e78a63a556 Mon Sep 17 00:00:00 2001 From: Orim Dominic Date: Wed, 12 Aug 2026 15:57:15 +0100 Subject: [PATCH 01/13] Update onClick handler to include position parameter --- demos/src/Extensions/Mathematics/React/index.jsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/demos/src/Extensions/Mathematics/React/index.jsx b/demos/src/Extensions/Mathematics/React/index.jsx index 462f4da106..8a6cf7f2a2 100644 --- a/demos/src/Extensions/Mathematics/React/index.jsx +++ b/demos/src/Extensions/Mathematics/React/index.jsx @@ -25,12 +25,12 @@ export default () => { }, }, inlineOptions: { - onClick: node => { + onClick: (node, pos) => { const newCalculation = prompt('Enter new calculation:', node.attrs.latex) if (newCalculation) { editor .chain() - .setNodeSelection(node.pos) + .setNodeSelection(pos) .updateInlineMath({ latex: newCalculation }) .focus() .run() From 93e37274b51bdbbb921b35d62e4b00bb0ca21c35 Mon Sep 17 00:00:00 2001 From: lazerg Date: Wed, 26 Aug 2026 23:56:06 +0500 Subject: [PATCH 02/13] fix(core): skip cross-editor drop cleanup for a destroyed source editor --- ...8-26-cross-editor-drop-destroyed-source.md | 5 ++ .../core/__tests__/crossEditorDrop.spec.ts | 75 +++++++++++++++++++ packages/core/src/PasteRule.ts | 8 ++ 3 files changed, 88 insertions(+) create mode 100644 .changeset/2026-08-26-cross-editor-drop-destroyed-source.md create mode 100644 packages/core/__tests__/crossEditorDrop.spec.ts diff --git a/.changeset/2026-08-26-cross-editor-drop-destroyed-source.md b/.changeset/2026-08-26-cross-editor-drop-destroyed-source.md new file mode 100644 index 0000000000..3086faacbe --- /dev/null +++ b/.changeset/2026-08-26-cross-editor-drop-destroyed-source.md @@ -0,0 +1,5 @@ +--- +'@tiptap/core': patch +--- + +Fix a `TypeError` thrown after dragging content between editors when the source editor is destroyed right after the drop. A destroyed editor is also no longer kept referenced after a drag. diff --git a/packages/core/__tests__/crossEditorDrop.spec.ts b/packages/core/__tests__/crossEditorDrop.spec.ts new file mode 100644 index 0000000000..f006299d27 --- /dev/null +++ b/packages/core/__tests__/crossEditorDrop.spec.ts @@ -0,0 +1,75 @@ +import { Editor, Mark, markPasteRule } from '@tiptap/core' +import Document from '@tiptap/extension-document' +import Paragraph from '@tiptap/extension-paragraph' +import Text from '@tiptap/extension-text' +import { afterEach, describe, expect, it, vi } from 'vite-plus/test' + +const editorElClass = 'tiptap-cross-editor-drop-test' + +const createEditorEl = () => { + const editorEl = document.createElement('div') + + editorEl.classList.add(editorElClass) + document.body.appendChild(editorEl) + + return editorEl +} + +const PasteMark = Mark.create({ + name: 'pasteMark', + addPasteRules() { + return [ + markPasteRule({ + find: /==(.*?)==/g, + type: this.type, + }), + ] + }, +}) + +const createEditor = () => + new Editor({ + element: createEditorEl(), + extensions: [Document, Paragraph, Text, PasteMark], + content: '

content

', + }) + +const dragBetween = (source: Editor, target: Editor) => { + source.view.dom.dispatchEvent(new Event('dragstart', { bubbles: true })) + target.view.dom.dispatchEvent(new Event('drop', { bubbles: true })) +} + +describe('cross editor drop', () => { + let source: Editor + let target: Editor + + afterEach(() => { + vi.useRealTimers() + source?.destroy() + target?.destroy() + document.querySelectorAll(`.${editorElClass}`).forEach(element => element.remove()) + }) + + it('does not throw when the source editor is destroyed before the deferred delete runs', () => { + vi.useFakeTimers() + source = createEditor() + target = createEditor() + + dragBetween(source, target) + source.destroy() + + expect(() => vi.advanceTimersByTime(10)).not.toThrow() + }) + + it('deletes the dragged selection from a source editor that is still alive', () => { + vi.useFakeTimers() + source = createEditor() + target = createEditor() + + source.commands.setTextSelection({ from: 1, to: 8 }) + dragBetween(source, target) + vi.advanceTimersByTime(10) + + expect(source.getHTML()).toBe('

') + }) +}) diff --git a/packages/core/src/PasteRule.ts b/packages/core/src/PasteRule.ts index b993c3ebfe..748122db84 100644 --- a/packages/core/src/PasteRule.ts +++ b/packages/core/src/PasteRule.ts @@ -281,6 +281,10 @@ export function pasteRulesPlugin(props: { editor: Editor; rules: PasteRule[] }): destroy() { window.removeEventListener('dragstart', handleDragstart) window.removeEventListener('dragend', handleDragend) + + if (tiptapDragFromOtherEditor === editor) { + tiptapDragFromOtherEditor = null + } }, } }, @@ -297,6 +301,10 @@ export function pasteRulesPlugin(props: { editor: Editor; rules: PasteRule[] }): if (dragFromOtherEditor?.isEditable) { // setTimeout to avoid the wrong content after drop, timeout arg can't be empty or 0 setTimeout(() => { + if (dragFromOtherEditor.isDestroyed) { + return + } + const selection = dragFromOtherEditor.state.selection if (selection) { From 65fb84f5b48033509df51e460152e76c4ef14869 Mon Sep 17 00:00:00 2001 From: lazerg Date: Thu, 27 Aug 2026 00:02:56 +0500 Subject: [PATCH 03/13] docs(core): describe the changeset in user-visible terms --- .changeset/2026-08-26-cross-editor-drop-destroyed-source.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/2026-08-26-cross-editor-drop-destroyed-source.md b/.changeset/2026-08-26-cross-editor-drop-destroyed-source.md index 3086faacbe..7993fe80d4 100644 --- a/.changeset/2026-08-26-cross-editor-drop-destroyed-source.md +++ b/.changeset/2026-08-26-cross-editor-drop-destroyed-source.md @@ -2,4 +2,4 @@ '@tiptap/core': patch --- -Fix a `TypeError` thrown after dragging content between editors when the source editor is destroyed right after the drop. A destroyed editor is also no longer kept referenced after a drag. +Fix a `TypeError` thrown after dragging content between editors when the source editor is destroyed right after the drop. Destroyed editors are also no longer leaked after such a drag. From 4976a1391edb4643ae4425af2a78419875f5d2ba Mon Sep 17 00:00:00 2001 From: lazerg Date: Thu, 27 Aug 2026 00:19:15 +0500 Subject: [PATCH 04/13] fix(core): clear the drag source on editor destroy, not on plugin reconfigure --- ...8-26-cross-editor-drop-destroyed-source.md | 2 +- .../core/__tests__/crossEditorDrop.spec.ts | 30 +++++++++++++++++++ packages/core/src/PasteRule.ts | 12 +++++--- 3 files changed, 39 insertions(+), 5 deletions(-) diff --git a/.changeset/2026-08-26-cross-editor-drop-destroyed-source.md b/.changeset/2026-08-26-cross-editor-drop-destroyed-source.md index 7993fe80d4..3fb5bacbc9 100644 --- a/.changeset/2026-08-26-cross-editor-drop-destroyed-source.md +++ b/.changeset/2026-08-26-cross-editor-drop-destroyed-source.md @@ -2,4 +2,4 @@ '@tiptap/core': patch --- -Fix a `TypeError` thrown after dragging content between editors when the source editor is destroyed right after the drop. Destroyed editors are also no longer leaked after such a drag. +Fix a `TypeError` thrown when the source editor is destroyed right after dragging content into another editor. diff --git a/packages/core/__tests__/crossEditorDrop.spec.ts b/packages/core/__tests__/crossEditorDrop.spec.ts index f006299d27..9de8ccd065 100644 --- a/packages/core/__tests__/crossEditorDrop.spec.ts +++ b/packages/core/__tests__/crossEditorDrop.spec.ts @@ -2,6 +2,7 @@ import { Editor, Mark, markPasteRule } from '@tiptap/core' import Document from '@tiptap/extension-document' import Paragraph from '@tiptap/extension-paragraph' import Text from '@tiptap/extension-text' +import { Plugin, PluginKey } from '@tiptap/pm/state' import { afterEach, describe, expect, it, vi } from 'vite-plus/test' const editorElClass = 'tiptap-cross-editor-drop-test' @@ -72,4 +73,33 @@ describe('cross editor drop', () => { expect(source.getHTML()).toBe('

') }) + + it('does not queue a delete when the source editor was destroyed before the drop', () => { + vi.useFakeTimers() + source = createEditor() + target = createEditor() + + source.view.dom.dispatchEvent(new Event('dragstart', { bubbles: true })) + source.destroy() + + const queuedBeforeDrop = vi.getTimerCount() + + target.view.dom.dispatchEvent(new Event('drop', { bubbles: true })) + + expect(vi.getTimerCount()).toBe(queuedBeforeDrop) + }) + + it('keeps the queued delete when the source editor registers a plugin during the drag', () => { + vi.useFakeTimers() + source = createEditor() + target = createEditor() + + source.commands.setTextSelection({ from: 1, to: 8 }) + source.view.dom.dispatchEvent(new Event('dragstart', { bubbles: true })) + source.registerPlugin(new Plugin({ key: new PluginKey('noop') })) + target.view.dom.dispatchEvent(new Event('drop', { bubbles: true })) + vi.advanceTimersByTime(10) + + expect(source.getHTML()).toBe('

') + }) }) diff --git a/packages/core/src/PasteRule.ts b/packages/core/src/PasteRule.ts index 748122db84..b01f12a93b 100644 --- a/packages/core/src/PasteRule.ts +++ b/packages/core/src/PasteRule.ts @@ -274,17 +274,21 @@ export function pasteRulesPlugin(props: { editor: Editor; rules: PasteRule[] }): } } + const handleEditorDestroy = () => { + if (tiptapDragFromOtherEditor === editor) { + tiptapDragFromOtherEditor = null + } + } + window.addEventListener('dragstart', handleDragstart) window.addEventListener('dragend', handleDragend) + editor.on('destroy', handleEditorDestroy) return { destroy() { window.removeEventListener('dragstart', handleDragstart) window.removeEventListener('dragend', handleDragend) - - if (tiptapDragFromOtherEditor === editor) { - tiptapDragFromOtherEditor = null - } + editor.off('destroy', handleEditorDestroy) }, } }, From 73da27088361b3cb3961bbdbba24cfa1fcb8b20f Mon Sep 17 00:00:00 2001 From: Bao Nguyen Date: Sun, 23 Aug 2026 11:21:59 +0700 Subject: [PATCH 05/13] fix(core): keep keepOnSplit attributes on the content half when splitting a list item at its start `Transform.split(pos, depth, typesAfter)` only applies `typesAfter` to the node created after the split, so `splitListItem` always handed the reset attributes to the trailing item. When the cursor is at the start of the item, the empty new item is the leading one, so the reset landed on the wrong half: pressing Enter before the text of a checked task item left the empty item checked and unchecked the item that kept the text. Detect that case and swap the roles: the trailing item keeps the attributes of the item being split, and the reset is applied to the leading item with setNodeMarkup after the split. This is generic to any `keepOnSplit: false` list attribute. Fixes #6289 --- .../fix-split-list-item-checked-state.md | 5 + packages/core/src/commands/splitListItem.ts | 20 +++- .../src/task-item/task-item.test.ts | 93 +++++++++++++++++++ 3 files changed, 116 insertions(+), 2 deletions(-) create mode 100644 .changeset/fix-split-list-item-checked-state.md diff --git a/.changeset/fix-split-list-item-checked-state.md b/.changeset/fix-split-list-item-checked-state.md new file mode 100644 index 0000000000..6e9ba257d6 --- /dev/null +++ b/.changeset/fix-split-list-item-checked-state.md @@ -0,0 +1,5 @@ +--- +'@tiptap/core': patch +--- + +Splitting a list item at the very start no longer moves attributes with `keepOnSplit: false` onto the half that keeps the content. Pressing Enter before the text of a checked task item now leaves the new empty item unchecked and the item with the text checked. diff --git a/packages/core/src/commands/splitListItem.ts b/packages/core/src/commands/splitListItem.ts index 00eda39c6a..bafd43600e 100644 --- a/packages/core/src/commands/splitListItem.ts +++ b/packages/core/src/commands/splitListItem.ts @@ -124,14 +124,26 @@ export const splitListItem: RawCommands['splitListItem'] = ...overrideAttrs, } + // `Transform.split` only ever applies `types` to the node created *after* the split, so + // attributes that should not survive a split (`keepOnSplit: false`) normally land on the + // trailing item, which is the newly created one. When the cursor sits at the very start + // of the item, that is inverted: the empty new item is the *leading* one and the trailing + // item is the original content. Detect that case and swap the two sets of attributes. + const splitsAtStartOfItem = $from.parentOffset === 0 && $from.index(-1) === 0 + const itemStart = $from.before(-1) + tr.delete($from.pos, $to.pos) + // The trailing item keeps the attributes of the item being split when it is the half that + // carries the content over; the reset is applied to the leading item after the split. + const trailingTypeAttributes = splitsAtStartOfItem ? grandParent.attrs : newTypeAttributes + const types = nextType ? [ - { type, attrs: newTypeAttributes }, + { type, attrs: trailingTypeAttributes }, { type: nextType, attrs: newNextTypeAttributes }, ] - : [{ type, attrs: newTypeAttributes }] + : [{ type, attrs: trailingTypeAttributes }] if (!canSplit(tr.doc, $from.pos, 2)) { return false @@ -144,6 +156,10 @@ export const splitListItem: RawCommands['splitListItem'] = tr.split($from.pos, 2, types).scrollIntoView() + if (splitsAtStartOfItem) { + tr.setNodeMarkup(itemStart, undefined, newTypeAttributes) + } + if (!marks || !dispatch) { return true } diff --git a/packages/extension-list/src/task-item/task-item.test.ts b/packages/extension-list/src/task-item/task-item.test.ts index 4dc951f5a3..d34053b2a0 100644 --- a/packages/extension-list/src/task-item/task-item.test.ts +++ b/packages/extension-list/src/task-item/task-item.test.ts @@ -901,4 +901,97 @@ describe('TaskItem', () => { expect(from).toBe(1) expect(to).toBe(14) }) + + describe('splitListItem', () => { + function createTaskEditor(checked: boolean, text: string) { + return new Editor({ + extensions: [Document, Paragraph, Text, TaskList, TaskItem], + content: { + type: 'doc', + content: [ + { + type: 'taskList', + content: [ + { + type: 'taskItem', + attrs: { checked }, + content: [{ type: 'paragraph', content: [{ type: 'text', text }] }], + }, + ], + }, + ], + }, + }) + } + + /** `[checked, text]` for every task item in the first (and only) task list. */ + function readTaskItems(instance: Editor) { + return (instance.getJSON().content?.[0].content ?? []).map(item => [ + item.attrs?.checked, + item.content?.[0].content?.[0]?.text ?? '', + ]) + } + + it('leaves the checked state on the half that keeps the text when splitting at the start', () => { + editor = createTaskEditor(true, 'Task') + // Offset 0 of the paragraph inside the first task item. + editor.commands.setTextSelection(3) + + expect(editor.commands.splitListItem('taskItem')).toBe(true) + expect(readTaskItems(editor)).toEqual([ + [false, ''], + [true, 'Task'], + ]) + }) + + it('resets the checked state on the trailing half when splitting mid-text', () => { + editor = createTaskEditor(true, 'Task') + editor.commands.setTextSelection(5) + + expect(editor.commands.splitListItem('taskItem')).toBe(true) + expect(readTaskItems(editor)).toEqual([ + [true, 'Ta'], + [false, 'sk'], + ]) + }) + + it('resets the checked state on the trailing half when splitting at the end', () => { + editor = createTaskEditor(true, 'Task') + editor.commands.setTextSelection(7) + + expect(editor.commands.splitListItem('taskItem')).toBe(true) + expect(readTaskItems(editor)).toEqual([ + [true, 'Task'], + [false, ''], + ]) + }) + + it('splits a plain list item at the start without moving its content', () => { + editor = new Editor({ + extensions: [Document, Paragraph, Text, BulletList, ListItem], + content: { + type: 'doc', + content: [ + { + type: 'bulletList', + content: [ + { + type: 'listItem', + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Item' }] }], + }, + ], + }, + ], + }, + }) + editor.commands.setTextSelection(3) + + expect(editor.commands.splitListItem('listItem')).toBe(true) + expect( + (editor.getJSON().content?.[0].content ?? []).map( + item => item.content?.[0].content?.[0]?.text ?? '', + ), + ).toEqual(['', 'Item']) + }) + }) }) From 167072bc0618222bf13b0b78d94afddd936c6970 Mon Sep 17 00:00:00 2001 From: Bao Nguyen Date: Mon, 24 Aug 2026 09:30:00 +0700 Subject: [PATCH 06/13] docs(core): lead the changeset with the user-visible split behaviour --- .changeset/fix-split-list-item-checked-state.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/fix-split-list-item-checked-state.md b/.changeset/fix-split-list-item-checked-state.md index 6e9ba257d6..fe86b5a395 100644 --- a/.changeset/fix-split-list-item-checked-state.md +++ b/.changeset/fix-split-list-item-checked-state.md @@ -2,4 +2,4 @@ '@tiptap/core': patch --- -Splitting a list item at the very start no longer moves attributes with `keepOnSplit: false` onto the half that keeps the content. Pressing Enter before the text of a checked task item now leaves the new empty item unchecked and the item with the text checked. +Pressing Enter before the text of a checked task item now leaves the new empty item unchecked and the item with the text checked. Attributes declared with `keepOnSplit: false` now reset on the new item, not on the one that keeps the text. From bef6bc1ea637070ea2768bb70e375fd7302aa725 Mon Sep 17 00:00:00 2001 From: Bao Nguyen Date: Sun, 23 Aug 2026 13:36:33 +0700 Subject: [PATCH 07/13] fix(extension-mathematics): drop the lookbehind from the inline math input rule The rule's `find` was a regex literal containing `(? InputRuleMatch | null`, so the preceding character can be inspected without consuming it, and the returned `text` stays exactly the `$$latex$$` sequence the range is computed from. The latex moves from `match[2]` to `match.data`, since a function finder builds the match array from its returned `text` alone. A rejected match is retried from the character after its start rather than from its end, because a valid match can begin inside a rejected one at its trailing `$$`. --- .../2026-08-23-inline-math-lookbehind.md | 5 ++ .../src/extensions/InlineMath.test.ts | 70 +++++++++++++++++++ .../src/extensions/InlineMath.ts | 45 +++++++++++- .../src/extensions/lookbehind.test.ts | 28 ++++++++ 4 files changed, 146 insertions(+), 2 deletions(-) create mode 100644 .changeset/2026-08-23-inline-math-lookbehind.md create mode 100644 packages/extension-mathematics/src/extensions/lookbehind.test.ts diff --git a/.changeset/2026-08-23-inline-math-lookbehind.md b/.changeset/2026-08-23-inline-math-lookbehind.md new file mode 100644 index 0000000000..da1994d359 --- /dev/null +++ b/.changeset/2026-08-23-inline-math-lookbehind.md @@ -0,0 +1,5 @@ +--- +'@tiptap/extension-mathematics': patch +--- + +The inline math input rule no longer uses a regex lookbehind, which WebKit older than Safari 16.4 rejects at parse time, taking down every chunk the extension is bundled into. diff --git a/packages/extension-mathematics/src/extensions/InlineMath.test.ts b/packages/extension-mathematics/src/extensions/InlineMath.test.ts index 93d4920cc3..167e6a80ed 100644 --- a/packages/extension-mathematics/src/extensions/InlineMath.test.ts +++ b/packages/extension-mathematics/src/extensions/InlineMath.test.ts @@ -77,4 +77,74 @@ describe('InlineMath', () => { // Expect no input rule to match expect(handled).toBeFalsy() }) + + /** + * Types a single `$` at the end of `initialText` and returns whether an input + * rule handled it, mirroring how the rule fires during real typing. + */ + const typeTrailingDollar = (initialText: string) => { + editor = new Editor({ + extensions: [Document, Paragraph, Text, InlineMath], + content: { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: initialText }], + }, + ], + }, + }) + + editor.commands.setTextSelection(editor.state.doc.nodeSize) + + return editor.view.someProp('handleTextInput', f => + f(editor.view, editor.state.selection.from, editor.state.selection.from, '$'), + ) + } + + it('replaces only the math when the preceding character is not a space', () => { + typeTrailingDollar('x$$a$') + + expect(editor.getJSON().content).toEqual([ + { + type: 'paragraph', + content: [ + { type: 'text', text: 'x' }, + { type: 'inlineMath', attrs: { latex: 'a' } }, + ], + }, + ]) + }) + + it('matches math at the very start of a paragraph', () => { + typeTrailingDollar('$$a$') + + expect(editor.getJSON().content).toEqual([ + { + type: 'paragraph', + content: [{ type: 'inlineMath', attrs: { latex: 'a' } }], + }, + ]) + }) + + it('skips a rejected match and still matches the one that follows it', () => { + // `$$a$$` is rejected because it is preceded by a third `$`, but a valid match + // starts at that sequence's trailing `$$` and runs to the end of the text. + typeTrailingDollar('$$$a$$b$') + + expect(editor.getJSON().content).toEqual([ + { + type: 'paragraph', + content: [ + { type: 'text', text: '$$$a' }, + { type: 'inlineMath', attrs: { latex: 'b' } }, + ], + }, + ]) + }) + + it('does not match when the closing delimiter is followed by a third $', () => { + expect(typeTrailingDollar('$$a$$')).toBeFalsy() + }) }) diff --git a/packages/extension-mathematics/src/extensions/InlineMath.ts b/packages/extension-mathematics/src/extensions/InlineMath.ts index 5e23171a44..d21e660288 100644 --- a/packages/extension-mathematics/src/extensions/InlineMath.ts +++ b/packages/extension-mathematics/src/extensions/InlineMath.ts @@ -1,3 +1,4 @@ +import type { InputRuleMatch } from '@tiptap/core' import { InputRule, mergeAttributes, Node } from '@tiptap/core' import type { Node as PMNode } from '@tiptap/pm/model' import katex, { type KatexOptions } from 'katex' @@ -65,6 +66,41 @@ declare module '@tiptap/core' { } } +/** + * Finds the `$$latex$$` sequence that the inline math input rule should replace. + * + * This is a function finder rather than a regex literal because the rule has to + * reject a sequence preceded by a third `$`, and there is no way to express that + * in a regex without a lookbehind. A lookbehind inside a regex *literal* fails at + * parse time on WebKit older than Safari 16.4, which takes down the whole chunk + * rather than just this rule. + * + * Rewriting the lookbehind as a consuming group such as `(^|[^$])` is not + * equivalent: the preceding character becomes part of the match, and the input + * rule range is derived from the match length, so that character gets replaced + * along with the math. A function finder can look at the preceding character + * without consuming it. + */ +const findInlineMath = (text: string): InputRuleMatch | null => { + const pattern = /\$\$([^$\n]+?)\$\$(?!\$)/g + + let match = pattern.exec(text) + + while (match !== null) { + // Stands in for a negative lookbehind rejecting a preceding `$`. + if (match.index === 0 || text[match.index - 1] !== '$') { + return { index: match.index, text: match[0], data: { latex: match[1] } } + } + + // Resume from the character after the rejected match rather than from its end: + // a valid match can begin inside a rejected one, at its trailing `$$`. + pattern.lastIndex = match.index + 1 + match = pattern.exec(text) + } + + return null +} + /** * InlineMath is a Tiptap extension for rendering inline mathematical expressions using KaTeX. * It allows users to insert LaTeX formatted math expressions inline within text. @@ -220,9 +256,14 @@ export const InlineMath = Node.create({ addInputRules() { return [ new InputRule({ - find: /(? { - const latex = match[2] + const latex = match.data?.latex + + if (typeof latex !== 'string') { + return null + } + const { tr } = state const start = range.from const end = range.to diff --git a/packages/extension-mathematics/src/extensions/lookbehind.test.ts b/packages/extension-mathematics/src/extensions/lookbehind.test.ts new file mode 100644 index 0000000000..ae44c9ee20 --- /dev/null +++ b/packages/extension-mathematics/src/extensions/lookbehind.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vite-plus/test' + +/** + * A lookbehind inside a regex *literal* is rejected at parse time by WebKit older + * than Safari 16.4, which takes down the entire chunk the module is bundled into + * rather than only the code path that uses the regex. This package has regressed + * on that twice already, so pin it rather than relying on review to catch it. + * + * The scan is deliberately coarse: it looks at raw source text and cannot tell a + * regex literal from a comment, so the sequence must not appear in prose either. + */ +const sources = Object.entries( + import.meta.glob('../**/*.ts', { + query: '?raw', + import: 'default', + eager: true, + }) as Record, +).filter(([path]) => !/\.(test|spec)\.ts$/.test(path)) + +describe('WebKit compatibility', () => { + it('uses no regex lookbehind anywhere in the package source', () => { + const offenders = sources + .filter(([, source]) => /\(\?<[=!]/.test(source)) + .map(([path]) => path) + + expect(offenders).toEqual([]) + }) +}) From ac1c7a0320457e072ce149aca20dcf8030c83de6 Mon Sep 17 00:00:00 2001 From: Bao Nguyen Date: Mon, 24 Aug 2026 09:28:27 +0700 Subject: [PATCH 08/13] docs(extension-mathematics): describe the changeset in user-visible terms State the crash in older WebKit instead of the regex that caused it. --- .changeset/2026-08-23-inline-math-lookbehind.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/2026-08-23-inline-math-lookbehind.md b/.changeset/2026-08-23-inline-math-lookbehind.md index da1994d359..228dae200d 100644 --- a/.changeset/2026-08-23-inline-math-lookbehind.md +++ b/.changeset/2026-08-23-inline-math-lookbehind.md @@ -2,4 +2,4 @@ '@tiptap/extension-mathematics': patch --- -The inline math input rule no longer uses a regex lookbehind, which WebKit older than Safari 16.4 rejects at parse time, taking down every chunk the extension is bundled into. +The mathematics extension no longer crashes the editor in older WebKit browsers and WKWebView. From f04027131b85096f8478edcadb5db4ca61d48aef Mon Sep 17 00:00:00 2001 From: Bao Nguyen Date: Sat, 29 Aug 2026 09:21:30 +0700 Subject: [PATCH 09/13] docs(extension-mathematics): shorten the comments around the inline math finder --- .../src/extensions/InlineMath.test.ts | 8 ++---- .../src/extensions/InlineMath.ts | 19 +++----------- .../src/extensions/lookbehind.test.ts | 26 +++++++------------ 3 files changed, 14 insertions(+), 39 deletions(-) diff --git a/packages/extension-mathematics/src/extensions/InlineMath.test.ts b/packages/extension-mathematics/src/extensions/InlineMath.test.ts index 167e6a80ed..c8dd54a197 100644 --- a/packages/extension-mathematics/src/extensions/InlineMath.test.ts +++ b/packages/extension-mathematics/src/extensions/InlineMath.test.ts @@ -78,10 +78,7 @@ describe('InlineMath', () => { expect(handled).toBeFalsy() }) - /** - * Types a single `$` at the end of `initialText` and returns whether an input - * rule handled it, mirroring how the rule fires during real typing. - */ + // Types a trailing `$` and returns whether an input rule handled it. const typeTrailingDollar = (initialText: string) => { editor = new Editor({ extensions: [Document, Paragraph, Text, InlineMath], @@ -129,8 +126,7 @@ describe('InlineMath', () => { }) it('skips a rejected match and still matches the one that follows it', () => { - // `$$a$$` is rejected because it is preceded by a third `$`, but a valid match - // starts at that sequence's trailing `$$` and runs to the end of the text. + // The first `$$a$$` is rejected, but a valid match starts at its trailing `$$`. typeTrailingDollar('$$$a$$b$') expect(editor.getJSON().content).toEqual([ diff --git a/packages/extension-mathematics/src/extensions/InlineMath.ts b/packages/extension-mathematics/src/extensions/InlineMath.ts index d21e660288..6ffe113f06 100644 --- a/packages/extension-mathematics/src/extensions/InlineMath.ts +++ b/packages/extension-mathematics/src/extensions/InlineMath.ts @@ -67,19 +67,8 @@ declare module '@tiptap/core' { } /** - * Finds the `$$latex$$` sequence that the inline math input rule should replace. - * - * This is a function finder rather than a regex literal because the rule has to - * reject a sequence preceded by a third `$`, and there is no way to express that - * in a regex without a lookbehind. A lookbehind inside a regex *literal* fails at - * parse time on WebKit older than Safari 16.4, which takes down the whole chunk - * rather than just this rule. - * - * Rewriting the lookbehind as a consuming group such as `(^|[^$])` is not - * equivalent: the preceding character becomes part of the match, and the input - * rule range is derived from the match length, so that character gets replaced - * along with the math. A function finder can look at the preceding character - * without consuming it. + * A negative lookbehind would crash old WebKit, and a consuming group like `(^|[^$])` + * would get replaced along with the math, so reject the preceding `$` in code instead. */ const findInlineMath = (text: string): InputRuleMatch | null => { const pattern = /\$\$([^$\n]+?)\$\$(?!\$)/g @@ -87,13 +76,11 @@ const findInlineMath = (text: string): InputRuleMatch | null => { let match = pattern.exec(text) while (match !== null) { - // Stands in for a negative lookbehind rejecting a preceding `$`. if (match.index === 0 || text[match.index - 1] !== '$') { return { index: match.index, text: match[0], data: { latex: match[1] } } } - // Resume from the character after the rejected match rather than from its end: - // a valid match can begin inside a rejected one, at its trailing `$$`. + // A valid match can start inside a rejected one, at its trailing `$$`. pattern.lastIndex = match.index + 1 match = pattern.exec(text) } diff --git a/packages/extension-mathematics/src/extensions/lookbehind.test.ts b/packages/extension-mathematics/src/extensions/lookbehind.test.ts index ae44c9ee20..0132215759 100644 --- a/packages/extension-mathematics/src/extensions/lookbehind.test.ts +++ b/packages/extension-mathematics/src/extensions/lookbehind.test.ts @@ -1,25 +1,17 @@ import { describe, expect, it } from 'vite-plus/test' -/** - * A lookbehind inside a regex *literal* is rejected at parse time by WebKit older - * than Safari 16.4, which takes down the entire chunk the module is bundled into - * rather than only the code path that uses the regex. This package has regressed - * on that twice already, so pin it rather than relying on review to catch it. - * - * The scan is deliberately coarse: it looks at raw source text and cannot tell a - * regex literal from a comment, so the sequence must not appear in prose either. - */ -const sources = Object.entries( - import.meta.glob('../**/*.ts', { - query: '?raw', - import: 'default', - eager: true, - }) as Record, -).filter(([path]) => !/\.(test|spec)\.ts$/.test(path)) +// Old WebKit rejects a lookbehind in a regex literal at parse time and takes the whole +// chunk down with it. Coarse on purpose: the sequence must not appear in comments either. +const sources = import.meta.glob('../**/*.ts', { + query: '?raw', + import: 'default', + eager: true, +}) as Record describe('WebKit compatibility', () => { it('uses no regex lookbehind anywhere in the package source', () => { - const offenders = sources + const offenders = Object.entries(sources) + .filter(([path]) => !/\.(test|spec)\.ts$/.test(path)) .filter(([, source]) => /\(\?<[=!]/.test(source)) .map(([path]) => path) From 1d8fba99731ea027493e5e1eb2e0ec06e29e6853 Mon Sep 17 00:00:00 2001 From: lazerg Date: Sat, 5 Sep 2026 20:13:48 +0500 Subject: [PATCH 10/13] fix(core): decouple drag-source cleanup from the paste plugin view --- packages/core/__tests__/crossEditorDrop.spec.ts | 16 ++++++++++++++++ packages/core/src/PasteRule.ts | 14 ++++++-------- 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/packages/core/__tests__/crossEditorDrop.spec.ts b/packages/core/__tests__/crossEditorDrop.spec.ts index 9de8ccd065..cb674a1acf 100644 --- a/packages/core/__tests__/crossEditorDrop.spec.ts +++ b/packages/core/__tests__/crossEditorDrop.spec.ts @@ -102,4 +102,20 @@ describe('cross editor drop', () => { expect(source.getHTML()).toBe('

') }) + + it('does not queue a delete when the source editor is unmounted then destroyed', () => { + vi.useFakeTimers() + source = createEditor() + target = createEditor() + + source.view.dom.dispatchEvent(new Event('dragstart', { bubbles: true })) + source.unmount() + source.destroy() + + const queuedBeforeDrop = vi.getTimerCount() + + target.view.dom.dispatchEvent(new Event('drop', { bubbles: true })) + + expect(vi.getTimerCount()).toBe(queuedBeforeDrop) + }) }) diff --git a/packages/core/src/PasteRule.ts b/packages/core/src/PasteRule.ts index b01f12a93b..ac240bc5dc 100644 --- a/packages/core/src/PasteRule.ts +++ b/packages/core/src/PasteRule.ts @@ -254,6 +254,12 @@ export function pasteRulesPlugin(props: { editor: Editor; rules: PasteRule[] }): return tr } + editor.on('destroy', () => { + if (tiptapDragFromOtherEditor === editor) { + tiptapDragFromOtherEditor = null + } + }) + const plugins = rules.map(rule => { return new Plugin({ // we register a global drag handler to track the current drag source element @@ -274,21 +280,13 @@ export function pasteRulesPlugin(props: { editor: Editor; rules: PasteRule[] }): } } - const handleEditorDestroy = () => { - if (tiptapDragFromOtherEditor === editor) { - tiptapDragFromOtherEditor = null - } - } - window.addEventListener('dragstart', handleDragstart) window.addEventListener('dragend', handleDragend) - editor.on('destroy', handleEditorDestroy) return { destroy() { window.removeEventListener('dragstart', handleDragstart) window.removeEventListener('dragend', handleDragend) - editor.off('destroy', handleEditorDestroy) }, } }, From 2affd482c90629e00b68968786cac4a478b8d5b7 Mon Sep 17 00:00:00 2001 From: breken-ai <312387581+breken-ai@users.noreply.github.com> Date: Fri, 25 Sep 2026 01:07:35 -0700 Subject: [PATCH 11/13] fix(static-renderer): close audio and video tags instead of self-closing them --- ...09-25-static-renderer-audio-closing-tag.md | 5 +++++ .../src/pm/html-string/html-string.test.ts | 21 +++++++++++++++++++ .../src/pm/html-string/html-string.ts | 2 ++ 3 files changed, 28 insertions(+) create mode 100644 .changeset/2026-09-25-static-renderer-audio-closing-tag.md diff --git a/.changeset/2026-09-25-static-renderer-audio-closing-tag.md b/.changeset/2026-09-25-static-renderer-audio-closing-tag.md new file mode 100644 index 0000000000..43463e6fd8 --- /dev/null +++ b/.changeset/2026-09-25-static-renderer-audio-closing-tag.md @@ -0,0 +1,5 @@ +--- +'@tiptap/static-renderer': patch +--- + +Render `