From bfb62e2bcf6ddd7f39d06e6f555fec173ef12e39 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 14 May 2026 01:18:46 -0700 Subject: [PATCH] perf(editor): debounce markdown doc link decorations (#1830) Debounce Monaco markdown doc-link decoration refreshes so rapid content changes coalesce into one full-model scan, while preserving immediate initial render and cleanup behavior. --- ...naco-markdown-doc-link-decorations.test.ts | 98 ++++++++++++++++++- .../monaco-markdown-doc-link-decorations.ts | 28 +++++- 2 files changed, 122 insertions(+), 4 deletions(-) diff --git a/src/renderer/src/components/editor/monaco-markdown-doc-link-decorations.test.ts b/src/renderer/src/components/editor/monaco-markdown-doc-link-decorations.test.ts index 06b23e8a225..65331bbd6b8 100644 --- a/src/renderer/src/components/editor/monaco-markdown-doc-link-decorations.test.ts +++ b/src/renderer/src/components/editor/monaco-markdown-doc-link-decorations.test.ts @@ -1,5 +1,10 @@ -import { describe, expect, it } from 'vitest' -import { getMarkdownDocLinkDecorationRanges } from './monaco-markdown-doc-link-decorations' +import type { editor } from 'monaco-editor' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + createMarkdownDocLinkDecorationController, + getMarkdownDocLinkDecorationRanges, + MARKDOWN_DOC_LINK_DECORATION_REFRESH_DELAY_MS +} from './monaco-markdown-doc-link-decorations' describe('getMarkdownDocLinkDecorationRanges', () => { it('returns Monaco ranges for valid doc links', () => { @@ -30,3 +35,92 @@ describe('getMarkdownDocLinkDecorationRanges', () => { ]) }) }) + +describe('createMarkdownDocLinkDecorationController', () => { + afterEach(() => { + vi.useRealTimers() + }) + + it('debounces full-model decoration rebuilds during rapid content changes', () => { + vi.useFakeTimers() + + let modelValue = '[[initial.md]]' + let contentListener = (): void => {} + const set = vi.fn() + const clear = vi.fn() + const dispose = vi.fn() + + const editorInstance = { + createDecorationsCollection: () => ({ set, clear }), + getModel: () => ({ getValue: () => modelValue }), + onDidChangeModelContent: (listener: () => void) => { + contentListener = listener + return { dispose } + } + } as unknown as editor.IStandaloneCodeEditor + + const controller = createMarkdownDocLinkDecorationController(editorInstance, () => 'markdown') + expect(set).toHaveBeenCalledTimes(1) + + set.mockClear() + modelValue = '[[first.md]]' + contentListener?.() + vi.advanceTimersByTime(MARKDOWN_DOC_LINK_DECORATION_REFRESH_DELAY_MS - 1) + modelValue = '[[second.md]]' + contentListener?.() + modelValue = '[[final.md]]' + contentListener?.() + + expect(set).not.toHaveBeenCalled() + vi.advanceTimersByTime(MARKDOWN_DOC_LINK_DECORATION_REFRESH_DELAY_MS - 1) + expect(set).not.toHaveBeenCalled() + + vi.advanceTimersByTime(1) + expect(set).toHaveBeenCalledTimes(1) + expect(set.mock.calls[0]?.[0]).toEqual([ + { + range: { + startLineNumber: 1, + startColumn: 1, + endLineNumber: 1, + endColumn: 13 + }, + options: { + inlineClassName: 'monaco-markdown-doc-link', + stickiness: 1 + } + } + ]) + + controller.dispose() + expect(dispose).toHaveBeenCalledTimes(1) + expect(clear).toHaveBeenCalledTimes(1) + }) + + it('cancels pending decoration rebuilds on dispose', () => { + vi.useFakeTimers() + + let contentListener = (): void => {} + const set = vi.fn() + const clear = vi.fn() + + const editorInstance = { + createDecorationsCollection: () => ({ set, clear }), + getModel: () => ({ getValue: () => '[[initial.md]]' }), + onDidChangeModelContent: (listener: () => void) => { + contentListener = listener + return { dispose: vi.fn() } + } + } as unknown as editor.IStandaloneCodeEditor + + const controller = createMarkdownDocLinkDecorationController(editorInstance, () => 'markdown') + set.mockClear() + + contentListener?.() + controller.dispose() + vi.advanceTimersByTime(MARKDOWN_DOC_LINK_DECORATION_REFRESH_DELAY_MS) + + expect(set).not.toHaveBeenCalled() + expect(clear).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/renderer/src/components/editor/monaco-markdown-doc-link-decorations.ts b/src/renderer/src/components/editor/monaco-markdown-doc-link-decorations.ts index 893ae8062a6..08f011f3a65 100644 --- a/src/renderer/src/components/editor/monaco-markdown-doc-link-decorations.ts +++ b/src/renderer/src/components/editor/monaco-markdown-doc-link-decorations.ts @@ -71,13 +71,25 @@ export type MarkdownDocLinkDecorationController = { dispose: () => void } +export const MARKDOWN_DOC_LINK_DECORATION_REFRESH_DELAY_MS = 120 + export function createMarkdownDocLinkDecorationController( editorInstance: editor.IStandaloneCodeEditor, getLanguage: () => string ): MarkdownDocLinkDecorationController { const collection = editorInstance.createDecorationsCollection() + let refreshTimer: ReturnType | null = null - const refresh = (): void => { + const cancelPendingRefresh = (): void => { + if (refreshTimer === null) { + return + } + clearTimeout(refreshTimer) + refreshTimer = null + } + + const refreshNow = (): void => { + cancelPendingRefresh() const model = editorInstance.getModel() if (!model || getLanguage() !== 'markdown') { collection.clear() @@ -94,12 +106,24 @@ export function createMarkdownDocLinkDecorationController( ) } + const refresh = (): void => { + if (getLanguage() !== 'markdown') { + refreshNow() + return + } + cancelPendingRefresh() + // Why: wiki-link decoration scans read the full Monaco model. During typing + // the exact highlight can lag briefly; coalescing avoids one full scan per key. + refreshTimer = setTimeout(refreshNow, MARKDOWN_DOC_LINK_DECORATION_REFRESH_DELAY_MS) + } + const listener: IDisposable = editorInstance.onDidChangeModelContent(refresh) - refresh() + refreshNow() return { refresh, dispose: () => { + cancelPendingRefresh() listener.dispose() collection.clear() }