diff --git a/ui/src/components/MarkdownEditor.test.tsx b/ui/src/components/MarkdownEditor.test.tsx
index 1d442922983..a172ff8853e 100644
--- a/ui/src/components/MarkdownEditor.test.tsx
+++ b/ui/src/components/MarkdownEditor.test.tsx
@@ -26,6 +26,8 @@ const mdxEditorMockState = vi.hoisted(() => ({
throwOnRender: false,
/** Markdown the mock emits through `onChange` once mounted, as the real editor would export it. */
emitMountChange: null as string | null,
+ /** Markdown the mock emits once mounted with MDXEditor's `initialMarkdownNormalize` flag set. */
+ emitMountNormalizedMarkdown: null as string | null,
/** Milliseconds the mock waits before painting its content, to model a slow import. */
populateDelayMs: 0,
markdownValues: [] as string[],
@@ -72,7 +74,7 @@ vi.mock("@mdxeditor/editor", async () => {
}: {
markdown: string;
placeholder?: string;
- onChange?: (value: string) => void;
+ onChange?: (value: string, initialMarkdownNormalize?: boolean) => void;
onError?: (error: unknown) => void;
suppressHtmlProcessing?: boolean;
className?: string;
@@ -150,6 +152,10 @@ vi.mock("@mdxeditor/editor", async () => {
if (mdxEditorMockState.emitMountChange !== null) {
onChange?.(mdxEditorMockState.emitMountChange);
}
+ if (mdxEditorMockState.emitMountNormalizedMarkdown !== null) {
+ setContent(mdxEditorMockState.emitMountNormalizedMarkdown);
+ onChange?.(mdxEditorMockState.emitMountNormalizedMarkdown, true);
+ }
}, 0);
return () => {
window.clearTimeout(timer);
@@ -326,6 +332,7 @@ describe("MarkdownEditor", () => {
mdxEditorMockState.emitMountSilentEmptyState = false;
mdxEditorMockState.throwOnRender = false;
mdxEditorMockState.emitMountChange = null;
+ mdxEditorMockState.emitMountNormalizedMarkdown = null;
mdxEditorMockState.populateDelayMs = 0;
mdxEditorMockState.markdownValues = [];
mdxEditorMockState.insertedMarkdownValues = [];
@@ -695,6 +702,31 @@ describe("MarkdownEditor", () => {
});
});
+ it("does not report MDXEditor's initial markdown normalization as a change", async () => {
+ // MDXEditor rewrites `-` bullets to `*` on load and flags it as normalization.
+ mdxEditorMockState.emitMountNormalizedMarkdown = "* first\n* second";
+ const handleChange = vi.fn();
+ const root = createRoot(container);
+
+ await act(async () => {
+ root.render(
+ ,
+ );
+ });
+
+ await flush();
+ await waitPastEmptyHeuristic();
+
+ expect(handleChange).not.toHaveBeenCalled();
+ // The editor keeps its normalized document; nothing pushes the original back.
+ expect(container.textContent).toContain("* first");
+ expect(container.querySelector("textarea")).toBeNull();
+
+ await act(async () => {
+ root.unmount();
+ });
+ });
+
it("returns editor output to the parent with the transport escaping removed", async () => {
const value = "Affected versions: <= v0.3.1\n\nRename to the real name.";
// What the real exporter emits for this document: `<` re-escaped, `<=` bare.
diff --git a/ui/src/components/MarkdownEditor.tsx b/ui/src/components/MarkdownEditor.tsx
index 80d13c6b5a3..39a0f3d9f6b 100644
--- a/ui/src/components/MarkdownEditor.tsx
+++ b/ui/src/components/MarkdownEditor.tsx
@@ -1532,7 +1532,7 @@ export const MarkdownEditor = forwardRef
suppressHtmlProcessing
placeholder={placeholder}
readOnly={readOnly}
- onChange={(rawNext) => {
+ onChange={(rawNext, initialMarkdownNormalize) => {
if (readOnly) return;
// Reverse the editor-only rewrites: blockquotes the exporter escaped
// as `\>` (so a `>`-prefixed line the user typed survives even when
@@ -1560,6 +1560,13 @@ export const MarkdownEditor = forwardRef
return;
}
}
+ // MDXEditor re-serialises the initial markdown on load (bullet
+ // markers, escapes, blank lines) and flags that emission. It is not
+ // a user edit: forwarding it marks every controlled parent with
+ // dirty tracking as unsaved before any keystroke. `latestValueRef`
+ // stays on the parent's value, so the prop-sync effect does not
+ // push the original back over the editor.
+ if (initialMarkdownNormalize) return;
// `latestValueRef` is compared against `editorValue`, so it has to
// hold editor space; storing `next` would make every edit containing
// an escaped bracket look like a pending prop sync and trigger a