cf.completefrontend
My progress
SYSTEM DESIGN · #32

Design an embeddable code editor

The editor box that playgrounds, online judges, and code review tools embed. Showing colored text is the easy part; the hard parts are catching typed text from every keyboard and input method without letting the browser corrupt the document, storing a 10 MB file so an edit costs microseconds, drawing only the 40 lines on screen, re-highlighting after each keystroke without blocking it, and staying usable with a screen reader.

The question

Our company ships three products that all need a code box: a playground, a coding interview tool, and a pull request review page. Each team has glued in a different library, and each has its own bugs with Japanese input, huge files, and screen readers.

Design one embeddable code editor component that all three can use. How does it take in text, store the document, track cursors, draw only what is visible, highlight syntax as you type, grow through extensions, and stay fast and accessible on a 50,000 line file?

Real-life examples: CodeMirror 6 (Replit, Chrome DevTools sources panel, Jupyter), Monaco, the editor inside VS Code, Ace (built for the Cloud9 IDE), GitHub's web file editor, LeetCode and CodePen editor panes

Requirements: what the host apps need from an editor

A code editor component is the box where you type code: colored text, line numbers, a blinking cursor, and keys like Tab and Ctrl+Z that behave the way programmers expect. It looks like a textarea with colors. It is not. A plain textarea cannot color parts of its text, cannot show a squiggly line under one word, and slows to a crawl when you paste in a 5 MB file.

This is a component question. The host app decides what the code is for: the codepen guide runs it in a sandboxed preview, the online-judge guide sends it to a test runner, and the code-review-diff guide shows two versions side by side. Those guides treat the editor as a part with a clear interface and pick a library.

This guide opens that part up. We design the internals the way the authors of the three libraries behind almost every web editor did: CodeMirror 6 (a modular 2022 rewrite by Marijn Haverbeke), Monaco (the editor of VS Code, packaged for the browser), and Ace (the older editor behind Cloud9). Each made a different bet on the hard parts, so we use them as worked examples and explain why they are built the way they are.

Questions that change the design

  • Who edits, and on what? Desktop keyboards only, or phones too? Phone keyboards insert text through autocorrect and prediction, which breaks editors that listen only to key events. Assume desktop first, but phones must work for small edits.
  • Which languages and scripts? Code is mostly ASCII, but comments and strings hold Chinese, Japanese, Arabic, and emoji. Typing Chinese needs an IME (input method editor: the small popup that turns keystrokes into characters). Assume full IME support; it is the bug users report most.
  • How large can a file get? A LeetCode answer is 50 lines. A pull request may touch a 20,000 line generated file. A minified bundle is 2 MB on a single line. Assume smooth editing up to 100,000 lines and 10 MB, with features turned off above that.
  • How smart must it be? Coloring only, or autocomplete, errors as you type, and go to definition? Smart features need a language engine, which may run in a worker or on a server. Assume coloring and bracket matching built in, smart features as optional extensions.
  • One editor, or many on a page? A review page can show 30 small read-only editors. That makes startup cost and memory per instance matter more than peak features.
  • Collaboration? Live multi-user editing is a layer on top. We only need a document model that can accept remote changes and map cursors through them.

Functional requirements

  • Edit text with the keyboard, mouse, touch, paste, drag and drop, and any IME, with undo and redo.
  • Multiple cursors and selections, rectangular selection, and the usual code keys: auto indent, bracket closing, comment toggle, move line up and down.
  • Syntax highlighting that stays correct while you type, for 10 to 50 languages loaded on demand.
  • A gutter with line numbers, fold markers, and lint markers; inline decorations such as squiggly underlines and widgets.
  • Search and replace, including regular expressions, over the whole document.
  • An extension system so each host app can add keymaps, languages, themes, and panels without forking the core.
  • An embedding API: read and set the value, listen to changes, switch language, toggle read only.

Non-functional requirements, with numbers

RequirementTargetWhy this number
Keystroke to paintUnder 16 ms on a mid-range laptop, under 50 ms on a mid-range phoneOne frame at 60 Hz is 16.7 ms. Many users notice typing lag of about 50 ms or more.
Edit cost on a large fileInserting a character in a 10 MB file costs well under 1 msA copy of a 10 MB string on every keystroke would cost several ms and create garbage for the collector.
Open timeA 100,000 line file shows its first screen in under 300 msThe user clicked a file and expects to see it, not a spinner.
DOM sizeOnly the visible lines plus a margin: a few hundred line elements at most100,000 line elements would take seconds to lay out and hundreds of MB of memory.
BundleAbout 200 KB gzipped or less for the core plus one language, loaded after first paintThe editor must not delay the host page; chapter 9 checks real library sizes.
AccessibilityUsable with NVDA, JAWS, and VoiceOver; never traps keyboard focusWCAG (the Web Content Accessibility Guidelines) 2.1.1 Keyboard and 2.1.2 No Keyboard Trap apply to every editor.

Out of scope, and why

  • Running the code and the preview iframe. The codepen and online-judge guides cover sandboxing and test runs.
  • Rich text and prose. Bold, headings, and embedded images belong to the rich-text-editor guide, which uses a tree document model; the markdown-editor guide covers writing prose with a live preview. Code is plain text plus decorations.
  • Collaboration algorithms, OT and CRDTs (the two families of merge algorithms for live editing). The google-docs guide and the crdts and collaboration-basics lessons cover them; we only keep the hooks they need.
  • Terminal rendering. A terminal is a grid of cells fed by a byte stream; the web-terminal guide covers it.
Wireframe of the editor component editing solution.ts. At the top, a search panel shows find total, 2 of 7. On the left, a gutter shows line numbers 1 to 7 and a red lint marker on line 4. The content area shows a function total that sums items, with a wavy underline under the misspelled word prise on line 4, and a completion popup below it offering price and print. At the bottom, a status bar shows Ln 4, Col 18, TypeScript. Labels on the right name the search panel, the content lines, the completion popup, and the status bar; a label below names the left column, the gutter.editor: solution.tsfind: total 2 of 71function total(items) {2  let sum = 0;3  for (const i of items) {4    sum += i.prise;5  }6  return sum;7}xpriceprintLn 4, Col 18 TypeScriptsearch panelvisible linesautocompletestatus barleft column, the gutter: line numbers, folds, lint markers
Every part is either the content, which is real editable text, or a decoration around it. The gutter, squiggle, popup, and panels are drawn by extensions, so a read-only review editor can leave most of them out.

Check your understanding · Exercise 1

Which answers from the host teams would change the editor's internal design, not just its configuration? Select all that apply.

What makes a code editor hard

Five properties of the problem drive every later decision. Name them early in an interview; each one maps to a chapter.

  1. The browser wants to own text input, and we can't let it own the document. Only the browser knows how an IME, a phone keyboard, autocorrect, or dictation produce text. But if we let the browser edit our DOM freely, it inserts stray span and br elements and our model drifts away from what is on screen. We need a narrow channel: let the browser produce text, then take it back.
  2. Documents are big and edits are tiny. A keystroke changes one character in a document that may hold 10 million. Any design that copies, re-splits, or re-renders the whole document per keystroke fails at size. Every structure must make an edit cost depend on the size of the edit, not of the file.
  3. Positions move. A lint error at offset 5,000, a collaborator's cursor, a fold, a search match: all point into the text. When you type at offset 100, every one of them after 100 shifts. The editor needs one shared way to map any position through any change.
  4. Highlighting needs the whole file but must not cost the whole file. Whether line 9,000 is inside a string depends on every quote before it. Yet a keystroke must repaint in one frame. That forces incremental parsing with a time budget.
  5. Text must stay real for assistive tech. A canvas or a pile of absolutely placed spans looks fine but reads as nothing to a screen reader. Whatever trick we use to draw only 40 lines, the text under the cursor must be readable and announced.

A sixth, softer property: the editor is a platform. Each host wants its own keymap, languages, and panels, so the core must stay small and everything else must plug in.

The keystroke pipeline. Keyboard and IME input goes to the Input layer, which reads beforeinput and composition events and builds a Transaction with changes and a selection. The Transaction produces a new EditorState, an immutable value. The View redraws only the viewport and patches about 40 line elements in the DOM. When the document changed, the Parser runs with a 20 millisecond budget and hands a syntax tree to the View. Below everything, Extensions such as keymaps, state fields, decorations, and view plugins hook into the Transaction and View steps.Input layerbeforeinput, IMETransactionchanges, selectionEditorStateimmutable valueViewviewport onlyKeyboardkeys, IME, pasteParser20 ms budgetdoc changedtreeDOMabout 40 linespatchExtensionskeymaps, state fields, decorations, view plugins
Each box maps to a chapter of this guide. The arrows only go one way: nothing writes to the DOM except the view, and nothing changes the state except a transaction.

