Preserve original markdown style when editing in the rich editor (#8862)

* Preserve original markdown style when editing in the rich editor

The rich markdown editor re-serialized the whole document into a canonical
style on the first edit, so a one-character change rewrote every non-canonical
construct (`_x_`->`*x*`, `__x__`->`**x**`, `* item`->`- item`, dropped trailing
newline) and produced a large, unexpected diff.

Reconcile the canonical getMarkdown() output back toward the original source
bytes at every disk-bound serialize site (debounced autosave, flush, Cmd/Ctrl+S)
so untouched regions keep their markup and only the edited region changes. A
divergent-base fuzzy patch (diff-match-patch) carries the user's edit onto the
original style; a safety re-parse requires the result to render-equal what the
editor shows, else it falls back to today's canonical output — so content
semantics can never change. The rich editor stays the default for markdown.

Fixes #6080

* Bound markdown reconciliation diff work
This commit is contained in:
Brennan Benson 2026-07-15 13:33:11 -07:00 committed by GitHub
parent a2d3451efd
commit ed2135d248
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 1247 additions and 19 deletions

View File

@ -131,6 +131,7 @@
"@electron/rebuild": "^4.2.0",
"@monaco-editor/react": "^4.7.0",
"@playwright/test": "^1.59.1",
"@sanity/diff-match-patch": "^3.2.0",
"@stablyai/playwright-test": "^2.1.14",
"@tailwindcss/vite": "^4.2.4",
"@tanstack/react-virtual": "^3.13.24",

View File

@ -110,6 +110,9 @@ importers:
'@playwright/test':
specifier: ^1.59.1
version: 1.59.1
'@sanity/diff-match-patch':
specifier: ^3.2.0
version: 3.2.0
'@stablyai/playwright-test':
specifier: ^2.1.14
version: 2.1.14(@playwright/test@1.59.1)(zod@4.4.3)
@ -2604,6 +2607,10 @@ packages:
cpu: [x64]
os: [win32]
'@sanity/diff-match-patch@3.2.0':
resolution: {integrity: sha512-4hPADs0qUThFZkBK/crnfKKHg71qkRowfktBljH2UIxGHHTxIzt8g8fBiXItyCjxkuNy+zpYOdRMifQNv8+Yww==}
engines: {node: '>=18.18'}
'@sec-ant/readable-stream@0.4.1':
resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==}
@ -8799,6 +8806,8 @@ snapshots:
'@rollup/rollup-win32-x64-msvc@4.60.3':
optional: true
'@sanity/diff-match-patch@3.2.0': {}
'@sec-ant/readable-stream@0.4.1': {}
'@sindresorhus/is@4.6.0': {}

View File

