cf.completefrontend
My progress
SYSTEM DESIGN · #29

Design a rich text editor

A reusable editing component where people write formatted text with headings, lists, links, mentions, and images. The hard parts are keeping a clean document model separate from the messy browser DOM, handling every kind of input (IME, mobile keyboards, paste) without losing text, and staying fast on every keystroke.

The question

Design a rich text editor component that product teams across the company can drop into their pages: a comment box, a ticket description, a long knowledge-base article. People can make text bold or italic, add headings, lists, quotes, links, @mentions, and images, and undo their changes.

It must work with every keyboard, including Japanese and Chinese input and phone keyboards with autocorrect, handle pasting from Word and Google Docs, and save drafts so nobody loses work. Walk me through how you would build it.

Real-life examples: Notion page editor, Slack and Discord message composer, Medium story editor, GitHub comment box (rich mode), Confluence and Jira description fields

Requirements: what kind of editor are we building?

"Build a rich text editor" can mean a three-button comment box or a full word processor. The two differ by about two years of engineering. So the first five minutes go to questions whose answers change the design.

A rich text editor is an editing area where text carries formatting (bold, headings, links) that the person sees while typing, instead of writing markup like Markdown by hand. The industry name for this is WYSIWYG: what you see is what you get.

The questions below are ordered by how much they move the design. First: who embeds the editor and how big documents get, because that decides how much engineering goes into rendering and storage. Then: which content types must exist, how input works on phones and in other languages, and whether several people edit at once. The table shows the answer this guide assumes and what changes if the answer is different.

QuestionAnswer we assumeWhat it changes
Who uses the component?Many product teams. It ships as a package with a small, stable API.We need a clean props and events API, a plugin system, and a stable saved format.
How large do documents get?Most are under 2,000 words. Knowledge-base articles reach 50,000 words, about 3,000 blocks.Rendering must touch only changed blocks. Full re-renders are out.
What content is allowed?Paragraphs, headings 1 to 3, bullet and numbered lists, quotes, code blocks, bold, italic, underline, strike, inline code, links, mentions, images, and embeds.The size of the schema, the toolbar, and the paste rules.
Which inputs must work?Desktop keyboards, IME for Chinese, Japanese, and Korean, Android and iOS keyboards with autocorrect, voice dictation, and paste.We can't rely on key events alone. The input pipeline is the hardest part.
Is it collaborative?Not in version 1. One person per document, but the design must allow live collaboration later.Edits are expressed as operations, so a sync layer can be added without a rewrite.
How is content saved?As JSON to the host's API, with drafts kept locally.JSON is the source of truth. HTML and Markdown are exports.
Which browsers?The last two versions of Chrome, Edge, Firefox, and Safari, on desktop and mobile.We can use the beforeinput event and the Input Events spec, with fallbacks.

Functional requirements

  • Type and edit text with a caret and selections, exactly like a native text field: arrows, Home and End, word jumps, double-click to select a word, drag to select.
  • Apply inline marks (bold, italic, underline, strike, inline code, link) and block types (paragraph, heading, list item, quote, code block) from a toolbar, from keyboard shortcuts, and from Markdown-like typing such as ## for a heading.
  • Insert links, @mentions with a suggestion popup, images (upload or paste), and embeds such as a video link.
  • Undo and redo, with sensible grouping: one undo removes a typed word or phrase, not a single letter.
  • Paste from Word, Google Docs, web pages, and plain text, keeping the formatting the editor supports and dropping the rest.
  • Save the content as JSON, export HTML and Markdown, and keep a local draft so a crash or a closed tab loses nothing.

Non-functional requirements

  • Input latency: a keystroke shows on screen in under 16 ms on a mid-range laptop (one frame at 60 Hz) and well under 50 ms on a mid-range phone. The page's Interaction to Next Paint (INP, the Core Web Vital that measures how fast the page reacts to input) stays under 200 ms at the 75th percentile.
  • Correctness: no lost or doubled characters, ever, for any input method. Losing text is the one bug people never forgive in an editor.
  • Safety: pasted or loaded content can never run script. The editor only produces markup it knows.
  • Accessibility: works with a keyboard alone and with screen readers (VoiceOver, NVDA, JAWS, TalkBack). Toolbar state is announced.
  • Size: the core loads in under about 60 KB gzipped, with heavy features such as tables or code highlighting loaded on demand.
  • Extensibility: teams add a node type (say, a callout box) without forking the editor.

Out of scope, and why

  • Live multi-person editing: we design the hooks for it (operations, positions that map through changes), but the merge algorithms belong to a different problem. The Google Docs guide covers them in depth.
  • Page layout and printing: page breaks, headers and footers, and columns are a word-processor problem, not a component problem.
  • Comments and suggestions mode: they ride on the same model (as marks or side ranges), so we mention where they would plug in but don't design them.

Check your understanding · Exercise 1

Which answers to scoping questions would change this design the most? Select all that apply.

What makes this hard: contenteditable is a trap

Every rich text editor on the web starts from one browser feature: contenteditable. Put contenteditable="true" on a div and the browser turns it into an editing surface. The caret blinks, typing inserts text, arrows move, the spell checker underlines words, IMEs work, and the phone keyboard opens. That is a huge amount of free work, and it is why almost nobody draws text on a canvas.

The naive design stops there. Make a div editable, wire the Bold button to document.execCommand("bold"), and save element.innerHTML when the person clicks Save. A first demo takes an afternoon, and then the bug reports arrive. Here is what goes wrong, and why each failure is built into the approach rather than an easy fix.

Five ways the naive editor breaks

  1. The output is whatever the browser felt like. execCommand is marked deprecated on MDN, its only spec is an unofficial draft that is no longer maintained, and engines differ in the markup they produce. Bold might come out as b, strong, or a span with font-weight, depending on the browser, its settings, and where the text came from. Enter used to insert a div in Chrome and Safari, a bare br in Firefox before version 60, and a p in old Internet Explorer. Engines agree more today, but the result still depends on the surrounding markup. The saved HTML becomes a mix of all of them, and the same document looks different depending on who last edited it.
  2. The saved format is the DOM. Paste a paragraph from a web page and the editor now holds that page's classes, inline styles, font tags, and maybe a table you never meant to support. Saving innerHTML stores all of it. Every reader of that HTML (email, mobile app, search index) must cope with markup nobody chose.
  3. It is a security hole. Pasted HTML can contain img tags with onerror handlers or javascript: links. If you store and later render innerHTML, you have built a stored XSS (cross-site scripting) bug. Sanitizing after the fact is fragile because you're cleaning arbitrary HTML instead of producing known HTML.
  4. Undo is the browser's, not yours. The built-in undo stack only knows about changes the browser made itself. The moment your code changes the DOM directly (inserting a mention chip, fixing up a list), the browser's history and the document disagree, and Ctrl+Z jumps to strange states or does nothing.
  5. Nothing has meaning. To answer "is the selection bold?" or "how many words?" or "where is the mention of Priya?", you walk a DOM tree that could have any shape. A heading could be an h2, or a div with a big font size pasted from somewhere. Features built on top (collaboration, comments, export) all inherit that chaos.
Before and after. On the left, DOM as data: the same bold word is stored three different ways depending on where it came from: a b tag, a strong tag, and a span with font-weight 700. Three shapes, one meaning. On the right, model as data: the text budget with a bold mark, one shape checked by the schema, which always renders as a strong tag.one meaning: the word budget is boldDOM as data<b>budget</b><strong>budget</strong>span, font-weight 7003 shapes, 1 meaningmodel as databudget + bold markrenders as <strong>1 shape, checked by schema
The naive editor saves whatever markup the browser or the paste source produced. The model keeps one meaning in one shape, and rendering always emits the same tag, here strong.

The common thread: the DOM is a view, a thing the browser draws, and the naive editor treats it as the data. Every serious editor today flips this around. It keeps its own document model as the source of truth, lets the browser handle the caret and the input methods inside contenteditable, and then translates every change into an edit on its model, which it renders back to the DOM. That translation layer is the core of the design, and most of this guide.

Decision What is the editing surface, and what is the source of truth?

Raw contenteditable with execCommand

The DOM is the document. Toolbar buttons call document.execCommand. Save reads innerHTML.

Pros

  • Tiny: a few hundred lines.
  • Native caret, IME, spell check, and autocorrect all work.

Cons

  • Output differs by browser and is unpredictable.
  • Deprecated API with no full spec.
  • Undo breaks once your code touches the DOM.
  • Paste and saved content are an XSS risk.

A textarea with Markdown, plus a preview

People type Markdown into a plain textarea. A preview pane renders it.

Pros

  • Native text input is very reliable, including IME.
  • The saved format is plain text, easy to diff and store.

Cons

  • It isn't WYSIWYG: many people won't learn Markdown.
  • Mentions, images, and embeds are awkward as text.
  • A different product (see the markdown editor problem).

contenteditable as the input surface, own model as truth

The editor keeps a structured model. The browser handles caret and input in contenteditable. Each input event becomes a model change, and the model is rendered back to the DOM.

Pros

  • Predictable output in every browser.
  • Native caret, IME, spell check, and accessibility stay.
  • Undo, paste, and collaboration work on clean data.

Cons

  • Real engineering: input events, selection mapping, and DOM reconciliation are hard.
  • Must cope with the browser changing the DOM without asking (IME, spell check).

Own model and own rendering (canvas or custom layout)

The editor draws text itself, often on a canvas, and uses a hidden input element to receive keystrokes. Google announced in 2021 that Docs was moving to canvas-based rendering.

