From 10a2a6c2afba23de5fd3b87645cdb7393e504baa Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 7 Oct 2026 22:53:01 -0700 Subject: [PATCH] perf: reuse Markdown fence scans across sibling toggles (#26324) --- .../editor/details-markdown-html.ts | 8 +++ .../raw-markdown-html.details-fences.test.ts | 70 +++++++++++++++++++ .../components/editor/raw-markdown-html.ts | 7 +- 3 files changed, 81 insertions(+), 4 deletions(-) create mode 100644 src/renderer/src/components/editor/raw-markdown-html.details-fences.test.ts diff --git a/src/renderer/src/components/editor/details-markdown-html.ts b/src/renderer/src/components/editor/details-markdown-html.ts index 5c9c9d8e307..7eaedf97cd3 100644 --- a/src/renderer/src/components/editor/details-markdown-html.ts +++ b/src/renderer/src/components/editor/details-markdown-html.ts @@ -92,6 +92,14 @@ export function renderDetailsAttributes(attrs: Record | undefin return attributes.join(' ') } +export function createDetailsMatcher(content: string): (start: number) => DetailsHtmlBlock | null { + let fenceRanges: MarkdownFenceRanges | undefined + return (start) => + /^]*>/i.test(content.slice(start)) + ? matchDetailsHtmlBlock(content, start, (fenceRanges ??= getMarkdownFenceRanges(content))) + : null +} + export function matchDetailsHtmlBlock( content: string, start: number, diff --git a/src/renderer/src/components/editor/raw-markdown-html.details-fences.test.ts b/src/renderer/src/components/editor/raw-markdown-html.details-fences.test.ts new file mode 100644 index 00000000000..33f5c1b6a30 --- /dev/null +++ b/src/renderer/src/components/editor/raw-markdown-html.details-fences.test.ts @@ -0,0 +1,70 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { encodeRawMarkdownHtmlForRichEditor } from './raw-markdown-html' +import { createRichMarkdownEditorCodec } from './rich-markdown-source-transport' +import * as fenceScanner from './markdown-fence-scanner' + +vi.mock('./markdown-fence-scanner', { spy: true }) + +const key = '0123456789abcdef0123456789abcdef' + +afterEach(() => vi.restoreAllMocks()) + +describe('raw Markdown details fence scanning', () => { + it('scans the document once for sibling toggles', () => { + const content = Array.from( + { length: 50 }, + (_, index) => `
Section ${index}\n\nBody ${index}\n\n
` + ).join('\n\n') + const scanFences = vi.mocked(fenceScanner.getMarkdownFenceRanges) + scanFences.mockClear() + + expect(encodeRawMarkdownHtmlForRichEditor(content, createRichMarkdownEditorCodec(key))).toBe( + content + ) + expect(scanFences).toHaveBeenCalledTimes(1) + }) + + it.each([ + 'Plain text\n\nMore plain text', + 'Code\n\nBody\n\n\n```' + ])('does not scan fences when no opening tag is matched: %j', (content) => { + const scanFences = vi.mocked(fenceScanner.getMarkdownFenceRanges) + scanFences.mockClear() + expect(encodeRawMarkdownHtmlForRichEditor(content, createRichMarkdownEditorCodec(key))).toBe( + content + ) + expect(scanFences).toHaveBeenCalledTimes(0) + }) + + it('preserves nested and mixed-case editable toggles', () => { + const content = + '
Outer\n\n
Inner\n\nBody\n\n
\n\n
' + expect(encodeRawMarkdownHtmlForRichEditor(content, createRichMarkdownEditorCodec(key))).toBe( + content + ) + }) + + it('uses each document’s own fences when the codec is reused', () => { + const codec = createRichMarkdownEditorCodec(key) + const first = '
First\n\nBody\n\n
' + const second = + '
Second\n\n```html\n
\n```\n\nBody\n\n' + expect(encodeRawMarkdownHtmlForRichEditor(first, codec)).toBe(first) + expect(encodeRawMarkdownHtmlForRichEditor(second, codec)).toBe( + codec.transport.create('block-html', second) + ) + expect(encodeRawMarkdownHtmlForRichEditor(first, codec)).toBe(first) + }) + + it('preserves fenced code before a non-editable sibling', () => { + const codec = createRichMarkdownEditorCodec(key) + const editable = '
Editable\n\nBody\n\n
' + const raw = '
Raw\n\nBody\n\n
' + const prefix = '~~~html\n
Code
\n~~~\n\n' + expect(encodeRawMarkdownHtmlForRichEditor(`${prefix}${editable}\n\n${raw}`, codec)).toBe( + `${prefix}${editable}\n\n${codec.transport.create('block-html', raw)}` + ) + }) +}) diff --git a/src/renderer/src/components/editor/raw-markdown-html.ts b/src/renderer/src/components/editor/raw-markdown-html.ts index 72fca0efafe..1c6c334b90b 100644 --- a/src/renderer/src/components/editor/raw-markdown-html.ts +++ b/src/renderer/src/components/editor/raw-markdown-html.ts @@ -1,10 +1,9 @@ -import { isEditableDetailsHtmlBlock, matchDetailsHtmlBlock } from './details-markdown-html' +import { createDetailsMatcher, isEditableDetailsHtmlBlock } from './details-markdown-html' import { formatMarkdownDocLinkBody, parseMarkdownDocLink } from './markdown-doc-links' import { normalizeMarkdownReferenceLinks } from './markdown-reference-link-normalization' import type { RichMarkdownEditorCodec } from './rich-markdown-source-transport' import { isReservedRichMarkdownTransportBody } from './rich-markdown-source-transport' import { matchHtmlSuperscriptLinkSource } from './rich-markdown-html-superscript-link-source' -import { getMarkdownFenceRanges } from './markdown-fence-scanner' const INLINE_HTML_PATTERN = /^|^<\/?[A-Za-z][\w.:-]*(?:\s[^<>]*?)?\/?>/ @@ -46,7 +45,7 @@ export function encodeRawMarkdownHtmlForRichEditor( { htmlSuperscriptLinks = false }: { htmlSuperscriptLinks?: boolean } = {} ): string { const normalizedContent = normalizeMarkdownReferenceLinks(content) - const fenceRanges = getMarkdownFenceRanges(normalizedContent) + const matchDetails = createDetailsMatcher(normalizedContent) const lastCommentClose = normalizedContent.lastIndexOf('-->') const { transport } = codec let index = 0 @@ -123,7 +122,7 @@ export function encodeRawMarkdownHtmlForRichEditor( } if (isLineStart) { - const detailsHtml = matchDetailsHtmlBlock(normalizedContent, index, fenceRanges) + const detailsHtml = matchDetails(index) if (detailsHtml && isEditableDetailsHtmlBlock(detailsHtml)) { // Why:
/ is an editable rich-mode node; raw passthrough // would make toggle blocks reopen as inert HTML instead.