Draft: Fix reference spacing, intraword emphasis and quoted references on rich text editor save
What does this MR do and why?
Saving a description from the rich text editor after any edit rewrites three kinds of markdown anywhere in the document. An untouched save is safe, because the editor only serializes once something changes.
- Adjacent references gain a space.
#1/#2saves as#1/ #2, and so do#1-#2,@root/@userandsee #1/#2, and !1.. The space shows in the rendered text too.ensureSpaceadded a space before every reference unless the preceding character was whitespace, a bracket, a quote or a mark delimiter. The parser only needs it where the neighbouring text would otherwise be absorbed into the reference, so the rule is inverted to exactly those cases: after a letter, digit or underscore (abc#1), or a path-like word ending in a hyphen or dot (foo-#1,v1.#1) unless that punctuation follows another reference, and after a reference when the following text would extend it (#1abc,~bugx,@root.x,%17.5x). - Intraword emphasis stops rendering.
a*b*csaves asa_b_c, which CommonMark renders literally, because an underscore cannot open or close emphasis next to a letter or digit. The italic serializer now writes*when the run touches a letter or digit on either side and keeps_elsewhere. The check is Unicode-aware (é_b_cis literal too), and the delimiter chosen at open is carried on serializer state for the close, as the link serializer does withinAutolink. - Quoted label and milestone references save as HTML entities.
~"UX Paper Cuts"becomes~"UX Paper Cuts". It still renders, but the source is unreadable in the plain text editor, in diffs and in history.data_attributes_forstoresdata-originalHTML-escaped and the extension read it raw. It now decodes it, and the writer escapes<and>the way the text serializer does, so a name with angle brackets writes as~"a \<b\> c"rather than becoming a tag.
Four commits: one per defect, plus a fourth hardening the spacing rule on both sides of a reference. Each carries the specs that fail at the merge base.
Unchanged: references separated by spaces, in brackets or quotes, or alone inside a mark (**#1**, _#1_); a word typed directly before a chip still saves as abc #1; ordinary italics and italics next to spaces or punctuation; bold followed by italics (**a**_b_); unquoted labels (~bug); references without data-original keep a null original text.
Resolves #628280
Evidence
- Verified on the production renderer, not on fixtures. Every claim above was reproduced by feeding
POST /api/v4/markdownoutput through the editor's own deserializer and serializer, then rendering the result back:#1/#2gives#1/ #2,a*b*cgivesa_b_cwhich renders as literal text,~"UX Paper Cuts"gives the entity form. - The parser's real boundaries were mapped against the same renderer rather than assumed, roughly 50 shapes on both sides of the rule. Full list in the collapsed block below; it is what the spacing rule is derived from.
- Jest: 21 new examples fail at the merge base and pass with their commit (5 reference, 8 italic, 8 across the extension and label serializer specs). The fourth commit adds 17 spacing cases, 10 of which fail at the merge base. Touched specs 75/75 under Vue 2 and Vue 3;
content_editortree 1247/1247 under Vue 2. - Twelve matrix rows and two GIF stories below. Two of the fourteen planned rows have no master-side still and are not shown; see the caption.
Parser boundaries as measured on the production renderer
Parser boundaries checked with the same renderer: both references render for #1#2, #1-#2, #1/#2, #1,#2, #1.#2, #1;#2, #1:#2, (#1)#2, #1|#2, #1=#2, #1+#2, %"17.5"#1, ~"UX Paper Cuts"#1 and @root+#1; the second reference is lost after a letter, digit or underscore (abc#1, 1#1, _#1, ~bug#1, @root#1, !1#1); é#1 and 日本#1 render. é_b_c stays literal while é*b*c renders. Rendered on 2026-09-09 for the spacing rule: foo-#1, v1.#1, x-y-#1, a.b#1, x_#1, #1abc, #1x, #1_x, ~bugx, ~bug?x, @root.x, @root-x and %17.5x lose the reference (@rootx resolves to a different user), while foo- #1, #1 abc, #1.x, #1-x, #1/x, #1é, #1**x**, #1*x*, ~bug -#1, ~"UX Paper Cuts"abc and #1.#2 keep it. a**_b_**c renders without the bold, a***b***c renders both. ~"a <b> c" written raw is parsed as an HTML tag, ~"a \<b\> c" renders as text.
Origins, and the two shapes the first version missed
Origins: the exception list comes from Fix reference serialization in RTE (!165017), which narrowed an earlier always-add-a-space rule; the underscore delimiter from Preserve bold, italic, code, and link syntax in the Content Editor (!87157); data-original has been read undecoded since Render references in content editor (!68230).
The fourth commit covers two shapes the first version left unhandled: a hyphenated word typed directly before a chip (foo- then #1) saved as foo-#1, which the renderer reads as a project path; and a word typed directly after a chip saved as #1abc, which the renderer does not read as a reference. Both now save with the protecting space. The italic form of an edge shared with Keep formatting marks in one span around links in the rich text editor (!254236) is tracked in issue 628260.
Screenshots or screen recordings
Every row is a description edited and saved in the rich text editor in real headless Chrome (a word typed into the plain first line, or for row 7 typed directly before the chip; the editor serializes only after an edit), on a project seeded with the issues, merge request, labels and milestone the references resolve against. Saved-markdown rows show the description re-opened in the plain text editor after the save. The two Unchanged rows are byte-identical between before and after. Two rows of the fourteen-row matrix have no master-side still yet and are not shown: the rendered intraword emphasis row (a*b*c and 2*3*4; its saved markdown is row 4 here, and the nested row shows the rendered loss) and the unchanged ordinary emphasis row (_word_ and **bold** and _two words_ here; covered by the jest examples). Their master-side captures timed out waiting for the editor to mount under machine load in the only complete run and will be re-captured. Head 7e97cf33 against master 26225e7e.
Recordings
Four beats per side: rendered from plain text, the editor open with a word typed into its first line, rendered after the save, and the saved markdown reopened in plain text. Frames 1 and 2 are byte-identical between the sides; the quoted label story stays identical through frame 3.
How to set up and validate locally
-
In a project with two issues, a merge request, a label with a space in its name and a milestone, set an issue description in the plain text editor to:
Either #1/#2 fixes it, see !1. a*b*c and 2*3*4 Tracked under ~"UX Paper Cuts" for %"Sprint 1". -
Edit it, switch to rich text, type a word anywhere, and save.
-
Before: the description reads
Either #1/ #2 fixes it, showsa_b_c and 2_3_4as literal text, and the plain text source shows~"UX Paper Cuts". After: the description and its source are what was typed. -
Regressions:
#1 and #2 with ~bug, (#1) or "#2"and_word_ and **bold** and _two words_ heresave unchanged after the same edit, and typing a word directly before a chip still saves with a separating space (abc #1).
MR acceptance checklist
Evaluate this MR against the MR acceptance checklist. It helps you analyze changes to reduce risks in quality, performance, reliability, security, and maintainability.



























