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.
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.
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.
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.
| Case | What used to happen |
|---|---|
| Blank line inside a blockquote | A second marker was added |
| Nested blockquote | Flattened and re-prefixed |
| Backslash hard break | The backslash doubled on every save |
| Windows path in text | Backslashes doubled |
| Tilde fence | Tildes escaped, destroying the fence |
| Double-backtick inline code | Backticks escaped |
| Loose list with a second paragraph | A stray blank line appeared |
| Setext heading | Kept as paragraph text plus a line break |
| Indented code block | Shown as literal paragraph text |
| Reference link | Never parsed |
| Reference image | Never parsed |
| Inline image | Rendered as a literal ! plus a link |
| Image with a title | Same |
| Autolink | Kept as plain text |
| HTML block | Raw source shown in prose |
| HTML comment | Same |
| Ordered list starting at 3 | Renumbered from 1 |
| Soft line break | Turned into a hard break |
| Underscore emphasis | Normalized to asterisks |
| Thematic break variants | Every variant rewritten to --- |
| HTML entities | Double-escaped |
| Runs of blank lines | Collapsed 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.
Related#
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.
Read next#
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