Skip to content

Commit 207a2bc

Browse files
authored
fix(core): release extension parent/child graph on Editor.destroy() to prevent memory leak (ueberdosis#7795)
* fix(core): release extension parent/child graph on Editor.destroy() to prevent memory leak (ueberdosis#7769) * fix(core): make Editor.destroy() idempotent with a destroyed flag * fix(core): avoid nulling ancestor.child in ExtensionManager.destroy() to prevent cross-editor side effects * fix(core): address PR review feedback — preserve extension.parent/child on shared instances, clear extensionStorage, fix walk-up loop * fix(core): walk full parent chain in ExtensionManager.destroy() and add multi-level test * docs(core): clarify ExtensionManager.destroy() JSDoc on what is mutated
1 parent 1852c73 commit 207a2bc

5 files changed

Lines changed: 173 additions & 1 deletion

File tree

.changeset/ninety-icons-hide.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@tiptap/core': patch
3+
---
4+
5+
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.

packages/core/__tests__/extendExtensions.spec.ts

Lines changed: 117 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
import { Extension, getExtensionField, Mark, Node } from '@tiptap/core'
1+
import { Editor, Extension, getExtensionField, Mark, Node } from '@tiptap/core'
2+
import Document from '@tiptap/extension-document'
3+
import Paragraph from '@tiptap/extension-paragraph'
4+
import Text from '@tiptap/extension-text'
25
import { describe, expect, it } from 'vitest'
36

47
declare module '@tiptap/core' {
@@ -416,3 +419,116 @@ describe('extend extensions', () => {
416419
})
417420
})
418421
})
422+
423+
describe('parent/child cleanup on destroy', () => {
424+
it('should not leak child reference when configure() is called on a singleton', () => {
425+
const singleton = Extension.create({
426+
name: 'testExtension',
427+
addOptions() {
428+
return { foo: 'bar' }
429+
},
430+
})
431+
432+
const configuredExtension = singleton.configure({ foo: 'baz' })
433+
434+
expect(singleton.child).toBeNull()
435+
expect(configuredExtension.parent).toBeNull()
436+
})
437+
438+
it('should break parent/child chain when editor is destroyed (extend path)', () => {
439+
const singleton = Extension.create({
440+
name: 'testExtension',
441+
addOptions() {
442+
return { foo: 'bar' }
443+
},
444+
})
445+
446+
const childExtension = singleton.extend({
447+
addOptions() {
448+
return { ...this.parent?.(), foo: 'baz' }
449+
},
450+
})
451+
452+
expect(singleton.child).toBe(childExtension)
453+
expect(childExtension.parent).toBe(singleton)
454+
455+
const editor = new Editor({
456+
element: null,
457+
extensions: [Document, Paragraph, Text, childExtension],
458+
})
459+
460+
editor.destroy()
461+
462+
expect(singleton.child).toBeNull()
463+
})
464+
465+
it('should clear forward parent.child links on all extensions after editor.destroy()', () => {
466+
const singletonA = Extension.create({
467+
name: 'extA',
468+
addOptions() {
469+
return { value: 'a' }
470+
},
471+
})
472+
const singletonB = Extension.create({
473+
name: 'extB',
474+
addOptions() {
475+
return { value: 'b' }
476+
},
477+
})
478+
479+
const configuredA = singletonA.configure({ value: 'a-configured' })
480+
const childB = singletonB.extend({ name: 'extB-child' })
481+
482+
const editor = new Editor({
483+
element: null,
484+
extensions: [Document, Paragraph, Text, configuredA, childB],
485+
})
486+
487+
const { extensions } = editor.extensionManager
488+
489+
editor.destroy()
490+
491+
extensions.forEach(ext => {
492+
if (ext.parent?.child === ext) {
493+
// This should never be true after destroy — the forward link is always broken
494+
expect(ext.parent.child).toBeNull()
495+
}
496+
})
497+
})
498+
499+
it('should break all ancestor child links in a multi-level extend chain after editor.destroy()', () => {
500+
const root = Extension.create({
501+
name: 'root',
502+
addOptions() {
503+
return { level: 0 }
504+
},
505+
})
506+
507+
const child = root.extend({
508+
addOptions() {
509+
return { ...this.parent?.(), level: 1 }
510+
},
511+
})
512+
513+
const grandchild = child.extend({
514+
addOptions() {
515+
return { ...this.parent?.(), level: 2 }
516+
},
517+
})
518+
519+
expect(root.child).toBe(child)
520+
expect(child.child).toBe(grandchild)
521+
expect(grandchild.parent).toBe(child)
522+
expect(child.parent).toBe(root)
523+
524+
const editor = new Editor({
525+
element: null,
526+
extensions: [Document, Paragraph, Text, grandchild],
527+
})
528+
529+
editor.destroy()
530+
531+
expect(root.child).toBeNull()
532+
expect(child.child).toBeNull()
533+
})
534+
})

packages/core/src/Editor.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,8 @@ export class Editor extends EventEmitter<EditorEvents> {
6868

6969
public isFocused = false
7070

71+
private destroyed = false
72+
7173
private editorState!: EditorState
7274

7375
/**
@@ -764,11 +766,23 @@ export class Editor extends EventEmitter<EditorEvents> {
764766
* Destroy the editor.
765767
*/
766768
public destroy(): void {
769+
if (this.destroyed) {
770+
return
771+
}
772+
773+
this.destroyed = true
774+
767775
this.emit('destroy')
768776

769777
this.unmount()
770778

771779
this.removeAllListeners()
780+
781+
this.extensionManager.destroy()
782+
this.extensionManager = null as any
783+
this.schema = null as any
784+
this.commandManager = null as any
785+
this.extensionStorage = {} as Storage
772786
}
773787

774788
/**

packages/core/src/Extendable.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -580,6 +580,8 @@ export class Extendable<
580580
extension.name = this.name
581581
extension.parent = this.parent
582582

583+
this.child = null
584+
583585
return extension
584586
}
585587

packages/core/src/ExtensionManager.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -363,6 +363,41 @@ export class ExtensionManager {
363363
)
364364
}
365365

366+
/**
367+
* Destroy the extension manager and clean up all extension references
368+
* to prevent memory leaks through parent/child extension chains.
369+
*
370+
* Walks each extension's full parent chain and nulls every forward
371+
* `parent.child → current` link where the parent still points to the
372+
* current node. This breaks the retention path from module-scope
373+
* singleton roots through deep extend() chains.
374+
*
375+
* Only ancestor `.child` links matching the current chain are cleared.
376+
* The `.parent` pointer on ancestors is never touched — extensions
377+
* may be shared across live editors, so their own backward references
378+
* and non-matching forward links must remain intact.
379+
*/
380+
destroy() {
381+
this.extensions.forEach(extension => {
382+
let current: any = extension
383+
384+
while (current.parent) {
385+
const parent = current.parent
386+
387+
if (parent.child === current) {
388+
parent.child = null
389+
}
390+
391+
current = parent
392+
}
393+
})
394+
395+
this.extensions = []
396+
this.baseExtensions = []
397+
this.schema = null as any
398+
this.editor = null as any
399+
}
400+
366401
/**
367402
* Go through all extensions, create extension storages & setup marks
368403
* & bind editor event listener.

0 commit comments

Comments
 (0)