Pros

  • Pixel-identical layout everywhere, needed for pages and print.
  • No fight with the browser over the DOM.

Cons

  • You must rebuild the caret, selection, text shaping, spell check, and much of accessibility.
  • Years of work: only worth it for a full word processor.

What we pick: Pick the third option: contenteditable for input, our own model as the truth. It keeps everything the browser does well (caret, IME, spell check, screen reader support) and removes everything it does badly (choosing the markup). It is also what ProseMirror, Lexical, Slate, and Tiptap all do, each in its own way. Canvas rendering is right for Google Docs with its pages, and wrong for a component other teams embed.

The challenges that drive every later decision

With that choice made, five properties of the problem shape the rest of the guide. Name them early in an interview; they are your agenda.

PropertyWhat it meansWhere we handle it
Two trees that must agreeThe model says one thing, the DOM shows another thing, and the browser's selection lives in the DOM. Every change must keep them in step.Document model, rendering
Input you don't controlIMEs, autocorrect, dictation, and spell check change the DOM directly. Some of these changes can't be cancelled.Input pipeline
Edits as dataUndo, autosave, and future collaboration all need changes as small, replayable records, not DOM snapshots.Transactions and history
Hostile contentPaste and drop bring in HTML from anywhere, including script.Paste and drop
Per-keystroke budgetAll of the above must run in a few milliseconds per key, on a 50,000-word document.Rendering, performance

Check your understanding · Exercise 2

A team saves innerHTML from a contenteditable div and renders it later on a profile page. Which problems does this create? Select all that apply.

The document model: a tree the browser doesn't own

The model is a plain data structure that describes the document's meaning, with no DOM in it. It must answer three questions fast: what content is here, where is the caret, and is this content allowed. Get the shape right and every later feature (toolbar state, undo, paste, export, collaboration) becomes a function over clean data.

Blocks, inline content, and marks

Documents have two levels. Blocks stack vertically: paragraphs, headings, list items, quotes, code blocks, images. Some blocks contain other blocks (a list contains list items, a quote contains paragraphs), so blocks form a tree. Inside a text block is inline content: runs of text, plus inline nodes such as a mention chip or an inline image. Formatting such as bold or a link is a mark: a label attached to a run of text, not a wrapper element.

Why marks instead of nested elements? In HTML, "Hello bold _both_ world" can be written as b containing i, or i inside b in a different order, or as two separate b tags next to each other. All mean the same.

If the model stores text runs where each run carries a set of marks, there is exactly one way to write it: Hello with no marks, bold with bold, both with bold and italic, world with none. Adjacent runs with the same marks are merged. One meaning gets one shape, which makes comparing, diffing, and merging simple.

ts
type Mark =
  | { type: "bold" } | { type: "italic" } | { type: "code" }
  | { type: "link"; href: string };

type Inline =
  | { type: "text"; text: string; marks: Mark[] }
  | { type: "mention"; userId: string; label: string }; // atom: one unit

type Block =
  | { type: "paragraph"; id: string; content: Inline[] }
  | { type: "heading"; id: string; level: 1 | 2 | 3; content: Inline[] }
  | { type: "list"; id: string; ordered: boolean; items: Block[] }
  | { type: "image"; id: string; src: string; alt: string };

type Doc = { type: "doc"; version: number; blocks: Block[] }; // schema version
The model as data. Blocks form a tree; text runs carry a set of marks.
Two trees side by side. On the left, the model, the source of truth: a doc node with a heading 2 holding the text Launch plan, and a paragraph holding three inline items: the text Ask, a mention atom for Priya, and the text budget with a bold mark. On the right, the DOM, a view: a contenteditable div with an h2 holding a text node, and a p holding a text node, a span that is not editable, and a strong element. An arrow labelled render goes from model to DOM, and an arrow labelled input goes back.Model: the truthdocheading 2paragraphLaunch planAsk@Priyabudget, boldDOM: a viewcontenteditableh2ptexttextspanstrongrenderinput
The model has one shape per meaning and knows nothing about elements. Rendering turns it into the DOM; input events are turned back into model changes. The mention becomes a contenteditable="false" span, and the bold mark becomes a strong.

Positions and selections live in the model too

The browser describes the caret as a DOM node plus an offset inside it. That is useless as stored data, because the DOM nodes are replaced whenever we re-render. So the model has its own way to point at a spot. There are two common styles.

  • Paths: an array of child indexes plus a character offset, like [2, 0] offset 5: block 2, its first text run, after the fifth character. Slate uses paths. They are easy to read, but they change whenever something earlier in the tree is inserted or removed.
  • Flat integer positions: walk the document and count. Each character counts one, each block boundary (start and end) counts one, each atom such as a mention counts one. Then any spot is a single number. ProseMirror uses this. A number is cheap to store and easy to shift: if three characters are inserted at position 10, every position after 10 moves by three.

A selection is then two positions: the anchor (where the person started selecting) and the head (where they are now). When the two are equal the selection is collapsed, which is just a caret. Keeping anchor and head separate, rather than start and end, matters: Shift plus arrow extends from the head, and a backwards selection must stay backwards. Besides text selections, editors need a node selection for picking a whole image or embed as one unit.

Positions must also survive edits. When a change is applied, the editor produces a mapping: a function that tells where an old position ends up in the new document. Selections, comment ranges, and later remote cursors are all moved through that mapping. It's a small idea that makes collaboration possible later without changing the model.

The schema: what is allowed where

A schema is the list of node types and mark types, plus the rules for how they nest: a list contains only list items, a heading contains only inline content, a code block allows no marks, an image has a required src and an alt. Every change is checked against it. Content that breaks the rules is either fixed up (a stray text run at the top level is wrapped in a paragraph) or rejected.

The schema is what makes the editor predictable. Paste can only produce allowed nodes. A team that embeds the comment box can pass a smaller schema (no headings, no images), and the same editor core enforces it. Loading a saved document from an older version runs it through the same rules, so bad data gets normalized instead of crashing the view.

Decision How should the model be shaped?

Flat list of insert operations with attributes

The document is a sequence like insert "Hello" {bold: true}, insert "\n" {header: 2}. Line formats ride on the newline character. Quill's Delta format works this way.

Pros

  • Very simple to store, diff, and transform.
  • Collaboration-friendly: it's close to a plain text sequence.

Cons

  • Nesting (lists in quotes, tables) is awkward to express.
  • Block identity is implicit, so keyed rendering is harder.

Tree of blocks with inline runs and marks

Blocks nest. Text blocks hold runs of text with mark sets. A schema defines what nests where. ProseMirror, Slate, Lexical, and Tiptap all use variations of this.

Pros

  • Matches how people think: sections, lists, quotes.
  • Each block can have a stable id for rendering, comments, and links.
  • Schema checks are natural on a tree.

Cons

  • Positions inside a tree need care (paths or flat counting).
  • Moving content across nesting levels needs careful operations.

Mirror of the HTML DOM

Store a cleaned-up HTML-like tree with elements and attributes.

Pros

  • Easy to import and export HTML.

Cons

  • Brings back many-ways-to-say-the-same-thing.
  • Marks as nested elements make comparison and merging hard.

What we pick: A tree of blocks with marked inline runs and a schema. It matches the content we need (nested lists, quotes, embeds), gives each block a stable id for keyed rendering, and has one canonical shape per meaning. We count positions as flat integers internally and expose block ids to the host for things like deep links.

How the well-known libraries approach it

You don't need to have used these libraries in an interview, but knowing their shapes shows you understand the space. The descriptions below are simplified.

LibraryModelHow changes flowRendering
ProseMirrorImmutable tree, strict schema, marks on text, flat integer positions.Transactions made of steps. Each step comes with a position map.Own view layer that patches the DOM and reads DOM changes back with a MutationObserver.
TiptapProseMirror underneath.Same as ProseMirror, with a friendlier extension and command API.Headless: you bring the UI. Has bindings for React and Vue.
Lexical (Meta)Tree of node objects with keys. Text formatting is stored as flags on text nodes, while links are element nodes that wrap text. The tree lives in an editor state changed only inside editor.update() calls.Updates work on a pending copy of the state. Updates in the same tick are batched and committed together, and commands and listeners react to them.Own reconciler that patches only the DOM belonging to changed nodes.
SlatePlain JSON tree: elements with children, leaves with text and format properties. Paths address nodes.Low-level operations such as insert_text and split_node.React components you write for each element and leaf.

Check your understanding · Exercise 3

Why do editors store bold and italic as marks on text runs, rather than as nested elements like HTML? Select all that apply.

The input pipeline: turning keystrokes into model changes

This is the hardest part of the editor and the place most homegrown editors fail. The browser lets people produce text in many ways: a key press, an IME (input method editor, the tool that turns keystrokes into Chinese, Japanese, or Korean characters), a phone keyboard's autocorrect, swipe typing, voice dictation, the spell checker's right-click menu, drag and drop, paste. The editor must catch every one of them and turn it into a change on the model.

Why listening to keydown is not enough

The naive approach handles keydown: if the key is a letter, insert it into the model, call preventDefault(), and re-render. It works on a desktop with an English keyboard, and breaks everywhere else. During IME composition, keydown usually reports key as Process and keyCode 229, and the real characters appear later. On Android with Gboard and similar keyboards, many keys arrive as keydown with key set to Unidentified and keyCode 229, so you can't tell what was typed.

Other input has no useful key event at all. Autocorrect replaces a whole word, dictation inserts whole sentences, and a spell-check fix from the context menu involves no key.

