Rich-Text and Code Editor Instance Leaks
A CMS, notebook or IDE-style app opens and closes documents all day, and memory climbs by megabytes per document: old editor DOM stays detached, selectionchange listeners pile up on document, and in Monaco-based apps monaco.editor.getModels() returns every file ever opened. This guide from Third-Party Library Memory Leaks, in Framework-Specific Memory Optimization, explains what editor instances hold, which teardown calls each popular editor needs, and how to keep undo history and document models bounded.
| Symptom | Root Cause | Immediate Action | Measurable Impact |
|---|---|---|---|
| Detached editor DOM after closing documents | Editor view never destroyed | Call view.destroy() / editor.destroy() / editor.dispose() |
Editor DOM and plugins collectable |
selectionchange/keydown listeners on document grow |
Each editor registers global listeners | Destroy editors; verify listener counts | Constant global listener count |
Monaco getModels() keeps every file |
Text models are global and outlive editors | model.dispose() when a document closes |
Model count equals open documents |
| Memory grows while editing one long document | Undo history retains every transaction or snapshot | Bound history depth or group changes | History memory capped |
| Editor re-created on each prop change | Effect depends on content and recreates the editor | Create once; apply content changes via transactions or setValue |
One instance per document |
Root Cause: Editors Are Applications Inside Your Application
A modern editor is not a widget but a small application. A ProseMirror EditorView (which TipTap wraps) owns a contenteditable DOM tree, node views that may mount framework components, plugin views and state, input handling with listeners on the editor element, and global listeners for selection tracking. view.destroy() (or TipTap’s editor.destroy()) removes DOM observers and listeners and calls destroy on plugin views and node views. CodeMirror 6’s EditorView similarly owns DOM, a mutation observer, measurement loops and extension state; view.destroy() releases them.
Monaco adds a second layer: text models. A Monaco editor displays a model, but models are registered globally in monaco.editor and are not disposed when the editor that displayed them is disposed. Apps that call monaco.editor.createModel(text, lang, uri) for each opened file and only dispose the editor accumulate every file’s full text, tokenisation state and decorations. monaco.editor.getModels().length growing with the number of files ever opened is the tell-tale sign. Monaco also creates language-service web workers whose memory grows with the models they track.
All editors keep undo history. ProseMirror’s history plugin stores inverted steps per transaction up to a configurable depth; CodeMirror’s history does the same; Monaco keeps undo stacks per model. For long editing sessions or large pasted content, history can dominate memory, and for collaborative editors the collaboration layer (for example Yjs documents) keeps its own history and tombstones. Bounding depth and grouping rapid changes keep it predictable.
The framework integration adds the usual pitfalls: creating the editor in an effect that re-runs when content changes, rendering framework components inside node views without unmounting them, and holding references to editor instances in module-level registries — the same detached DOM retention problem as any widget, just with a much larger subtree.
Step-by-Step Fix
- Cycle documents and measure. Open and close twenty documents; record JS heap after GC, detached DOM count,
documentlistener counts and — for Monaco —monaco.editor.getModels().length. Verification: you know which of these grow. - Destroy the view on close. Call
view.destroy()(ProseMirror, CodeMirror),editor.destroy()(TipTap) oreditor.dispose()(Monaco) in the component’s cleanup. Verification: detached editor DOM disappears and global listener counts return to baseline. - Dispose Monaco models with their documents. Track models by URI and call
model.dispose()when the document closes (unless it is intentionally kept for fast reopening, with a bound). Verification: model count equals open documents. - Unmount framework content in node views. If node views render React, Vue or other components, unmount those roots in the node view’s
destroy(). Verification: no framework component instances from closed documents remain. - Bound undo history. Configure history depth (for example ProseMirror
history({ depth: 200 })) and group rapid typing into single history events. Verification: memory during a long editing session plateaus. - Create once per document. Apply external content changes through the editor’s API (transactions,
setValue,dispatch) instead of re-creating the editor. Verification: one editor instance per open document for the whole session.
Command and Code Reference
Use case: TipTap editor owned by a React component.
import { useEffect, useRef } from 'react';
import { Editor } from '@tiptap/core';
import StarterKit from '@tiptap/starter-kit';
function DocEditor({ initialContent, onSave }) {
const el = useRef(null);
useEffect(() => {
const editor = new Editor({
element: el.current,
extensions: [StarterKit.configure({ history: { depth: 200 } })], // bounded undo
content: initialContent,
onUpdate: ({ editor: e }) => onSave(e.getJSON()),
});
return () => editor.destroy(); // DOM, listeners, plugin views, node views
}, []); // create once per mount
return <div ref={el} />;
}
Use case: Monaco with models tied to document lifetime.
// monaco-docs.js
const models = new Map(); // uri string → model
export function openDoc(editor, doc) {
const uri = monaco.Uri.parse(`file:///${doc.path}`);
let model = monaco.editor.getModel(uri);
if (!model) model = monaco.editor.createModel(doc.text, doc.language, uri);
models.set(uri.toString(), model);
editor.setModel(model); // reuse one editor, switch models
}
export function closeDoc(editor, path) {
const key = monaco.Uri.parse(`file:///${path}`).toString();
const model = models.get(key);
if (editor.getModel() === model) editor.setModel(null);
model?.dispose(); // releases text, tokens, decorations, undo stack
models.delete(key);
}
export function disposeAll(editor) {
editor.dispose();
for (const m of models.values()) m.dispose();
models.clear();
}
Use case: a ProseMirror node view that mounts framework content.
class ChartNodeView {
constructor(node) {
this.dom = document.createElement('div');
this.root = createRoot(this.dom); // e.g. React root inside the editor
this.root.render(<InlineChart spec={node.attrs.spec} />);
}
destroy() {
this.root.unmount(); // ProseMirror calls this on removal/destroy
}
}
Verification and Regression Prevention
Verify with the open/close cycle: detached editor DOM absent from snapshots, document listener counts constant, Monaco model count equal to open documents, and heap flat across cycles. For long editing sessions, record a Performance trace with memory while typing for several minutes; with bounded history, the heap floor should level off rather than rise steadily.
Put each editor behind one integration module that exposes open/close operations and owns views, models and history configuration, and add an end-to-end test that opens and closes many documents while asserting on model counts and heap growth. Editors inside modals or routes also benefit from the modal cleanup rules in React portals and modal memory leaks.
Edge Cases and Gotchas
Collaborative editing state
Real-time collaboration layers keep document history and deletion markers so peers can merge changes. Their memory grows with edit history, independent of the editor’s undo depth. Check the collaboration library’s garbage-collection options and persist or compact documents periodically.
Monaco workers
Language services run in workers that hold state for registered models. Disposing models releases their worker-side state; creating many models for “background” files keeps workers busy and large.
Paste of huge content
Pasting megabytes of text creates large transactions and history entries. Consider limits on paste size, or clearing history after large imports.
Editors inside keep-alive caches
Framework keep-alive caches keep editor instances alive while hidden. That is fine for a few recent documents, but bound the cache — see Vue KeepAlive cache memory growth.
Frequently Asked Questions
Does disposing a Monaco editor free the file contents?
No. The file’s text lives in a model registered globally in monaco.editor. Disposing the editor leaves the model in the registry. Call model.dispose() when the document closes, or getModels() will keep growing.
Which method destroys a ProseMirror or TipTap editor?
view.destroy() for ProseMirror and editor.destroy() for TipTap. They remove observers and listeners and call destroy on plugin views and node views, which is where you unmount any framework components rendered inside the editor.
Why does memory grow while I keep typing in one document?
Undo history stores information for each change. Without a depth limit, long sessions accumulate history. Configure a maximum depth and group rapid edits so each typing burst becomes a single history entry.
Should I reuse one editor for several documents?
For code editors like Monaco, reusing one editor and switching models is efficient and common. For rich-text editors, one instance per open document is simpler; either way, destroy instances and models you no longer need.
What about CodeMirror 6 specifically?
Call view.destroy() when the editor’s host unmounts; it disconnects the mutation observer, removes listeners and stops measurement loops. State objects (EditorState) are immutable values and are collected normally once nothing references them, so keeping old states in your own history or cache is the main way to retain extra memory.
Does autosave affect editor memory?
Autosave handlers that serialise the whole document on every change allocate large strings or JSON trees repeatedly, which shows up as churn and GC pauses rather than retention. Debounce autosave and serialise incrementally where the editor supports it.
How can I detect editor leaks automatically?
Script an open/close cycle with Playwright, then assert on heap growth, document listener counts via the DevTools Protocol, and — for Monaco — monaco.editor.getModels().length evaluated in the page.
Related
- Third-Party Library Memory Leaks — the parent topic
- Observer APIs Keeping Detached Nodes Alive — the observers editors rely on
- Analytics and Tag Manager Memory Growth — third-party scripts that grow with activity
- Framework-Specific Memory Optimization — the section overview