Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/clean-bobcats-double.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tiptap/markdown': patch
---

Fix editor becoming unresponsive when initialized with empty markdown content and `contentType: 'markdown'`
5 changes: 5 additions & 0 deletions .changeset/clean-hairs-push.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tiptap/extension-drag-handle': patch
---

Improved JSDoc documentation for the nested drag handle options, making threshold, strength, edge detection, and custom rules easier to understand directly in your IDE.
5 changes: 5 additions & 0 deletions .changeset/fix-block-math-input-rule-empty-paragraph.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@tiptap/extension-mathematics": patch
---

Fix `$$$...$$$` input rule leaving an empty paragraph above the inserted block math node when the match consumes the entire host paragraph.
5 changes: 5 additions & 0 deletions .changeset/ninety-icons-hide.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tiptap/core': patch
---

Fixed a memory leak where `Editor.destroy()` did not release the Extension parent/child graph. Module-scope extension singletons retained references to configured extensions, preventing garbage collection of extension options (including DOM closures). The fix also cleans up `ExtensionManager`, `schema`, and `commandManager` references on destroy.
118 changes: 117 additions & 1 deletion packages/core/__tests__/extendExtensions.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { Extension, getExtensionField, Mark, Node } from '@tiptap/core'
import { Editor, Extension, getExtensionField, Mark, 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 'vitest'

declare module '@tiptap/core' {
Expand Down Expand Up @@ -416,3 +419,116 @@ describe('extend extensions', () => {
})
})
})

