Skip to main content

Lossless Markdown editing: the round-trip test suite

A Markdown round trip is one operation: open a file in an editor, save it, compare. It is lossless when the two files are the same bytes. Most rich Markdown editors are not tested against that, so the damage is found by a code reviewer in a diff nobody asked for.

This page is the evidence behind the round-trip claim in @react-markdown-kit/editor: what goes wrong elsewhere, 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 as a warning about one package, because the cause is a design that many editors share: it had no Markdown parser. Import and export ran through a transformer protocol that matches Markdown line by line with regular expressions.

Everything below follows from that single choice.

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, and compounded on the next save. This is 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.

Two layers, two different promises

Conflating these is how the original bugs went unnoticed, so the suite keeps them apart.

The editor layer is open, look around, close. Byte identity is required. The editor retains the original source and the byte span of every top-level block, and writes unchanged blocks back from those bytes rather than re-serializing them.

The serializer layer is documentToMarkdown(compileMarkdown(source)). That path canonicalizes on purpose, so byte identity is neither expected nor wanted. What must hold there is that the meaning is unchanged, and that saving twice equals saving once. Idempotence is the property the audit was really about, because the damage it found compounded 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 holds a list of the cases that are byte-identical today; that list may grow and must not shrink, so losing byte identity is a failure even when the meaning survives.

The 22 documents

Every case is a real construct, not a synthetic string.

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 rows in that table are read from the fixture at build time, so the page cannot drift from the suite.

See it

Edit one block below. The others come back exactly as they were written: the setext heading stays setext, the tilde fence stays tilde, the loose list keeps its blank lines.

Loading editor

For 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

The round trip needs no browser. 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. A file that comes back changed is worth reading closely: either it uses a construct your preset does not enable, or it is a bug worth reporting.

As a test, the shape is three lines:

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 }. Nothing in it is specific to this package, so it also runs against any other editor that can import and export a Markdown string.

What is still normalized

Editing a block rewrites that block, in the serializer canonical form: - bullets, ** for strong, backtick fences, ATX headings. A block you did not edit is not touched, so a commit diff shows what you changed and nothing else.

If you want the canonical form for a whole document on purpose, that is the serializer path: documentToMarkdown(compileMarkdown(source)). Compiling

FAQ

What is a Markdown round trip?

A round trip is opening a Markdown document in an editor and saving it again. It is 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 convert Markdown into an editing model and serialize the whole document back out on every save. Anything the model cannot hold is lost, and anything the serializer spells differently is rewritten, including blocks nobody touched. Line-oriented regular expression importers add a second class of damage, because they never parse constructs such as 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 needs no DOM, so it runs in plain Node, in a test or in a script over a directory of files. There is a working script on this page.

Is byte identity always the right goal?

No. It is the right goal for a block you did not edit. A block you did edit is rewritten in the serializer canonical form, which uses dash bullets, double asterisks for strong and backtick fences. That is normalization of something you changed, not corruption of something you did not.

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