diff --git a/ui/src/components/MarkdownEditor.test.tsx b/ui/src/components/MarkdownEditor.test.tsx index 7c3c1be645..5787060965 100644 --- a/ui/src/components/MarkdownEditor.test.tsx +++ b/ui/src/components/MarkdownEditor.test.tsx @@ -302,6 +302,70 @@ describe("MarkdownEditor", () => { }); }); + it("does not recreate the mention decoration observer when the external value changes", async () => { + const originalMutationObserver = globalThis.MutationObserver; + + class MockMutationObserver implements MutationObserver { + static instances: MockMutationObserver[] = []; + + readonly observe = vi.fn(); + readonly disconnect = vi.fn(); + readonly takeRecords = vi.fn<() => MutationRecord[]>(() => []); + + constructor(readonly callback: MutationCallback) { + MockMutationObserver.instances.push(this); + } + } + + vi.stubGlobal("MutationObserver", MockMutationObserver); + const root = createRoot(container); + + try { + await act(async () => { + root.render( + {}} + placeholder="Markdown body" + />, + ); + }); + + await flush(); + const editable = container.querySelector('[contenteditable="true"]'); + expect(editable).not.toBeNull(); + const mentionObserverCountAfterInitialRender = MockMutationObserver.instances.filter( + (observer) => observer.observe.mock.calls.some(([target]) => target === editable), + ).length; + + await act(async () => { + root.render( + {}} + placeholder="Markdown body" + />, + ); + }); + + await flush(); + + // A separate rich-editor health observer is expected to recreate when the + // controlled value changes. This assertion only covers the mention + // decoration observer that attaches to the editable element itself. + expect( + MockMutationObserver.instances.filter( + (observer) => observer.observe.mock.calls.some(([target]) => target === editable), + ), + ).toHaveLength(mentionObserverCountAfterInitialRender); + } finally { + await act(async () => { + root.unmount(); + }); + vi.stubGlobal("MutationObserver", originalMutationObserver); + } + }); + it("converts advisory-style html image tags to markdown image syntax before mounting the editor", async () => { const root = createRoot(container); diff --git a/ui/src/components/MarkdownEditor.tsx b/ui/src/components/MarkdownEditor.tsx index 41ad6de02e..9819cbf033 100644 --- a/ui/src/components/MarkdownEditor.tsx +++ b/ui/src/components/MarkdownEditor.tsx @@ -1000,17 +1000,39 @@ export const MarkdownEditor = forwardRef useEffect(() => { const editable = containerRef.current?.querySelector('[contenteditable="true"]'); if (!editable) return; - decorateProjectMentions(); - const observer = new MutationObserver(() => { + let frameId: number | null = null; + let disposed = false; + const observe = () => { + observer.observe(editable, { + subtree: true, + childList: true, + characterData: true, + }); + }; + const flushDecorations = () => { + frameId = null; + if (disposed) return; + observer.disconnect(); decorateProjectMentions(); + if (!disposed) observe(); + }; + const scheduleDecorations = () => { + if (frameId !== null) return; + frameId = requestAnimationFrame(flushDecorations); + }; + const observer = new MutationObserver(() => { + scheduleDecorations(); }); - observer.observe(editable, { - subtree: true, - childList: true, - characterData: true, - }); - return () => observer.disconnect(); - }, [decorateProjectMentions, value]); + + flushDecorations(); + return () => { + disposed = true; + observer.disconnect(); + if (frameId !== null) { + cancelAnimationFrame(frameId); + } + }; + }, [decorateProjectMentions]); const selectMention = useCallback( (option: AutocompleteOption) => {