Lossless Markdown editing: the round-trip test suite
A Markdown round trip means opening a file in an editor, saving it, and comparing the two. It's lossless when they're the same bytes. Most rich Markdown editors aren't tested against that, so the damage usually gets noticed by a code reviewer looking at a diff nobody asked for.
This page backs up the round-trip claim in @react-markdown-kit/editor. It covers what
goes wrong in other editors, what the suite asserts, and how to run the same suite
against your own documents.
What breaks, and why
The corpus comes from an audit of a production editor, written up in
docs/AUDIT.md. The audited editor is not
named here, because the problem is a design a lot of editors share. It had no Markdown
parser. Import and export ran through a transformer protocol that matches Markdown line
by line with regular expressions.
Most of what's in the table below comes from that one decision.
| Class of failure | What the audit found |
|---|---|
| Constructs that are never parsed | Reference links and reference images survived only as dead literal text |
| Constructs outside the preset |  rendered as a literal exclamation mark followed by a link |
| Unsupported syntax degraded into prose | Raw HTML blocks and indented code appeared as paragraph text, and were then re-escaped on the next save |
| Escaping applied to text that was not text | Backslashes, tildes and backticks were escaped, which destroyed fences and Windows paths |
| Structure regenerated instead of preserved | An ordered list starting at 3 was renumbered from 1; blank-line runs were collapsed |
| Semantics changed | A CommonMark soft break became a hard line break |
Seven of those were reproducible corruptions that stuck around and got worse with each save. Here's the table from the audit.
| Input | Audited output | Correct |
|---|---|---|
> a then > then > b | a second > added to the blank quote line | unchanged |
> a then > then > > b | the nested quote flattened and re-prefixed | unchanged |
a\ then b (backslash hard break) | the backslash doubled | unchanged |
C:\path\to | the backslashes doubled | unchanged |
a ~~~js fence | the tildes escaped, destroying the fence | unchanged, or a backtick fence |
| a double-backtick inline code span | the backticks escaped | unchanged |
| a loose list with a second paragraph | a stray blank line before the next item | unchanged |
Each one is now a fixture. The corpus grew to 22 documents, listed in
fixtures/editor-roundtrip/corruption.json,
where every case carries the source and a note saying what used to happen to it.
Editor layer and serializer layer
Mixing these two up is how the original bugs went unnoticed, so the suite tests them separately.
The editor layer covers opening a document, looking around and closing it. Byte identity is required here. The editor keeps the original source and the byte span of every top-level block, and writes unchanged blocks back from those bytes instead of re-serializing them.
The serializer layer is documentToMarkdown(compileMarkdown(source)). That path
canonicalizes on purpose, so we don't expect or want byte identity there. What has to hold
is that the meaning doesn't change and that saving twice gives the same result as saving
once. Idempotence is what the audit really cared about, since the damage it found got
worse on every save.
| Layer | Assertion | Result | Test |
|---|---|---|---|
| Editor, GFM on | Bytes identical | 22 of 22 | packages/editor/tests/roundtrip.test.ts |
| Editor, GFM off | Bytes identical | 22 of 22 | same file |
| Editor, 16 extra cases beyond the corpus | Bytes identical | 16 of 16 | same file |
| Serializer | Meaning unchanged | 22 of 22 | tests/roundtrip.test.ts |
| Serializer | Saving twice equals saving once | 22 of 22 | same file |
| Serializer | Bytes identical | 14 of 22 | same file |
Both suites fail the build if a case stops passing. The serializer test also prints the per-case table and keeps a list of the cases that are byte-identical today. That list can grow but shouldn't shrink, so losing byte identity counts as a failure even if the meaning survives.
The 22 documents
Each case is a construct you'd actually find in documents (none of them are made-up strings).
| Case | Source | What used to happen |
|---|---|---|
blockquote-blank-line | "> a\n>\n> b\n" | the audited editor added a second > to the blank quote line |
blockquote-nested | "> a\n>\n> > b\n" | the audited editor flattened and re-prefixes nested quotes |
backslash-hard-break | "a\\\nb\n" | the audited editor doubled the backslash on every save |
windows-path | "Open `C:\\path\\to` or C:\\path\\to now.\n" | the audited editor doubled backslashes in plain text |
tilde-fence | "~~~js\nconst x = 1\n~~~\n" | the audited editor escaped tildes, destroying the fence |
double-backtick-code | "Use `` a ` b `` here.\n" | the audited editor escaped the backticks |
loose-list-second-paragraph | "- a\n\n second para\n\n- b\n" | the audited editor inserted a stray blank line |
setext-heading | "Title\n=====\n\nBody.\n" | the audited editor kept it as paragraph text with a line break |
indented-code | "Para.\n\n indented code\n line two\n" | the audited editor showed it as literal paragraph text |
reference-link | "See [text][ref].\n\n[ref]: https://example.com\n" | the audited editor never parsed it (audit G2) |
reference-image | "![alt][img]\n\n[img]: https://example.com/a.png\n" | the audited editor never parsed it (audit G2) |
image-inline | "\n" | the audited editor rendered a literal ! plus a link (audit G1) |
image-with-title | "\n" | the audited editor rendered a literal ! plus a link (audit G1) |
autolink | "<https://example.com>\n" | the audited editor kept it as text |
html-block | "<div class=\"note\">\n <p>hi</p>\n</div>\n" | the audited editor showed raw source in prose (audit G3) |
html-comment | "<!-- a note -->\n\nBody.\n" | the audited editor showed raw source in prose (audit G3) |
ordered-list-start | "3. three\n4. four\n" | the audited editor renumbered from 1 (audit G11) |
soft-break | "a\nb\n" | the audited editor turned a soft break into a hard line break (audit G5) |
underscore-emphasis | "_em_ and __strong__\n" | the audited editor normalized to asterisks, changing the source |
thematic-break-variants | "a\n\n***\n\nb\n\n___\n\nc\n" | the audited editor rewrote every variant to --- |
entity | "AT&T and © 2026\n" | the audited editor double-escaped the entity |
blank-line-runs | "a\n\n\n\nb\n" | the audited editor collapsed to one blank line |
The table rows are read from the fixture at build time, so the page stays in sync with the suite.
See it
Edit one block below and the others should come back exactly as they were written. The setext heading stays setext, the tilde fence keeps its tildes and the loose list keeps its blank lines.
If you want to try a document of your own, the editor demo has a panel that does the whole round trip and diffs it line by line. Paste Markdown, save it, read the diff.
Run it against your own documents
You don't need a browser for the round trip. createMarkdownBridge with headless: true
is the same pipeline the React editor runs, built on @lexical/headless, so it works in
plain Node.
npm install @react-markdown-kit/editor @react-markdown-kit/renderer
// roundtrip.mjs: node roundtrip.mjs docs/**/*.md
import { readFileSync } from 'node:fs'
import { createMarkdownBridge } from '@react-markdown-kit/editor'
import { defineMarkdownPreset, gfm } from '@react-markdown-kit/renderer'
const preset = defineMarkdownPreset({ extensions: [gfm()] })
let changed = 0
for (const file of process.argv.slice(2)) {
const source = readFileSync(file, 'utf8')
const bridge = createMarkdownBridge({ preset, headless: true })
bridge.load(source)
const saved = bridge.getMarkdown()
if (saved !== source) {
changed += 1
console.log(`${file}: ${source.length} bytes in, ${saved.length} bytes out`)
}
}
console.log(`${process.argv.length - 2 - changed} unchanged, ${changed} changed`)
Use the same preset your application renders with. If a file comes back changed, it's worth a close look. Either it uses a construct your preset doesn't enable, or it's a bug worth reporting.
As a Vitest test it looks something like this:
import { describe, expect, it } from 'vitest'
import { createMarkdownBridge } from '@react-markdown-kit/editor'
import { defineMarkdownPreset, gfm } from '@react-markdown-kit/renderer'
const preset = defineMarkdownPreset({ extensions: [gfm()] })
const roundTrip = (source: string): string => {
const bridge = createMarkdownBridge({ preset, headless: true })
bridge.load(source)
return bridge.getMarkdown()
}
describe('our content', () => {
for (const file of files) {
it(`${file} survives a save`, () => {
const source = readFileSync(file, 'utf8')
expect(roundTrip(source)).toBe(source)
})
}
})
The corpus itself is plain JSON with a cases array of { name, source, why }. None of
it is specific to this package, so you can run it against any other editor that imports
and exports a Markdown string.
What is still normalized
Editing a block rewrites that block in the serializer's canonical form (- bullets, **
for strong, backtick fences, ATX headings). Blocks you didn't edit are left alone, so a
commit diff only shows what you changed.
If you do want the whole document in canonical form, use the serializer path,
documentToMarkdown(compileMarkdown(source)).
Compiling
FAQ
What is a Markdown round trip?
A round trip means opening a Markdown document in an editor and saving it again. It's lossless when the saved file is the same as the file you opened. The corpus here holds 22 documents that a line-oriented editor changed on save, and the editor package asserts byte identity for every one of them.
Why do Markdown editors corrupt files?
Most of them convert Markdown into an editing model and serialize the whole document back out on every save. Anything the model can't hold gets lost, and anything the serializer spells differently gets rewritten, including blocks nobody touched. Importers that work line by line with regular expressions cause a second kind of damage, since they never parse things like reference links at all.
How do I test round-trip fidelity on my own documents?
Load each file through createMarkdownBridge with headless set to true, read the Markdown back, and compare the strings. The bridge doesn't need a DOM, so it runs in plain Node, either in a test or in a script over a directory of files. There's a working script on this page.
Is byte identity always the right goal?
Only for blocks you didn't edit. A block you did edit gets rewritten in the serializer's canonical form (dash bullets, double asterisks for strong, backtick fences). That rewrite only applies to the block you changed, so the rest of the file is left as it was.
Next
Editor demo with the diff panel · @react-markdown-kit/editor on npm · The audit · Source on GitHub
React Markdown editor · Round-trip reference · Compared with MDXEditor · Compared with Milkdown