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.

Editor views versus global models Each opened document creates an editor view with its DOM, plugins, listeners and undo history, released by the view's destroy or dispose method. In Monaco, the text of each document lives in a model registered in a global model registry. Disposing the editor leaves the model registered, so models for documents one, two and three stay in memory until each model is disposed explicitly. Editor view (per open doc) DOM, plugins, node views, listeners released by destroy() / dispose() monaco.editor model registry model: file-1.ts (editor disposed) model: file-2.ts (editor disposed) model: file-3.ts (open) call model.dispose() when a file closes disposing the editor does not dispose the model it displayed

Step-by-Step Fix

  1. Cycle documents and measure. Open and close twenty documents; record JS heap after GC, detached DOM count, document listener counts and — for Monaco — monaco.editor.getModels().length. Verification: you know which of these grow.
  2. Destroy the view on close. Call view.destroy() (ProseMirror, CodeMirror), editor.destroy() (TipTap) or editor.dispose() (Monaco) in the component’s cleanup. Verification: detached editor DOM disappears and global listener counts return to baseline.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
Monaco models across 40 opened files Disposing only the editor leaves every model registered, so after opening and closing forty files there are 40 models and heap has grown by about 180 megabytes including language service state. Disposing each model when its file closes keeps the count at the number of open files, 1, and heap flat. 40 0 editor.dispose() only: models accumulate model.dispose() on close: 1 model files opened and closed (0 → 40)

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.

Editor open/close and long-session checks In the open and close cycle, detached editor DOM is absent from snapshots, document listener counts stay constant, and the Monaco model count equals open documents. In a long editing session recorded with memory in the Performance panel, a bounded undo history makes the heap floor level off. Open/close cycle and long edit session Detached editor DOM Absent from snapshots after closing editors. document listeners Count constant across open/close cycles. Monaco models monaco.editor.getModels().length equals open documents. Long session With bounded history the heap floor levels off.

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.