@ -17,6 +17,8 @@ import { RichMarkdownEditorSurface } from './RichMarkdownEditorSurface'
import { useRichMarkdownEditorInstance } from './useRichMarkdownEditorInstance'
import { useRichMarkdownMenuController } from './useRichMarkdownMenuController'
import { useRichMarkdownProgrammaticSync } from './useRichMarkdownProgrammaticSync'
import { useRichMarkdownReconcileRoundTrip } from './useRichMarkdownReconcileRoundTrip'
import { commitRichMarkdownSerialization } from './rich-markdown-serialization-commit'
import { useRichMarkdownReviewController } from './useRichMarkdownReviewController'
import { useRichMarkdownReviewEditorEffects } from './useRichMarkdownReviewEditorEffects'
import {
@ -99,6 +101,11 @@ export default function RichMarkdownEditor({
const menu = useRichMarkdownMenuController({ markdownDocuments })
const isMac = navigator.userAgent.includes('Mac')
const lastCommittedMarkdownRef = useRef(content)
// Why: three-way source-preserving reconciliation baseline — the raw on-disk
// bytes and their canonical serialization — so edits patch onto the original
// style rather than re-canonicalizing untouched regions (#6080).
const originalSourceRef = useRef(content)
const baseCanonicalRef = useRef('')
const onContentChangeRef = useRef(onContentChange)
const onDirtyStateHintRef = useRef(onDirtyStateHint)
const onSaveRef = useRef(onSave)
@ -159,6 +166,13 @@ export default function RichMarkdownEditor({
onSaveRef.current = onSave
onOpenDocLinkRef.current = onOpenDocLink
isEditingLinkRef.current = isEditingLink
const reconcileRoundTripRef = useRichMarkdownReconcileRoundTrip({
htmlSuperscriptLinkContext,
filePath,
runtimeEnvironmentId,
worktreeId,
worktreeRoot
})
const flushPendingSerialization = useCallback(() => {
if (serializeTimerRef.current === null) {
@ -167,16 +181,19 @@ export default function RichMarkdownEditor({
window.clearTimeout(serializeTimerRef.current)
serializeTimerRef.current = null
try {
const markdown = editorRef.current?.getMarkdown()
if (markdown !== undefined) {
lastCommittedMarkdownRef.current = markdown
const { markdown, didSerialize } = commitRichMarkdownSerialization(
editorRef.current,
{ originalSourceRef, baseCanonicalRef, lastCommittedMarkdownRef },
reconcileRoundTripRef.current
)
if (didSerialize) {
onContentChangeRef.current(markdown)
}
} catch {
// Why: save/restart flows should never crash the UI just because the
// editor was torn down between scheduling and flushing a debounced sync.
}
}, [])
}, [reconcileRoundTripRef])
useEffect(() => {
// Why: autosave/restart paths live outside the editor component tree, so a
@ -216,6 +233,9 @@ export default function RichMarkdownEditor({
rootRef,
editorRef,
lastCommittedMarkdownRef,
originalSourceRef,
baseCanonicalRef,
reconcileRoundTripRef,
onContentChangeRef,
onDirtyStateHintRef,
onSaveRef,
@ -288,6 +308,8 @@ export default function RichMarkdownEditor({
filePath,
isApplyingProgrammaticUpdateRef,
lastCommittedMarkdownRef,
originalSourceRef,
baseCanonicalRef,
markdownDocuments,
rootRef,
runtimeEnvironmentId,

View File

@ -48,6 +48,9 @@ function createConfigParams(overrides: Partial<EditorConfigParams> = {}): Editor
rootRef: ref<HTMLDivElement | null>(null),
editorRef: ref<Editor | null>(null),
lastCommittedMarkdownRef: ref(''),
originalSourceRef: ref(''),
baseCanonicalRef: ref(''),
reconcileRoundTripRef: ref<(markdown: string) => string | null>(() => null),
onContentChangeRef: ref(vi.fn()),
onDirtyStateHintRef: ref(vi.fn()),
onSaveRef: ref(vi.fn()),

View File

@ -22,6 +22,7 @@ import {
type RichMarkdownRuntimeSettings
} from './rich-markdown-editor-click-routing'
import { createRichMarkdownKeyHandler } from './rich-markdown-key-handler'
import { commitRichMarkdownSerialization } from './rich-markdown-serialization-commit'
import {
createRichMarkdownImageResolverContext,
setRichMarkdownImageResolverContext
@ -51,6 +52,9 @@ export type EditorConfigParams = {
rootRef: MutableRefObject<HTMLDivElement | null>
editorRef: MutableRefObject<Editor | null>
lastCommittedMarkdownRef: MutableRefObject<string>
originalSourceRef: MutableRefObject<string>
baseCanonicalRef: MutableRefObject<string>
reconcileRoundTripRef: MutableRefObject<(markdown: string) => string | null>
onContentChangeRef: MutableRefObject<(content: string) => void>
onDirtyStateHintRef: MutableRefObject<(dirty: boolean) => void>
onSaveRef: MutableRefObject<(content: string) => void>
@ -100,6 +104,9 @@ export function createRichMarkdownEditorConfig(params: EditorConfigParams): UseE
rootRef,
editorRef,
lastCommittedMarkdownRef,
originalSourceRef,
baseCanonicalRef,
reconcileRoundTripRef,
onContentChangeRef,
onDirtyStateHintRef,
onSaveRef,
@ -170,6 +177,9 @@ export function createRichMarkdownEditorConfig(params: EditorConfigParams): UseE
editorRef,
rootRef,
lastCommittedMarkdownRef,
originalSourceRef,
baseCanonicalRef,
reconcileRoundTripRef,
onContentChangeRef,
onSaveRef,
isEditingLinkRef,
@ -236,6 +246,11 @@ export function createRichMarkdownEditorConfig(params: EditorConfigParams): UseE
// split on load.
normalizeEmptyListItems(nextEditor)
lastCommittedMarkdownRef.current = content
// Why: seed the source-preserving reconciliation baseline — the raw loaded
// bytes and their canonical serialization — so the first edit patches onto
// the original style instead of re-canonicalizing the whole file.
originalSourceRef.current = content
baseCanonicalRef.current = nextEditor.getMarkdown()
isInitializingRef.current = false
cancelAutoFocusRef.current?.()
cancelAutoFocusRef.current = autoFocusRichEditor(nextEditor, rootRef.current)
@ -268,9 +283,14 @@ export function createRichMarkdownEditorConfig(params: EditorConfigParams): UseE
serializeTimerRef.current = window.setTimeout(() => {
serializeTimerRef.current = null
try {
const markdown = nextEditor.getMarkdown()
lastCommittedMarkdownRef.current = markdown
onContentChangeRef.current(markdown)
const { markdown, didSerialize } = commitRichMarkdownSerialization(
nextEditor,
{ originalSourceRef, baseCanonicalRef, lastCommittedMarkdownRef },
reconcileRoundTripRef.current
)
if (didSerialize) {
onContentChangeRef.current(markdown)
}
} catch {
// Why: save/restart flows should never crash the UI just because
// the editor was torn down between scheduling and serializing.

View File

@ -53,6 +53,9 @@ function createContext(editor: Editor, typedMarker: boolean): KeyHandlerContext
editorRef: { current: editor },
rootRef: { current: null },
lastCommittedMarkdownRef: { current: '' },
originalSourceRef: { current: '' },
baseCanonicalRef: { current: '' },
reconcileRoundTripRef: { current: () => null },
onContentChangeRef: { current: vi.fn() },
onSaveRef: { current: vi.fn() },
isEditingLinkRef: { current: false },

View File

@ -3,7 +3,6 @@ import type { Editor } from '@tiptap/react'
import { getShortcutPlatform } from '@/lib/shortcut-platform'
import { useAppStore } from '@/store'
import { isMarkdownPreviewFindShortcut } from './markdown-preview-search'
import { editorShortcutMatches } from './editor-shortcuts'
import type { LinkBubbleState } from './RichMarkdownLinkBubble'
import { commitRow, type DocLinkMenuRow, type DocLinkMenuState } from './rich-markdown-commands'
import {
@ -21,12 +20,16 @@ import { deleteAdjacentEmptyParagraph } from './rich-markdown-empty-paragraph-de
import { handleRichMarkdownCitationKey } from './rich-markdown-citation-keyboard'
import type { RichMarkdownHtmlSuperscriptLinkContext } from './rich-markdown-html-superscript-link-context'
import { handleRichMarkdownLinkShortcut } from './rich-markdown-link-shortcut'
import { handleRichMarkdownSaveShortcut } from './rich-markdown-save-shortcut'
export type KeyHandlerContext = {
isMac: boolean
editorRef: MutableRefObject<Editor | null>
rootRef: MutableRefObject<HTMLDivElement | null>
lastCommittedMarkdownRef: MutableRefObject<string>
originalSourceRef: MutableRefObject<string>
baseCanonicalRef: MutableRefObject<string>
reconcileRoundTripRef: MutableRefObject<(markdown: string) => string | null>
onContentChangeRef: MutableRefObject<(content: string) => void>
onSaveRef: MutableRefObject<(content: string) => void>
isEditingLinkRef: MutableRefObject<boolean>
@ -127,15 +130,7 @@ export function createRichMarkdownKeyHandler(
ctx.openSearchRef.current()
return true
}
if (editorShortcutMatches('editor.save', event)) {
event.preventDefault()
// Why: flush any pending debounced serialization so the save
// captures the very latest editor content, not a stale snapshot.
ctx.flushPendingSerialization()
const markdown = ctx.editorRef.current?.getMarkdown() ?? ctx.lastCommittedMarkdownRef.current
ctx.lastCommittedMarkdownRef.current = markdown
ctx.onContentChangeRef.current(markdown)
ctx.onSaveRef.current(markdown)
if (handleRichMarkdownSaveShortcut(ctx, event)) {
return true
}

View File

@ -0,0 +1,61 @@
import { Editor } from '@tiptap/core'
import { encodeRawMarkdownHtmlForRichEditor } from './raw-markdown-html'
import { createRichMarkdownExtensions } from './rich-markdown-extensions'
import { createRichMarkdownEditorCodec } from './rich-markdown-source-transport'
import {
createRichMarkdownHtmlSuperscriptLinkContext,
type RichMarkdownHtmlSuperscriptLinkContext
} from './rich-markdown-html-superscript-link-context'
import {
setRichMarkdownImageResolverContext,
type RichMarkdownImageResolverContext
} from './rich-markdown-image-context'
import { normalizeEmptyListItems } from './rich-markdown-normalize'
export type RichMarkdownReconcileSerializerContext = {
htmlSuperscriptLinkContext: RichMarkdownHtmlSuperscriptLinkContext
imageResolverContext: RichMarkdownImageResolverContext
}
/**
* Re-serializes markdown through a throwaway editor that REPLICATES the mounted
* editor's post-load state: same extension set, the file's own superscript-link
* classification context, its image resolver context, and normalizeEmptyListItems.
* Reconciliation's safety re-parse must match the live getMarkdown() output, or
* docs with empty list items / local images / superscript links would spuriously
* mismatch and silently fall back to canonical. Returns null if serialization throws.
*/
export function serializeRichMarkdownForReconcile(
content: string,
{ htmlSuperscriptLinkContext, imageResolverContext }: RichMarkdownReconcileSerializerContext
): string | null {
try {
const codec = createRichMarkdownEditorCodec()
// Why: mirror the live editor's link classification (sourceOwner/paths drive
// whether a link serializes as a superscript link) via a fresh context built
// from the live snapshot, without subscribing to the live editor's context.
const { version: _version, ...snapshot } = htmlSuperscriptLinkContext.getSnapshot()
const context = createRichMarkdownHtmlSuperscriptLinkContext(snapshot)
const editor = new Editor({
element: null,
extensions: createRichMarkdownExtensions({
codec,
htmlSuperscriptLinks: true,
htmlSuperscriptLinkContext: context
}),
content: encodeRawMarkdownHtmlForRichEditor(content, codec, { htmlSuperscriptLinks: true }),
contentType: 'markdown',
onBeforeCreate: ({ editor: nextEditor }) => {
setRichMarkdownImageResolverContext(nextEditor, imageResolverContext)
}
})
try {
normalizeEmptyListItems(editor)
return editor.getMarkdown()
} finally {
editor.destroy()
}
} catch {
return null
}
}

View File

@ -0,0 +1,31 @@
import type { KeyHandlerContext } from './rich-markdown-key-handler'
import { editorShortcutMatches } from './editor-shortcuts'
import { commitRichMarkdownSerialization } from './rich-markdown-serialization-commit'
/**
* Cmd/Ctrl+S: flush the debounced serialization, then reconcile toward the
* original source style before saving so untouched regions keep their bytes.
*/
export function handleRichMarkdownSaveShortcut(
ctx: KeyHandlerContext,
event: KeyboardEvent
): boolean {
if (!editorShortcutMatches('editor.save', event)) {
return false
}
event.preventDefault()
// Why: flush pending debounced serialization so the save captures the very
// latest editor content, not a stale snapshot.
ctx.flushPendingSerialization()
// Why: the flush already reconciled + updated refs, so this re-serialize is
// idempotent (edited === baseCanonical → returns the reconciled bytes). On a
// torn-down editor it falls back to the last committed bytes without patching.
const { markdown } = commitRichMarkdownSerialization(
ctx.editorRef.current,
ctx,
ctx.reconcileRoundTripRef.current
)
ctx.onContentChangeRef.current(markdown)
ctx.onSaveRef.current(markdown)
return true
}

View File

@ -0,0 +1,149 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { Editor } from '@tiptap/react'
import {
commitRichMarkdownSerialization,
type RichMarkdownReconcileRefs
} from './rich-markdown-serialization-commit'
import { handleRichMarkdownSaveShortcut } from './rich-markdown-save-shortcut'
import type { KeyHandlerContext } from './rich-markdown-key-handler'
// Style-only canonicalizer mirroring the #6080 rewrites; stands in for the live
// editor's getMarkdown so these tests need no DOM. Strips the trailing newline
// like the real getMarkdown, so the branch-6 exact re-parse comparison behaves as
// in production (the reconcile roundTrip and the edited getMarkdown agree on EOF).
function fakeCanonicalize(md: string): string {
return md
.replace(/__([^_]+)__/g, '**$1**')
.replace(/_([^_]+)_/g, '*$1*')
.replace(/\n+$/, '')
}
const roundTrip = (md: string): string => fakeCanonicalize(md)
function refs(originalSource: string, baseCanonical: string): RichMarkdownReconcileRefs {
return {
originalSourceRef: { current: originalSource },
baseCanonicalRef: { current: baseCanonical },
lastCommittedMarkdownRef: { current: '' }
}
}
function fakeEditor(getMarkdown: () => string): Editor {
return { getMarkdown } as unknown as Editor
}
describe('commitRichMarkdownSerialization (shared disk-bound serialize chokepoint)', () => {
it('persists SOURCE-PRESERVING bytes, not raw getMarkdown (regression gate for #6080)', () => {
const r = refs('# Title\n\n_word_\n', '# Title\n\n*word*')
const editor = fakeEditor(() => '# Title!\n\n*word*') // canonical edit
const { markdown, didSerialize } = commitRichMarkdownSerialization(editor, r, roundTrip)
// Fails if this site reverted to raw getMarkdown() (would emit *word*).
expect(markdown).toBe('# Title!\n\n_word_\n')
expect(didSerialize).toBe(true)
})
it('advances all three refs so the next incremental edit patches onto the reconciled source', () => {
const r = refs('# Title\n\n_word_\n', '# Title\n\n*word*')
const editor = fakeEditor(() => '# Title!\n\n*word*')
commitRichMarkdownSerialization(editor, r, roundTrip)
expect(r.originalSourceRef.current).toBe('# Title!\n\n_word_\n') // reconciled bytes
expect(r.baseCanonicalRef.current).toBe('# Title!\n\n*word*') // canonical of reconciled
expect(r.lastCommittedMarkdownRef.current).toBe('# Title!\n\n_word_\n') // exact disk bytes
})
it('falls back to the last committed bytes when the editor is torn down (null)', () => {
const r = refs('# Title\n\n_word_\n', '# Title\n\n*word*')
r.lastCommittedMarkdownRef.current = '# Title\n\n_word_\n'
const { markdown, didSerialize } = commitRichMarkdownSerialization(null, r, roundTrip)
expect(didSerialize).toBe(false)
expect(markdown).toBe('# Title\n\n_word_\n')
// Refs untouched on a torn-down editor.
expect(r.originalSourceRef.current).toBe('# Title\n\n_word_\n')
})
it('does not crash when getMarkdown throws (editor destroyed mid-flush)', () => {
const r = refs('src', 'src')
r.lastCommittedMarkdownRef.current = 'safe'
const editor = fakeEditor(() => {
throw new Error('editor destroyed')
})
const { markdown, didSerialize } = commitRichMarkdownSerialization(editor, r, roundTrip)
expect(didSerialize).toBe(false)
expect(markdown).toBe('safe')
})
})
describe('handleRichMarkdownSaveShortcut (Cmd/Ctrl+S persistence site)', () => {
afterEach(() => {
vi.unstubAllGlobals()
})
function saveEvent(): KeyboardEvent & { preventDefault: ReturnType<typeof vi.fn> } {
return {
key: 's',
code: 'KeyS',
metaKey: true,
ctrlKey: false,
altKey: false,
shiftKey: false,
isComposing: false,
preventDefault: vi.fn()
} as unknown as KeyboardEvent & { preventDefault: ReturnType<typeof vi.fn> }
}
function saveContext(editor: Editor | null): {
ctx: KeyHandlerContext
onSave: ReturnType<typeof vi.fn>
onContentChange: ReturnType<typeof vi.fn>
flush: ReturnType<typeof vi.fn>
} {
const onSave = vi.fn()
const onContentChange = vi.fn()
const flush = vi.fn()
const ctx = {
editorRef: { current: editor },
originalSourceRef: { current: '# Title\n\n_word_\n' },
baseCanonicalRef: { current: '# Title\n\n*word*' },
lastCommittedMarkdownRef: { current: '' },
reconcileRoundTripRef: { current: roundTrip },
onContentChangeRef: { current: onContentChange },
onSaveRef: { current: onSave },
flushPendingSerialization: flush
} as unknown as KeyHandlerContext
return { ctx, onSave, onContentChange, flush }
}
it('flushes then saves SOURCE-PRESERVING bytes on Cmd+S (mac)', () => {
vi.stubGlobal('navigator', { userAgent: 'Macintosh' })
const editor = fakeEditor(() => '# Title!\n\n*word*')
const { ctx, onSave, onContentChange, flush } = saveContext(editor)
const event = saveEvent()
expect(handleRichMarkdownSaveShortcut(ctx, event)).toBe(true)
expect(event.preventDefault).toHaveBeenCalled()
expect(flush).toHaveBeenCalledTimes(1)
// onSave/onContentChange receive reconciled bytes, not raw *word*.
expect(onSave).toHaveBeenCalledWith('# Title!\n\n_word_\n')
expect(onContentChange).toHaveBeenCalledWith('# Title!\n\n_word_\n')
})
it('ignores non-save keystrokes and touches nothing', () => {
vi.stubGlobal('navigator', { userAgent: 'Macintosh' })
const editor = fakeEditor(() => '# Title!\n\n*word*')
const { ctx, onSave, flush } = saveContext(editor)
const event = { ...saveEvent(), key: 'a', code: 'KeyA' } as KeyboardEvent & {
preventDefault: ReturnType<typeof vi.fn>
}
expect(handleRichMarkdownSaveShortcut(ctx, event)).toBe(false)
expect(flush).not.toHaveBeenCalled()
expect(onSave).not.toHaveBeenCalled()
})
})

View File

@ -0,0 +1,59 @@
import type { MutableRefObject } from 'react'
import type { Editor } from '@tiptap/react'
import { reconcileSerializedMarkdown } from './rich-markdown-source-reconcile'
export type RichMarkdownReconcileRefs = {
/** Current on-disk source bytes; updated to the reconciled output each commit. */
originalSourceRef: MutableRefObject<string>
/** Canonical serialization of `originalSourceRef` (getMarkdown of the unedited doc). */
baseCanonicalRef: MutableRefObject<string>
/** Exact bytes last handed to disk; gates the external-change reload. */
lastCommittedMarkdownRef: MutableRefObject<string>
}
export type RichMarkdownSerializationCommit = {
/** Bytes to persist to disk (reconciled, or last committed on a torn-down editor). */
markdown: string
/** False when the editor was torn down before serializing (refs left untouched). */
didSerialize: boolean
}
/**
* Single place where reconciliation and ref updates happen for every disk-bound
* serialize site. Computes edited=getMarkdown(), reconciles toward the original
* source style, and advances the refs for the next incremental edit.
*/
export function commitRichMarkdownSerialization(
editor: Editor | null,
refs: RichMarkdownReconcileRefs,
roundTrip: (markdown: string) => string | null
): RichMarkdownSerializationCommit {
let edited: string | undefined
try {
edited = editor?.getMarkdown()
} catch {
// Why: the editor can be destroyed between scheduling and serializing; a
// save/restart flush must never crash here.
edited = undefined
}
if (edited === undefined) {
// Torn-down fallback: reuse the already-reconciled bytes without patching.
return { markdown: refs.lastCommittedMarkdownRef.current, didSerialize: false }
}
const reconciled = reconcileSerializedMarkdown({
originalSource: refs.originalSourceRef.current,
baseCanonical: refs.baseCanonicalRef.current,
edited,
roundTrip
})
refs.originalSourceRef.current = reconciled
// Why: reconciled ≡ edited semantically, so its canonical form is `edited`
// (also correct in every fallback branch, which returns `edited` verbatim).
refs.baseCanonicalRef.current = edited
// Why: the external-change guard short-circuits on lastCommittedMarkdownRef, so
// it must hold the exact reconciled bytes that reach disk, not the canonical form.
refs.lastCommittedMarkdownRef.current = reconciled
return { markdown: reconciled, didSerialize: true }
}

View File

@ -0,0 +1,516 @@
import { describe, expect, it, vi } from 'vitest'
import { reconcileSerializedMarkdown } from './rich-markdown-source-reconcile'
import { serializeRichMarkdownForReconcile } from './rich-markdown-reconcile-serializer'
import { createRichMarkdownHtmlSuperscriptLinkContext } from './rich-markdown-html-superscript-link-context'
import type { RichMarkdownImageResolverContext } from './rich-markdown-image-context'
// A deterministic stand-in for the live editor's canonicalization, covering the
// exact style rewrites #6080 reports. Used both to derive baseCanonical/edited
// and as the injected safety `roundTrip`, so the fake stays self-consistent.
function fakeCanonicalize(md: string): string {
return md
.split('\n')
.map((line) => line.replace(/^(\s*)\* /, '$1- ')) // `* bullet` -> `- bullet`
.join('\n')
.replace(/__([^_]+)__/g, '**$1**') // `__strong__` -> `**strong**` (before emphasis)
.replace(/_([^_]+)_/g, '*$1*') // `_emphasis_` -> `*emphasis*`
}
const roundTrip = (md: string): string => fakeCanonicalize(md)
/** Reconcile using the fake canonicalizer for both the base and the safety re-parse. */
function reconcileWithFake(originalSource: string, edited: string): string {
return reconcileSerializedMarkdown({
originalSource,
baseCanonical: fakeCanonicalize(originalSource),
edited,
roundTrip
})
}
describe('reconcileSerializedMarkdown', () => {
it('preserves untouched non-canonical regions on a 1-char edit', () => {
const originalSource = '# Title\n\n_emphasis_ and __strong__\n\n* one\n* two\n'
const baseCanonical = fakeCanonicalize(originalSource)
const edited = baseCanonical.replace('# Title', '# Title!')
const reconciled = reconcileWithFake(originalSource, edited)
// Untouched markup keeps its original bytes.
expect(reconciled).toContain('_emphasis_')
expect(reconciled).toContain('__strong__')
expect(reconciled).toContain('* one')
expect(reconciled).toContain('* two')
// The edited region reflects the change.
expect(reconciled).toContain('# Title!')
// Never re-canonicalized.
expect(reconciled).not.toContain('*emphasis*')
expect(reconciled).not.toContain('- one')
})
it('preserves the trailing newline of the original source', () => {
const originalSource = '# H\n\n_word_\n'
const edited = fakeCanonicalize(originalSource).replace('# H', '# H!')
const reconciled = reconcileWithFake(originalSource, edited)
expect(reconciled.endsWith('\n')).toBe(true)
expect(reconciled).toContain('_word_')
})
it('keeps newly-typed content canonical while preserving the original style', () => {
const originalSource = '_old_\n'
const edited = '*old* and *new*\n'
const reconciled = reconcileWithFake(originalSource, edited)
expect(reconciled).toContain('_old_') // original preserved
expect(reconciled).toContain('*new*') // new content stays canonical
})
it('returns edited when the source is already canonical (branch 2)', () => {
const originalSource = '*word* here\n'
const edited = '*word* there\n'
// baseCanonical === originalSource, so there is nothing to preserve.
expect(reconcileWithFake(originalSource, edited)).toBe(edited)
})
it('returns the source verbatim when there is no semantic change (branch 1)', () => {
const originalSource = '_word_ and * bullet\n'
const baseCanonical = fakeCanonicalize(originalSource)
// edited === baseCanonical: e.g. cursor moved, selection changed, no edit.
const reconciled = reconcileSerializedMarkdown({
originalSource,
baseCanonical,
edited: baseCanonical,
roundTrip
})
// Exact bytes, including trailing newline — zero disk churn.
expect(reconciled).toBe(originalSource)
})
it('falls back to canonical when either string exceeds the size cap (branch 3)', () => {
const big = 'line of text\n'.repeat(9000) // ~117 KB, above the size cap
const originalSource = `${big}_x_\n`
const edited = `${big}*x* y\n`
// Non-canonical source + real semantic change, but oversize → canonical fallback.
expect(reconcileWithFake(originalSource, edited)).toBe(edited)
})
it('bounds diff work for replacement-heavy edits instead of using the 1s library default', () => {
let now = 0
const dateNow = vi.spyOn(Date, 'now').mockImplementation(() => {
now += 10
return now
})
const baseCanonical = 'a'.repeat(20_000)
const edited = 'b'.repeat(20_000)
try {
const reconciled = reconcileSerializedMarkdown({
originalSource: `_${baseCanonical.slice(1)}`,
baseCanonical,
edited,
roundTrip: (markdown) => markdown
})
expect(reconciled).toBe(edited)
// The 10ms budget expires almost immediately; the dependency's default
// one-second budget takes ~102 reads with this deterministic clock.
expect(dateNow.mock.calls.length).toBeLessThan(10)
} finally {
dateNow.mockRestore()
}
})
it('skips the dependency half-match path when its long seed repeats', () => {
const dateNow = vi.spyOn(Date, 'now')
const baseCanonical = 'ab'.repeat(24_500)
const edited = 'ba'.repeat(24_500)
try {
const reconciled = reconcileSerializedMarkdown({
originalSource: `_${baseCanonical.slice(1)}`,
baseCanonical,
edited,
roundTrip: (markdown) => markdown
})
expect(reconciled).toBe(edited)
// The dependency reads the clock when diffing; zero reads proves the
// repetitive-input preflight returned before its unbounded half-match scan.
expect(dateNow).not.toHaveBeenCalled()
} finally {
dateNow.mockRestore()
}
})
it('still preserves a small edit inside a highly repetitive document', () => {
const dateNow = vi.spyOn(Date, 'now')
const baseCanonical = 'ab'.repeat(24_500)
const editIndex = Math.floor(baseCanonical.length / 2)
const edited = `${baseCanonical.slice(0, editIndex)}X${baseCanonical.slice(editIndex + 1)}`
try {
const reconciled = reconcileSerializedMarkdown({
originalSource: `_${baseCanonical.slice(1)}`,
baseCanonical,
edited,
roundTrip: (markdown) => `a${markdown.slice(1)}`
})
expect(reconciled).toBe(`_${edited.slice(1)}`)
expect(dateNow).toHaveBeenCalled()
} finally {
dateNow.mockRestore()
}
})
it('falls back to canonical when a hunk fails to apply (branch 5)', () => {
const baseCanonical = 'The quick brown fox jumps over the lazy dog every morning.\n'
const edited = 'The quick brown fox LEAPS over the lazy dog every morning.\n'
const originalSource = 'Zzz totally unrelated content sharing nothing at all here.\n'
const reconciled = reconcileSerializedMarkdown({
originalSource,
baseCanonical,
edited,
roundTrip
})
expect(reconciled).toBe(edited)
})
it('falls back to canonical when the safety re-parse mismatches (branch 6)', () => {
const reconciled = reconcileSerializedMarkdown({
originalSource: '_a_\n',
baseCanonical: '*a*\n',
edited: '*a* b\n',
// Simulates a fuzzy misplacement whose render differs from `edited`.
roundTrip: () => 'something entirely different'
})
expect(reconciled).toBe('*a* b\n')
})
it('falls back to canonical when the safety serializer returns null (branch 6)', () => {
const reconciled = reconcileSerializedMarkdown({
originalSource: '_a_\n',
baseCanonical: '*a*\n',
edited: '*a* b\n',
roundTrip: () => null
})
expect(reconciled).toBe('*a* b\n')
})
it('preserves non-canonical regions across an incremental two-edit ref chain', () => {
const originalSource = '# Title\n\n_emphasis_\n\n* item\n'
const base = fakeCanonicalize(originalSource)
const edited1 = base.replace('# Title', '# Title A')
const reconciled1 = reconcileWithFake(originalSource, edited1)
expect(reconciled1).toContain('_emphasis_')
expect(reconciled1).toContain('* item')
// Simulate the commit helper advancing the refs: originalSource := reconciled1,
// baseCanonical := edited1. The second edit must still preserve the markup.
const edited2 = edited1.replace('# Title A', '# Title AB')
const reconciled2 = reconcileSerializedMarkdown({
originalSource: reconciled1,
baseCanonical: edited1,
edited: edited2,
roundTrip
})
expect(reconciled2).toContain('_emphasis_')
expect(reconciled2).toContain('* item')
expect(reconciled2).toContain('# Title AB')
})
it('skips the safety re-parse for a canonical LF file with a trailing newline (branch 2)', () => {
// The common case: file is already canonical but ends in \n, so getMarkdown
// (which strips the trailing newline) never equals it byte-for-byte. This
// must NOT pay the expensive re-parse — the whole point of branch 2.
const originalSource = '# Title\n\n*word*\n\n- one\n- two\n'
const baseCanonical = '# Title\n\n*word*\n\n- one\n- two' // getMarkdown: no trailing \n
const edited = '# Title!\n\n*word*\n\n- one\n- two'
const roundTrip = vi.fn(() => edited)
const reconciled = reconcileSerializedMarkdown({
originalSource,
baseCanonical,
edited,
roundTrip
})
expect(roundTrip).not.toHaveBeenCalled()
// Edit applied, and the source's trailing newline is preserved.
expect(reconciled).toBe('# Title!\n\n*word*\n\n- one\n- two\n')
})
it('preserves CRLF + trailing newline for a canonical CRLF file without re-parsing (branch 2)', () => {
const originalSource = '# H\r\n\r\n*word*\r\n'
const baseCanonical = '# H\n\n*word*' // getMarkdown: LF, no trailing
const edited = '# H!\n\n*word*'
const roundTrip = vi.fn(() => edited)
const reconciled = reconcileSerializedMarkdown({
originalSource,
baseCanonical,
edited,
roundTrip
})
expect(roundTrip).not.toHaveBeenCalled()
expect(reconciled).toBe('# H!\r\n\r\n*word*\r\n')
// No mixed endings.
expect(reconciled.replace(/\r\n/g, '')).not.toContain('\n')
})
it('restores CRLF on the oversize fallback so a uniform-CRLF file never flips to LF (branch 3)', () => {
const big = 'line of text\r\n'.repeat(9000) // ~130 KB, above the cap, all CRLF
const originalSource = `${big}_x_\r\n` // non-canonical emphasis, oversize
const editedLf = `${big.replace(/\r\n/g, '\n')}*x* y\n`
const reconciled = reconcileSerializedMarkdown({
originalSource,
baseCanonical: `${big.replace(/\r\n/g, '\n')}*x*\n`,
edited: editedLf,
roundTrip: () => null
})
// Canonical fallback content, but the source's CRLF endings are kept.
expect(reconciled).toContain('\r\n')
expect(reconciled.replace(/\r\n/g, '')).not.toContain('\n')
})
it('restores CRLF on the hunk-apply fallback (branch 5)', () => {
const originalSource = 'Zzz unrelated content sharing nothing here.\r\n'
const baseCanonical = 'The quick brown fox jumps over the lazy dog.\n'
const edited = 'The quick brown fox LEAPS over the lazy dog.\n'
const reconciled = reconcileSerializedMarkdown({
originalSource,
baseCanonical,
edited,
roundTrip
})
// Falls back to canonical content, but in the source's CRLF ending.
expect(reconciled).toBe('The quick brown fox LEAPS over the lazy dog.\r\n')
})
it('preserves CRLF endings on disk without mixing (branch 4 EOL restore)', () => {
const originalSource = '# H\r\n\r\n_word_\r\n\r\n* one\r\n'
// getMarkdown always emits LF, so base/edited are LF.
const baseCanonical = '# H\n\n*word*\n\n- one\n'
const edited = '# H!\n\n*word*\n\n- one\n'
const reconciled = reconcileSerializedMarkdown({
originalSource,
baseCanonical,
edited,
roundTrip
})
expect(reconciled).toContain('\r\n')
expect(reconciled).toContain('_word_')
expect(reconciled).toContain('# H!')
// No mixed endings: stripping CRLF leaves no lone LF.
expect(reconciled.replace(/\r\n/g, '')).not.toContain('\n')
})
it('lands a repeated-substring edit on the correct occurrence or falls back', () => {
const originalSource = ['- _alpha_', '- _beta_', '', '- _alpha_', '- _beta_', ''].join('\n')
const baseCanonical = fakeCanonicalize(originalSource)
// Edit only the FIRST "alpha".
const edited = baseCanonical.replace('*alpha*', '*alpha edited*')
const reconciled = reconcileWithFake(originalSource, edited)
// Invariant: the reconciled bytes render exactly to `edited` — the change is
// never silently relocated to the wrong occurrence or dropped.
expect(fakeCanonicalize(reconciled).trimEnd()).toBe(edited.trimEnd())
// Either the source-preserving path landed it, or it cleanly fell back.
const landedInStyle =
reconciled.includes('_alpha edited_') && reconciled.split('_beta_').length === 3
expect(landedInStyle || reconciled === edited).toBe(true)
})
})
describe('serializeRichMarkdownForReconcile (real editor pipeline)', () => {
const serializerContext = {
htmlSuperscriptLinkContext: createRichMarkdownHtmlSuperscriptLinkContext({
sourceFilePath: '',
worktreeId: '',
worktreeRoot: null,
sourceOwner: { kind: 'unknown' as const }
}),
imageResolverContext: {
filePath: '',
runtimeContext: undefined
} satisfies RichMarkdownImageResolverContext
}
const serialize = (md: string): string | null =>
serializeRichMarkdownForReconcile(md, serializerContext)
it('applies normalizeEmptyListItems so empty list items round-trip stably', () => {
// `3. ` immediately before a heading parses as an empty list item; without the
// normalize step the safety re-parse would spuriously mismatch and no-op.
const doc = '3. \n# Heading\n'
const once = serialize(doc)
expect(once).not.toBeNull()
// Idempotent: re-serializing the output is stable (the live editor's steady state).
expect(serialize(once!)?.trimEnd()).toBe(once!.trimEnd())
})
it('reconciles a non-canonical doc end-to-end with the real serializer, preserving style', () => {
const originalSource = '# Title\n\n_emphasis_ text\n'
const baseCanonical = serialize(originalSource)!
const edited = baseCanonical.replace('# Title', '# Title!')
const reconciled = reconcileSerializedMarkdown({
originalSource,
baseCanonical,
edited,
roundTrip: (md) => serialize(md)
})
expect(reconciled).toContain('_emphasis_') // original style preserved
expect(reconciled).toContain('# Title!') // edit applied
// Safety invariant: reconciled renders exactly to the editor's canonical output.
expect(serialize(reconciled)!.trimEnd()).toBe(edited.trimEnd())
})
it('minimizes the diff for the exact #6080 repro (real serializer)', () => {
// The reported case: a 1-char H1 edit must not rewrite untouched markup.
const originalSource =
'# Title\n\n_emphasis_ and __strong__\n\n* one\n* two\n* three\n\n_more emphasis_\n'
const baseCanonical = serialize(originalSource)!
const edited = baseCanonical.replace('# Title', '# Title!')
const reconciled = reconcileSerializedMarkdown({
originalSource,
baseCanonical,
edited,
roundTrip: (md) => serialize(md)
})
// Every untouched non-canonical construct keeps its original bytes.
for (const preserved of [
'_emphasis_',
'__strong__',
'* one',
'* two',
'* three',
'_more emphasis_'
]) {
expect(reconciled).toContain(preserved)
}
// The edit landed, and nothing else was re-canonicalized.
expect(reconciled).toContain('# Title!')
expect(reconciled).not.toContain('*emphasis*')
expect(reconciled).not.toContain('- one')
// Safety invariant: reconciled renders exactly to the editor's canonical output.
expect(serialize(reconciled)!.trimEnd()).toBe(edited.trimEnd())
})
it('branch-2 fast path output still renders to the canonical edit (real serializer)', () => {
// Guards the re-parse-skipping fast path: even though branch 2 does NOT run
// the safety re-parse, its output must satisfy the same invariant the full
// path proves — reconciled renders exactly to the editor's canonical output.
const originalSource = '# Title\n\n*emphasis* text\n\n- one\n- two\n' // canonical + trailing \n
const baseCanonical = serialize(originalSource)!
expect(originalSource).not.toBe(baseCanonical) // differs only by the trailing \n
const edited = baseCanonical.replace('# Title', '# Title!')
let roundTripCalls = 0
const reconciled = reconcileSerializedMarkdown({
originalSource,
baseCanonical,
edited,
roundTrip: (md) => {
roundTripCalls += 1
return serialize(md)
}
})
expect(roundTripCalls).toBe(0) // took the cheap path
expect(reconciled).toBe('# Title!\n\n*emphasis* text\n\n- one\n- two\n')
expect(serialize(reconciled)!.trimEnd()).toBe(edited.trimEnd())
})
it('does not drop a user-added trailing empty paragraph (branch 2 edited-side guard)', () => {
// getMarkdown keeps `\n\n` for a real trailing empty paragraph, so the fast
// path must NOT strip it: a canonical doc with a single trailing newline
// (branch-2 eligible on the source side) edited to add a trailing empty
// paragraph must persist that block, not silently drop it on save.
const originalSource = '# H\n\ntext\n'
const baseCanonical = serialize(originalSource)! // '# H\n\ntext'
const edited = '# H\n\ntext\n\n' // user pressed Enter at EOF
let roundTripCalls = 0
const reconciled = reconcileSerializedMarkdown({
originalSource,
baseCanonical,
edited,
roundTrip: (md) => {
roundTripCalls += 1
return serialize(md)
}
})
expect(roundTripCalls).toBeGreaterThan(0) // deferred to the safety-verified path
// Exact reload equality (NOT trimEnd): the empty paragraph survives.
expect(serialize(reconciled)).toBe(serialize(edited))
})
it('does not introduce a spurious &nbsp; when a trailing-blank-line doc is edited (branch 2 guard)', () => {
// A canonical heading-ending doc with 3 trailing blank lines: the fast path
// must NOT preserve that long trailing run while skipping the re-parse, or a
// reload materializes a literal &nbsp; paragraph (a content change). The
// >1-trailing-newline guard defers this to the safety-checked path.
const originalSource = '# Notes\n\nSome text here.\n\n## TODO\n\n\n\n'
const baseCanonical = serialize(originalSource)!
const edited = baseCanonical.replace('## TODO', '## TODO\n\nA new note.')
let roundTripCalls = 0
const reconciled = reconcileSerializedMarkdown({
originalSource,
baseCanonical,
edited,
roundTrip: (md) => {
roundTripCalls += 1
return serialize(md)
}
})
expect(roundTripCalls).toBeGreaterThan(0) // took the safety-verified path
expect(reconciled).not.toContain('&nbsp;')
// Safety invariant: reconciled renders exactly to the editor's canonical edit.
expect(serialize(reconciled)!.trimEnd()).toBe(edited.trimEnd())
})
it('falls back cleanly when an empty-list doc cannot be source-preserved', () => {
// Combines an empty list item with non-canonical emphasis; whatever the fuzzy
// patch does, the output must render to `edited` (no corruption).
const originalSource = '_lead_\n\n3. \n# Heading\n'
const baseCanonical = serialize(originalSource)!
const edited = baseCanonical.replace('Heading', 'Heading!')
const reconciled = reconcileSerializedMarkdown({
originalSource,
baseCanonical,
edited,
roundTrip: (md) => serialize(md)
})
expect(serialize(reconciled)!.trimEnd()).toBe(edited.trimEnd())
})
})

View File

@ -0,0 +1,195 @@
import {
applyPatches,
cleanupEfficiency,
cleanupSemantic,
makeDiff,
makePatches
} from '@sanity/diff-match-patch'
// Why: bound the safety re-parse cost — it builds a throwaway TipTap editor per
// commit, whose parse time scales with document length (UTF-16 code units, the
// unit `.length` returns and the closest cheap proxy for parse cost — not UTF-8
// bytes). Measured ~50-67ms at the cap on fast HW (higher on low-end/SSH), under
// the 300ms serialize debounce; a higher cap risks a main-thread stall on
// slow/SSH hosts. Above the cap we use today's canonical output (no regression).
const RECONCILE_SIZE_CAP_CODE_UNITS = 50_000
// Why: diff-match-patch defaults to a one-second search, which freezes the
// renderer on replacement-heavy paste/edit paths. A timed-out coarse diff is
// still safe because the round-trip proof below rejects any bad placement.
const RECONCILE_DIFF_TIMEOUT_SECONDS = 0.01
export type ReconcileSerializedMarkdownParams = {
/** Current on-disk source bytes (possibly non-canonical, possibly CRLF). */
originalSource: string
/** Canonical serialization of `originalSource` (what getMarkdown returns unedited). */
baseCanonical: string
/** Canonical serialization after the user's edit (getMarkdown, always LF). */
edited: string
/**
* Re-serializes reconciled bytes through the live editor's pipeline. Injected
* so the reconcile logic is unit-testable without a DOM. Returns null when the
* throwaway serializer fails (treated as a safety mismatch canonical fallback).
*/
roundTrip: (markdown: string) => string | null
}
/**
* Carries the user's edit into the original source style so untouched regions
* keep their non-canonical bytes. Falls back to the canonical `edited` output
* (today's behavior) whenever the source-preserving transform cannot be proven
* render-equivalent so it can never corrupt or relocate content.
*/
export function reconcileSerializedMarkdown({
originalSource,
baseCanonical,
edited,
roundTrip
}: ReconcileSerializedMarkdownParams): string {
// Branch 1: no semantic change vs the unedited doc → preserve the original
// bytes verbatim (incl. trailing newline / EOL); zero disk churn.
if (edited === baseCanonical) {
return originalSource
}
// Work in LF space: getMarkdown emits LF while `originalSource` may be CRLF, so
// patching LF-context hunks against raw CRLF would fuzzy-match poorly and mix
// endings. The detected EOL is restored on EVERY non-verbatim return below so a
// uniform-CRLF file never silently flips to LF, even on a canonical fallback.
const eol = detectDominantEol(originalSource)
const originalSourceLf = toLf(originalSource)
const baseLf = toLf(baseCanonical)
const editedLf = toLf(edited)
// Branch 2: the source body equals the canonical `edited` body apart from its
// EOL/trailing newlines, so nothing non-canonical remains to preserve. Skip the
// expensive safety re-parse and carry the source's EOL + trailing newline onto
// the edit. Two guards keep the re-parse skip provably drift-free:
// - source trailing run ≤ 1 newline: a longer trailing-blank run can
// materialize a spurious `&nbsp;` empty paragraph on reload when the edit
// turns the EOF block into a paragraph;
// - `edited` has no trailing newline: getMarkdown keeps a trailing `\n\n` for a
// real trailing empty paragraph, so stripping it here would silently drop a
// block the user added.
// Both cases (rare) defer to the branch-6-verified path below instead.
const originalTrailingNewlines = originalSourceLf.match(/\n+$/)?.[0] ?? ''
if (
originalTrailingNewlines.length <= 1 &&
!editedLf.endsWith('\n') &&
stripTrailingNewlines(originalSourceLf) === stripTrailingNewlines(baseLf)
) {
return restoreEol(editedLf + originalTrailingNewlines, eol)
}
// Branch 3: oversize → bounded-cost canonical fallback (today's behavior).
if (
Math.max(originalSource.length, baseCanonical.length, edited.length) >
RECONCILE_SIZE_CAP_CODE_UNITS
) {
return restoreEol(editedLf, eol)
}
// Branch 4: run the divergent-base patch entirely in LF space.
// Why: the dependency's half-match accelerator ignores its diff deadline and
// can spend 100ms+ scanning repeated long seeds. Returning canonical is safer
// than entering that unbounded path for highly repetitive replacements.
if (hasRepeatedHalfMatchSeed(baseLf, editedLf)) {
return restoreEol(editedLf, eol)
}
let diffs = makeDiff(baseLf, editedLf, {
checkLines: true,
timeout: RECONCILE_DIFF_TIMEOUT_SECONDS
})
// Match makePatches(textA, textB)'s cleanup behavior while supplying the
// bounded diff ourselves instead of inheriting the library's 1s timeout.
if (diffs.length > 2) {
diffs = cleanupSemantic(diffs)
diffs = cleanupEfficiency(diffs)
}
const patches = makePatches(baseLf, diffs)
const [reconciledLf, results] = applyPatches(patches, originalSourceLf)
// Branch 5: a hunk that failed to locate in the non-canonical source → the
// fuzzy match is unreliable here, so fall back to canonical.
if (results.some((applied) => !applied)) {
return restoreEol(editedLf, eol)
}
// Branch 6: prove the reconciled bytes render-equal the editor's document.
// Any fuzzy misplacement (e.g. onto a repeated substring) changes canonical
// output and is caught here → canonical fallback. Compared under norm so only
// style/EOL/EOF differences are tolerated.
const reparsed = roundTrip(reconciledLf)
if (reparsed === null || normalizeForSafety(reparsed) !== normalizeForSafety(editedLf)) {
return restoreEol(editedLf, eol)
}
// Restore the detected EOL as the final step so reconciled CRLF stays CRLF.
return restoreEol(reconciledLf, eol)
}
function stripTrailingNewlines(lfText: string): string {
return lfText.replace(/\n+$/, '')
}
function detectDominantEol(text: string): '\n' | '\r\n' {
const totalLf = (text.match(/\n/g) ?? []).length
const crlf = (text.match(/\r\n/g) ?? []).length
const lfOnly = totalLf - crlf
return crlf > 0 && crlf >= lfOnly ? '\r\n' : '\n'
}
function toLf(text: string): string {
return text.replace(/\r\n/g, '\n')
}
function restoreEol(lfText: string, eol: '\n' | '\r\n'): string {
// lfText is pure LF, so a blind LF→CRLF replace produces no mixed endings.
return eol === '\r\n' ? lfText.replace(/\n/g, '\r\n') : lfText
}
function normalizeForSafety(text: string): string {
// Why: both operands are canonical getMarkdown outputs, so an equal-render
// reconciliation is byte-identical here apart from EOL. Compare exactly (only
// CRLF-normalized) — a trailing `\n\n` empty paragraph is semantic, so a lenient
// trimEnd would mask exactly the trailing-block drift branch 6 must catch.
return text.replace(/\r\n/g, '\n')
}
function hasRepeatedHalfMatchSeed(textA: string, textB: string): boolean {
// Match diff-match-patch's own prefix/suffix trimming so a small edit in a
// repetitive document is not mistaken for a repetitive replacement.
const minimumLength = Math.min(textA.length, textB.length)
let prefixLength = 0
while (
prefixLength < minimumLength &&
textA.charCodeAt(prefixLength) === textB.charCodeAt(prefixLength)
) {
prefixLength += 1
}
let suffixLength = 0
while (
suffixLength < minimumLength - prefixLength &&
textA.charCodeAt(textA.length - suffixLength - 1) ===
textB.charCodeAt(textB.length - suffixLength - 1)
) {
suffixLength += 1
}
const middleA = textA.slice(prefixLength, textA.length - suffixLength)
const middleB = textB.slice(prefixLength, textB.length - suffixLength)
const longText = middleA.length > middleB.length ? middleA : middleB
const shortText = middleA.length > middleB.length ? middleB : middleA
if (longText.length < 4 || shortText.length * 2 < longText.length) {
return false
}
const seedLength = Math.floor(longText.length / 4)
for (const start of [Math.ceil(longText.length / 4), Math.ceil(longText.length / 2)]) {
const seed = longText.slice(start, start + seedLength)
const firstMatch = shortText.indexOf(seed)
if (firstMatch !== -1 && shortText.includes(seed, firstMatch + 1)) {
return true
}
}
return false
}

View File

@ -60,6 +60,9 @@ function createContext(editor: Editor): KeyHandlerContext {
editorRef: { current: editor },
rootRef: { current: null },
lastCommittedMarkdownRef: { current: '' },
originalSourceRef: { current: '' },
baseCanonicalRef: { current: '' },
reconcileRoundTripRef: { current: () => null },
onContentChangeRef: { current: vi.fn() },
onSaveRef: { current: vi.fn() },
isEditingLinkRef: { current: false },

View File

@ -0,0 +1,90 @@
// @vitest-environment happy-dom
import { describe, expect, it, vi } from 'vitest'
import { renderHook } from '@testing-library/react'
import { Editor } from '@tiptap/core'
import { useRichMarkdownProgrammaticSync } from './useRichMarkdownProgrammaticSync'
import { createRichMarkdownExtensions } from './rich-markdown-extensions'
import {
createRichMarkdownEditorCodec,
type RichMarkdownEditorCodec
} from './rich-markdown-source-transport'
import {
createRichMarkdownHtmlSuperscriptLinkContext,
type RichMarkdownHtmlSuperscriptLinkContext
} from './rich-markdown-html-superscript-link-context'
import { encodeRawMarkdownHtmlForRichEditor } from './raw-markdown-html'
function buildEditor(
codec: RichMarkdownEditorCodec,
htmlSuperscriptLinkContext: RichMarkdownHtmlSuperscriptLinkContext,
markdown: string
): Editor {
return new Editor({
element: null,
extensions: createRichMarkdownExtensions({
codec,
htmlSuperscriptLinks: true,
htmlSuperscriptLinkContext
}),
content: encodeRawMarkdownHtmlForRichEditor(markdown, codec, { htmlSuperscriptLinks: true }),
contentType: 'markdown'
})
}
describe('useRichMarkdownProgrammaticSync external-reload baseline adoption (#6080)', () => {
it('adopts externally-canonicalized bytes as the reconciliation baseline without a reload', () => {
const codec = createRichMarkdownEditorCodec()
const htmlSuperscriptLinkContext = createRichMarkdownHtmlSuperscriptLinkContext({
sourceFilePath: '',
worktreeId: '',
worktreeRoot: null,
sourceOwner: { kind: 'unknown' as const }
})
// The editor currently shows emphasis loaded from the original `_old_` source;
// its canonical (getMarkdown) form is `*old*` with no trailing newline.
const canonical = '# T\n\n*old*'
const editor = buildEditor(codec, htmlSuperscriptLinkContext, canonical)
expect(editor.getMarkdown()).toBe(canonical)
// Refs still reflect the ORIGINAL non-canonical source bytes on disk.
const lastCommittedMarkdownRef = { current: '# T\n\n_old_' }
const originalSourceRef = { current: '# T\n\n_old_' }
const baseCanonicalRef = { current: canonical }
const isApplyingProgrammaticUpdateRef = { current: false }
try {
// An external tool rewrites disk to the canonical bytes (same semantics,
// new byte-level style). The doc already renders them, so no reload — but
// the reconciliation baseline must still be refreshed to the new bytes.
renderHook(() =>
useRichMarkdownProgrammaticSync({
codec,
content: canonical,
docLinkMenuSetter: vi.fn(),
editor,
fileId: 'f1',
filePath: '/repo/README.md',
isApplyingProgrammaticUpdateRef,
lastCommittedMarkdownRef,
originalSourceRef,
baseCanonicalRef,
markdownDocuments: undefined,
rootRef: { current: null },
runtimeEnvironmentId: null,
settings: null,
slashMenuSetter: vi.fn(),
worktreeId: 'w1',
worktreeRoot: null
})
)
// Without the fix these stay stale at `_old_`, and the next edit would
// rewrite the file back to `_old_`, silently undoing the external change.
expect(lastCommittedMarkdownRef.current).toBe(canonical)
expect(originalSourceRef.current).toBe(canonical)
expect(baseCanonicalRef.current).toBe(canonical)
} finally {
editor.destroy()
}
})
})

View File

@ -22,6 +22,8 @@ type RichMarkdownProgrammaticSyncOptions = {
filePath: string
isApplyingProgrammaticUpdateRef: MutableRefObject<boolean>
lastCommittedMarkdownRef: MutableRefObject<string>
originalSourceRef: MutableRefObject<string>
baseCanonicalRef: MutableRefObject<string>
markdownDocuments?: MarkdownDocument[]
rootRef: MutableRefObject<HTMLDivElement | null>
runtimeEnvironmentId?: string | null
@ -46,6 +48,8 @@ export function useRichMarkdownProgrammaticSync({
filePath,
isApplyingProgrammaticUpdateRef,
lastCommittedMarkdownRef,
originalSourceRef,
baseCanonicalRef,
markdownDocuments,
rootRef,
runtimeEnvironmentId,
@ -101,12 +105,29 @@ export function useRichMarkdownProgrammaticSync({
if (!editor) {
return
}
if (content === lastCommittedMarkdownRef.current || editor.getMarkdown() === content) {
if (content === lastCommittedMarkdownRef.current) {
return
}
if (editor.getMarkdown() === content) {
// Why: disk bytes changed but already render-equal to the current doc (e.g.
// an external tool canonicalized byte-level style). Skip the disruptive
// reload, but adopt the new bytes as the reconciliation baseline so the next
// edit patches onto the fresh source, not the stale pre-change source.
lastCommittedMarkdownRef.current = content
originalSourceRef.current = content
baseCanonicalRef.current = content
return
}
isApplyingProgrammaticUpdateRef.current = true
try {
applyExternalRichMarkdownContent(editor, content, lastCommittedMarkdownRef, codec)
applyExternalRichMarkdownContent(
editor,
content,
lastCommittedMarkdownRef,
originalSourceRef,
baseCanonicalRef,
codec
)
} finally {
isApplyingProgrammaticUpdateRef.current = false
}
@ -120,6 +141,8 @@ export function useRichMarkdownProgrammaticSync({
fileId,
isApplyingProgrammaticUpdateRef,
lastCommittedMarkdownRef,
originalSourceRef,
baseCanonicalRef,
rootRef,
slashMenuSetter
])
@ -129,6 +152,8 @@ function applyExternalRichMarkdownContent(
editor: Editor,
content: string,
lastCommittedMarkdownRef: MutableRefObject<string>,
originalSourceRef: MutableRefObject<string>,
baseCanonicalRef: MutableRefObject<string>,
codec: RichMarkdownEditorCodec
): void {
try {
@ -145,6 +170,10 @@ function applyExternalRichMarkdownContent(
// external content, matching onCreate's single-paragraph reflow behavior.
normalizeEmptyListItems(editor)
lastCommittedMarkdownRef.current = content
// Why: reset the reconciliation baseline to the freshly loaded external bytes
// so subsequent edits preserve the new source style, not the pre-reload one.
originalSourceRef.current = content
baseCanonicalRef.current = editor.getMarkdown()
if (hadFocus) {
const docSize = editor.state.doc.content.size
editor

View File

@ -0,0 +1,42 @@
import { useRef, type MutableRefObject } from 'react'
import { useAppStore } from '@/store'
import { serializeRichMarkdownForReconcile } from './rich-markdown-reconcile-serializer'
import { createRichMarkdownImageResolverContext } from './rich-markdown-image-context'
import type { RichMarkdownHtmlSuperscriptLinkContext } from './rich-markdown-html-superscript-link-context'
type ReconcileRoundTripParams = {
htmlSuperscriptLinkContext: RichMarkdownHtmlSuperscriptLinkContext
filePath: string
runtimeEnvironmentId?: string | null
worktreeId: string
worktreeRoot: string | null
}
/**
* Exposes the reconciliation safety serializer as a render-updated ref. It
* mirrors the live editor's codec/link/image context so the step-6 re-parse
* matches getMarkdown(), and only runs on commit so rebuilding the closure
* each render is cheap and always reflects the latest context.
*/
export function useRichMarkdownReconcileRoundTrip({
htmlSuperscriptLinkContext,
filePath,
runtimeEnvironmentId,
worktreeId,
worktreeRoot
}: ReconcileRoundTripParams): MutableRefObject<(markdown: string) => string | null> {
const settings = useAppStore((s) => s.settings)
const ref = useRef<(markdown: string) => string | null>(() => null)
ref.current = (markdown) =>
serializeRichMarkdownForReconcile(markdown, {
htmlSuperscriptLinkContext,
imageResolverContext: createRichMarkdownImageResolverContext({
filePath,
runtimeEnvironmentId,
settings,
worktreeId,
worktreeRoot
})
})
return ref
}