So keys are the wrong level. What we want is the browser's statement of intent: "the person wants to insert this text here" or "delete the word before the caret", whatever device produced it.

beforeinput and inputType

The Input Events spec gives exactly that. Before the browser changes an editable element, it fires beforeinput, supported in every major engine since Firefox 87 shipped it in 2021. The event has an inputType string that names the intent and getTargetRanges(), which returns the static ranges the change will affect. The text travels in data for typing, and in dataTransfer for paste, drop, and (in contenteditable) replacement text.

Almost every input type is cancelable; the spec's main exception is insertCompositionText. For a cancelable event, the editor calls preventDefault(), applies the change to its model, and renders the result itself, so the browser never touches the DOM. After a change that was not cancelled, the browser fires input.

inputTypeProduced byWhat the editor does
insertTextTyping a character, some autocompleteInsert data at the selection, with the current stored marks.
insertReplacementTextA spell-check fix, and autocorrect in some browsersReplace the target range with the new text, keeping marks.
insertParagraphEnterSplit the current block, or exit an empty list item.
insertLineBreakShift+EnterInsert a hard break node inside the block.
deleteContentBackwardBackspaceDelete one grapheme back, or join with the previous block at a block start.
deleteWordBackwardCtrl+Backspace, Option+BackspaceDelete to the previous word boundary.
insertFromPaste, insertFromDropPaste and dropRun the paste pipeline (see the paste chapter).
formatBold, formatItalicBrowser shortcuts or menusToggle the mark through our own command.
historyUndo, historyRedoUndo in the browser's Edit or context menu, or Ctrl+Z when no keydown handler caught itRun our own undo, not the browser's.
insertCompositionTextIME compositionNot cancelable. Handled by the composition path below.
Sequence diagram of one keystroke. The browser fires beforeinput with inputType insertText and the letter a to the input controller, which calls preventDefault. The input controller sends a transaction, insert a at position 14, to the engine. The engine gives the new state to the view. The view patches one text node and sets the caret in the browser, all in the same frame. Later, the engine sends a PUT to the server after 1 second idle, and the server replies 200 with server revision 8.BrowserInputEngineViewServerbeforeinput 'a'preventDefaultinsert a at 14new statepatch + caretPUT, 1 s idle200, rev 8top five arrows: same frame, a few ms
The first five arrows run inside one input event, before the next paint. The browser never edits the DOM itself, because the editor cancelled beforeinput. Saving happens later and never blocks typing.

One subtle rule hides in the Backspace row: delete one grapheme, not one UTF-16 code unit. A family emoji or a flag is several code points joined together, and deleting half of it leaves broken characters. Intl.Segmenter with granularity: "grapheme" finds the right boundary. The same applies to arrow keys, but the browser already moves the caret by graphemes, which is another reason to let it handle caret movement.

IME composition: when the browser must own the DOM

To type the Japanese word for "thank you", a person types the romaji arigatou on a Latin keyboard. As they type, the IME shows underlined hiragana that keeps changing, opens a candidate window, and when they press Enter or Space it commits the final characters. During that time the text is in composition: provisional, owned by the IME, drawn with an underline.

The events are compositionstart, then for each change a compositionupdate plus beforeinput and input of type insertCompositionText, then compositionend. The key fact: the spec makes beforeinput during composition not cancelable, because the IME writes into the DOM directly.

If the editor re-renders the paragraph during composition, it replaces the text node the IME is writing into, and the composition breaks: characters double, the candidate window closes, or text is lost. Browsers also differ in event order at the end of a composition. Chrome and Firefox fire the Enter keydown that commits the text before compositionend, with isComposing true. Safari fires compositionend first, then a keydown with isComposing false and keyCode 229, so code that only checks isComposing splits the paragraph by mistake.

