From 233f04988f5d9eb4f60d753ca861cee9ed1fc5bd Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Fri, 2 Oct 2026 18:25:35 -0700 Subject: [PATCH] fix(editor): avoid newline match arrays when saving LF Markdown (#24795) Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- ...ich-markdown-source-eol-allocation.test.ts | 62 +++++++++++++++++++ .../editor/rich-markdown-source-reconcile.ts | 3 + 2 files changed, 65 insertions(+) create mode 100644 src/renderer/src/components/editor/rich-markdown-source-eol-allocation.test.ts diff --git a/src/renderer/src/components/editor/rich-markdown-source-eol-allocation.test.ts b/src/renderer/src/components/editor/rich-markdown-source-eol-allocation.test.ts new file mode 100644 index 00000000000..32e1beb4da0 --- /dev/null +++ b/src/renderer/src/components/editor/rich-markdown-source-eol-allocation.test.ts @@ -0,0 +1,62 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { RICH_MARKDOWN_MAX_SIZE_BYTES } from '../../../../shared/constants' +import { + reconcileSerializedMarkdown, + restoreMarkdownSourceEol +} from './rich-markdown-source-reconcile' + +describe('rich Markdown source line-ending allocation', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it('serializes a supported LF edit without collecting a match for every newline', () => { + const source = `\`\`\`js\n${'x\n'.repeat(300_000)}const value=1;\n\`\`\`\n` + const canonical = source.slice(0, -1) + const edited = canonical.replace('value=1', 'value=2') + const roundTrip = vi.fn(() => null) + const originalMatch = RegExp.prototype[Symbol.match] + let largestGlobalMatchArray = 0 + vi.spyOn(RegExp.prototype, Symbol.match).mockImplementation(function ( + this: RegExp, + value: string + ) { + const result = originalMatch.call(this, value) + if (this.global && result) { + largestGlobalMatchArray = Math.max(largestGlobalMatchArray, result.length) + } + return result + }) + + expect(source.length).toBeLessThanOrEqual(RICH_MARKDOWN_MAX_SIZE_BYTES) + expect( + reconcileSerializedMarkdown({ + originalSource: source, + baseCanonical: canonical, + edited, + roundTrip + }) + ).toBe(`${edited}\n`) + expect(restoreMarkdownSourceEol(edited, source)).toBe(edited) + expect(roundTrip).not.toHaveBeenCalled() + expect(largestGlobalMatchArray).toBe(0) + }) + + it.each([ + ['empty source', '', '\n'], + ['no line ending', 'text', '\n'], + ['LF only', 'a\nb\n', '\n'], + ['CRLF only', 'a\r\nb\r\n', '\r\n'], + ['lone CR only', 'a\rb\r', '\n'], + ['lone CR before CRLF', 'a\r\r\nb', '\r\n'], + ['CRLF first in a tie', 'a\r\nb\n', '\r\n'], + ['LF first in a tie', 'a\nb\r\n', '\r\n'], + ['LF majority', 'a\nb\r\nc\n', '\n'], + ['CRLF majority', 'a\r\nb\nc\r\n', '\r\n'], + ['Unicode separators', '\u2028\u2029', '\n'], + ['lone surrogates and NUL', '\ud800\n\udfff\0', '\n'] + ])('preserves the existing decision for %s', (_name, source, eol) => { + const content = 'new\r\nline\n\ud800\udfff\0' + expect(restoreMarkdownSourceEol(content, source)).toBe(`new${eol}line${eol}\ud800\udfff\0`) + }) +}) diff --git a/src/renderer/src/components/editor/rich-markdown-source-reconcile.ts b/src/renderer/src/components/editor/rich-markdown-source-reconcile.ts index 27a776ee163..e737689b6aa 100644 --- a/src/renderer/src/components/editor/rich-markdown-source-reconcile.ts +++ b/src/renderer/src/components/editor/rich-markdown-source-reconcile.ts @@ -119,6 +119,9 @@ function stripTrailingNewlines(lfText: string): string { } function detectDominantEol(text: string): '\n' | '\r\n' { + if (!text.includes('\r')) { + return '\n' + } const totalLf = (text.match(/\n/g) ?? []).length const crlf = (text.match(/\r\n/g) ?? []).length const lfOnly = totalLf - crlf