Skip to content

Round-trip preservation

If you open a document in the editor, switch between rich, source and preview and then close it, the file on disk is byte-for-byte the same as before.

This page covers that guarantee and the related ones. It's the reason the editor is safe to point at Markdown you already have.

Loading editor

Why this needs saying#

A WYSIWYG editor that parses into an editing model and serializes back out loses anything the model can't represent. You see that as a diff nobody asked for.

The editor this package replaces had exactly that problem. Its audit, docs/AUDIT.md, lists 22 constructs it damaged on save. For example a blank quote line gained a second marker, backslashes doubled, a tilde fence got escaped into prose, and reference links were never parsed at all.

The damage was permanent, and it got worse with each save.

All 22 of those cases are fixtures in this repository. The editor round-trips every one byte-identically, with GFM on and with GFM off, in packages/editor/tests/roundtrip.test.ts. The serializer path has its own suite, tests/roundtrip.test.ts: 22 of 22 keep their meaning, 22 of 22 are idempotent, 14 of 22 come back byte-identical, because that path canonicalizes. Either suite fails the build if a case regresses.

The four guarantees#

No-op preservation#

Importing and exporting a document shouldn't change it at all, including blank lines and which fence character you used.

The editor keeps the original source and the byte span of every top-level block. A block you didn't touch is written back from those original bytes instead of being re-serialized from a tree.

That's why _emphasis_ doesn't turn into *emphasis*, and why four blank lines stay four blank lines.

Supported edits preserve meaning#

When you edit something the editor understands, the result means the same thing, and any normalization it does is documented.

If you edit one paragraph in a long document, only that paragraph's bytes change. The blocks around it come back verbatim.

Loading editor

Unsupported syntax becomes an opaque node#

Some Markdown can't be edited in rich mode, such as raw HTML, link definitions, footnote definitions, front matter, math or a node from a third-party extension.

None of it gets turned into prose. Each one becomes an opaque node, which keeps both the parsed subtree and the exact source slice it came from.

An opaque node shows up as a non-editable block with its source. On save it writes back the original bytes and is never re-serialized.

Loading editor

This fixes the audit's third finding. The previous editor turned anything it didn't understand into literal paragraph text, which then got re-escaped on the next save.

No silent loss#

If the editor can't safely preserve something, it won't flatten it or drop it.

You can always see the fallback, either as an opaque block or in source mode (which is always available and always shows the real file).

Parse problems are reported as diagnostics. The editor doesn't quietly repair them.

<MarkdownEditor
  value={value}
  onChange={setValue}
  onDiagnostics={(diagnostics) => report(diagnostics)}
/>

How it works#

One parser for the whole document. The editor parses with compileMarkdown from the renderer, the same micromark and mdast pipeline the renderer uses. There's no line-by-line regex transformer anywhere in the package. That approach was the root cause of every finding in the audit.

Source spans. Every top-level block is indexed by a canonical key and its exact byte range in the original source.

Dirty-region writing. On save the writer lines up the edited blocks against that index. Unchanged blocks are copied from the original bytes, and only changed blocks get serialized.

Opaque nodes carry their source. A node the bridge doesn't know how to edit keeps the slice it came from and gets written back from that slice.

All three modes project the same document. Source mode doesn't unmount and rebuild the document, so switching modes can't lose anything the rich surface wasn't able to show.

The 22 audited cases#

Each of these got corrupted on save in the previous editor. All of them now round-trip byte-identically.

CaseWhat used to happen
Blank line inside a blockquoteA second marker was added
Nested blockquoteFlattened and re-prefixed
Backslash hard breakThe backslash doubled on every save
Windows path in textBackslashes doubled
Tilde fenceTildes escaped, destroying the fence
Double-backtick inline codeBackticks escaped
Loose list with a second paragraphA stray blank line appeared
Setext headingKept as paragraph text plus a line break
Indented code blockShown as literal paragraph text
Reference linkNever parsed
Reference imageNever parsed
Inline imageRendered as a literal ! plus a link
Image with a titleSame
AutolinkKept as plain text
HTML blockRaw source shown in prose
HTML commentSame
Ordered list starting at 3Renumbered from 1
Soft line breakTurned into a hard break
Underscore emphasisNormalized to asterisks
Thematic break variantsEvery variant rewritten to ---
HTML entitiesDouble-escaped
Runs of blank linesCollapsed to one

The corpus lives in fixtures/editor-roundtrip/corruption.json, and the gate is packages/editor/tests/roundtrip.test.ts.

Editor layer and serializer layer#

Mixing these two up is how the old bugs went unnoticed, so it's worth keeping them separate.

The editor layer covers opening a document, looking around and closing it. Byte identity is required here, 22 out of 22. That's what this page guarantees, and it works because unchanged regions are never re-serialized.

The serializer layer is documentToMarkdown(compileMarkdown(source)). That path canonicalizes on purpose, so we don't expect (or want) byte identity there. What it does guarantee is semantic stability, 22 out of 22, and idempotence, 22 out of 22, in tests/roundtrip.test.ts. Saving twice gives the same result as saving once.

Use the editor when the exact bytes matter, and the serializer when you want a canonical form. See Compiling.

What is still normalized#

Editing a block rewrites that block. A heading you retype is written in the serializer's canonical form, which uses - bullets, * for strong and backtick fences.

So the only blocks that get normalized are ones you actually changed. Blocks you didn't touch stay as they were.

If your team wants commit diffs that only show what someone touched, that's what the dirty-region writer gives you.

The editor API is covered in Editor basics.

The same round-trip pipeline also runs without React, which is how it's tested. See Headless editing.

The three modes and a round-trip check you can run on your own Markdown: Editor demo.

What a round trip has to preserve, and how to run the corpus on your own files: Lossless Markdown editing.

Last updated on