State machine for IME composition. In idle, the editor intercepts beforeinput and applies changes itself. compositionstart moves to composing, where the IME owns the DOM and the editor does not re-render that block on updates. compositionend moves to committing, where the editor reads the DOM, diffs it with the model, and creates one transaction, then re-renders and returns to idle. A note says keydown with isComposing true or keyCode 229 passes through, and remote changes to this block wait.idleintercept inputcomposingIME owns the DOMcommittingread DOM, diffcompositionstartupdates: no re-rendercompositionendone transaction, re-rendertyping: cancel, applykeydown, isComposing or 229: let it passremote edits to this block: wait
The rule fits in one line: while composing, the editor watches and doesn't touch. Everything the IME did becomes one normal transaction at compositionend.
  • While composing, don't touch the DOM of the block that holds the selection. Pause re-rendering for that block. Remote changes and autosave can wait a moment.
  • At compositionend, read what the DOM now contains in that block (or the event's data), diff it against the model, and turn the difference into one normal transaction. Then re-render to normalize.
  • Let keydown pass through when isComposing is true or keyCode is 229 (the Safari case above). Enter during composition commits the candidate; it must not split the paragraph.
  • Keep a fallback: a MutationObserver on the editor root catches any DOM change the editor didn't make (an extension, a browser quirk) and reconciles it back into the model, or reverts it.

That last point is a design principle worth saying out loud. The editor has two ways in: intercept (cancel beforeinput and apply to the model) when possible, and observe (let the browser change the DOM, then read it back) when not. ProseMirror leans on observing, letting the browser insert typed text and reading the DOM change back; Lexical and Slate lean on intercepting beforeinput. All of them need both paths.

Input
Step 1 of 6: keydown
type a letter, step 1 of 6, keydown. Event: keydown, key: d. Model: b1: Hello wor|. DOM: p: Hello wor|. No shortcut matches, so the editor lets keydown pass. Keys are not the source of truth.keydown, key: dmodel (the truth)DOM (the view)b1: Hello wor|p: Hello wor|the bar marks the caret
No shortcut matches, so the editor lets keydown pass. Keys are not the source of truth.

Mobile keyboards and autocorrect

Phone keyboards are the second hard case. Android keyboards often treat the current word as a composition, even for English, so every letter arrives as insertCompositionText. Autocorrect should arrive as insertReplacementText with a target range covering the corrected word. WebKit bug reports show Safari, especially on iOS, sending a plain insertText instead, so the editor must also spot replacements by comparing text.

Both platforms' keyboards read the text around the caret to suggest words. If the editor rewrites that text (for example, re-creating text nodes on every keystroke), suggestions reset and the keyboard can flicker or re-capitalize. Keep the DOM text node around the caret stable across keystrokes: update its text instead of replacing the node. Test on real devices, because simulators and emulators don't always run the same keyboards.

Keeping the selection in step

The person moves the caret with arrows, the mouse, and touch handles, and the browser updates the DOM selection. The editor listens to selectionchange on document, reads document.getSelection(), converts its anchor and focus (DOM node plus offset) into model positions, and stores that as the model selection. After the editor renders a change, it does the reverse: converts the model selection to DOM points and calls selection.setBaseAndExtent(). To avoid a loop, it ignores the selectionchange caused by its own update.

The DOM-to-model conversion needs every rendered node to know which model node it came from. A common trick is a WeakMap from DOM node to model node or block id, filled in during rendering. Offsets inside text are the same in both, as long as the editor never renders extra characters (such as a zero-width space used to give empty blocks a caret spot) without accounting for them.

Check your understanding · Exercise 4

A Japanese user types in your editor. Every few words a character appears twice. Which causes are likely? Select all that apply.

Transactions and undo: edits as data

Every change to the document, whatever caused it, goes through one door: a transaction. A transaction is a small record that says how to get from the old document to the new one. It holds a list of steps (insert text at 14, add the bold mark from 20 to 31, split the block at 40), the selection after the change, and some metadata: was it typing, a paste, an undo, a remote change? Applying a transaction produces a new immutable document plus a position mapping.

Why not simply mutate the model in place? Because three features need the change itself, not only the result. Undo needs to invert it, autosave and collaboration need to send it, and plugins (word count, mention detection, the toolbar) need to know what changed so they don't rescan the whole document. A transaction is the shared language for all of them.

ts
type Step =
  | { kind: "insertText"; pos: number; text: string; marks: Mark[] }
  | { kind: "deleteRange"; from: number; to: number }
  | { kind: "addMark"; from: number; to: number; mark: Mark }
  | { kind: "removeMark"; from: number; to: number; mark: Mark }
  | { kind: "splitBlock"; pos: number; newType?: string }
  | { kind: "setBlockType"; pos: number; type: string; attrs?: object };

type Transaction = {
  steps: Step[];
  selectionAfter: { anchor: number; head: number };
  meta: { origin: "input" | "paste" | "command" | "history" | "remote"; time: number };
  addToHistory: boolean;
};

// Each applied step returns its inverse, computed against the old doc.
// deleteRange keeps the removed slice so its inverse can re-insert it.
Steps are small and invertible. A transaction bundles steps with metadata.

Note the comment at the bottom: a step can only be inverted if it remembers what it destroyed. deleteRange on its own doesn't know which text it removed. So when it is applied, the editor stores the removed slice (the content, with its marks and block structure) alongside it. The inverse of that step is "insert this slice at from".

Undo and redo stacks

The history keeps two stacks. Each entry is a group of inverted steps plus the selection from before the change. Undo pops the top group, applies its steps as a new transaction (marked origin: "history" so it isn't recorded again as a fresh change), restores the saved selection, and pushes the forward steps onto the redo stack. Any new edit clears the redo stack.

Grouping: what does one undo remove?

If each keystroke were its own entry, undoing a sentence would take 60 presses of Ctrl+Z. If the whole session were one entry, one undo would wipe an hour of work. People expect undo to remove roughly one burst of typing. The usual rules:

  • Time: consecutive typing transactions join the current group if they arrive within a short gap. ProseMirror's history plugin uses 500 ms by default (newGroupDelay). A pause longer than that starts a new group.
  • Kind: switching from typing to deleting, or from typing to formatting, starts a new group. Paste, drop, and toolbar commands are always their own group.
  • Adjacency: typing that jumps to a different spot in the document starts a new group, even within the time window.
  • Explicit breaks: some editors also close the group at word boundaries, such as a space after a word, so undo removes a word at a time.
Timeline of undo grouping. The person types H e l l o within 500 ms, pauses 2 seconds, types space w o r l d within 500 ms, then presses Ctrl+B. Below the time line, three undo groups: group 1 holds Hello, group 2 holds world, group 3 holds the bold. Ctrl+Z pops group 3, then 2, then 1, restoring the selection each time.typed within 500 mstyped within 500 msformatHello2 s pause_worldCtrl+Bgroup 1: Hellogroup 2: worldgroup 3Ctrl+Z pops group 3, then 2, then 1, restoring the selection each time
A pause longer than newGroupDelay (500 ms here) or a change of kind, like typing then formatting, closes the group. One Ctrl+Z removes one group, not one letter.

Restoring the selection

Undo that changes the text but leaves the caret somewhere random feels broken. Each history group stores the selection from before its first transaction. Undo restores it, so the caret jumps back to where the person was when they made the change, and the toolbar updates to match. Redo restores the selection from after the group, and the editor scrolls the restored selection into view.

Limits and memory

History grows forever if you let it. Each group holds steps and removed slices, which are small for typing and large for a big paste or a delete-all. Cap the history by count (ProseMirror defaults to 100 groups with its depth option) and drop the oldest groups first. History lives in memory only and is not saved with the document: reloading the page starts with an empty undo stack, which matches what people expect from most web editors.

Decision How should undo be stored?

Snapshots of the whole document

Before each change group, keep a copy of the entire document. Undo swaps it back.

Pros

  • Trivial to build and always correct for one user.
  • Structural sharing of an immutable tree makes copies cheaper than they sound.

Cons

  • Breaks with collaboration: restoring a snapshot erases other people's edits made since.
  • Still uses more memory than steps on very large documents.

Inverted steps per group

Each group stores the inverse of its steps and the selection before it. Undo applies the inverse as a new transaction.

Pros

  • Small entries.
  • With position mapping, inverse steps can be moved past other people's changes, so undo removes only your own work.

Cons

  • Every step type needs a correct inverse.
  • Mapping inverses through remote changes is subtle.

The browser's native undo

Let contenteditable keep its own history.

Pros

  • Zero code.

Cons

  • Breaks as soon as the editor writes to the DOM itself.
  • Doesn't know about the model, marks, or nodes.

What we pick: Inverted steps per group. Snapshots would work today for one person, but the brief asks us not to block collaboration, and undo that erases a colleague's paragraph is a serious bug. Steps plus mappings is the shape ProseMirror's history and the Yjs UndoManager both follow. The google-docs guide and the undo-redo problem go deeper on collaborative undo.

Check your understanding · Exercise 5

A person types "Hello", waits two seconds, types " world", then selects everything and presses Ctrl+B. They then press Ctrl+Z once. With the grouping rules above, what should happen? Select all that apply.

Rendering: from model back to the DOM, one block at a time

After a transaction, the new model must appear on screen. The naive way is to throw away the editor's DOM and build it again from the model on every keystroke. On a 200-word comment that costs well under a millisecond and nobody notices.

On a 50,000-word article with 3,000 blocks, it means creating tens of thousands of DOM nodes per key, then a full style and layout pass, so typing can lag by hundreds of milliseconds, worse on a phone. It also destroys the text node the caret sits in, which resets the phone keyboard's suggestions and breaks any IME composition in progress.

So the renderer must change only what changed, and keep untouched DOM nodes exactly as they were, the same objects, not equal copies. This is reconciliation: compare the old and new model, find the differences, and patch the DOM.

Why an immutable model makes this cheap

When a transaction changes one paragraph, an immutable model creates a new object for that paragraph and new objects for each of its ancestors up to the root. Every other block is the very same object as before.

So the renderer walks the new block list and, for each block, asks one question: is it the same object (===) as last time? If yes, it skips the block, because its DOM is already right. On a typical keystroke that check rules out all but one block, at a tiny cost per block. This is the same idea React uses with memo and props compared by reference.

Before and after for rendering one keystroke typed in block b3. On the left, rebuild everything: all five blocks, b1 heading, b2 paragraph, b3 paragraph, b4 list, and b5 image, are rewritten, which means 3,000 blocks rebuilt per key in a big document. On the right, keyed by reference: b1, b2, b4, and b5 are the same objects as before and are skipped, and only b3 is updated, which means one text node updated.rebuild everythingkeyed, by referenceb1 headingb2 paragraphb3 paragraph, typedb4 listb5 imageb1 headingb2 paragraphb3 paragraph, typedb4 listb5 image3,000 blocks rebuilt per key1 text node updated
An immutable model makes the check cheap: if newBlock === oldBlock, its DOM is already right. Stable block ids keep that true when blocks are inserted above.

Keyed blocks

Comparing by index fails as soon as blocks move. Insert a paragraph at the top and every block's index shifts by one, so an index-based diff thinks every block changed and rewrites the whole document. Give each block a stable key (the id field in our model, generated once when the block is created and kept through edits), and the renderer can tell "one block was inserted, the rest moved down" from "every block changed". It inserts one DOM node and leaves the others alone.

Keys also help beyond rendering. Comments can anchor to a block id, a "copy link to heading" feature uses the heading's id, and analytics can count edits per block. When a block is split by Enter, the first half keeps the old id and the second half gets a new one, so the caret's block, the one being split, keeps its DOM node.

Inside a block: patching text

Within the changed paragraph, the renderer compares old and new inline runs. If the runs have the same marks and only the text of one run changed (the common case: typing a letter), it sets that text node's data in place. The node keeps its identity, the caret stays put, and phone keyboards keep their suggestions. Only when marks change (bold applied to the middle of a run) does it rebuild the run's elements, and only for that paragraph.

There's one more twist: when the editor let the browser make the change itself (the observe path, or a composition), the DOM is already correct. The renderer must notice that the DOM already matches the new model and do nothing. Rewriting identical text would still reset the IME and the keyboard.

Decision How should the model be rendered to the DOM?

Rebuild everything per change

Clear the root and render the whole model on every transaction.

Pros

  • Simple and always consistent.

Cons

  • Cost grows with document size: unusable at thousands of blocks.
  • Destroys the caret's text node, breaking IME and mobile suggestions.

Render through a UI framework (React components per block)

Each block type is a component. The framework's virtual DOM diff patches the DOM.

Pros

  • Teams write node views in a familiar way.
  • Keys and memo give block-level skipping.

Cons

  • The framework may replace text nodes during composition.
  • Extra work per keystroke from the framework's own diff and scheduling.

Own reconciler with framework islands

The editor's own view layer patches text and marks directly. Rich nodes (mention popups, embeds, image toolbars) are framework components mounted into placeholder elements.

Pros

  • Full control over text nodes during composition.
  • Cheap per keystroke: reference checks plus one text update.
  • Teams still use React for complex node views.

Cons

  • More code to own and test.
  • Two rendering systems to keep coordinated.

What we pick: Our own reconciler for text and marks, with framework islands for rich nodes. Typing is the hot path and IME safety needs exact control over text nodes, so we don't hand that to a general-purpose diff. ProseMirror and Lexical both reconcile with their own code for this reason. Slate shows a React-first renderer can work, at the cost of fighting React around composition.

Very large documents

Skipping unchanged blocks fixes the per-keystroke cost, but the first render of 3,000 blocks still costs: a rough estimate is 300 to 600 ms of DOM creation and layout on a mid-range phone, so measure your own. Options, from cheap to drastic:

  • content-visibility: auto on each block, with contain-intrinsic-size giving an estimated height. The browser skips layout and paint for blocks far off screen while keeping them in the DOM, so find-in-page, selection, and screen readers still work. This is the first thing to try.
  • Render in chunks: render the first screen of blocks immediately, then the rest in slices of about 100 blocks during idle time, so the page responds within the first frame.
  • Windowing (virtualization): only keep the visible blocks in the DOM. It cuts memory and node count, but it breaks native find-in-page, select-all across the document, and screen reader navigation, and the selection can't extend into blocks that don't exist. Editors that do this need their own find and selection handling. Too costly for a reusable component unless documents get much larger than ours.

Check your understanding · Exercise 6

Your editor gives each block an index-based key. A user presses Enter at the start of the first paragraph of a 2,000-block document. What happens? Select all that apply.

Paste and drop: letting outside content in safely

People paste constantly: a paragraph from Google Docs, a table from Word, a quote from a web page, a screenshot, a URL. Paste is where the outside world's HTML meets our strict model, and where most editor security bugs live.

The rule that keeps both under control: pasted HTML is never inserted into the DOM. It's parsed, converted into model nodes allowed by our schema, and inserted as an ordinary transaction. Whatever the schema doesn't know is dropped or turned into plain text.

What the clipboard actually holds

A copy puts several flavors on the clipboard at once. In the paste event, event.clipboardData exposes them: getData("text/html") for the formatted version, getData("text/plain") for plain text, and files for images such as screenshots. Each source fills them differently, and each has quirks the converter must know. The markup Word and Google Docs produce is not documented and changes between versions, so treat the table below as what these apps commonly produce today, and keep test fixtures of real pastes.

SourceWhat text/html looks likeWhat to do
Microsoft Word (desktop)A full HTML document with mso- styles, o:p elements, conditional comments, and class names like MsoNormal. Lists are often paragraphs with special styles, not ul and li.Strip comments and Office elements, detect Word's list paragraphs and rebuild real lists, read bold and italic from b, i, and styles.
Google DocsThe content is wrapped in an element with an id starting docs-internal-guid, and the wrapper is often a b with font-weight: normal. Real bold is a span with font-weight: 700.Ignore the wrapper's fake bold. Map font-weight 600 and above to bold, font-style: italic to italic.
Web pagesAnything: div soup, classes, inline styles, script and style tags, tracking pixels, relative links.Keep only structure the schema knows. Resolve or drop relative URLs. Drop every attribute except a few on an allowlist.
Plain text editors, terminalsNo HTML at all.Split text/plain on blank lines into paragraphs and keep single newlines as line breaks.
ScreenshotsNo HTML, one image in files.Insert an image node with a local preview, upload in the background.

The pipeline, step by step

  1. Catch the paste event (or beforeinput with insertFromPaste) and call preventDefault(), so the browser inserts nothing. Read the data from the event's clipboardData, which needs no permission. The async navigator.clipboard.read() is for custom Paste buttons, and it asks for a permission or a user confirmation depending on the browser.
  2. If there are image files and no HTML, go to the image path. Otherwise read text/html, and fall back to text/plain if it's empty.
  3. Parse the HTML with DOMParser into a separate, inert document. Scripts don't run there and the nodes never touch the page.
  4. Run source-specific cleanup rules (the Word and Google Docs fixes above).
  5. Walk the parsed tree with the schema's parse rules: each rule says which elements and styles map to which node or mark (h1 to h3 become headings, strong, b, and heavy font-weight become bold, a with an http, https, or mailto href becomes a link). Anything unmatched contributes only its text.
  6. Fit the result into the cursor's context. Pasting into a code block keeps plain text only. Pasting a heading into the middle of a paragraph splits it. Then run schema validation and normalization on the slice.
  7. Insert the slice as one transaction with origin: "paste", which is its own undo group.
Copied from
Handling
Paste lab: Google Docs, schema parse rules. b id=docs-internal-guid... becomes wrapper ignored; h2: Launch plan becomes heading, level 2; span, weight 700: budget becomes text + bold mark; span, Arial: due Friday becomes plain text; a href=https://...: spec becomes text + link mark. 3 kept with formatting, 1 as plain text, 1 dropped, 0 unsafe.on the clipboard (text/html)what the editor ends up withb id=docs-internal-guid...wrapper ignoredh2: Launch planheading, level 2span, weight 700: budgettext + bold markspan, Arial: due Fridayplain texta href=https://...: spectext + link mark3 kept with formatting, 1 as plain text, 1 dropped, 0 unsafe.
3 kept with formatting, 1 as plain text, 1 dropped, 0 unsafe. Only nodes and marks the schema knows get in. Everything else is dropped or kept as text.

Security: why the schema is the sanitizer

The usual XSS payloads in pasted HTML are script tags, event handler attributes such as onerror and onclick, javascript: URLs in links, iframe and object tags, and SVG with embedded script. An allowlist schema defeats all of them by construction: there is no node type for script, no attribute for onerror, and the link mark's parse rule only accepts a URL whose protocol is http:, https:, or mailto:. Nothing that isn't explicitly modelled gets through. This is far stronger than a blocklist that tries to remove known bad things.

Defense in depth still matters, because content also arrives from the API (a document saved by an old client, or by a bug). So the schema check runs again on load, the server validates the JSON against the same schema on save, and HTML export escapes text and re-checks URLs. If the host app ever renders exported HTML outside the editor, it should also run it through a sanitizer such as DOMPurify and serve a Content Security Policy. The XSS lesson covers those layers.

Images and drop

A pasted screenshot or a dropped file goes through the same steps, with one long-running part: the upload. The editor inserts an image node right away with a temporary src from URL.createObjectURL(file) and an uploadId, so the person sees the image instantly and keeps typing. A separate upload manager sends the file to the host's upload endpoint.

When the upload finishes, the editor dispatches a transaction that swaps the temporary src for the real URL, marked addToHistory: false so undo doesn't revert to a blob URL. If the upload fails, the node shows an error state with a retry button. If the document is saved while an upload is still running, the serializer must not write the blob URL; it either waits or saves a placeholder.

Sequence diagram of pasting a screenshot. The user pastes an image file into the paste handler, which sends an image node with a blob URL to the editor, which shows a preview to the user. The paste handler asks the uploader to upload the file. The uploader posts to the uploads API, which fails with 503. The uploader retries in 2 seconds and gets 201 with the final URL. The uploader tells the editor to swap the src with no undo entry, and the editor shows the final image. A note says that if retries run out, the image shows an error state with a Retry button.UserPasteEditorUploaderUploadspaste imageblob imageshow previewupload(file)POST file503retry in 2 s201 + URLswap, no undoshow imageretries run out: error state with a Retry button
The person sees the image at once and keeps typing; the upload runs beside the editing. The swap to the real URL is a transaction with addToHistory: false, so undo never brings back a blob URL.

Drop has one extra decision: where. insertFromDrop provides target ranges, and otherwise document.caretPositionFromPoint converts the drop coordinates into a DOM position, which the editor maps to a model position. It is in every major engine since Safari 26.2 added it in late 2025, so keep the older, non-standard caretRangeFromPoint as a fallback for older Safari. Dragging content within the editor is a move: one transaction that deletes the source range and inserts at the mapped target.

Think first

A person pastes a bare YouTube URL into an empty paragraph. What should the editor do, and how should undo behave?

Check your understanding · Exercise 7

Which paste handling choices are safe and correct? Select all that apply.

Toolbar state, links, mentions, and embeds

The toolbar is what people see most, and it has to tell the truth: Bold looks pressed when the caret is in bold text, the block menu says "Heading 2" inside a heading, and Link is disabled inside a code block. With a model, all of this is a pure function of the document and the selection. Without one, editors call document.queryCommandState("bold"), which is deprecated alongside execCommand and reports what the DOM looks like, not what your model means.

Wireframe of the rich text editor. At the top, a toolbar with Bold pressed, Italic, Underline, Link, and a block menu showing Heading 2. Below it, the editable area holds a heading Launch plan, a paragraph Ask @Priya about the budget where @Priya is a mention chip, and an image block with alt text. At the bottom, a status line says Saved on this device and 1,240 words. Notes name the parts: the toolbar uses role toolbar and aria-pressed, the editable area is a textbox with aria-multiline, the mention and image are atoms rendered by node views, and the status line is a polite live region.Article editorBIULinkHeading 2Launch planAsk@Priyaabout the budgetimage, alt textSaved on this device, 1,240 wordsrole=toolbararia-pressedrole=textboxaria-multilinemention, image:atoms, node viewsstatus: politelive region
Every visible part is derived from the model and the selection: Bold is pressed because the caret sits in bold text, and the block menu reads the type of the caret's block. The mention and the image are single units the caret jumps over.

Deriving toolbar state from the selection

  • Mark active (collapsed caret): use the stored marks if set (the person pressed Ctrl+B with nothing selected, so the next typed text will be bold), otherwise the marks of the text just before the caret. That's how native word processors behave.
  • Mark active (range): active only if every text run in the range has it. Bold on half the selection shows as not pressed, and pressing it bolds the whole range.
  • Block type: the type of the block holding the selection head. If the range spans blocks of different types, show a mixed state.
  • Enabled: ask the schema. If the current block allows no marks (code block), Bold is disabled. If the command can't apply here (unindent at the top level), the button is disabled.

Compute this once per transaction or selection change, not once per button, and compare against the last result before updating the toolbar. A toolbar with 15 buttons that each scan the selection on every keystroke is a quiet source of input lag in large documents. When the selection spans thousands of blocks (after select-all), stop scanning early: one run without bold is enough to say "not active".

Commands: one function behind every way of doing something

Bold can come from the toolbar, from Ctrl+B or Cmd+B, from a formatBold input event, from Markdown-style typing (**word**), or from a slash menu. All of them call the same command: a function that takes the editor state and returns a transaction, or nothing if it can't apply. The toolbar uses the same command with a dry run to decide whether the button is enabled. That way, the button state and the behavior can never disagree.

ts
type Command = (state: EditorState, dispatch?: (tr: Transaction) => void) => boolean;

const toggleBold: Command = (state, dispatch) => {
  const { from, to, empty } = state.selection;
  if (!state.schema.allowsMark(state.blockAt(from), "bold")) return false;
  if (!dispatch) return true; // dry run: the toolbar asks "can I?"
  if (empty) {
    dispatch(state.tr.toggleStoredMark({ type: "bold" }));
  } else if (state.rangeHasMark(from, to, "bold")) {
    dispatch(state.tr.removeMark(from, to, { type: "bold" }));
  } else {
    dispatch(state.tr.addMark(from, to, { type: "bold" }));
  }
  return true;
};
A command checks whether it can run and builds the transaction. Toolbar, shortcuts, and input rules share it.

A link is a mark with an href attribute, so it can cover bold text or part of a word. Creating one opens a small popover anchored to the selection (range.getBoundingClientRect() gives its position), and the URL is checked against the same protocol allowlist as paste.

Typing at the end of a link should not extend the link: the link mark is non-inclusive, unlike bold, which is inclusive. That single flag in the schema is the difference between a natural editor and one where everything typed after a link becomes part of it.

Mentions: an atom with a lookup

Typing @ followed by letters opens a suggestion list. Behind it, an input rule watches transactions: when the text before the caret matches @ plus word characters at a word start, it records a suggestion range and asks the host for matches (debounced by about 150 ms, with the previous request cancelled through an AbortController). Arrow keys move through the list while focus stays in the editor, and aria-activedescendant (borrowed from the combobox pattern) lets screen readers announce the active option. Enter replaces the range with a mention node.

The mention is an atom: an inline node with no editable content inside, which counts as one position. The caret jumps over it, Backspace deletes it whole, and its stored data is the user id plus a display label captured at insert time. Storing the id, not just the name, means notifications, permission checks, and renames work. The autocomplete and mention-input problems go deeper into the suggestion list itself.

Images and embeds: node views

Images, videos, and other embeds are block atoms rendered by a node view: a small component that owns that node's DOM. The node view is marked contenteditable="false" so the browser treats it as one unit, and it can contain its own interactive UI (a caption field, a resize handle, an alt text button).

Embeds from other sites render inside a sandboxed iframe with an allowlist of providers, so a pasted URL can't load arbitrary pages. Node views are the extension point teams use for their own blocks, such as a callout or a status chip.

Check your understanding · Exercise 8

The caret sits right after a bold word with nothing selected, and the person has not pressed any shortcut. What should the toolbar and the next keystroke do? Select all that apply.

Architecture: the parts and their boundaries

We now have all the pieces. This chapter puts them in boxes, says what each owns, and explains why the lines are where they are. The guiding rule: the core knows nothing about React, the network, or the host app. It is a state machine that turns transactions into new states, and everything else plugs into it.

Architecture of the editor component. On top, the Toolbar and the Node views for mentions and images. Inside a zone called Editor core: the View, which reconciles the DOM, the Input controller for beforeinput and IME, the Editor state holding the doc and selection, and Transactions, which applies steps and checks the schema. The toolbar sends commands to Transactions. The input controller sends transactions to it. Transactions produce a new Editor state, which the View renders, including node views. On the right, the Host app passes props down to Plugins and receives onChange. Transactions feed Plugins for history and input rules, and Persistence for drafts and autosave, which writes to IndexedDB and the Save API.ToolbarNode viewsmention, imageHost appprops, save()Editor coreViewreconcilerInput controllerbeforeinput, IMEEditor statedoc, selectionTransactionssteps, schemaPluginshistory, rulesPersistencedraft, autosaveIndexedDBSave APIcommandschangesnew statepropsonChange
The core in the dashed zone knows nothing about React or the network, so it can run on the server too. The toolbar, node views, plugins, and persistence plug in around it, and hosts can replace any of them.
PartOwnsWhy it's separate
SchemaNode and mark types, nesting rules, parse rules (HTML to model) and serialize rules (model to HTML).Hosts pass a different schema per use (comment box versus article). Shared by client, server validation, and export.
Editor stateThe current document, the selection, stored marks, and plugin state. Immutable: each transaction makes a new state.One source of truth. Easy to snapshot, test, and compare by reference.
Transaction engineApplying steps, producing mappings and inverses, enforcing the schema.Every change goes through one door, so history, autosave, and collaboration see everything.
Input controllerbeforeinput, keyboard shortcuts, composition handling, the MutationObserver fallback, selectionchange.All browser quirks live here. The rest of the editor never reads DOM events.
View (reconciler)Rendering state to the DOM by block key, mapping DOM points to positions, node views.Hot path for performance. Isolating it lets us optimize without touching logic.
PluginsHistory, input rules (Markdown shortcuts, mentions), paste handling, placeholder, word count, collaboration binding.Features are optional and tree-shakable. Teams add their own.
Toolbar and menusButtons, block menu, link popover, mention list, slash menu. Built from commands plus derived state.Hosts replace the UI without touching the core (a headless design, as Tiptap does).
Persistence adapterAutosave timer, local drafts in IndexedDB, calls to the host's save function.Storage differs by host. The core must not know about networks.
Upload managerImage uploads with progress, retries, and cancel.Long-running work outside the transaction cycle.

The update cycle

Every change, from any source, follows the same cycle, and the cycle runs synchronously within the input event so the screen updates in the same frame:

  1. Something produces a transaction: the input controller (typing), a command (toolbar), a plugin (an input rule), the upload manager (image URL ready), or later the collaboration binding (remote change).
  2. Plugins get a chance to inspect it, add steps (an input rule turns ## into a heading), or block it.
  3. The engine applies it, checks the schema, and produces the new state and a mapping.
  4. The view reconciles the DOM for changed blocks and restores the DOM selection.
  5. Listeners run: the toolbar recomputes state, history records the group, the persistence adapter marks the document dirty, and the host's onChange fires.

Steps 1 to 4 must finish quickly because they block the next frame. Step 5 contains slower, non-visual work (serialization for onChange, autosave, analytics), so it runs after the paint, batched, or in idle time. That split is what keeps typing fast in big documents, and we come back to it in the performance chapter.

Data flow from server to screen and back. On load, the Docs API sends the JSON at server revision 7 to the Host app, which passes it as initial content to the Schema check, which migrates and validates it and produces a state for the View and DOM. On edits, the DOM fires beforeinput to the Engine, which builds transactions, and the engine renders back to the View. The engine sends changes to Persistence, which debounces for 1 second. Persistence writes a draft to IndexedDB after 500 ms and sends a PUT of the JSON with If-Match back to the Docs API.Docs APIHost appSchema checkmigrate, validateView + DOMJSON, rev 7initialstateIndexedDBPersistencedebounce 1 sEnginetransactionsbeforeinputrenderchangesdraft, 500 msPUT JSON, If-Match
JSON comes in once and is checked against the schema before anything renders. After that, every change flows as a transaction, and only the persistence layer turns the state back into JSON: a local draft first, then a PUT with If-Match so a stale save can't overwrite a newer version.

Decision How does the host app get the content out?

Controlled component: value in, onChange out, every keystroke

The host holds the document in its own React state and passes it back as value on every render, like a controlled input.

Pros

  • Familiar React pattern.
  • The host always has the latest value.

Cons

  • Serializing and re-passing a 50,000-word document per keystroke is slow.
  • Round-tripping through the host can drop the selection or a composition.

Uncontrolled with events and an imperative handle

The host passes initialContent once. The editor owns its state, fires a lightweight onChange with a version number, and exposes getJSON(), getHTML(), and commands through a ref.

Pros

  • No per-keystroke serialization.
  • The host reads content when it needs it (save, submit).

Cons

  • The host must ask for content instead of always having it.
  • Replacing content from outside needs an explicit setContent call.

What we pick: Uncontrolled, with events and an imperative handle. The editor's state is too large and too sensitive (selection, composition) to round-trip through host state on every key. onChange tells the host something changed. The host pulls JSON when it saves. Small comment boxes can still opt into a debounced serialized value for convenience.

Check your understanding · Exercise 9

Why does the core of the editor know nothing about React or the network? Select all that apply.

Interface and saved format: props, events, JSON, HTML, Markdown

A component used by many teams is judged by its API. It should be small, hard to misuse, and stable for years, because every breaking change means a migration in every product. The saved format matters even more: documents written today must open in five years.

Props, events, and the handle

ts
type RichTextEditorProps = {
  initialContent?: DocJSON;          // loaded once; use handle.setContent later
  schema?: "comment" | "article" | SchemaSpec;
  extensions?: Extension[];          // mentions, embeds, custom node views
  placeholder?: string;
  readOnly?: boolean;
  label: string;                     // accessible name, required
  onChange?: (e: { version: number; isEmpty: boolean }) => void;
  onSelectionChange?: (s: { marks: string[]; blockType: string }) => void;
  onMentionQuery?: (q: string, signal: AbortSignal) => Promise<MentionItem[]>;
  onUploadImage?: (file: File, onProgress: (p: number) => void) => Promise<{ url: string }>;
  draftKey?: string;                 // enables local drafts in IndexedDB
};

type RichTextEditorHandle = {
  getJSON(): DocJSON;
  getHTML(): string;
  getMarkdown(): string;
  setContent(doc: DocJSON, opts?: { addToHistory?: boolean }): void;
  run(command: string, ...args: unknown[]): boolean;
  focus(position?: "start" | "end"): void;
};
The public API. Content goes in once; the host is told about changes and pulls content when it needs it.

A few choices worth explaining. label is required, because an editor with no accessible name is announced as just "edit text". Mentions and uploads are callbacks, not URLs: the editor doesn't know the host's API, auth, or user search, so it asks the host, and the mention callback receives an AbortSignal so stale queries are cancelled.

onChange carries a version number and isEmpty, which is enough to enable a Submit button or mark the page dirty without serializing anything.

JSON is the source of truth

Decision What format do we store in the host's database?

HTML

Serialize the model to HTML and store it. Load by parsing HTML back.

Pros

  • Renders anywhere directly.
  • Human-readable.

Cons

  • Lossy for mentions and embeds unless you invent data attributes.
  • Loading means re-parsing and re-sanitizing every time.
  • Many HTML shapes per meaning, so diffs are noisy.

Markdown

Serialize to Markdown and store text.

Pros

  • Compact, diffable, portable.

Cons

  • Can't express underline, mentions with ids, image sizes, or embed settings without extensions.
  • Round trips change the content (Markdown has many ways to write a list).

Versioned JSON of the model

Store the model tree as JSON with a version field. Generate HTML and Markdown from it when needed.

Pros

  • Lossless: exactly what the editor holds.
  • The server can validate it with the same schema.
  • Easy to migrate with versioned upgrade functions.

Cons

  • Needs a renderer wherever it's displayed (email, mobile, search).
  • Larger than Markdown.

What we pick: Versioned JSON. It's the only one of the three that stores exactly what the editor means, including mention ids and embed settings. HTML and Markdown are exports produced from it, never stored as the truth. The server keeps a cached HTML rendering for read-only pages and email if it needs one.

json
{
  "type": "doc",
  "version": 3,
  "blocks": [
    { "type": "heading", "id": "b1", "level": 2,
      "content": [{ "type": "text", "text": "Launch plan", "marks": [] }] },
    { "type": "paragraph", "id": "b2", "content": [
      { "type": "text", "text": "Ask ", "marks": [] },
      { "type": "mention", "userId": "u_42", "label": "Priya" },
      { "type": "text", "text": " about the ", "marks": [] },
      { "type": "text", "text": "budget", "marks": [{ "type": "bold" }] }
    ] }
  ]
}
A saved document: small, typed, and validated against the schema on both client and server.

HTML and Markdown export

  • HTML export walks the model and emits one fixed tag per node and mark: p, h2, ul and li, strong, em, a with rel="noopener noreferrer". Text is escaped. URLs are re-checked against the allowlist. Mentions become a or span elements with a data-user-id attribute. Because the model has one shape per meaning, the HTML has one shape too, which makes it safe to cache and diff.
  • Markdown export maps nodes to Markdown syntax and escapes characters that would otherwise become syntax (a literal * in text becomes \*). Content Markdown can't express (underline, image width) is either dropped or written as inline HTML, and the export should say which in its docs.
  • Plain text (for notifications, search indexing, and the text/plain clipboard flavor on copy) joins block text with newlines and renders mentions as @Name.
  • Copy puts both text/html and text/plain on the clipboard, plus the editor's own JSON under a custom type, so pasting between editors on your site is lossless. Browsers limit who can read custom types (Safari exposes them only to pages from the same origin), so the HTML flavor must stay good enough on its own.

Schema versions and migrations

Schemas change: version 2 adds a callout block, version 3 renames code_block to codeBlock. Each saved document carries its version. On load, the editor runs it through an ordered list of pure upgrade functions (2 to 3, then 3 to 4) before validating. Old clients that meet a newer document they don't understand show it read-only rather than silently dropping unknown nodes on save, which would destroy data.

Check your understanding · Exercise 10

Why store versioned JSON instead of the exported HTML? Select all that apply.

Autosave, drafts, and hooks for collaboration

Losing text is the worst editor bug, and most text is lost not by the editor but around it: a closed tab, a crashed browser, an expired session, a flaky train connection. The editor protects against this in two layers: a local draft that is written within a second of every change, and an autosave to the host's server that runs in the background. The form-state lesson covers autosave for ordinary forms; here we focus on what's different for a large document.

Local drafts

When the host passes a draftKey (for example ticket-123-description), the persistence adapter saves the document to IndexedDB about 500 ms after the last change. IndexedDB, not localStorage, because localStorage is synchronous (it blocks the main thread while writing) and limited to roughly 5 MB per origin, which a long article with pasted content can approach.

The draft record holds the JSON, the schema version, the server version it was based on, and a timestamp. When the editor mounts with a draftKey and finds a draft newer than the server copy, it offers to restore it rather than silently replacing the server content.

Drafts must be cleaned up: delete the draft after a successful save and expire old drafts after, say, 30 days. On shared computers, drafts should be scoped by user id and cleared on logout, because they contain whatever people wrote.

Autosave to the server

Autosave sends the document to the host's save function after typing pauses for about 1 to 2 seconds, with a maximum interval (every 10 seconds of continuous typing) so a long burst still gets saved. The save is a small state machine, because several things can happen at once: the person keeps typing while a save is in flight, the network fails, or the server rejects the save because someone else changed the document.

Autosave state machine. From saved, an edit moves to dirty. After 1 second idle, dirty moves to saving, which allows one request in flight. If the save returns 200 with no new edits, it goes back to saved. If edits arrived meanwhile, it goes back to dirty. A network error moves to retrying, where the draft is safe and the save is retried after a backoff. A 409 or 412 response moves to conflict, where the person chooses to reload or keep their version, which leads back to saved. A note says one save in flight, and the draft is always written locally.saveddirtysavingone in flightretryingdraft is safeconflictask the personedit1 s idle200, no new editsedits meanwhilenetwork error409 or 412reload or keep mineone save in flightdraft always local
Saves never overlap, so an older body can't land after a newer one. Network trouble only delays the server copy, because the draft in IndexedDB already holds every change.
  • Only one save in flight. If changes arrive during a save, mark the document dirty again and save once more when the current save returns, with the latest content. Never run two saves in parallel: the older one could finish last and overwrite newer content.
  • Optimistic concurrency. Send the server version the content was based on (an If-Match header with an ETag, or a baseVersion field). If the server has a newer version, it replies 409 Conflict or 412 Precondition Failed, and the editor must not silently overwrite. For a single-editor document, show "This document was changed elsewhere" with options to reload or keep yours.
  • Retry with backoff on network errors (1 s, 2 s, 4 s, up to about 30 s), and show a quiet "Offline, saved on this device" status since the draft is safe in IndexedDB.
  • Leaving the page. On visibilitychange to hidden, flush any pending save with fetch and keepalive: true (its body limit is 64 KB in total across in-flight keepalive requests, so large documents may not fit) and make sure the local draft is written. Use beforeunload to warn only when there are unsaved changes that also failed to reach the draft store.

For large documents, sending the full JSON on every save gets heavy. A rough estimate for 50,000 words: about 300 KB of text plus per-block and per-run overhead, so roughly 400 to 800 KB of JSON. Two fixes: compress the request body, or send the transactions since the last acknowledged version instead of the whole document. The second option is the first step toward collaboration, which is why it's worth designing the save path around transactions from day one even if version 1 sends full documents.

Collaboration hooks: what to build now so you don't rewrite later

Live collaboration is out of scope, but the brief says not to block it. The google-docs guide covers the merge algorithms; operational transformation (OT) and CRDTs (conflict-free replicated data types) each have their own lessons. What matters for the editor component is that the core already has the four properties a sync layer needs:

  1. Changes are data. Transactions with steps can be sent, received, rebased, or converted to CRDT operations. A DOM-diff editor has nothing to send.
  2. Positions can be mapped. Remote changes shift the local selection, comments, and pending suggestions through the same mapping used for undo.
  3. Transactions carry an origin. A remote change is applied with origin: "remote" and addToHistory: false, so undo skips it and the local person's undo only removes their own work.
  4. The view accepts changes it didn't initiate. Rendering is driven by state, not by the input event, so a paragraph changed by someone else re-renders like any other, while the composition guard protects the block the local person is typing in.

With those in place, adding collaboration is a plugin. With Yjs (a widely used CRDT library), bindings such as y-prosemirror and @lexical/yjs map the editor tree to a shared Yjs XML type, apply local transactions to it, and turn remote Yjs updates into editor transactions. The Yjs UndoManager replaces the local history so undo stays per-person. Cursors from other people are drawn as decorations: visual overlays that are not part of the document.

Check your understanding · Exercise 11

A person types for 30 seconds on a weak connection. Saves take 3 seconds each. Which autosave behaviors are correct? Select all that apply.

Accessibility: an editor everyone can use

Choosing contenteditable over a canvas already did most of the accessibility work: screen readers know how to read and edit an editable region, move by character, word, and line, and announce formatting changes they can see in the DOM. Our job is not to break that, and to make the parts the browser doesn't know about (toolbar, mentions, embeds, custom shortcuts) just as usable.

Semantics of the editing area

  • Give the editable element role="textbox" and aria-multiline="true", so assistive technology knows Enter creates a new line rather than submitting. Name it with aria-label or aria-labelledby from the required label prop.
  • Render real semantic elements from the model: h2 for headings, ul, ol, and li for lists, blockquote, strong, em, a. Screen reader users jump by heading and list inside the editor just as on a page. A heading rendered as a styled div is invisible to them.
  • Images need alt text. The image node view has an alt text button, and pasted images without alt get a visible "Add description" prompt. Store alt in the model; an empty string marks a decorative image on purpose.
  • Mentions and embeds are contenteditable="false" islands. Give them an accessible name ("Mention: Priya Shah", "Embedded video: Launch demo") so a screen reader doesn't read an unlabelled chip or skip it.
  • Placeholder text is shown with CSS from a data attribute on the empty block, and also exposed with aria-placeholder so it's announced but never becomes part of the content.

Keyboard: shortcuts and getting in and out

Everything must work without a mouse. Formatting has the familiar shortcuts: Ctrl or Cmd plus B, I, U, K for a link, and Ctrl+Alt plus 1 to 3 for headings (use the platform modifier, Cmd on macOS). Document them in a shortcuts dialog and in each button's tooltip and aria-keyshortcuts attribute.

The trickier question is Tab. In a text editor, people may expect Tab to indent a list item, but if Tab never leaves the editor, keyboard users are trapped, which fails WCAG 2.1.2 (No Keyboard Trap). A common compromise: Tab and Shift+Tab indent and outdent only inside list items, and move focus out of the editor everywhere else. Escape always moves focus out of the editor to the next sensible element; say which you chose and how users learn it.

The toolbar follows the WAI-ARIA toolbar pattern: one Tab stop for the whole toolbar, arrow keys move between buttons (roving tabindex), and Home and End jump to the first and last. Toggle buttons use aria-pressed, and the block type menu is a menu button with aria-haspopup.

A shortcut moves focus from the text to the toolbar and back without losing the selection; Alt+F10 is the convention TinyMCE and CKEditor use. When focus returns, restore the model selection, since clicking or focusing toolbar buttons can collapse the DOM selection.

Announcing what isn't visible in the text

Some changes are invisible to a screen reader that is reading the text. Pressing Ctrl+B with a collapsed caret changes nothing in the DOM yet, converting ## into a heading changes the element silently, and a failed upload shows only a red border.

Use one visually hidden live region with aria-live="polite" and post short messages: "Bold on", "Heading 2", "Image upload failed, press Enter to retry". Keep them short and don't announce on every keystroke, or the screen reader becomes noise.

The mention list borrows the combobox pattern's focus model. The editing element keeps role="textbox", gets aria-controls pointing at the list, and aria-activedescendant pointing at the highlighted option, so the screen reader announces each option while focus never leaves the text. ARIA doesn't allow aria-expanded on a textbox, so also announce "5 suggestions" in the live region when the list opens. The aria-patterns and keyboard-navigation lessons cover these patterns.

Zoom, contrast, motion, and direction

  • Size text in rem and let the editor reflow at 400 percent zoom with no horizontal scrolling inside paragraphs.
  • Floating toolbars and popovers must not cover the caret, and must stay reachable at high zoom; position them with the selection rectangle and flip when there's no room.
  • Respect prefers-reduced-motion for any popover or toolbar animation.
  • Support right-to-left text: set dir="auto" on each block so Arabic and Hebrew paragraphs align correctly next to English ones, and let arrow keys follow the browser's visual caret movement.

Check your understanding · Exercise 12

A screen reader user reports: "I can't tell when I've turned bold on, and I can't get out of the editor." Which fixes address this? Select all that apply.

Performance: a budget for every keystroke

Typing is the most frequent interaction on the web, and people notice lag in text input sooner than anywhere else. The usual rule that a response within 100 ms feels instant is too loose for letters appearing under your fingers; editors aim for the next frame. INP (Interaction to Next Paint) measures this: the time from an input to the next frame that shows its result, and it counts every keystroke. An editor that is fine on first load can still fail INP on a long document because each key does too much.

The per-keystroke budget

At 60 Hz a frame is about 16.7 ms. The browser needs part of that for style, layout, and paint, so the editor's own work per keystroke should stay under roughly 4 to 6 ms on a mid-range laptop. Phones are often three to five times slower for the same JavaScript, which turns 5 ms into 15 to 25 ms: still within a frame or two, and well inside the 200 ms INP target. Measure on real mid-range Android devices, where most problems show up.

Per-keystroke budget. On a laptop at 60 Hz, a 16.7 ms frame holds about 4 ms of editor work (input and apply, plugins, render), then about 4 ms of browser style, layout, and paint, leaving about 8 ms spare. On a phone about four times slower, the same editor work takes about 16 ms and the browser work spills past the frame: about two frames, still far under the 200 ms INP threshold.laptop, 60 Hzeditor: 4 msbrowser: 4 msabout 8 ms spareinput, apply, plugins, render0 ms16.7 ms framephone, about 4x slowereditor work, about 16 msabout 2 frames: still far under the 200 ms INP limit
Keep the editor's own work to a few milliseconds on a laptop, because phones multiply it. Anything that can wait, like serializing, autosave, and word counts, runs after the paint.
StageTarget (laptop)How to stay inside it
Input handlingunder 0.5 msMap inputType to a command with a lookup table. No DOM reads beyond the selection.
Apply transactionunder 1 msImmutable updates touch only the changed block and its ancestors. No full-document scans.
Pluginsunder 1 ms totalEach plugin looks at the transaction's changed range, not the whole document.
Reconcile DOMunder 1 msReference checks skip unchanged blocks. Update one text node's data in place.
Selection restoreunder 0.5 msSkip it when the DOM selection already matches.
Style, layout, painta few msAvoid layout reads (offsetHeight, getBoundingClientRect) during the update. Contain each block's layout.
Deferred workoutside the frameSerialization, autosave, word count, analytics, spell-check extensions: batched after paint or in idle time.

What usually breaks the budget

  • Serializing on every keystroke. Calling JSON.stringify on a 500 KB document for onChange costs several milliseconds on a laptop and much more on a phone. Fire a cheap event instead, and serialize when the host asks or when autosave runs.
  • Full-document plugins. A word counter or a "find broken links" plugin that walks all 3,000 blocks per key. Make plugins incremental: update counts using the changed range, or debounce them.
  • Layout thrashing. Positioning a floating toolbar by reading getBoundingClientRect() right after writing the DOM forces a synchronous layout. Batch the read into the next requestAnimationFrame.
  • Framework re-renders. A host component that stores editor content in React state and re-renders the toolbar, sidebar, and editor wrapper on each key. Keep editor state outside the host's render cycle.
  • Huge mark sets and decorations. Spell-check or search highlights drawn as thousands of wrapper elements. Draw them only for visible blocks, or with the CSS Custom Highlight API where supported, which paints ranges without changing the DOM.

Load and bundle size

The editor usually isn't the first thing on the page: a comment box sits under an article, a description field sits in a ticket. So don't make the page pay for it up front. Render the saved content as static HTML first (read-only and fast, from the server's cached export), and load the editor bundle when the person focuses or clicks into it, or during idle time.

Keep the core small, and load heavy pieces (code-block syntax highlighting, tables, the embed providers) with dynamic import() the first time a document uses them. Swapping static HTML for the live editor must not shift layout: render the same styles, so CLS stays at zero.

Measuring in production

Field data beats lab data for editors, because document sizes and devices vary so much. Report INP with attribution (the web-vitals library tells you which interaction and element), and add the editor's own timings: transaction apply time, render time, and document block count, sampled at a low rate. Log unexpected DOM mutations caught by the observer, composition errors, schema validation fixes on load, and failed saves. These reveal browser-specific input bugs long before users describe them.

Check your understanding · Exercise 13

Typing in a 40,000-word document has an INP of 350 ms on mid-range Android phones. Which changes are likely to help most? Select all that apply.

Running it in the interview

A rich text editor question is a test of whether you know where the difficulty is. Weak answers spend 30 minutes on the toolbar and save innerHTML. Strong answers spend the time on the model, the input pipeline, and rendering, and mention the toolbar in one sentence as "derived from selection". Here is one way to use 60 minutes.

MinutesWhat to doWhat to draw or say
0 to 5Scope: who embeds it, document size, content types, IME and mobile, collaboration later?Write the numbers: 50,000 words, 16 ms per keystroke, INP under 200 ms.
5 to 12Why raw contenteditable plus execCommand fails. Pick contenteditable for input, own model as truth.The two-trees picture: model on the left, DOM on the right, arrows both ways.
12 to 22Document model: blocks, marks, positions, selection, schema.The model type in a few lines. A flat-position example.
22 to 32Input pipeline: beforeinput and inputType, composition, mobile, selection sync.The keystroke sequence: event, command, transaction, state, render, selection.
32 to 40Transactions, undo grouping, reconciliation by block key.Undo stack with groups. Changed block highlighted in the render diff.
40 to 48Paste pipeline and XSS, mentions and embeds as nodes.Paste steps from clipboard to transaction. The protocol allowlist.
48 to 55Saved format, autosave and drafts, collaboration hooks, accessibility.The architecture boxes and the save state machine.
55 to 60Performance budget and follow-ups.The per-keystroke budget table.

Expect the interviewer to pull you into one area and push. The input pipeline and paste are the most common, because they separate people who have built an editor from people who have used one. If they push on collaboration, give the four hooks from the autosave chapter and say that the merge algorithm is its own topic, then go as deep as they want. If they push on the component API, talk about uncontrolled state, the required label, callbacks for mentions and uploads, and schema versions.

Follow-up questions to expect

Think first

How would you add tables?

Think first

A browser extension like Grammarly injects elements into the editor and changes text. What happens and what do you do?

Think first

Product wants Markdown shortcuts: typing - starts a list, **bold** becomes bold. How?

Think first

Next year two people edit the same description at once. What changes in your design?

Check your understanding · Exercise 14

An interviewer has 10 minutes left and asks "What would you cut from version 1 to ship sooner?" Which answers show good judgment? Select all that apply.

Summary

  • The DOM is a view, not the data. Keep a document model (a tree of blocks with marked text runs, checked by a schema) as the source of truth, and use contenteditable only as the input surface.
  • Listen to intent, not keys: beforeinput with inputType covers typing, autocorrect, dictation, paste, and deletion. During IME composition, let the browser own the DOM and read the result back at compositionend.
  • Every change is a transaction of invertible steps with a position mapping. Undo, autosave, plugins, and future collaboration all build on that one shape.
  • Group undo by time, kind, and place, and restore the selection with each undo step.
  • Render by stable block keys and skip unchanged blocks by reference. Update text nodes in place so IME and phone keyboards keep working.
  • Paste goes through DOMParser and the schema's parse rules, never innerHTML. The allowlist schema is the XSS defense, backed by server validation and safe export.
  • Store versioned JSON. HTML, Markdown, and plain text are exports.
  • Budget a few milliseconds of editor work per keystroke and defer serialization, autosave, and full-document plugins until after paint.

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