Migration runbook / schema 1

Change the editor.
Keep the recording.

Move from CodeMirror 5 and codemirror-record@1 to CodeMirror 6 and codemirror-record@2. The editor architecture changes; the recorder/player calls and recording JSON seam stay in place.

Written as a deterministic runbook for application developers and coding agents. Read the canonical prose docs/MIGRATING.md or load the canonical structured migration-contract.json.

Runtime migration ledger wire revision 1
from CM5 codemirror-record@1 CodeMirror instance
opaque JSON { p · r · x · s · e } no transform
to CM6 codemirror-record@2 EditorView
CM5 wire CM6, and back again
00 / Contract

One deliberate break at the editor boundary

Upgrade the editor and recorder package together. Pass an EditorView where v1 received a CM5 instance. Keep the recorder/player surface and serialized recording seam.

Changes Editor runtime API

Construction, values, edits, positions, events, options, CSS, and history.

Retained Recorder/player API

Named exports, constructor roles, methods, options, and player events.

Invariant Recording JSON

Opaque v1 wire payload, tested in both producer/player directions.

Compatibility is a release contract enforced by bidirectional packaged-artifact tests. It does not mean that CM5 and CM6 editor APIs are interchangeable. The canonical structured fields live in the machine-readable migration contract.

01 / Install

Select the package major by editor generation

CodeMirror 5 / maintenance Keep v1 while preparing fixtures
npm install codemirror-record@^1 codemirror@^5
Open the v1 README
CodeMirror 6 / current Install v2 with required peer packages
npm install codemirror-record@^2 @codemirror/state@^6 @codemirror/view@^6
Open the default v2 README