The figure is the spine of this guide: input surface (chapter 3), immutable document and transactions (chapter 4), selection (chapter 5), the windowed view (chapter 6), highlighting that catches up after the paint (chapter 7), and extensions at every step (chapter 8).

The input surface: letting the browser type without letting it edit

The naive plan: put tabindex on a div, listen to keydown, and insert event.key. It works for an English keyboard. Then three bug reports arrive.

A Japanese user types "nihon" and gets the letters n, i, h, o, n instead of 日本, because the IME only works in editable elements. An Android user sees nothing at all, because phone keyboards often report every key as Unidentified with keyCode 229. A user pastes from Slack and nothing happens, because there is no paste target. The lesson: text input is an operating system service, and only real editable elements get it.

So the editor needs a real editable element somewhere. The design question is which one, and how much of the browser's editing we let through.

Three input surfaces side by side. Hidden textarea, used by Ace, CodeMirror 5, and Monaco outside Chromium: the visible lines are drawn divs that are not editable, and a tiny textarea at the cursor receives input events; IME and paste work but the textarea has little context. contenteditable, used by CodeMirror 6: the content div holds real editable text and a DOM observer reads mutations back into the model; the IME draws in place and assistive tech gets full context. EditContext, used by Monaco in Chromium: an OS text service sends textupdate events and you draw on a canvas or the DOM yourself; it is Chromium only and you must place the IME window.Hidden textareadrawn linesdivs, not editabletextareatiny, at the cursorinputIME workslittle contextcontenteditablecontent divreal text, editableDOM observerreads changes backmutationsIME in placefull contextEditContextcanvas or DOMyou draw itEditContextOS text servicetextupdateChromium onlyyou place IME
All three keep the model as the source of truth. They differ in where the browser is allowed to put text first, and so in how much context phone keyboards and screen readers get.

Decision Which element receives the user's text?

Hidden textarea (Ace, CodeMirror 5 on desktop, Monaco outside Chromium)

A tiny, transparent textarea sits at the cursor position and holds focus. Everything you see is drawn separately from the model.

On each input event, the editor reads what changed in the textarea, applies it to the model, and clears or resets the textarea.

Pros

  • A textarea is plain text, so browsers behave the same everywhere. No stray markup to clean up.
  • IME, paste, and dictation work because it is a real input. Placing it at the cursor makes the IME popup appear in the right spot.

Cons

  • The textarea holds only a little text, so phone autocorrect and screen readers see almost no context. Monaco works around this by copying nearby lines into it when a screen reader is on.
  • Native touch selection handles and the context menu don't work on the drawn text; the editor must rebuild them.
  • The editor must draw the IME's in-progress text itself.

contenteditable content (CodeMirror 6, CodeMirror 5 on mobile)

The visible lines live inside one contenteditable element. The browser edits that DOM directly: typing, IME, autocorrect, spellcheck menus.

A MutationObserver plus beforeinput handlers notice the change, the editor turns it into a transaction, then redraws its own DOM so the markup is always what the model says.

Pros

  • The real text is in the DOM, so screen readers, native selection, touch handles, and phone keyboards get full context.
  • The IME draws its own underline in place. CodeMirror moved to this model in version 6 largely for mobile and accessibility.

Cons

  • Each browser mutates the DOM a little differently; the editor needs a careful diffing layer and many browser-specific fixes.
  • The editor must never touch the DOM under an active composition, or the IME breaks.

EditContext API, with DOM or canvas drawing

A new browser API: you attach an EditContext to an element, and the OS input service talks to it directly. You get textupdate events with the text and selection, and draw the result however you like.

Shipped in Chrome and Edge 121 (January 2024); not in Firefox or Safari as of 2026. VS Code and Monaco use it by default in Chromium (the editContext option) and fall back to the hidden textarea elsewhere.

Pros

  • No hidden textarea and no DOM diffing: input arrives as clean text updates, even for IMEs.
  • Works with a canvas renderer.

Cons

  • Chromium only, so it needs a fallback, which means building one of the other two anyway.
  • You must report the caret and character bounds yourself, or the IME popup appears in the wrong place.

What we pick: Our editor uses contenteditable for the content, like CodeMirror 6, because the three host apps include phones and screen reader users, and both get the most from real text in the DOM. We accept the cost of a DOM observer that reads changes back and redraws. We design the input layer behind an interface so an EditContext path can replace it on Chromium later.

The events, in the order they arrive

Modern browsers fire beforeinput before they change the DOM, with an inputType that names the intent: insertText, deleteContentBackward, insertFromPaste, insertParagraph, historyUndo, and about 40 more. Most are cancelable, so the editor can call preventDefault() and apply the change to its own model instead. The big exception is text produced during IME composition: insertCompositionText cannot be canceled. The browser must draw it, and the editor reads it back afterwards.

Composition is the IME's in-progress state. It starts with compositionstart, sends compositionupdate each time the candidate text changes ("に", then "にほ", then "日本" after the user presses Space), and finishes with compositionend carrying the committed text.

Each update also fires beforeinput and input with the inputType insertCompositionText. Meanwhile keydown events still fire, with isComposing true and keyCode 229. Desktop Safari can even deliver the Enter that confirms a candidate just after compositionend, which is why guards check for 229 as well.

Sequence diagram of IME input with four columns: IME, Browser, Input layer, EditorState. The user types n i h o n and the IME shows the kana candidate にほん; the browser sends keydown with keyCode 229, which the input layer skips, then compositionstart and compositionupdate. The user presses Space and the IME converts it to 日本. compositionend carries the committed 日本, and the input layer dispatches it to the state as one change and one undo step. Failure path: the user presses Enter to confirm while isComposing is true; an unguarded keydown handler runs the newline command and the state gets an extra line break.IMEBrowserInput layerEditorStatenihon: にほんkeydown 229: skipcompositionstartcompositionupdateSpace: 日本compositionendone undo stepEnter, isComposingbug: extra newline
The happy path ends in one transaction. The red path is the most common IME bug: a keydown handler that runs Enter as a command without checking isComposing.
ts
content.addEventListener("keydown", (event) => {
  // Keys pressed while an IME is open belong to the IME.
  if (event.isComposing || event.keyCode === 229) return;
  const command = keymap.lookup(event); // Mod-z, Tab, Enter...
  if (command && command(view)) event.preventDefault();
});

content.addEventListener("beforeinput", (event) => {
  if (event.inputType === "insertCompositionText") return; // browser draws it
  const change = changeFromInputEvent(event, view.state);
  if (!change) return; // unknown intent: let the DOM observer read it back
  event.preventDefault();
  view.dispatch({ changes: change, userEvent: "input.type" });
});

content.addEventListener("compositionend", () => {
  observer.flush(); // read the committed text from the DOM as one change
});
The two guards every editor needs: don't run commands during composition, and let composition text through.

Phones, autocorrect, and the clipboard

  • Phone keyboards often send keyCode 229 with key set to Unidentified for every key, compose even English words, and replace whole words on autocorrect. Chrome on Android may also ignore preventDefault() on beforeinput and report Enter or Backspace only as DOM changes. So the DOM observer, which diffs the changed DOM range against the model, is the path that must always work; beforeinput is a fast path on top of it.
  • Autocorrect and spellcheck turn function into Function and paint red lines under every variable. Set spellcheck="false", autocorrect="off", and autocapitalize="off" on the content element. CodeMirror 6 sets these by default.
  • Copy handles the copy event and writes text/plain to clipboardData, joining multiple selections with line breaks. With an empty selection, VS Code copies the whole line, a habit users expect.
  • Paste reads text/plain only (never HTML, which brings fonts and colors), normalizes \r\n to \n, and, when the pasted text has exactly as many lines as there are cursors, puts one line at each cursor.

Check your understanding · Exercise 2

A Japanese user presses Enter to confirm the IME candidate 日本, and the editor inserts 日本 followed by an extra new line. Which fixes are correct? Select all that apply.

Document and state: immutable values, one path for every change

The naive model is one JavaScript string. Insert a character with doc.slice(0, pos) + ch + doc.slice(pos). For a 50 line answer this is perfect.

For a 10 MB file it builds a new 10 MB string per keystroke (engines can delay the copy, but the first read forces it), and finding line 80,000 means scanning for 79,999 line breaks. Both costs grow with the file, and the editor asks "where does line N start?" many times per frame for rendering, gutters, and cursor movement.

