Skip to content

fix: leave whitespace that starts a line out of the saved file - #342

Merged
Azganoth merged 1 commit into
mainfrom
bug/line-initial-whitespace
Aug 31, 2026
Merged

fix: leave whitespace that starts a line out of the saved file#342
Azganoth merged 1 commit into
mainfrom
bug/line-initial-whitespace

Conversation

@Azganoth

Copy link
Copy Markdown
Owner

Summary

A space typed at the start of a line was written as  , reloaded with the space intact, written raw on the next save, and gone on the open after that. The document took three saves to settle and lost the whitespace on the way.

serializeMarkdownText now leaves that whitespace out, the way #278 leaves out whitespace closing a line. opensTrimmedContent asks whether a text node begins the content of a paragraph, a heading, or a table cell, which are the parents a parse trims, and the value is escaped without the leading whitespace so the escape passes read the real line start.

Two things decide where a line begins, because neither covers the other:

  • A line ending in before marks the line a hard break leaves behind.
  • Everywhere else the position is read off the tree, because a heading hands its first child the ATX marker as before and a cell hands its own padding, so neither block's first line is visible in before at all.

Reading the tree is also what separates a hoisted space from an ordinary one. Milkdown's SerializerState lifts a leading ASCII space out of the mark that records a character reference and leaves the mark empty (@milkdown/transformer@7.22.1, #moveSpaces), so an authored   arrives as a whitespace-only sibling with an emptied reference in front of it. An emptied reference writes nothing, while any sibling that writes even one character puts the space mid-line, which is the difference between  plain and a b.

The position comes from the tree rather than from state.indexStack for a second reason. containerPhrasing peeks the next child's handler to learn what the current one must be escaped against, and during that peek the stack still points at the child being written. Trusting it made " and " in testing@example.com and … look like the first child, so the peek dropped its leading space and reported a as the following character; the bare autolink was then written as <testing@example.com>. Resolving the index by identity makes a peek agree with the write it predicts.

Not writing the character is the direction, rather than encoding it as &#x20;. This edge is not symmetric with #278: a leading space in a heading and a leading tab in a paragraph already round-tripped, so preservation was reachable here in a way it was not there. It would still return the entity noise #242 removed to every file whose line happens to begin in whitespace, and a parse strips the character on read while a block renders it as nothing, so the two directions differ in what the file carries rather than in what an author can observe.

Related Issue

Closes #339

Verification

markdownCompatibility.test.ts gains Line-initial whitespace, which drives the editor mount through edit, save, reload, save and asserts each stage rather than the bytes alone.

Eight shapes that diverged now converge after a space is typed at the start: a paragraph, a paragraph with two spaces, a paragraph with a tab, a heading, a list item, a blockquote, a later paragraph in the same document, and emphasis opening a paragraph. Alongside them a table cell, the line a hard break leaves behind, and an authored &#x20; opening a paragraph and a heading. Two rows assert the reloaded document text, for the typed space and for the authored reference.

Five rows assert what must not change, each being whitespace a parse keeps or a reference written from its source: a character reference away from a line edge, &nbsp;, &#x9;, a space a construct on the same line precedes, and whitespace inside fenced code.

corpusRoundTrip.test.ts and the testing@example.com and first.last+tag@example.co.uk autolink fixture both caught the peek defect described above, and both pass.

pnpm check:frontend passes. The backend is untouched, so pnpm check:backend was not run.

Not verified: the manual pass over corpus/ in the running application, which covers rendering, interaction, and navigator behavior beyond the automated round trip.

Notes

Whitespace that round-tripped as a character reference is now dropped: a typed tab, which was written as &#x9;, and a leading space in a heading, which mdast-util-to-markdown's own heading handler encodes as &#x20; before this change reaches it. An authored reference is dropped only where Milkdown hoists its character out of the mark, which it does for the ASCII space alone, so &nbsp; and &#x9; keep their source and are written as they were.

The issue framed this edge as a mirror of #278 and expected preservation to be unreachable. Measurement contradicted that on both counts, and the body has been left as filed; the direction was confirmed against the measurements before implementing.

The corpus round-trip guard cannot see any of this and is unchanged, for the reason #278 recorded: a file it has opened once no longer holds line-initial whitespace, so its baseline is already stable.

TRAILING_WHITESPACE_PATTERN is /\s+$/u, which matches every Unicode space separator rather than the space and tab CommonMark trims, so #278's trim deletes a no-break space that ends a line. The leading pattern added here is /^[\t ]+/u and does not repeat it, which is why the two edges disagree until that is fixed. Filed as #341.

Encoding it as `&#x20;` would preserve it, and unlike the closing edge that
direction was reachable here: a leading space in a heading and a leading tab
in a paragraph already round-trip. It would still return the entity noise
#242 removed to every file whose line happens to begin in whitespace, and a
parse strips the character on read while a block renders it as nothing, so
the two directions differ in what the file carries rather than in what an
author can observe.

Whitespace typed at one of those positions is dropped as a result, a tab and
a leading space in a heading included, both of which round-tripped as a
character reference before. An authored reference is dropped only where
Milkdown hoists its character out of the mark that records it, which it does
for the ASCII space alone; `&nbsp;` and `&#x9;` keep their source and are
written as they were.
@Azganoth Azganoth added the Bug Something isn't working label Aug 31, 2026
@Azganoth Azganoth self-assigned this Aug 31, 2026
@Azganoth
Azganoth merged commit 2f6f0ed into main Aug 31, 2026
3 checks passed
@Azganoth
Azganoth deleted the bug/line-initial-whitespace branch August 31, 2026 19:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Whitespace that starts a line is saved as a character reference and then lost

1 participant