describe('parent/child cleanup on destroy', () => {
it('should not leak child reference when configure() is called on a singleton', () => {
const singleton = Extension.create({
name: 'testExtension',
addOptions() {
return { foo: 'bar' }
},
})

const configuredExtension = singleton.configure({ foo: 'baz' })

expect(singleton.child).toBeNull()
expect(configuredExtension.parent).toBeNull()
})

it('should break parent/child chain when editor is destroyed (extend path)', () => {
const singleton = Extension.create({
name: 'testExtension',
addOptions() {
return { foo: 'bar' }
},
})

const childExtension = singleton.extend({
addOptions() {
return { ...this.parent?.(), foo: 'baz' }
},
})

expect(singleton.child).toBe(childExtension)
expect(childExtension.parent).toBe(singleton)

const editor = new Editor({
element: null,
extensions: [Document, Paragraph, Text, childExtension],
})

editor.destroy()

expect(singleton.child).toBeNull()
})

it('should clear forward parent.child links on all extensions after editor.destroy()', () => {
const singletonA = Extension.create({
name: 'extA',
addOptions() {
return { value: 'a' }
},
})
const singletonB = Extension.create({
name: 'extB',
addOptions() {
return { value: 'b' }
},
})

const configuredA = singletonA.configure({ value: 'a-configured' })
const childB = singletonB.extend({ name: 'extB-child' })

const editor = new Editor({
element: null,
extensions: [Document, Paragraph, Text, configuredA, childB],
})

const { extensions } = editor.extensionManager

editor.destroy()

extensions.forEach(ext => {
if (ext.parent?.child === ext) {
// This should never be true after destroy — the forward link is always broken
expect(ext.parent.child).toBeNull()
}
})
})

it('should break all ancestor child links in a multi-level extend chain after editor.destroy()', () => {
const root = Extension.create({
name: 'root',
addOptions() {
return { level: 0 }
},
})

const child = root.extend({
addOptions() {
return { ...this.parent?.(), level: 1 }
},
})

const grandchild = child.extend({
addOptions() {
return { ...this.parent?.(), level: 2 }
},
})

expect(root.child).toBe(child)
expect(child.child).toBe(grandchild)
expect(grandchild.parent).toBe(child)
expect(child.parent).toBe(root)

const editor = new Editor({
element: null,
extensions: [Document, Paragraph, Text, grandchild],
})

editor.destroy()

expect(root.child).toBeNull()
expect(child.child).toBeNull()
})
})
14 changes: 14 additions & 0 deletions packages/core/src/Editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ export class Editor extends EventEmitter<EditorEvents> {

public isFocused = false

private destroyed = false

private editorState!: EditorState

/**
Expand Down Expand Up @@ -764,11 +766,23 @@ export class Editor extends EventEmitter<EditorEvents> {
* Destroy the editor.
*/
public destroy(): void {
if (this.destroyed) {
return
}

this.destroyed = true

this.emit('destroy')

this.unmount()

this.removeAllListeners()

this.extensionManager.destroy()
this.extensionManager = null as any
this.schema = null as any
this.commandManager = null as any
this.extensionStorage = {} as Storage
}

/**
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/Extendable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -580,6 +580,8 @@ export class Extendable<
extension.name = this.name
extension.parent = this.parent

this.child = null

return extension
}

Expand Down
35 changes: 35 additions & 0 deletions packages/core/src/ExtensionManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,41 @@ export class ExtensionManager {
)
}

/**
* Destroy the extension manager and clean up all extension references
* to prevent memory leaks through parent/child extension chains.
*
* Walks each extension's full parent chain and nulls every forward
* `parent.child → current` link where the parent still points to the
* current node. This breaks the retention path from module-scope
* singleton roots through deep extend() chains.
*
* Only ancestor `.child` links matching the current chain are cleared.
* The `.parent` pointer on ancestors is never touched — extensions
* may be shared across live editors, so their own backward references
* and non-matching forward links must remain intact.
*/
destroy() {
this.extensions.forEach(extension => {
let current: any = extension

while (current.parent) {
const parent = current.parent

if (parent.child === current) {
parent.child = null
}

current = parent
}
})

this.extensions = []
this.baseExtensions = []
this.schema = null as any
this.editor = null as any
}

/**
* Go through all extensions, create extension storages & setup marks
* & bind editor event listener.
Expand Down
34 changes: 19 additions & 15 deletions packages/extension-drag-handle/src/drag-handle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,24 +45,14 @@ export interface DragHandleOptions {
/**
* Enable drag handles for nested content (list items, blockquotes, etc.).
*
* When enabled, the drag handle will appear for nested blocks, not just
* top-level blocks. A rule-based scoring system determines which node
* to target based on cursor position and configured rules.
* When enabled, the drag handle appears for block nodes at any depth, not just
* top-level blocks. A rule-based scoring system evaluates all ancestor nodes
* at the cursor position and selects the best drag target.
*
* **Values:**
* - `false` (default): Only root-level blocks show drag handles
* - `true`: Enable with sensible defaults (left edge detection, default rules)
* - `NestedOptions`: Enable with custom configuration
*
* **Configuration options:**
* - `rules`: Custom rules to determine which nodes are draggable
* - `defaultRules`: Whether to include default rules (default: true)
* - `allowedContainers`: Restrict nested dragging to specific container types
* - `edgeDetection`: Control when to prefer parent over nested node
* - `'left'` (default): Prefer parent near left/top edges
* - `'right'`: Prefer parent near right/top edges (for RTL)
* - `'both'`: Prefer parent near any horizontal edge
* - `'none'`: Disable edge detection
* - `NestedOptions`: Enable with full custom configuration
*
* @default false
*
Expand All @@ -81,7 +71,7 @@ export interface DragHandleOptions {
* })
*
* @example
* // With custom rules
* // With custom rules and edge detection disabled
* DragHandle.configure({
* nested: {
* rules: [{
Expand All @@ -91,6 +81,20 @@ export interface DragHandleOptions {
* edgeDetection: 'none',
* },
* })
*
* @example
* // Full configuration
* DragHandle.configure({
* nested: {
* defaultRules: true,
* allowedContainers: ['bulletList', 'orderedList', 'blockquote'],
* edgeDetection: { threshold: 20 },
* rules: [{
* id: 'preferShallow',
* evaluate: ({ depth }) => depth * 200,
* }],
* },
* })
*/
nested?: boolean | NestedOptions
}
Expand Down
Loading
Loading