Add only the language and feature packages the editor uses. The umbrella codemirror@^6 package provides basicSetup; language support remains separate. Keep one resolved copy of each @codemirror/* package.

02 / Constructor

Replace the editor object, not the workflow

Before CM5 instance
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 CM6 EditorView
import {EditorState}
  from '@codemirror/state';
import {EditorView, lineNumbers}
  from '@codemirror/view';
import {javascript}
  from '@codemirror/lang-javascript';
import {CodePlay, CodeRecord}
  from 'codemirror-record';

const recordEditor = new EditorView({
  parent: recordMount,
  doc: initialDocument,
  extensions: [lineNumbers(), javascript()],
});

const playEditor = new EditorView({
  parent: playMount,
  doc: initialDocument,
  extensions: [
    EditorState.readOnly.of(true),
    EditorView.editable.of(false),
  ],
});

const recorder = new CodeRecord(recordEditor);
recorder.listen();
const player = new CodePlay(playEditor);

CodeRecord.listen() installs its transaction listener. Do not add a second DOM/input listener for recording. CM6 has no direct CodeMirror.fromTextArea equivalent; create a view and copy view.state.doc.toString() back on form submit.

03 / Retained API

Keep recorder and player call sites

Surface Retained in v2 Migration action
Recorder CodeRecord(editor), listen() Pass an EditorView
External activity recordExtraActivity(value) Keep values JSON-serializable
Serialization getRecords() Store the returned string unchanged
Player CodePlay(editor, options) Pass an EditorView; keep options
Load + transport addOperations(), play(), pause(), seek(), clear() No call-site change
Timeline reads getStatus(), getCurrentTime(), getDuration() No call-site change
Player events on(), off(), once() Keep play, pause, seek, end, and clear

Player options stay named the same

maxDelayMaximum gap between operations; zero disables the cap.
autoplayStart when operations are added.
autofocusFocus the editor during playback.
speedPlayback speed multiplier.
extraActivityHandlerApply surrounding application activity.
extraActivityReverterRevert application activity during backward seek.

Keep the matching setters: setMaxDelay, setAutoplay, setAutofocus, setSpeed, setExtraActivityHandler, and setExtraActivityReverter.

Corrected edge behavior. Maintained v1.1.8 and v2.0.0 deliberately align these runtime details:

  • 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 enters PAUSE and emits pause before end; do not preserve the v1.1.6 PLAY-inside-end bug.
  • Paste capture never mutates or duplicates a non-cursor predecessor.
  • 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 an interval and produced invalid timing; v1.1.8 and v2 accept the unchanged bytes and write t: [time, time] for new compressed equal-time groups.
  • An ungrouped record with interval t: [start, end] and no l is one operation at the interval end. Published v0.3.1 through v1.1.7 players exposed a non-numeric duration when the record was terminal; v1.1.8 and v2 normalize it and emit a scalar for new ungrouped records.
04 / Editor map

Translate application-owned editor calls

CM6 state is immutable. Apply changes with transactions and use numeric UTF-16 offsets in application code.

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 replacement over 0..doc.length
cm.operation(() => edits)Dispatch one transaction with a change set
cm.focus()view.focus()
cm.getWrapperElement()view.dom
Remove wrapper DOMview.destroy()
Application boundary {line, ch} becomes a numeric offset
Recording boundary Legacy line/character positions stay unchanged

CM6 Text.line(n) is one-based. A CM5 line maps with the clipping cm5PositionToOffset helper below. It clips lines before/after the document and clips ch to the target line, matching CM5. Use that conversion only for application-owned coordinates, never serialized recordings.

05 / Selections

Preserve anchor, head, and primary range

CodeMirror 5CodeMirror 6
cm.getCursor()view.state.selection.main.head
cm.listSelections()view.state.selection.ranges
cm.getSelection()Slice selection.main.from..to
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 the numeric anchor
cm.setSelection(anchor, head)Convert both positions to offsets, then dispatch numeric anchor/head values
cm.setSelections(ranges, primary)Convert each anchor/head to offsets with EditorSelection.range, then call EditorSelection.create(selectionRanges, primaryIndex)

Use anchor and head when direction matters; from and to are normalized bounds.

Convert each anchor/head to offsets with EditorSelection.range, then call EditorSelection.create(selectionRanges, primaryIndex).

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;

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;
}

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),
  });
}

Enable EditorState.allowMultipleSelections.of(true) when creating the state. This recipe accepts an already-normalized cm.listSelections() snapshot. CM5's default selectionsMayTouch: false can merge touching or overlapping raw setSelections inputs differently from CM6, including direction; normalize them while CM5 is available or define and test the application's merge rule first. Pass through CM5's explicit primary argument. When omitted, CM5 preserves its previous primary index and clamps it to the new last range, so use Math.min(selectionRanges.length - 1, previousPrimaryIndex); do not simply choose the last range. For an existing CM5 snapshot, match getCursor('anchor'/'head') against listSelections() and pass that index. CM6 exposes the choice as selection.mainIndex. Empty ranges are a no-op, and raw CM5 {anchor, head} objects are not CM6 SelectionRange instances.

06 / Events

Observe transactions, not DOM text

CM5 integrationCM6 integration
change / changesEditorView.updateListener, view plugin, or state field
cursorActivityCompare starting and resulting selections
beforeChangeEditorState.changeFilter or transaction filter
beforeSelectionChangeTransaction filter
change.originTransaction.userEvent and isUserEvent()
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);
  }
});

A view update may contain several transactions, and one transaction may contain several simultaneous changes. Iterate update.transactions for transaction-sensitive logic. The recorder already does this and maps user-event annotations to the established recording origin vocabulary.

Playback uses the reserved Transaction.userEvent value codemirror-record.playback. A string marker remains visible across mixed CommonJS/ESM entry paths, where an identity-based custom Annotation from another @codemirror/state instance would not. Application observers may ignore this value, but must not attach it to ordinary edits.

07 / Options

Move configuration into extensions

CodeMirror 5CodeMirror 6
valuedoc in state/view configuration
mode: 'javascript'javascript() language extension
lineNumbers: truelineNumbers() extension
extraKeys / keyMapkeymap.of([...bindings])
readOnlyEditorState.readOnly and optionally EditorView.editable
cm.setOption(...)Reconfigure an extension in a Compartment
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. After view.setState(newState) or a full top-level StateEffect.reconfigure.of(...), call the idempotent recorder.listen() again. Because setState is not a transaction, dispatch any document or selection replacement that belongs in the recording before resetting; otherwise start a new recorder and baseline. Keep a recordable change separate from top-level reconfiguration.

Rebuild custom CSS for the CM6 DOM. Common changes include .CodeMirror to .cm-editor, .CodeMirror-line to .cm-line, and .CodeMirror-scroll to .cm-scroller. Use decorations for presentation; do not mutate view.contentDOM directly.

08 / Read-only

Separate state policy from DOM editability

Block editing commands EditorState.readOnly.of(true)

Keeps the editor focusable and selectable.

Disable DOM editing EditorView.editable.of(false)

Add this for a non-interactive playback surface.

Restore keyboard focus EditorView.contentAttributes.of({tabindex: '0'})

Add when an uneditable surface still needs keyboard focus.

editable.of(false) alone does not block programmatic dispatch. Playback uses programmatic transactions, so a read-only view can still replay recorded changes.

09 / History

Install undo history explicitly

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);

A bare CM6 view has no history. Use the extensions above or basicSetup. CM6 has no direct clearHistory(); create a fresh state and call view.setState(newState) for a full document reset, then immediately call recorder.listen() when that view is being recorded. setState itself is invisible to the recorder: first dispatch an equivalent replacement when it must remain in the same recording, or finish the old recording and use the fresh state as a new baseline. The player marks replay transactions to stay out of the user's undo stack.

10 / Recordings

Pass the same bytes in both directions

const records = recorder.getRecords();

// Store or transfer `records` without parsing or rewriting it.
player.addOperations(records);
player.play();
v1 producer Release gate: CM5 recording → CM6 player

Old stored recordings must load directly in the v2 player.

v2 producer Release gate: CM6 recording → CM5 player

New recordings must remain readable by the released v1 player.

This bidirectional release contract covers supported text, cursor/selection, timing, origin, and external-activity operations. Serialized positions remain zero-based legacy line/character pairs. The gate requires trace equality at every logical boundary for the same payload in both real players, except for operation time, duration, and affected seek timing when a published v0.3.1-v1.1.7 reader consumes either an ungrouped t: [start, end] record with no l or a compressed record with scalar t plus l > 1. Document, selection, origin, and extra-activity traces must still match; v1.1.8 and v2 use the interval end for the first shape and the scalar time for every operation in the second. Never rewrite either payload to hide these reader defects. Exclude operation time only for operations expanded from an affected record, duration only when that record is terminal, and any seek comparison when a payload contains either shape. Keep representative production recording strings as application fixtures in addition to the package compatibility suite.

11 / Procedure

Execute the migration in this order

  1. 01
    Freeze a baseline.

    Pin v1/CM5 and save representative raw getRecords() strings.

  2. 02
    Inventory CM5 seams.

    Find constructors, methods, events, options, CSS, selections, read-only, history, and cleanup.

  3. 03
    Install v2 and CM6.

    Add state/view plus only the language and feature extensions in use.

  4. 04
    Replace editor construction.

    Create EditorView objects and pass them to existing recorder/player constructors.

  5. 05
    Translate application integrations.

    Use transactions, offsets, selection ranges, listeners, extensions, facets, and explicit history.

  6. 06
    Prove both wire directions.

    Load unchanged v1 strings in v2 and unchanged v2 strings in a released v1 player.

12 / Verification gate

Checklist for developers and coding agents

Treat every unchecked item as an application migration blocker.

  • Dependencies

    One compatible resolved copy of each required @codemirror/* package.

  • Construction

    Every recorder and player receives an EditorView.

  • Recorder lifecycle

    Dispatch a recordable document/selection replacement before view.setState(newState), or start a new baseline; then call the idempotent recorder.listen() after every full reset or top-level reconfiguration.

  • No transform

    Recording strings reach addOperations() without parsing, conversion, or reserialization.

  • Old → new

    Representative v1 recordings finish with the expected CM6 document, selections, timing, and application state.

  • New → old

    Representative v2 recordings finish with the expected released-v1 CM5 state.

  • Selections

    Forward/backward selections, multi-cursor state, and primary range replay correctly.

  • Input origins

    Typing, multiline edits, delete, paste, drop, IME composition, undo, and redo are covered where used.

  • Playback

    Play, pause/resume, seek zero/forward/backward, speed, max delay, end, and clear work.

  • External activity

    Handlers and reverters receive original JSON-serializable values in both directions.

  • Read-only + focus

    The player matches intended focus, selection, and editability behavior.

  • History + cleanup

    History exists where needed, replay adds no user undo entries, and replaced views call destroy().

Sources

First-party references