--- title: Migrate CodeMirror Record from CodeMirror 5 to CodeMirror 6 schema_version: 1 document_id: codemirror-record-cm5-to-cm6 audience: - application_developers - coding_agents from: editor: codemirror@5 recorder: codemirror-record@1 to: editor: "@codemirror/state@6 + @codemirror/view@6" recorder: codemirror-record@2 wire_contract: transform: forbidden format: established-v1-json verification: bidirectional-release-gate canonical_html: https://codemirror-record.haoranyu.com/migration/ machine_contract: ./migration-contract.json last_verified: 2026-08-24 --- # Migrate CodeMirror Record from CodeMirror 5 to CodeMirror 6 This is the canonical migration runbook for humans and coding agents. It moves an application from CodeMirror 5 with `codemirror-record@1` to CodeMirror 6 with `codemirror-record@2` while preserving the recorder/player interface and the established recording JSON contract. The CodeMirror editor API changes substantially. The `codemirror-record` surface does not: construct `CodeRecord` and `CodePlay` with the new editor object, then keep the same recorder and player calls. - Current CodeMirror 6 documentation: [default README](../README.md) - CodeMirror 5 maintenance documentation: [`v1` README](https://github.com/Jisuanke/CodeMirror-Record/tree/v1#readme) - Rendered version of this guide: [migration page](https://codemirror-record.haoranyu.com/migration/) ## Quick decision | Application editor | Install | Pass to `CodeRecord` / `CodePlay` | | --- | --- | --- | | CodeMirror 5 | `codemirror-record@^1` + `codemirror@^5` | CM5 `CodeMirror` instance | | CodeMirror 6 | `codemirror-record@^2` + CM6 state/view packages | CM6 `EditorView` | Upgrade the editor and recorder package together. Do not pass a CM5 instance to v2 or an `EditorView` to v1. If the application must host both editor generations, install the recorder majors under npm aliases and keep each adapter next to its matching editor runtime. > **Do not transform existing recordings.** Treat the serialized string from > `getRecords()` as an opaque payload and pass it directly to > `CodePlay.addOperations()`. Do not rewrite line/character positions into CM6 > offsets. The v2 adapter performs the coordinate translation at its boundary. ## Machine-readable migration contract Load [migration-contract.json](./migration-contract.json) before automating a migration. It is the single structured source for package coordinates, the retained surface, wire invariants, corrected runtime behavior, and the ordered Markdown/HTML section map. The release tests verify that every declared field and section is represented by both this runbook and the rendered page. Compatibility is a release contract backed by the repository's bidirectional package tests. It does not mean that the CodeMirror 5 and CodeMirror 6 editor APIs are interchangeable. ### Corrected legacy runtime quirks Package v2 keeps the public method and event names, but it does not preserve eight accidental v1.1.6 behaviors. The same corrections ship on the maintained CM5 line in v1.1.8 so applications can test them before changing editor generations: - `seek(0)` restores the configured playback speed and a normal `PAUSE` state. - Backward seek restores the document, every directed selection, and its primary range. - Terminal playback is already in `PAUSE`, and emits `pause`, before `end` listeners run. Do not assert the old `PLAY`-inside-`end` bug. - Paste capture never mutates or duplicates an earlier non-cursor predecessor. Already-stored ambiguous v1.1.6 bytes still replay exactly as encoded. - A replacement seek issued while an earlier seek is still running preserves the configured playback speed and the original `PLAY` or `PAUSE` state. - A seek to duration during active playback emits exactly one `end` event. - An equal-time compressed group with scalar `t` expands every logical operation at that timestamp. Published v0.3.1 through v1.1.7 readers indexed the scalar as if it were an interval, producing invalid operation times, terminal duration, and seeks. v1.1.8 and v2 accept the unchanged bytes; their writers use `t: [time, time]` for new compressed equal-time groups. - An ungrouped record with interval `t: [start, end]` and no `l` is one logical operation at the interval end. Published v0.3.1 through v1.1.7 players exposed the array as a non-numeric duration when that record was terminal; v1.1.8 and v2 use the end timestamp, and their writers emit a scalar for new ungrouped records. These are runtime corrections, not a recording schema migration. Do not add a payload transform for them. ## 1. Change the installed packages Keep the current CM5 line pinned while preparing fixtures: ```bash npm install codemirror-record@^1 codemirror@^5 ``` Then install the stable CM6 line and its required peer dependencies: ```bash npm install codemirror-record@^2 @codemirror/state@^6 @codemirror/view@^6 ``` Add only the language and editor features the application uses. For example: ```bash npm install codemirror@^6 @codemirror/lang-javascript@^6 ``` The CM6 umbrella `codemirror` package provides `basicSetup`. Language support still comes from a separate package. Keep one resolved copy of each `@codemirror/*` package so extension and state identities match. ## 2. Replace editor construction The argument changes from a mutable CM5 `CodeMirror` instance to a CM6 `EditorView`. The recorder/player constructors and method calls stay in place. ### Before: CodeMirror 5 ```js import CodeMirror from 'codemirror'; import {CodePlay, CodeRecord} from 'codemirror-record'; const recordEditor = CodeMirror(recordMount, { value: initialDocument, mode: 'javascript', lineNumbers: true, }); const playEditor = CodeMirror(playMount, { value: initialDocument, readOnly: 'nocursor', }); const recorder = new CodeRecord(recordEditor); recorder.listen(); const player = new CodePlay(playEditor); ``` ### After: CodeMirror 6 ```js import {EditorState} from '@codemirror/state'; import {EditorView, lineNumbers} from '@codemirror/view'; import {javascript} from '@codemirror/lang-javascript'; import {CodePlay, CodeRecord} from 'codemirror-record'; function createEditor(parent, extensions = []) { return new EditorView({ parent, state: EditorState.create({ doc: initialDocument, extensions, }), }); } const recordEditor = createEditor(recordMount, [ lineNumbers(), javascript(), ]); const playEditor = createEditor(playMount, [ lineNumbers(), javascript(), EditorState.readOnly.of(true), EditorView.editable.of(false), ]); const recorder = new CodeRecord(recordEditor); recorder.listen(); const player = new CodePlay(playEditor); ``` `CodeRecord.listen()` installs its CM6 transaction listener. Do not add a second DOM or input listener for the recorder; it can duplicate typed input and miss programmatic transactions. CM6 has no direct `CodeMirror.fromTextArea` equivalent. Create an `EditorView` beside the textarea and copy `view.state.doc.toString()` back into the textarea when the form is submitted. ## 3. Keep the recorder/player surface Only the editor argument changes. Retain these application-level calls: | Surface | Retained in v2 | Migration action | | --- | --- | --- | | Recorder constructor | `new CodeRecord(editor)` | Pass an `EditorView` | | Start recording | `recorder.listen()` | No call-site change | | External activity | `recorder.recordExtraActivity(value)` | Keep the value JSON-serializable | | Serialize | `recorder.getRecords()` | Store or transfer the returned string unchanged | | Player constructor | `new CodePlay(editor, options)` | Pass an `EditorView`; keep options | | Load | `player.addOperations(records)` | Pass v1 or v2 recording strings directly | | Transport | `play()`, `pause()`, `seek(ms)`, `clear()` | No call-site change | | Timeline reads | `getStatus()`, `getCurrentTime()`, `getDuration()` | No call-site change | | Player events | `on()`, `off()`, `once()` | Keep the same event names | The player options also retain their names: | Player option | Purpose | | --- | --- | | `maxDelay` | Cap pauses between operations; `0` means no cap | | `autoplay` | Start when operations are added | | `autofocus` | Focus the player editor during playback | | `speed` | Playback speed multiplier | | `extraActivityHandler` | Apply recorded application activity | | `extraActivityReverter` | Revert application activity while seeking backward | The associated setters remain `setMaxDelay`, `setAutoplay`, `setAutofocus`, `setSpeed`, `setExtraActivityHandler`, and `setExtraActivityReverter`. The player event names remain `play`, `pause`, `seek`, `end`, and `clear`. These are `CodePlay` events. They are distinct from CM5 editor events, which do change during the editor migration. ## 4. Translate application-owned editor calls CM6 stores an immutable `EditorState` and applies updates by dispatching transactions. Numeric positions are UTF-16 offsets. Translate application code at the editor boundary: | CodeMirror 5 | CodeMirror 6 | | --- | --- | | `cm.getValue()` | `view.state.doc.toString()` | | `cm.getRange(a, b)` | `view.state.sliceDoc(a, b)` | | `cm.getLine(n)` | `view.state.doc.line(n + 1).text` | | `cm.lineCount()` | `view.state.doc.lines` | | `cm.replaceRange(text, from, to)` | `view.dispatch({changes: {from, to, insert: text}})` | | `cm.replaceSelection(text)` | `view.dispatch(view.state.replaceSelection(text))` | | `cm.setValue(text)` | Dispatch a change replacing `0..view.state.doc.length` | | `cm.operation(() => edits)` | Dispatch one transaction with a change set | | `cm.focus()` | `view.focus()` | | `cm.getWrapperElement()` | `view.dom` | | Remove the editor DOM | `view.destroy()` | When a transaction contains multiple ordinary change specs, their `from` and `to` coordinates refer to the transaction's starting document. Selection coordinates in the same transaction refer to the document after its changes. Use a fresh `EditorState` with `view.setState(newState)` when loading a logically different document whose old undo history and state fields must be discarded. `view.setState()` is not a transaction: an attached recorder cannot observe or serialize its document or selection replacement, and the new state also discards the listener installed by `CodeRecord.listen()`. Choose one explicit recording boundary: - To keep the replacement inside the same recording, first dispatch an equivalent document/selection transaction while the recorder is attached. Then install an equivalent fresh state to clear history and immediately call the idempotent `recorder.listen()` again. - To start a new logical session, drain the old recorder, call `setState`, then create a new `CodeRecord` whose documented initial value is that new state. Calling `setState` and only re-running `listen()` records future transactions; it does not retroactively record the reset. ### Positions: application offsets versus recording positions Application-owned CM5 `{line, ch}` positions become CM6 numeric offsets: ```js function cm5PositionToOffset(doc, {line, ch}) { if (line < 0) return 0; if (line >= doc.lines) return doc.length; const targetLine = doc.line(line + 1); const clippedCh = ch == null ? targetLine.length : Math.max(0, Math.min(targetLine.length, ch)); return targetLine.from + clippedCh; } function offsetToCm5Position(doc, offset) { const clippedOffset = Math.max(0, Math.min(doc.length, offset)); const targetLine = doc.lineAt(clippedOffset); return { line: targetLine.number - 1, ch: clippedOffset - targetLine.from, }; } ``` These helpers are for application data only. Do not apply them to a serialized `codemirror-record` payload. Recording positions deliberately remain zero-based line/character pairs, and the package adapter converts them during record and playback. The bounds checks preserve CM5's clipping behavior: a position before the first line becomes document start, a position after the last line becomes document end, and `ch` is clipped to the selected line. ## 5. Translate selections without losing direction | CodeMirror 5 | CodeMirror 6 | | --- | --- | | `cm.getCursor()` | `view.state.selection.main.head` | | `cm.listSelections()` | `view.state.selection.ranges` | | `cm.getSelection()` | Slice `selection.main.from..to` from state | | `cm.getSelections()` | Slice every range from state | | `cm.somethingSelected()` | Test whether any range is non-empty | | `cm.setCursor(pos)` | Convert `pos` to an offset, then dispatch `{selection: {anchor: offset}}` | | `cm.setSelection(anchor, head)` | Convert both positions to offsets, then dispatch `{selection: {anchor: anchorOffset, head: headOffset}}` | | `cm.setSelections(ranges, primary)` | Normalize touching/overlapping input according to the application's CM5 semantics, convert anchor/head offsets with `EditorSelection.range`, then call `EditorSelection.create(selectionRanges, primaryIndex)` | Use `anchor` and `head` when direction matters. `from` and `to` are normalized lower and upper bounds and cannot preserve a backward selection by themselves. Enable `EditorState.allowMultipleSelections.of(true)` for application-owned multi-cursor behavior. Convert each anchor/head to offsets with `EditorSelection.range`, then call `EditorSelection.create(selectionRanges, primaryIndex)`. The copyable recipe below accepts an already-normalized snapshot from `cm.listSelections()`. CM5's default `selectionsMayTouch: false` merges some touching or overlapping raw `setSelections` inputs, while CM6 normalization is not identical and can choose a different merged direction. Do not pass arbitrary pre-normalization CM5 inputs directly to this recipe. Normalize them while CM5 is still available, or define and test the application's intended merge behavior before converting to CM6. ```js import {EditorSelection, EditorState} from '@codemirror/state'; import {EditorView} from '@codemirror/view'; const editorState = EditorState.create({ doc: initialValue, extensions: [ EditorState.allowMultipleSelections.of(true), // ...the rest of the application's extensions ], }); const view = new EditorView({state: editorState, parent: editorMount}); const previousPrimaryIndex = view.state.selection.mainIndex; const selectionRanges = cm5Ranges.map(({anchor, head = anchor}) => EditorSelection.range( cm5PositionToOffset(view.state.doc, anchor), cm5PositionToOffset(view.state.doc, head), )); if (selectionRanges.length > 0) { const primaryIndex = cm5PrimaryIndex ?? Math.min( selectionRanges.length - 1, previousPrimaryIndex, ); view.dispatch({ selection: EditorSelection.create(selectionRanges, primaryIndex), }); } ``` Pass the CM5 `primary` argument through when the application supplied one. When it was omitted, CM5 preserved the previous primary index and clamped it to the new last index, so use `Math.min(selectionRanges.length - 1, previousPrimaryIndex)`. Do not simply pick the last range. When migrating an existing CM5 snapshot, find the range from `cm5.listSelections()` whose anchor/head equal `cm5.getCursor('anchor')` and `cm5.getCursor('head')`, and pass that index explicitly. An empty CM5 range list is a no-op. CM6 tracks the primary range with `selection.mainIndex`; `EditorSelection.create` would otherwise default to the first range. It requires `SelectionRange` instances, so raw CM5 `{anchor, head}` objects are not valid CM6 ranges. ## 6. Replace CM5 editor events with transaction observers CM5 used `cm.on('change', ...)`, `changes`, `cursorActivity`, and filtering events. CM6 describes state transitions as transactions: | CM5 integration | CM6 integration | | --- | --- | | `change` / `changes` | `EditorView.updateListener`, a view plugin, or state field | | `cursorActivity` | Compare `update.startState.selection` and `update.state.selection` | | `beforeChange` | `EditorState.changeFilter` or a transaction filter | | `beforeSelectionChange` | Transaction filter | | `change.origin` | `Transaction.userEvent` annotations and `isUserEvent()` | For a common application observer: ```js const appObserver = EditorView.updateListener.of((update) => { if (update.docChanged) { onDocumentValue(update.state.doc.toString()); } if (!update.startState.selection.eq(update.state.selection)) { onSelection(update.state.selection); } }); ``` For transaction-sensitive behavior, iterate `update.transactions`. A single view update may contain multiple transactions, and one transaction may contain multiple simultaneous changes. Avoid reconstructing changes by diffing DOM text. The v2 recorder already observes CM6 transactions and maps supported `Transaction.userEvent` annotations into the established origin vocabulary. Do not maintain a parallel recorder listener. Playback is marked with the reserved `Transaction.userEvent` value `codemirror-record.playback`. This string marker is deliberate: an identity-based custom `Annotation` created by one CommonJS/ESM entry path can be invisible to another copy of `@codemirror/state`. The adapter obtains the host view's own transaction types, so playback suppression continues to work across mixed CommonJS and ESM consumers. Application code may ignore this user-event value, but must not attach it to ordinary edits. ## 7. Replace CM5 options with CM6 extensions | CodeMirror 5 | CodeMirror 6 | | --- | --- | | `value` | `doc` in `EditorState` or `EditorView` configuration | | `mode: 'javascript'` | `javascript()` from `@codemirror/lang-javascript` | | `lineNumbers: true` | `lineNumbers()` from `@codemirror/view` | | `extraKeys` / `keyMap` | `keymap.of([...bindings])` | | `readOnly` | `EditorState.readOnly` and optionally `EditorView.editable` | | `cm.setOption(name, value)` | Reconfigure an extension in a `Compartment` | Use a `Compartment` when configuration changes at runtime: ```js import {Compartment, EditorState} from '@codemirror/state'; const tabSize = new Compartment(); const view = new EditorView({ parent: mount, extensions: [tabSize.of(EditorState.tabSize.of(2))], }); view.dispatch({ effects: tabSize.reconfigure(EditorState.tabSize.of(4)), }); ``` A `Compartment` reconfiguration preserves the recorder listener. A full top-level `StateEffect.reconfigure.of(...)` does not; call `recorder.listen()` immediately afterward to reinstall it. Keep any document or selection change that must be recorded in a separate transaction before the top-level reconfiguration. CM5 CSS selectors also change. For example, `.CodeMirror` becomes `.cm-editor`, `.CodeMirror-line` becomes `.cm-line`, and `.CodeMirror-scroll` becomes `.cm-scroller`. Rework custom themes against the CM6 DOM and decoration APIs; do not mutate `view.contentDOM` directly. ## 8. Split read-only state from DOM editability CM5's `readOnly` option combined several behaviors. CM6 separates them: | Intent | CM6 extension | | --- | --- | | Block editing commands but keep focus and selection | `EditorState.readOnly.of(true)` | | Disable DOM editing for a non-interactive player | Add `EditorView.editable.of(false)` | | Keep an uneditable editor keyboard-focusable | Add `EditorView.contentAttributes.of({tabindex: '0'})` | `EditorView.editable.of(false)` alone does not prevent programmatic dispatch. `EditorState.readOnly.of(true)` informs commands and input handlers that edits are blocked. Choose both facets deliberately based on focus and selection requirements. Playback dispatches programmatic transactions, so a read-only player can still replay recorded changes. ## 9. Install history explicitly A bare CM6 editor has no undo history or history keybindings. Install `history()` and `historyKeymap`, or use `basicSetup`: ```js import {history, historyKeymap, redo, undo} from '@codemirror/commands'; import {EditorView, keymap} from '@codemirror/view'; const view = new EditorView({ parent: mount, extensions: [history(), keymap.of(historyKeymap)], }); undo(view); redo(view); ``` CM6 has no direct `clearHistory()` method. Create a new `EditorState` and call `view.setState(newState)` when stale history must not survive a document reset, then immediately call `recorder.listen()` if that view is being recorded. When the reset belongs in the current recording, dispatch the equivalent replacement first so it is captured: ```js import {EditorState} from '@codemirror/state'; const resetSelection = {anchor: 0}; view.dispatch({ changes: {from: 0, to: view.state.doc.length, insert: newDocument}, selection: resetSelection, }); view.setState(EditorState.create({ doc: newDocument, selection: resetSelection, extensions: applicationExtensions, })); recorder.listen(); ``` The dispatch records the replacement; `setState` only resets local state fields and history to the same visible document and selection. The v2 player marks replay transactions with `Transaction.addToHistory.of(false)`, keeping playback out of the user's undo stack. Undo and redo actions performed while recording are ordinary visible document changes and can be captured through their transaction annotations. ## 10. Move recordings without conversion The editor implementation changes; the recording seam does not. Continue to store and transport the exact string returned by `getRecords()`: ```js const records = recorder.getRecords(); // Store or transfer `records` without parsing or rewriting it. player.addOperations(records); player.play(); ``` The v2 release contract requires both directions: 1. A recording produced by the released v1 CM5 recorder is accepted by the v2 CM6 player. 2. A recording produced by the v2 CM6 recorder is accepted by the released v1 CM5 player. That contract includes text changes, cursor and directed selection activity, timing, supported origins, and JSON-serializable external activities. The repository's compatibility suite gates stable releases against real packaged artifacts and compares both real players at every logical boundary. There are two classified timing exceptions in published v0.3.1 through v1.1.7 readers: an ungrouped interval `t: [start, end]` with no `l`, and a compressed record with scalar `t` plus `l > 1`. Document, directed selection, origin, and external-activity traces must still match, but operation time, duration, and affected seek timing may differ. v1.1.8 and v2 use the interval end for the first shape and the scalar time for every logical operation in the second. The operation-time exception is limited to operations expanded from an affected record; duration may differ only when that record is terminal, and any seek comparison may differ when its payload contains either shape. Never rewrite either payload to hide these reader defects. Applications should retain representative production fixtures as an additional integration gate. ## 11. Deterministic migration procedure 1. Pin the working application to `codemirror-record@^1` and `codemirror@^5`. 2. Save representative raw `getRecords()` strings without parsing them. 3. Inventory CM5 construction, `fromTextArea`, methods, events, options, custom CSS, selections, read-only behavior, and history calls. 4. Install `codemirror-record@^2`, `@codemirror/state@^6`, and `@codemirror/view@^6` plus required language/feature packages. 5. Replace every `CodeMirror(...)` instance with an `EditorView`; move `value` to `doc` and options to extensions. 6. Pass the `EditorView` to existing `CodeRecord` and `CodePlay` constructors. Preserve recorder/player method, option, and event names. 7. Translate application-owned value, edit, position, selection, event, configuration, read-only, history, DOM, and cleanup integrations using the mappings above. 8. Load the saved v1 recording strings directly into the v2 player. Do not transform coordinates or operation fields. 9. Record the same high-risk interactions in v2 and load those raw strings in a released v1 player test fixture. 10. Run the checklist below against the application's real CM6 extension set. ## 12. Verification checklist for coding agents Treat every unchecked item as a release blocker for the application migration. - [ ] **Dependencies:** the lockfile resolves one compatible copy of each required `@codemirror/*` package. - [ ] **Construction:** every recorder and player receives an `EditorView`. - [ ] **Recorder lifecycle:** a recordable document/selection replacement is dispatched before `view.setState(newState)` (or starts a new recording baseline), and after every `setState` or full top-level `StateEffect.reconfigure.of(...)` the attached recorder calls the idempotent `recorder.listen()` again. - [ ] **No transform:** saved recording strings reach `addOperations()` without parsing, coordinate conversion, schema migration, or reserialization. - [ ] **Old to new:** representative v1 CM5 recordings finish in the expected CM6 document, selection, timing, and application state. - [ ] **New to old:** representative v2 CM6 recordings finish in the expected released v1 CM5 document, selection, timing, and application state. - [ ] **Selections:** forward/backward selections, multi-cursor state, and the primary range replay correctly. - [ ] **Input origins:** typing, multiline edits, deletion, paste, drop, IME composition, undo, and redo are covered where the application uses them. - [ ] **Playback:** play, pause/resume, seek to zero, seek forward/backward, speed, max delay, end, and clear behave as expected. - [ ] **External activity:** handlers and reverters receive the original JSON-serializable values in both playback directions. - [ ] **Read-only and focus:** the player matches the intended keyboard focus, selection, and editability behavior. - [ ] **History:** the application installs history where needed and replay does not create user undo entries. - [ ] **Cleanup:** replaced views call `view.destroy()` and obsolete CM5 DOM/CSS hooks are removed. ## First-party sources - [CodeMirror 5 manual](https://codemirror.net/5/doc/manual.html) - [Official CodeMirror 5 to 6 migration guide](https://codemirror.net/docs/migration/) - [CodeMirror 6 system guide](https://codemirror.net/docs/guide/) - [CodeMirror 6 reference](https://codemirror.net/docs/ref/) - [CM6 dynamic configuration example](https://codemirror.net/examples/config/) - [CM6 read-only example](https://codemirror.net/examples/readonly/) - [CM6 bundling example](https://codemirror.net/examples/bundle/) - [Current CodeMirror Record README](../README.md) - [CodeMirror Record CM5 maintenance README](https://github.com/Jisuanke/CodeMirror-Record/tree/v1#readme) When this guide and a CodeMirror API source disagree, follow the official CodeMirror source. When this guide and the package surface disagree, treat the current major's README and TypeScript declarations as authoritative and update this runbook in the same change.