From fc6f15534ed0a09ccddd7dac42f558dd6578ec59 Mon Sep 17 00:00:00 2001 From: Dmitry Verkhoturov Date: Sun, 12 Apr 2026 10:40:20 +0200 Subject: [PATCH] fix(frontend): preserve orig verbatim in edit textarea (#2040) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The edit textarea was running `data.orig` through the browser's HTML parser via a detached `.innerHTML` to "decode entities", which turned user-typed `<`/`>` into real `<`/`>`. On save, blackfriday then saw a real ``'], + ['inline code with entity script tag', '`<script>`'], + [ + 'fenced html with iframe and double-escaped', + '```html\n\n&lt;b&gt;\n```', + ], + ['javascript link', '[click](javascript:alert(1))'], + ['image with entity alt and title', '![<alt>](x.png "&title&")'], + ['markdown backslash escapes', '\\*not em\\* \\_not em\\_ \\\\ \\< \\& \\`not code\\`'], + ['entities inside emphasis markers', '*<em>* _>underscore<_ **&bold&**'], + ['headers with entities', '# <h1>\n## &header&'], + ['blockquote with entities multiline', '> <foo>\n> &quoted&\n>\n> nested <bar/>'], + ['raw autolinks', ' see also '], + ['raw script tag no markdown', ''], + ['iframe object embed chain', ''], + ['kitchen sink', 'mix: `a` \\* *b* <c> &d& \\\\ \\`e\\` [f](javascript:0) ![g](h """)'], + + ['empty string', ''], + ['only whitespace', ' \t\n '], + ]; + + // HTML5 textarea.value always normalises \r\n and lone \r to \n. + // This happens inside the browser regardless of any remark42 code, + // so the edit round-trip guarantee is "byte-equal after newline normalisation". + const expectedTextareaValue = (raw: string) => raw.replace(/\r\n|\r/g, '\n'); + + it.each(cases)('renders unchanged: %s', (_label, payload) => { + CommentForm.textareaCounter = 0; + StaticStore.config.edit_duration = 300; + + const p = getProps(); + p.repliesCount = 0; + p.user!.id = '100'; + p.data.user.id = '100'; + p.editMode = CommentMode.Edit; + // @ts-ignore - CommentForm prop is optional on CommentProps + p.CommentForm = CommentForm; + Object.assign(p.data, { + id: '101', + vote: 1, + time: Date.now(), + delete: false, + orig: payload, + }); + + render(); + + const textarea = screen.getByTestId('textarea_1') as HTMLTextAreaElement; + expect(textarea.value).toBe(expectedTextareaValue(payload)); + }); + }); }); diff --git a/frontend/apps/remark42/app/components/comment/comment.tsx b/frontend/apps/remark42/app/components/comment/comment.tsx index a2d23141..2b49340d 100644 --- a/frontend/apps/remark42/app/components/comment/comment.tsx +++ b/frontend/apps/remark42/app/components/comment/comment.tsx @@ -303,14 +303,7 @@ export class Comment extends Component { ? intl.formatMessage(messages.deletedComment) : props.data.text, time: new Date(props.data.time), - orig: isEditing - ? props.data.orig && - props.data.orig.replace(/&[#A-Za-z0-9]+;/gi, (entity) => { - const span = document.createElement('span'); - span.innerHTML = entity; - return span.innerText; - }) - : props.data.orig, + orig: props.data.orig, user: props.data.user, };