Skip to main content

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 failureWhat the audit found
Constructs that are never parsedReference links and reference images survived only as dead literal text
Constructs outside the preset![alt](src) rendered as a literal exclamation mark followed by a link
Unsupported syntax degraded into proseRaw 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 textBackslashes, tildes and backticks were escaped, which destroyed fences and Windows paths
Structure regenerated instead of preservedAn ordered list starting at 3 was renumbered from 1; blank-line runs were collapsed
Semantics changedA 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.

InputAudited outputCorrect
> a then > then > ba second > added to the blank quote lineunchanged
> a then > then > > bthe nested quote flattened and re-prefixedunchanged
a\ then b (backslash hard break)the backslash doubledunchanged
C:\path\tothe backslashes doubledunchanged
a ~~~js fencethe tildes escaped, destroying the fenceunchanged, or a backtick fence
a double-backtick inline code spanthe backticks escapedunchanged
a loose list with a second paragrapha stray blank line before the next itemunchanged

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.

LayerAssertionResultTest
Editor, GFM onBytes identical22 of 22packages/editor/tests/roundtrip.test.ts
Editor, GFM offBytes identical22 of 22same file
Editor, 16 extra cases beyond the corpusBytes identical16 of 16same file
SerializerMeaning unchanged22 of 22tests/roundtrip.test.ts
SerializerSaving twice equals saving once22 of 22same file
SerializerBytes identical14 of 22same 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).

CaseSourceWhat 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"![alt](https://example.com/a.png)\n"the audited editor rendered a literal ! plus a link (audit G1)
image-with-title"![alt](https://example.com/a.png \"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&amp;T and &copy; 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.

Loading editor

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