The next idea is an array of lines (string[]): line lookup is instant, and typing inside a line replaces one short string. VS Code used exactly this until 2018. Its team then wrote up why it failed: a user's 35 MB file had 13.7 million lines, each line object cost about 40 to 60 bytes, and the array alone used about 600 MB, roughly 20 times the file. Splitting a big file into millions of strings also made opening slow, and inserting a line in the middle shifts every later entry.

Decision How do we store the document text?

Piece table in a balanced tree (VS Code's piece tree)

The file's text stays in its original buffers, untouched. Typed text is appended to an add buffer. The document is a sequence of pieces: (buffer, start, length).

Pieces sit in a red-black tree. Each node caches the length and the line break count of its left subtree, so finding offset X or line N walks one path: O(log n).

VS Code keeps the loaded file as a list of 64 KB chunk buffers instead of one string, and stores each buffer's line start offsets once.

Pros

  • Memory close to the file size plus the edits. No per-line objects.
  • Opening a file is fast: no splitting, just chunks and line start arrays.

Cons

  • The tree is mutable; undo and snapshots need extra work (VS Code keeps an edit stack).
  • Many small edits create many small pieces; a long session may need compaction.

Immutable balanced tree of line chunks (CodeMirror 6 Text)

Leaves hold small arrays of lines (about 32 per leaf in CodeMirror's source). Branches hold children and cache their total length and line count.

An edit builds a new leaf and new copies of the nodes on the path to the root. Everything else is shared with the old version: O(log n) new nodes.

Pros

  • Every version is a cheap, immutable value. Undo, "did the doc change?" checks (a === b), and collaboration all become simple.
  • Line lookup and offset lookup are both O(log n).

Cons

  • A string per line again, though grouped in leaves; uses more memory than a piece table on files with millions of short lines.

Gap buffer (Emacs)

One big array with a gap at the cursor. Typing fills the gap; moving the cursor far moves the gap, copying text.

Pros

  • Very simple and fast for typing at one place.

Cons

  • Multiple cursors far apart and remote collaborators make the gap jump around: each jump copies text.
  • Needs a separate line index anyway.

What we pick: We pick the immutable tree of line chunks. Our editor's state is an immutable value (the second half of this chapter), and a document that shares structure between versions makes every snapshot almost free. The piece tree is the better choice if you build a desktop editor around a mutable model and expect files of hundreds of MB; for an embedded web editor tuned for files up to 10 MB, cheap snapshots matter more.

A piece tree. The original buffer holds let a = 1; newline print(a); and is read-only. The add buffer holds bc, which the user typed after let a. The document is three pieces kept in a balanced tree: the root is add 0 to 2, with 5 characters and 0 line breaks in its left subtree. Its left child is original 0 to 5, the text let a. Its right child is original 5 to 20, the text equals 1 semicolon, newline, print(a);. A note says the document reads let abc = 1; print(a); and each node caches the length and line breaks of its left subtree, so lookups take O(log n) steps.original bufferlet a = 1;⏎print(a); read-onlyadd bufferbc append-onlyadd 0..2left: 5 chars, 0 LForig 0..5let aorig 5..20 = 1;⏎print(a);leftrightdocument: let abc = 1;⏎print(a);each node caches its left subtree's lengthand line breaks: lookups are O(log n)
Typing never copies the file. It appends to the add buffer and splits one piece. Finding line 2 starts at the root: its left part has 0 line breaks, so the walk goes right.

Positions and changes

A position is one number: the offset from the start of the document, counted in UTF-16 code units (the unit JavaScript strings use), with each line break counting as 1. CodeMirror 6 works this way and converts to line and column only for display. Monaco's API uses 1-based line and column pairs instead, with getOffsetAt and getPositionAt to convert. One number is easier to compare, sort, and store in a decoration.

An edit is not a new string. It is a change set: a value that says "keep 4 characters, insert alpha, keep the rest". Because the change is data, any part of the system can answer the question from chapter 2: where does my position go? This is position mapping, and it moves a lint marker at offset 15 to 20 when 5 characters are inserted before it.

A position exactly at the insertion point is ambiguous, so mapping takes a side: assoc = -1 stays before the new text, assoc = 1 moves after it. A marker at the start of a word should use 1, so it stays with the word when someone types in front of it. A marker at the end of a word should use -1, so text typed right after the word stays outside it.

ts
import { ChangeSet, Text } from "@codemirror/state";

const doc = Text.of(["let a = 1;", "let b = 2;"]); // length 21
const change = ChangeSet.of({ from: 4, insert: "alpha" }, doc.length);

change.apply(doc).line(1).text; // "let alphaa = 1;"
change.mapPos(15);     // 20: the "b" on line 2 shifted by 5
change.mapPos(4, -1);  // 4: stays before the inserted text
change.mapPos(4, 1);   // 9: moves after the inserted text
doc.lineAt(15).number; // 2, found in O(log n)
CodeMirror 6: a change is a value that maps any position.
Marker at an insertion point

Document: let a = 1;⏎print(a);. 1 pieces. Last change: no edits yet. Marker at offset 11, line 2, column 0.

Document model lab. Original buffer: let a = 1;⏎print(a);. Add buffer: empty. Document: let a = 1;⏎print(a);. 1 pieces. Last change: no edits yet. Marker at offset 11, line 2, column 0. A list scan visited 1 pieces; a balanced tree needs about 1 steps.original"let a = 1;⏎print(a);"add""orig 0..20let a = 1;⏎...1 let a = 1;2 |print(a);marker | at offset 11line 2, column 0change set: no edits yetlookup: list scanned 1 of 1 pieces, tree about 1 steps
The original buffer never changes. Each insert appends to the add buffer and splits at most one piece; each delete trims pieces, or splits one when it cuts out its middle. The marker starts on print and is mapped through every change, the same way lint markers and other cursors move.

Check your understanding · Exercise 3

Which statements about the document model are true? Select all that apply.

State, transactions, and the view: one path for every change

The document is one part of the editor's state. The rest is the selection, folds, lint markers, and the data of every extension. The naive editor keeps all of it in one mutable object. Typing calls doc.insert(), which fires change listeners one by one.

The bracket matcher runs first and redraws. Then autocomplete runs and reads the selection, which has not moved yet because the selection listener comes later, so the popup opens at the wrong position. Undo is worse: it must know how to reverse each feature's side effects. Every bug comes from one root: features see the editor half updated.

Decision How do changes flow through the editor?

Mutable model with change events (Monaco, Ace)

The text model is an object with methods like applyEdits and events like onDidChangeContent. View models, decorations, and the host listen to events.

Undo is an edit stack inside the model, with explicit "stop" markers that group edits.

Pros

  • Familiar object style; easy to call from outside (model.setValue).
  • Works well for a single big app like VS Code, where one team owns every listener.

Cons

  • The order of listeners matters, and a listener that edits during an event causes re-entrant bugs.
  • No cheap snapshot of "the whole editor before this keystroke" for comparisons or tests.

Immutable state plus transactions (CodeMirror 6, ProseMirror)

The state is a value: document, selection, and every extension's data (its fields). It never changes.

A transaction describes one step: document changes, a new selection, effects ("fold lines 10 to 20"), and annotations ("this was typing"). Applying it produces a new state in one go.

The view owns the DOM. It receives the new state and redraws what differs.

Pros

  • Every extension computes its new value from the same old state and the same transaction: no half-updated reads.
  • Snapshots are free; tests are pure functions: state in, transaction, state out.
  • Filters can inspect or veto a transaction before it applies, such as read-only ranges.

Cons

  • More concepts to learn (fields, effects, facets).
  • Every change, even from the host app, must go through dispatch; direct DOM edits are lost on the next redraw.

What we pick: We pick immutable state plus transactions. Our editor is a platform where many extensions written by different teams react to the same keystroke, and a single atomic step is the only way to keep them consistent. It also matches the immutable document chosen above, and it is what lets undo and collaboration live outside the core.

Data flow of one transaction. A DOM event such as beforeinput reaches the Input layer, which builds a transaction spec. Filters such as read-only guards can drop or change it. The accepted transaction produces a New state, in which every state field updates. A ViewUpdate carrying the old state, new state, and changes goes to the View, which measures and patches the DOM for the visible lines, and to Listeners such as the host's onChange. A note says history, lint, and folds are all computed from the same transaction.DOM eventbeforeinputInput layerbuilds a specFiltersguardsNew statefields updateeventspecacceptedread-only: droppedold + new state, changesViewUpdateflags, changesViewmeasure, patchDOMvisible linesListenershost onChangestate fields: history, lint, foldsall computed from one transaction
Everything to the right of Filters sees one finished transaction. A feature can't read the editor half updated, because there is no half updated state to read.

One update cycle, step by step

  1. The input layer turns a DOM event into a transaction spec: { changes, selection, userEvent }.
  2. Filters run. A read-only facet or a change filter can drop or shrink the change, for example to protect a problem's starter code in the online judge.
  3. Every state field computes its next value from the old value and the transaction: history records the inverse change, lint maps its markers, folds map their ranges.
  4. The view gets a view update: the old state, the new state, the changes, and flags such as docChanged, selectionSet, and viewportChanged.
  5. The view redraws only the lines whose content or decorations changed. It batches DOM reads (measuring line heights, cursor coordinates) and DOM writes into separate phases so the browser lays out once per frame, not once per read.
  6. Update listeners, including the host's onChange, run last and see a consistent new state.
ts
import { StateField } from "@codemirror/state";

const todoCount = StateField.define<number>({
  create: (state) => countTodos(state.doc.toString()),
  update(count, tr) {
    if (!tr.docChanged) return count; // most transactions: no work
    let delta = 0;
    tr.changes.iterChanges((fromA, toA, _fromB, _toB, inserted) => {
      delta -= countTodos(tr.startState.sliceDoc(fromA, toA));
      delta += countTodos(inserted.toString());
    });
    return count + delta; // a sketch: ignores TODOs split by the edit
  },
});
A state field that counts TODO comments. It updates from the same transaction as everything else.

Undo: grouping keystrokes into steps

If each keystroke were its own undo step, undoing a typed word would take 8 presses. If everything were one step, one undo would erase a minute of work. Editors group by three signals. CodeMirror 6's history extension is a good model, and its defaults are worth quoting:

  • Time. A change joins the previous group only if it comes within newGroupDelay, 500 ms by default. A pause starts a new step.
  • Place. It must touch the range the previous group changed. Typing on line 3 and then on line 40 within 500 ms is still two steps.
  • Kind. Typing and deleting (input.type, delete) can join the group before them. A paste (input.paste) never does, so it always starts a new step, and moving the cursor ends the group. IME composition updates always join, even after a pause, so a composed word is one step.
  • Depth. At least 100 steps are kept (minDepth). Each step stores the inverse change set and the selection before it, not a copy of the document.
Undo grouping on a timeline in milliseconds. Step 1: r at 0, e at 120, t at 240, Backspace at 380, s at 500, all in the same place and under 500 ms apart, so they join. A 1.4 second pause follows. Step 2: a paste at 1,900, which never joins the group before it. Then a click at 2,300 moves the cursor, which ends the group, and typing b at 2,400 is step 3.undo steps with a 500 ms group delayr0e120t240del380s5001.4 spaste1,900click2,300b2,400step 1: typingnear, under 500 msstep 2: pastenever joins backstep 3: bcursor moved first
Three undo presses undo b, then the paste, then the whole typed word. The rules are time, place, and kind of edit; the numbers are CodeMirror 6's defaults.

Collaboration adds one rule. When a remote change arrives, it is marked addToHistory: false, and the history maps its stored inverse changes through it. Undo then reverts only your own edits, at their new positions. The undo-redo guide covers command stacks in general; here it is enough that inverse changes are values that can be mapped like any position.

Check your understanding · Exercise 4

With the default grouping above, which sequences produce exactly two undo steps? Select all that apply.

Cursors and selections: more than one blinking line

The naive plan is to trust the browser's selection: read window.getSelection() whenever you need the cursor. It breaks in three ways: most browsers show only one selection range, but programmers expect 5 cursors after Ctrl+D. The DOM selection points into nodes that the editor deletes on every redraw. And with only 40 lines in the DOM (next chapter), a selection that starts on line 10 and ends on line 9,000 has no DOM node to point at.

So the selection is part of the state, like the document. It is a list of ranges, each with an anchor (where the selection started) and a head (where the cursor is now, the end that moves with Shift+arrow). An empty range, where anchor equals head, is a plain cursor, and one range is the main one. After every transaction the ranges are sorted and overlapping ones are merged, so two cursors that meet become one.

The main range is copied into the real DOM selection so that the IME, the screen reader, and the phone's selection handles all see it. The other ranges are drawn by the editor as absolutely placed div elements in a layer behind the text. Every command runs once per range: CodeMirror's changeByRange takes a function that returns a change and a new range for each one, and maps later ranges through earlier changes.

The selection model. Top: the line let ab = cd; as 12 cells with offsets 0 to 11. The first range has anchor 4 and head 6, selecting ab. The second range is an empty cursor at offset 9. A note lists the ranges and says the first one, index 0, is main and mirrored in the DOM selection. Bottom: three lines of lengths long, short, long. The cursor starts on the first line at a goal x of 300 pixels, lands at the end of the short line, and returns to the goal x on the third line, shown by a dashed vertical line.text01234567891011let ab = cd;anchor 4head 6cursor 9ranges: {anchor 4, head 6}, {anchor 9, head 9}main: 0, mirrored in the DOM selectionline 7line 8line 9goal x = 300 pxDown, Down:back at goal
The selection is state, not DOM: a sorted list of ranges, one of them main. The goal column lives on a range and is dropped by any horizontal move or edit.

The goal column

Put the cursor at column 30 of a long line and press Down. The next line has only 5 characters, so the cursor lands at its end. Press Down again onto another long line, and users expect column 30 again, not column 5.

The editor remembers a goal column on the range when vertical movement starts, uses it for every Up and Down, and drops it on any horizontal move or edit. CodeMirror 6 stores it as a horizontal pixel offset rather than a character count, which keeps it right for tabs, wide characters, and proportional fonts.

What is one character?

  • Code units versus graphemes. The thumbs up emoji with a skin tone is 4 UTF-16 code units but one visible character, a grapheme cluster. So is a flag, and so is e followed by a combining accent. Arrow keys must step over the whole cluster or the cursor lands inside an emoji and the next keystroke corrupts it. Use Intl.Segmenter with granularity: "grapheme", or a built-in helper such as CodeMirror's findClusterBreak.
  • Tabs. A tab is one character but jumps to the next multiple of the tab size. Column numbers in the status bar and the goal column must count visual columns, not characters (code below).
  • Wide characters. Chinese and Japanese characters are roughly twice as wide as Latin letters in most monospace fonts, but not exactly, and emoji widths vary by font. An editor that computes x as column * charWidth puts the cursor in the wrong place on those lines. Measure from the DOM (Range.getClientRects()) for any line that is not plain ASCII.
  • Bidirectional text. A string literal in Arabic or Hebrew runs right to left inside a left to right line. Pressing Right should move the cursor one step to the right on screen, which is a different character order from the stored one. The editor runs the Unicode Bidirectional Algorithm per line to find the runs and moves visually; a selection across a direction change can look like two separate highlights. The rtl-layouts lesson explains the algorithm.
ts
function visualColumn(line: string, offset: number, tabSize = 4): number {
  let column = 0;
  for (let i = 0; i < offset; i++) {
    column = line[i] === "\t"
      ? column + tabSize - (column % tabSize) // jump to next tab stop
      : column + 1;
  }
  return column;
}

visualColumn("\tif (x)", 1);    // 4
visualColumn("ab\tc", 3);       // 4: the tab only filled 2 columns
Visual column of an offset in a line, counting tabs the way editors do.

Check your understanding · Exercise 5

A user has three cursors, on lines 2, 5, and 9, and types the letter x. Which statements describe a correct implementation? Select all that apply.

Rendering only the visible lines

The naive view renders one div per line. A 100,000 line file then has well over half a million nodes counting highlight spans: seconds of layout and hundreds of MB. The virtualization lesson and the virtualized-list guide explain windowing in general. An editor adds three twists: line heights are unknown when lines wrap, the cursor and selection can sit in lines that are not rendered, and a single line can be 2 MB long.

Decision What do we draw the text with?

DOM lines, windowed to the viewport (CodeMirror 6, Monaco, Ace)

Only lines inside the visible area plus a margin exist as elements. Spacers above and below (or absolute positions) give the scroll area its full height.

Highlighting is span elements with classes; the browser shapes and paints the text.

Pros

  • Real text: the browser handles fonts, ligatures, emoji, bidirectional text, and selection painting. Screen readers and find-in-page can read the visible part.
  • Fast enough: a screen of 50 lines is a few thousand nodes.

Cons

  • Layout cost grows with nodes; very long lines and many decorations need care.

Canvas or WebGL

Draw glyphs onto a canvas from a glyph cache. Only a hidden input or an EditContext is in the DOM. Monaco draws its minimap (the zoomed-out view of the whole file at the side) on a canvas, VS Code has an experimental WebGPU text renderer that is off by default (editor.experimentalGpuAcceleration), and xterm.js draws terminals this way (see the web-terminal guide).

Pros

  • Very fast painting of huge grids; full control of every pixel.

Cons

  • You rebuild text shaping, bidi, selection, and IME placement yourself, and the text is invisible to assistive tech unless you mirror it in the DOM.
  • Fonts with ligatures and complex scripts are hard to get right.

What we pick: We render DOM lines windowed to the viewport. Code is text, and the browser's text engine already handles every script, font, and selection rule we need, plus accessibility. Canvas pays off for fixed grids of cells such as terminals or minimaps, not for an editor whose content must stay readable.

The viewport and the height map

The viewport is the range of the document that gets DOM elements: the visible lines plus a margin, so a quick scroll doesn't show blank space before the next frame. CodeMirror 6 uses a margin of 1,000 pixels above and below. To know which lines fall in a pixel range without rendering them, the editor keeps a height map: a balanced tree over the lines where each node stores the total height of its lines. Finding the line at y = 1,200,000 px is a walk down the tree, O(log n).

Heights start as estimates. Without wrapping, every line is one line height. With wrapping, the estimate is the line length divided by the characters that fit in the width, rounded up, times the line height. When lines enter the viewport, the editor measures their real height and replaces the estimate.

If lines above the visible area turn out taller than estimated, the content below would jump. The editor prevents that with scroll anchoring: it notes the top visible line and its offset before the update, and adjusts scrollTop after measuring so that line stays put.

Top visible line 50,001, wrapping on. DOM holds lines 49,966 to 50,057: 92 elements of 100,000. Visible: 22 lines. Height map total 2,653,240 px estimated. Scroll anchor correction plus 40 px. Edit: no edit: nothing to parse, pick an edit above; background: nothing, colors already right.

Viewport lab. Top visible line 50,001, wrapping on. DOM holds lines 49,966 to 50,057: 92 elements of 100,000. Visible: 22 lines. Height map total 2,653,240 px estimated. Scroll anchor correction plus 40 px. Edit: no edit: nothing to parse, pick an edit above; background: nothing, colors already right.100,000 linesmargin: 35 lines49,966 to 50,000visible: 22 lines50,001 to 50,022margin: 35 lines50,023 to 50,057DOM: 92 line elementsmap: 2,653,240 pxanchor fix: +40 pxmargin lines measuredno edit: nothing to parsepick an edit abovebackground: nothingcolors already right
A toy model with assumed numbers: lines of 10 to 159 characters, 100 columns before wrapping, a height estimate that guesses 110, a 600 px viewport with a 1,000 px margin, and a parser doing 100 lines per ms. Only the lines in the middle column exist in the DOM, wherever you scroll.

Measuring text: grid or real

To place the cursor and to turn a mouse click into a position, the editor converts between offsets and pixels. A monospace grid assumes every character is charWidth wide, so x is column * charWidth. It is fast and right for ASCII code, but wrong for Chinese, emoji, proportional fallback fonts, and bidi text.

Real measurement asks the browser: create a DOM Range over the character and call getClientRects(), or use document.caretPositionFromPoint (or the older, non-standard caretRangeFromPoint where it is missing) for clicks. A good editor uses the grid as a fast path on plain lines and measures the rest, caching results per line until the line changes.

Very long lines and very tall documents

  • A minified 2 MB line would be one element with millions of characters and spans. CodeMirror 6, when wrapping is off and a line is longer than a few thousand characters, renders only the part near the visible columns and stands in for the rest with empty gaps of the estimated width. Monaco stops rendering a line after 10,000 characters by default (stopRenderingLineAfter).
  • Browsers cap element height: a tall enough scroll area hits a limit in the tens of millions of pixels, lower in some browsers. One million lines at 20 px is 20 million pixels. CodeMirror 6 caps the real DOM height at 7 million pixels and scales positions inside it.
  • Cursor in an unrendered line. A selection can end on line 9,000 while lines 1 to 60 are rendered. The real selection lives in the state, so commands such as Shift+Down read the state, never the DOM.

Check your understanding · Exercise 6

A user opens a 60,000 line Markdown file with line wrapping on and scrolls fast to the middle. Text above the viewport then gets measured and is taller than estimated. What should the editor do? Select all that apply.

Syntax highlighting that keeps up with typing

The naive highlighter reruns its regular expressions over the whole document per keystroke and rebuilds every span. Cheap at 300 lines; hundreds of ms at 50,000, and typing stutters. Worse, regexes per line can't know that line 9,000 sits inside a comment that opened on line 8,990. Highlighting needs context from far above, but each keystroke can only afford a few milliseconds.

Decision How do we find out what each piece of text is?

Line-by-line state machine tokenizer (Monaco's Monarch, VS Code's TextMate grammars)

Each line is tokenized with regex rules, starting from the state at the end of the line before ("inside a block comment", "inside a template string"). The end state of every line is stored. Monarch is Monaco's declarative format for such rules.

After an edit on line k, re-tokenize from line k and stop as soon as a line ends in the same state it had before. VS Code runs TextMate grammars with the Oniguruma regex engine compiled to WebAssembly.

Pros

  • Grammars exist for nearly every language (TextMate grammars started in the TextMate editor and are shared by VS Code, Sublime Text, and many others).
  • Simple incremental rule; usually only the edited line is redone.

Cons

  • No real structure: no tree for folding, indentation, or "select the enclosing block".
  • Typing /* changes the end state of every following line, so the work runs to the end of the file.

Incremental parser producing a syntax tree (Lezer in CodeMirror 6, tree-sitter)

A grammar is compiled into a parser that builds a full syntax tree. After an edit, the parser reuses every subtree the edit did not touch and only re-parses around the change.

Lezer is written in JavaScript and stores trees compactly in typed arrays. tree-sitter is written in C, runs in the browser through WebAssembly (web-tree-sitter), and powers Neovim, Zed, and GitHub's code navigation.

Pros

  • One tree feeds highlighting, folding, indentation, bracket matching, and autocomplete context.
  • Error recovery: a half-typed line yields a tree with an error node, not garbage colors for the rest of the file.

Cons

  • Fewer grammars than TextMate; each language needs a parser package.
  • tree-sitter in the browser adds a WebAssembly runtime plus one grammar file per language: an estimated few hundred KB per language.

Semantic tokens from a language server

The language engine (for TypeScript, the compiler itself) tells the editor which identifiers are types, parameters, or constants.

Pros

  • Most accurate: knows that User is a class imported from another file.

Cons

  • Slow (tens to hundreds of ms) and needs a worker or server. Only useful as a layer on top of a fast tokenizer.

What we pick: We use an incremental parser with a syntax tree (Lezer style) as the base, because one tree serves highlighting, folding, indentation, and autocomplete, and it recovers well from half-typed code. Semantic tokens from a language engine can be layered on later for languages where the host wants them. TextMate grammars remain a fallback for rare languages, through a legacy-mode adapter.

Parsing on a budget

Even an incremental parser cannot finish a 10 MB file in one frame. So parsing is split into time-boxed work. CodeMirror 6's language package is a good concrete model:

  • When a transaction changes the document, the parser gets up to 20 ms of work right away, aimed at reaching the end of the viewport. The new tree is ready for the very paint that shows the keystroke.
  • If it runs out of time, the view paints with the tree it has. Text past the parsed point is shown plain for a moment.
  • The rest happens in the background: requestIdleCallback slices of 25 to 100 ms, with pauses between them, and a cap of 3 seconds of background work per 30 seconds so a huge file doesn't drain a laptop battery.
  • It parses ahead of the viewport only by about 100,000 characters. The tail of a 10 MB file is parsed when you scroll there, or when a feature asks for it with a timeout (ensureSyntaxTree).
State machine of the incremental parser. From Idle, a document change moves to Viewport, a synchronous parse with at most 20 ms. If the viewport is done, it moves to Background. If 20 ms are used up first, it moves to Partial, which paints with stale or missing colors, then keeps going in Background. Background works in idle slices of 25 to 100 ms and returns to Idle once it has parsed to the viewport plus about 100,000 characters. A note says an edit in any state restarts at Viewport.Idletree up to dateViewportsync, 20 ms maxPartialpaint, stale colorsBackgroundidle slicesdoc changed20 ms usedviewport donekeep goingparsed far enoughan edit in any staterestarts at Viewport
The keystroke always gets its paint on time. Only the colors can be late, and only when an edit changes the structure of a big part of the file.

Why not a Web Worker? Tokens would come back a frame or more after the keystroke, so fresh text would flash uncolored. Time-boxed parsing on the main thread avoids that; workers suit slow, project-wide work such as type checking and linting (chapter 8). VS Code made the other trade: since late 2024 its TextMate tokenization runs in a worker by default (editor.experimental.asyncTokenization), accepting a short delay so a slow grammar never blocks input.

From tree to colored spans

ts
import { syntaxTree } from "@codemirror/language";
import { Decoration, type EditorView } from "@codemirror/view";
import { RangeSetBuilder } from "@codemirror/state";

function highlightVisible(view: EditorView) {
  const out = new RangeSetBuilder<Decoration>();
  const tree = syntaxTree(view.state); // whatever is parsed so far
  for (const { from, to } of view.visibleRanges) {
    tree.iterate({
      from, to,
      enter: (node) => {
        const cls = classForNode(node.name); // "String" -> "tok-string"
        // Clip to the visible range so ranges reach the builder in order.
        const start = Math.max(node.from, from), end = Math.min(node.to, to);
        if (cls && start < end) out.add(start, end, Decoration.mark({ class: cls }));
      },
    });
  }
  return out.finish();
}
Highlight only the visible ranges: walk the tree there and emit mark decorations.

Two details matter. First, node types map to abstract highlight tags (string, keyword, comment), and a theme maps tags to classes. Grammars never mention colors, so one theme works for every language. Second, the result is only for the visible ranges: highlighting 50 lines costs the same in a 50 line file as in a 50,000 line one.

Check your understanding · Exercise 7

A user types /* at the top of a 40,000 line file. Which statements are true? Select all that apply.

Extensions, architecture, and the embedding API

The naive editor grows an options object: lineNumbers: true, lint: true, vimMode: true, until there are 80 flags and each new feature is a pull request to the core. The online judge then wants read-only starter code, the review tool wants comment widgets between lines, and the playground wants a color swatch next to every CSS color. None of these fit a flag. The editor needs a way for outside code to add state, drawing, keys, and behavior, with clear rules for who wins when two extensions disagree.

Decision How do host apps add behavior?

Options plus events (CodeMirror 5, Ace)

A big options object, setOption at runtime, and events such as change and cursorActivity to hook into.

Pros

  • Easy to start; one place to look.

Cons

  • Every feature is either in the core or fights it through events. No ordering rules, so two plugins binding Tab collide.

Provider registries (Monaco)

Global registries per language: registerCompletionItemProvider, registerHoverProvider, plus addAction and decoration collections per editor.

Pros

  • Clear for language features; mirrors VS Code's extension API, so docs and examples are plentiful.

Cons

  • Registries are global to the page, so two editors with different needs for the same language interfere.
  • Deep changes, such as a custom input rule, still need internal hooks.

Composable extensions with facets and precedence (CodeMirror 6)

An extension is a value, or an array of values, passed when the state is created. It can add state fields, view plugins, keymaps, decorations, and DOM event handlers.

A facet is a named slot many extensions can feed. Each facet says how to combine inputs: tabSize takes the highest precedence value, keymaps form an ordered list, allowMultipleSelections takes a logical or.

Order comes from explicit precedence (Prec.highest down to Prec.lowest), then position in the list. A compartment marks a part of the configuration that can be swapped later by a transaction.

Pros

  • The core stays small: line numbers, history, and even the default keymap are extensions.
  • Per-editor configuration, so 30 editors on a page can each differ.

Cons

  • A steeper learning curve for host teams; needs good docs and a preset bundle.

What we pick: We pick composable extensions with facets, and ship a preset (basicSetup) so most hosts never see the machinery. Precedence rules settle conflicts, such as the online judge's Tab override against the default keymap, without anyone editing the core.

Four kinds of decoration

KindWhat it doesExample
MarkAdds a class or attributes to a range of textSyntax colors, search matches, a red squiggle under an error
WidgetInserts an element at a position, not part of the textInline type hints, a color swatch, a review comment box between lines
ReplaceHides a range and optionally shows a widget insteadA folded function shown as {...}
LineAdds a class to a whole lineAdded and removed lines in a diff, the active line highlight

Decorations are stored in a range set: a sorted, chunked structure of ranges that maps through a change set in one pass. When you type above 300 lint markers, they all shift without 300 updates. Gutters follow the same rule: line numbers, fold arrows, and lint icons are markers per line, computed only for the lines in the viewport.

ts
import { Compartment } from "@codemirror/state";

const language = new Compartment();
const view = new EditorView({
  extensions: [basicSetup, language.of([])], // no grammar yet
  parent: host,
});

async function setLanguage(name: "python" | "javascript") {
  const ext = name === "python"
    ? (await import("@codemirror/lang-python")).python()
    : (await import("@codemirror/lang-javascript")).javascript();
  view.dispatch({ effects: language.reconfigure(ext) });
}
Switching language at runtime: a compartment plus a lazy import keeps unused grammars out of the first load.

Autocomplete, hover, diagnostics, and where they run

Smart features need a language engine that understands the code: the TypeScript compiler, Pyright for Python, clangd for C++. These are big and slow, so they never run inside the keystroke path. There are three homes for them:

  • In process, for small things. Completing words from the document, or keywords of a language, is fast enough on the main thread.
  • A Web Worker. Monaco runs the TypeScript language service in a worker and talks to it by messages. The worker holds a copy of the document, updated with each change, so only the changes cross the boundary.
  • A language server over a WebSocket. The Language Server Protocol (LSP) is JSON-RPC: the editor sends textDocument/didChange, asks textDocument/completion, and receives textDocument/publishDiagnostics. Any of the hundreds of existing servers works, running next to the user's code in a container. LSP positions are line and character, counted in UTF-16 units by default, which happens to match JavaScript strings.
Sequence diagram of autocomplete with four columns: User, Editor, Completion, LSP worker. The user types us; the editor reports doc changed at version 41; the completion extension asks the LSP worker for completion at v41. The user types e, and the editor sends didChange v42 to the worker. The worker replies with items for v41, which are stale and dropped. Completion asks again for v42, gets items for v42, and the editor opens the popup.UserEditorCompletionLSP workertypes uschanged, v41completion v41types edidChange v42items v41: stalecompletion v42items v42open popup
Every request carries the document version. A reply for an older version is dropped (or, for diagnostics, mapped through the changes since), so the popup never shows items for text that is gone.

The figure's failure path is the important part: replies can come back for an older document. Every request carries the document version, and the editor checks it when a reply arrives. If the user kept typing, it either maps the reply's positions through the changes since then (fine for diagnostics) or drops it and asks again (better for completions, which depend on the word being typed).

Requests that are no longer needed are canceled ($/cancelRequest in LSP) so a slow server doesn't build a queue. Lint runs after a pause: CodeMirror's lint extension waits 750 ms after the last change by default, the same idea as the debounce-throttle lesson.

Check your understanding · Exercise 8

The online judge wants Tab to insert two spaces, while the default keymap uses Tab to indent the line. Both are installed. What makes the judge's binding win? Select all that apply.

The architecture, part by part

With the hard parts and the extension model decided, the pieces fall into clear boxes. Each boundary is drawn where the rate of change or the cost differs: input changes per keystroke, the view per frame, the parser on a time budget, the language engine on its own thread.

PartOwnsWhy it is separate
Input layerThe contenteditable element, beforeinput, composition, clipboard, the DOM observerAll browser quirks live here, so nothing else touches raw DOM events
Editor stateDocument tree, selection, state fields, facet valuesPure values: testable without a DOM, snapshots for undo and collaboration
ViewViewport, height map, line DOM, cursor layer, measuringThe only part that reads or writes layout, in batched phases
LanguageIncremental parser, syntax tree, highlight tagsRuns on a time budget, separate from input
ExtensionsKeymaps, decorations, gutters, panels, lint, autocompleteHost apps add and swap them without forking
Language clientWorker or WebSocket link to a language engineSlow, project-wide work off the main thread
Host wrapperProps, events, lifecycle in React or another frameworkKeeps framework re-renders from rebuilding the editor
Architecture of the editor component. In the Host app zone, a Wrapper dispatches props as transactions into the editor and receives onChange from the EditorState. In the Editor component zone, the top row is Input layer for DOM events, Transaction for changes, EditorState holding the document and selection, and View for the viewport and DOM. The bottom row is Extensions with facets feeding the Input layer, the Parser as a state field feeding Transaction, a Language client, and Lint and popups such as autocomplete feeding the View with decorations. In the Workers and servers zone, the Language client talks to a Web Worker by postMessage and to an LSP server over a WebSocket.Host appWrapperdispatchonChangeEditor componentInput layerDOM eventsTransactionchangesEditorStatedoc, selectionViewviewport, DOMExtensionsfacetsParserstate fieldLang clientmessagesLint, popupsdecorationsWorkers and serversWeb WorkerLSP serverpostMessageWebSocket
The boundaries follow cost: input per keystroke, view per frame, parser on a budget, language engines on another thread or machine. Only the wrapper knows about React.
Data flow end to end. The CDN serves the editor and language chunks to the Host page, which first shows the code in a pre element. The Host page passes defaultValue and props to the Editor. The Editor sends only changes to an LSP server through didChange and receives diagnostics and completion items. On save, the host calls getValue, debounced by 1 second, and sends the text to a Draft API. A note says the pre shows code first and the editor loads after.CDNstatic chunkseditor + language chunksHost pagepre placeholder firstpropsEditorstate, viewLSP serverlanguage enginechanges onlydiagnosticsgetValue() on save, 1 s debounceDraft APIautosavepre shows code first,editor loads after
Two things never cross an arrow: the whole document on every keystroke, and the editor bundle before first paint.

The component's interface

ts
type CodeEditorProps = {
  defaultValue?: string;     // read once at mount
  value?: string;            // controlled: synced by a minimal diff
  onChange?: (update: { getValue: () => string; changes: ChangeSet }) => void;
  language?: "javascript" | "typescript" | "python" | "css" | "html";
  readOnly?: boolean;
  extensions?: Extension[];  // host additions, compared by identity
  theme?: "light" | "dark" | "high-contrast";
  ariaLabel: string;         // required: what the editor is for
  onReady?: (api: EditorApi) => void;
};

type EditorApi = {
  focus(): void;
  getValue(): string;        // builds the string only when asked
  setSelection(from: number, to?: number): void;
  revealLine(line: number): void;
};
Props for the wrapper. Uncontrolled by default; controlled only when the host must rewrite the text.

Three choices in that interface are deliberate. onChange passes a getter, not the string: building a 10 MB string on every keystroke costs milliseconds and garbage, while most hosts only need it on save. ariaLabel is required, because an unlabeled editor is announced as just "edit text". Uncontrolled is the default, because the editor already owns its state; the host reads it when it needs it.

Wrapper pitfalls in React

  • Re-creating the editor on every render. An effect that depends on an inline extensions array sees a new array each render, destroys the view, and builds a new one: undo history, cursor, scroll, and focus are lost, and each rebuild costs tens of ms. Create the view once, and push prop changes in with compartment reconfigure effects.
  • The controlled value loop. onChange calls setCode, React re-renders, the wrapper sees a new value and calls setValue, which replaces the whole document. The cursor jumps to 0, undo history resets, and an IME composition in progress is destroyed. The fix: when the incoming value equals the text the editor already holds, do nothing. When it really differs (the host formatted the code), dispatch only the changed middle part, found by comparing common prefix and suffix, so the cursor maps through it.
  • Double mounting in development. React's Strict Mode runs effects twice. Without view.destroy() in the cleanup, you get two editors stacked in one box.
  • Server rendering. The editor needs the DOM. Render the code as a pre with the same font and line height on the server, then swap in the editor after a dynamic import. The page shows the code at once and nothing shifts when the editor arrives.
Sequence diagram of a controlled value, with four columns: User, Editor, Wrapper, Host state. The user types x; the editor sends an update with docChanged to the wrapper; the wrapper calls the host's onChange with a getter; the host re-renders and passes value back. A safe wrapper sees the value equals the editor's text and does nothing. Failure path with a naive wrapper: the value comes back, the wrapper calls setValue with the whole document, and the user's cursor jumps to 0 and the IME composition is lost.UserEditorWrapperHost statetypes xupdate, docChangedonChange(getter)re-render, valuesame text: no-opnaive wrapper:value againsetValue(whole doc)cursor to 0, IME lost
The fix is one comparison: check the incoming value against the editor's text, and dispatch only when it really differs, as a minimal change.
tsx
const External = Annotation.define<boolean>(); // marks changes that came from props

function CodeEditor({ value, onChange, ariaLabel }: CodeEditorProps) {
  const host = useRef<HTMLDivElement>(null);
  const view = useRef<EditorView | null>(null);
  const synced = useRef(value); // last value known to match the document
  const onChangeRef = useLatest(onChange); // avoids re-creating on new callbacks

  useEffect(() => {
    view.current = new EditorView({
      parent: host.current!,
      doc: value ?? "",
      extensions: [
        basicSetup,
        EditorView.contentAttributes.of({ "aria-label": ariaLabel }),
        EditorView.updateListener.of((u) => {
          if (!u.docChanged || u.transactions.some((t) => t.annotation(External))) return;
          synced.current = undefined; // the text changed inside the editor
          onChangeRef.current?.({ getValue: () => u.state.doc.toString(), changes: u.changes });
        }),
      ],
    });
    return () => view.current?.destroy();
  }, []); // eslint-disable-line react-hooks/exhaustive-deps

  useEffect(() => {
    const v = view.current;
    if (!v || value === undefined || value === synced.current) return;
    if (value !== v.state.doc.toString()) {
      v.dispatch({ changes: minimalDiff(v.state.doc, value), annotations: External.of(true) });
    }
    synced.current = value;
  }, [value]);

  return <div ref={host} />;
}
The core of a safe wrapper: one view per mount, value synced by diff, cleanup on unmount.

Check your understanding · Exercise 9

A host passes extensions={[lineNumbers(), myLint]} inline and the editor loses its undo history every time the parent re-renders. Which fixes work? Select all that apply.

Large files, search, and bundle size

Large files: decide the limits up front

Everything so far keeps the cost of a keystroke independent of file size. But some features are inherently whole-file: folding ranges for every function, bracket pair colors, a minimap, word-based completion that indexes every identifier. On a 50 MB log they freeze the tab, and the answer is not cleverness but thresholds decided in the design. VS Code does exactly this: above 20 MB or 300,000 lines it switches to a large file mode and turns off tokenization and other heavy features, unless the user turns editor.largeFileOptimizations off.

Our thresholds. The numbers are a starting point to tune with field data, not a standard.
File sizeWhat stays onWhat turns offWhy
Under 1 MB (almost all code)EverythingNothingWhole-file work costs a few ms here
1 to 10 MBEditing, viewport highlighting, searchBracket pair colors, minimap, word completion, background parse beyond the parse-ahead windowThese scan the whole file; the parser's budget still covers what you see
10 to 50 MBEditing, plain text, search in chunksHighlighting, folding, lint, language server syncSending 50 MB to a worker or server on open costs seconds
Over 50 MB, or lines over 1 MBRead-only view, or an "open anyway" promptEditing by defaultThe text alone is a large share of a phone's memory budget for a tab

Minified one-line files need their own rule, since line count says nothing about them: measure the longest line on load. Above a few thousand characters, render the line in windowed slices (chapter 6), cap tokenization per line (VS Code and Monaco skip lines over 20,000 characters by default, set by editor.maxTokenizationLineLength), and offer a "format document" button that pretty-prints it into a normal file.

  • Never build the whole string. A search cursor iterates over the document tree's chunks, so a search in a 10 MB document allocates almost nothing. Plain text search can match across chunk borders by keeping a small tail buffer. Regular expressions run line by line, with a separate, slower cursor for patterns that span lines.
  • Highlight what is visible, count the rest lazily. Matches in the viewport become mark decorations at once. The total count ("3 of 1,284") comes from a background pass in idle slices and can show "1,000+" until it finishes.
  • Guard against runaway regexes. A pattern like (a+)+b on a 1 MB minified line can backtrack for seconds. Search in slices with a time check between lines, and offer to stop.
  • Replace all is one transaction. Thousands of replacements become one change set, one undo step, and one redraw. Mapping the selection through it keeps the cursor where the user left it.
  • Debounce the search box by about 100 to 200 ms on big files (see the debounce-throttle lesson).

Bundle size and loading

The editor must not slow the host page. We measured the published CodeMirror 6 packages that a basic setup with JavaScript pulls in, each minified and gzipped without tree shaking. @codemirror/view 6.43 alone is about 63 KB. The whole set (state, view, commands, language, autocomplete, search, lint, the Lezer runtime, and the JavaScript grammar) is about 185 KB, inside our 200 KB target.

Monaco is a different scale. In version 0.52 its main editor file is about 3.8 MB minified and 950 KB gzipped, and each language service runs in a separately loaded worker; the TypeScript worker alone adds about 1.3 MB gzipped. Your bundler and the languages you include move all of these numbers, so measure your own build.

  • Don't put the editor on the critical path. Render a pre with the code first (a fast Largest Contentful Paint, no layout shift), then import() the editor after first paint or when the user focuses the box.
  • One chunk per language. Load the Python grammar only when a Python file opens, through the compartment swap from chapter 8. The code-splitting lesson covers chunking strategy.
  • Read-only views stay light. The review page's 30 small editors need highlighting and line numbers, not autocomplete, lint, or history. A read-only preset can skip those extensions.
  • Preload on intent. Hovering the "Edit" button for 100 ms can start the download so the editor is ready on click.

Check your understanding · Exercise 10

A user opens a 30 MB JSON export in the review tool. Which behaviors match a sound design? Select all that apply.

Accessibility and theming

A code editor fails accessibility in quiet ways, and each has a concrete fix. A screen reader announces "blank" on every line because the text is drawn in a canvas. Tab inserts indentation, so a keyboard user who tabs into the editor can never tab out. Errors are shown only as red squiggles, which a color blind user cannot see and a screen reader never mentions.

Screen readers need real text

  • The text must be in the accessibility tree. With a contenteditable content element marked role="textbox", aria-multiline="true", and an aria-label, screen readers read lines, words, and characters as the cursor moves, using their normal text commands.
  • The hidden textarea needs help. A textarea holding one character gives a screen reader nothing to read. Monaco has an accessibility mode (accessibilitySupport, detected automatically on some platforms) that fills the textarea with the lines around the cursor, so arrow keys read real text; Alt+F1 opens its accessibility help.
  • Windowing has a cost. Only rendered lines are in the DOM, so a screen reader's "read all" stops at the edge of the viewport. Keep the margin generous and make sure the cursor's line is always rendered.
  • Announce what sighted users see at a glance. Put line and column in a status region, and add a command that reads the diagnostic at the cursor ("Error: missing semicolon, line 12") through a polite aria-live region. Make lint panels and autocomplete lists real lists with aria-activedescendant, the combobox pattern from the autocomplete guide.

The Tab key and the keyboard trap

WCAG success criterion 2.1.2, No Keyboard Trap, says that if keyboard focus can move into a component, it must be able to move out with the keyboard, and if that needs more than the standard keys, the user must be told how. Programmers want Tab to indent. Both are reasonable, so editors offer an escape hatch.

CodeMirror 6 doesn't bind Tab by default at all. If the host adds indentWithTab, pressing Escape makes the next Tab move focus out (for 2 seconds), and Ctrl+M (Shift+Alt+M on macOS, in the default keymap) toggles a mode where Tab always moves focus. Monaco's equivalent is Ctrl+M (Ctrl+Shift+M on macOS) to toggle "Tab moves focus". Our editor binds Tab to indent only when the host opts in, and then shows the escape hatch in a visible hint on first focus, as the focus-management and keyboard-navigation lessons recommend.

Think first

The online judge wants Tab to indent because users type code there for 45 minutes. The review page has 30 small read-only editors. Should both bind Tab the same way?

Contrast, forced colors, and motion

  • Token colors are text, so each needs 4.5:1 contrast with the background (WCAG 1.4.3). Comments in pale gray fail this in many popular themes.
  • Never use color alone: an error is a wavy underline plus a gutter icon plus a message on hover and in the lint panel.
  • Windows high contrast mode sets forced-colors: active. It removes gradient backgrounds, so squiggles drawn with gradients vanish, and SVG squiggles keep a fixed color the user did not choose. Use text-decoration or borders with system colors such as CanvasText and Highlight, and ship a high contrast theme.
  • With prefers-reduced-motion: reduce, turn off smooth scrolling for go to line and search jumps, and consider a steady cursor instead of a blinking one.

Theming with CSS variables

Grammars produce highlight tags, the highlighter maps tags to classes, and the theme gives classes colors. If those colors are CSS custom properties (--cm-keyword, --cm-bg, --cm-selection), switching from light to dark is one attribute change on the host's root element: no editor rebuild, no re-highlight, and the host's design system tokens can feed the editor directly. CodeMirror injects its base styles in a style element; under a strict Content Security Policy, pass the page's nonce through the EditorView.cspNonce facet, or the styles are blocked.

css
.cm-editor {
  background: var(--cm-bg);
  color: var(--cm-fg);
}
.tok-keyword { color: var(--cm-keyword); }
.tok-string { color: var(--cm-string); }
.tok-comment { color: var(--cm-comment); font-style: italic; }

[data-theme="dark"] {
  --cm-bg: #1e1e2e; --cm-fg: #e4e4ef;
  --cm-keyword: #c792ea; --cm-string: #a5d6a7; --cm-comment: #9aa0b4;
}

@media (forced-colors: active) {
  .cm-lintRange-error { background-image: none; text-decoration: wavy underline CanvasText; }
}
A theme as variables. The host flips data-theme; nothing in the editor re-renders.

Check your understanding · Exercise 11

Which changes would make an editor that binds Tab to indent meet WCAG 2.1.2? Select all that apply.

Running it in the interview

This question has more depth than any interview can cover, so the skill is choosing. Show you know the whole pipeline in two minutes, then go deep on the two or three parts the interviewer cares about. A good 60 minute plan:

MinutesWhat you doWhat you draw
0 to 6Scope: hosts, devices and IME, file sizes, smart features, read-only instances, collaborationA list of requirements with numbers
6 to 12The pipeline: input, transaction, state, view, parser, extensionsThe keystroke pipeline from chapter 2
12 to 22Input surface and IME, then the document model with position mappingThe surfaces comparison, a piece table or line tree
22 to 32State and transactions, undo grouping, multiple selectionsThe transaction flow
32 to 44Viewport and height map; incremental parsing on a time budgetThe viewport with margin, the parser state machine
44 to 52Extensions, language features over a worker or LSP, the embedding APIThe architecture diagram
52 to 60Large files, accessibility, bundle size; follow-up questionsThe thresholds table

If the interviewer steers early, follow them, but draw the pipeline in one minute first so they see where their part sits. Expect pushes on IME, position mapping, and the controlled value loop: those are the bugs editor teams actually fix.

Follow-up questions and strong answers

Think first

How would you add real-time collaboration to this editor?

Think first

Why not render with canvas, like some fast native editors do with the GPU?

Think first

A user pastes 5 MB into an empty editor. Walk through what happens.

Common mistakes

  • Answering with a library name and stopping. The interviewer asked how it works.
  • Designing input with keydown only, which breaks IME, phone keyboards, and dictation.
  • Storing the document as one string, or an array of lines, without discussing the cost at 100,000 lines.
  • Re-highlighting the whole file per keystroke, or moving highlighting to a worker without noticing the extra frame of delay.
  • Forgetting that decorations, cursors, and diagnostics must move when text is inserted before them.
  • Making Tab indent with no way out of the editor.

Summary

  • An editor is a pipeline: input surface, transaction, immutable state, windowed view, with incremental parsing and extensions on the side. Name it first, then go deep.
  • Text input is an OS service. Use a real editable element (contenteditable, a hidden textarea, or EditContext), respect composition events, and never run commands on keys with isComposing.
  • Store text in a balanced tree (line chunks or a piece tree) so edits and line lookups cost O(log n), and describe edits as change sets that map every position, decoration, and cursor.
  • Immutable state plus transactions keeps every extension consistent and makes undo and collaboration add-ons. Undo groups by time, place, and kind of edit.
  • Render only the viewport plus a margin, track heights in a height map of estimates corrected by measurement, and anchor the scroll when estimates change.
  • Highlight with an incremental parser on a time budget: the viewport within the keystroke's frame, the rest in idle slices. Slow language intelligence goes to a worker or a language server.
  • Decide large-file thresholds and Tab escape hatches in the design, keep the core small with lazy language chunks, and give hosts an uncontrolled-first API that never re-creates the editor.

Check your answer

Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.

Junior 0 of 3
Mid-level 0 of 4

References

Concepts behind it

Try next