fix(editor): fail safe when rich-markdown reconcile throws (#9685)

* fix(editor): fail safe when rich-markdown reconcile throws

commitRichMarkdownSerialization called reconcileSerializedMarkdown with no
guard. Its three callers all assumed the only failure was editor teardown —
which commit already handles internally (didSerialize:false) — so their empty
`catch {}` (debounced onUpdate, flush) and missing catch (Cmd+S shortcut)
actually only ever swallowed, or crashed on, a genuine reconcile throw.

When reconcile threw, the Cmd+S path crashed uncaught, and the debounced/flush
paths dropped the draft update silently — leaving editorDrafts stale and
stalling auto-save with no UI signal (the auto-save half of #9158/STA-2027,
diagnosed by @klay7w).

Make the shared choke point degrade to canonical `edited` on any reconcile
throw — the same fallback reconcile already uses internally — so no path can
crash or silently lose content; worst case is a canonical-style save (#6080).
The two remaining catch blocks now log instead of swallowing so an unexpected
serialize failure can't be invisible.

The multi-byte crash that was the known trigger is already fixed (#9642); this
hardens the shared path against any future reconcile failure.

* fix(editor): preserve source EOL on reconcile fallback
This commit is contained in:
Brennan Benson 2026-07-21 00:04:44 -07:00 committed by GitHub
parent 58bcfe2a33
commit 643cbe4358
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 54 additions and 13 deletions

View File

@ -178,9 +178,9 @@ export default function RichMarkdownEditor({
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.
} catch (error) {
// Why: teardown and reconcile failures are handled above; other failures must stay observable.
console.error('[editor] rich markdown serialize (flush) failed', error)
}
}, [reconcileRoundTripRef])

View File

@ -254,9 +254,9 @@ export function createRichMarkdownEditorConfig(params: EditorConfigParams): UseE
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.
} catch (error) {
// Why: teardown and reconcile failures are handled above; other failures must stay observable.
console.error('[editor] rich markdown serialize (debounced) failed', error)
}
}, 300)
},

View File

@ -78,6 +78,33 @@ describe('commitRichMarkdownSerialization (shared disk-bound serialize chokepoin
expect(didSerialize).toBe(false)
expect(markdown).toBe('safe')
})
it('preserves source EOL and advances refs when reconciliation throws', () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
const r = refs('# 이전\r\n\r\n_강조_\r\n', '# 이전\n\n*강조*')
const editor = fakeEditor(() => '# 변경\n\n*강조*')
const throwingRoundTrip = vi.fn(() => {
throw new Error('round-trip failed')
})
try {
const { markdown, didSerialize } = commitRichMarkdownSerialization(
editor,
r,
throwingRoundTrip
)
expect(markdown).toBe('# 변경\r\n\r\n*강조*')
expect(didSerialize).toBe(true)
expect(r.originalSourceRef.current).toBe(markdown)
expect(r.baseCanonicalRef.current).toBe('# 변경\n\n*강조*')
expect(r.lastCommittedMarkdownRef.current).toBe(markdown)
expect(throwingRoundTrip).toHaveBeenCalledTimes(1)
expect(consoleError).toHaveBeenCalledTimes(1)
} finally {
consoleError.mockRestore()
}
})
})
describe('handleRichMarkdownSaveShortcut (Cmd/Ctrl+S persistence site)', () => {

View File

@ -1,6 +1,9 @@
import type { MutableRefObject } from 'react'
import type { Editor } from '@tiptap/react'
import { reconcileSerializedMarkdown } from './rich-markdown-source-reconcile'
import {
reconcileSerializedMarkdown,
restoreMarkdownSourceEol
} from './rich-markdown-source-reconcile'
export type RichMarkdownReconcileRefs = {
/** Current on-disk source bytes; updated to the reconciled output each commit. */
@ -41,12 +44,19 @@ export function commitRichMarkdownSerialization(
return { markdown: refs.lastCommittedMarkdownRef.current, didSerialize: false }
}
const reconciled = reconcileSerializedMarkdown({
originalSource: refs.originalSourceRef.current,
baseCanonical: refs.baseCanonicalRef.current,
edited,
roundTrip
})
let reconciled: string
try {
reconciled = reconcileSerializedMarkdown({
originalSource: refs.originalSourceRef.current,
baseCanonical: refs.baseCanonicalRef.current,
edited,
roundTrip
})
} catch (error) {
// Why: style reconciliation is best-effort; preserve content and source EOL when it fails.
console.error('[editor] markdown reconcile failed; falling back to canonical output', error)
reconciled = restoreMarkdownSourceEol(edited, refs.originalSourceRef.current)
}
refs.originalSourceRef.current = reconciled
// Why: reconciled ≡ edited semantically, so its canonical form is `edited`

View File

@ -26,6 +26,10 @@ export type ReconcileSerializedMarkdownParams = {
roundTrip: (markdown: string) => string | null
}
export function restoreMarkdownSourceEol(markdown: string, source: string): string {
return restoreEol(toLf(markdown), detectDominantEol(source))
}
/**
* Carries the user's edit into the original source style so untouched regions keep their non-canonical bytes.
* Falls back to canonical `edited` when the transform can't be proven render-equivalent, so it never corrupts or relocates content.