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.
This commit is contained in:
Brennan Benson
2026-07-20 21:29:29 -07:00
parent 1293e1c0d8
commit 1df653c38e
4 changed files with 88 additions and 12 deletions
@@ -178,9 +178,11 @@ 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: commit already tolerates teardown (didSerialize:false) and reconcile failures
// (canonical fallback), so this only guards truly unexpected errors — log rather than swallow
// so a flush can't fail invisibly (STA-2027/#9158).
console.error('[editor] rich markdown serialize (flush) failed', error)
}
}, [reconcileRoundTripRef])
@@ -254,9 +254,11 @@ 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: commit already tolerates teardown (didSerialize:false) and reconcile failures
// (canonical fallback), so this only guards truly unexpected errors — log rather than
// swallow so a debounced serialize can't fail invisibly (STA-2027/#9158).
console.error('[editor] rich markdown serialize (debounced) failed', error)
}
}, 300)
},
@@ -0,0 +1,62 @@
import { describe, expect, it, vi } from 'vitest'
import type { Editor } from '@tiptap/react'
const { reconcileSerializedMarkdown } = vi.hoisted(() => ({
reconcileSerializedMarkdown: vi.fn()
}))
vi.mock('./rich-markdown-source-reconcile', () => ({ reconcileSerializedMarkdown }))
import { commitRichMarkdownSerialization } from './rich-markdown-serialization-commit'
function editorReturning(markdown: string): Editor {
return { getMarkdown: () => markdown } as unknown as Editor
}
function refs(source: string, base: string, last: string) {
return {
originalSourceRef: { current: source },
baseCanonicalRef: { current: base },
lastCommittedMarkdownRef: { current: last }
}
}
describe('commitRichMarkdownSerialization reconcile-failure fallback (STA-2027/#9158)', () => {
it('degrades to canonical output instead of throwing when reconcile fails', () => {
// Regression: the Cmd+S path (rich-markdown-save-shortcut) has no try/catch, and the debounced
// onUpdate/flush paths swallowed the throw silently — leaving the draft stale and stalling
// auto-save. commit must never propagate a reconcile throw on a live editor.
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
reconcileSerializedMarkdown.mockImplementation(() => {
throw new Error('Failed to determine byte offset')
})
const r = refs('# old\n\n_강조_', '# old\n\n*강조*', '# old\n\n_강조_')
let threw = false
let result: ReturnType<typeof commitRichMarkdownSerialization> | undefined
try {
result = commitRichMarkdownSerialization(editorReturning('# 제목\n\n_강조_'), r, (md) => md)
} catch {
threw = true
}
// No throw; degrades to the canonical getMarkdown output so content is never lost.
expect(threw).toBe(false)
expect(result).toEqual({ markdown: '# 제목\n\n_강조_', didSerialize: true })
// All three refs advance consistently to the canonical output for the next incremental commit.
expect(r.originalSourceRef.current).toBe('# 제목\n\n_강조_')
expect(r.baseCanonicalRef.current).toBe('# 제목\n\n_강조_')
expect(r.lastCommittedMarkdownRef.current).toBe('# 제목\n\n_강조_')
// The failure is observable, not silent.
expect(consoleError).toHaveBeenCalled()
})
it('returns the torn-down fallback (no reconcile) when the editor is gone', () => {
// A null editor short-circuits before reconcile, so the last committed bytes are reused as-is.
const result = commitRichMarkdownSerialization(
null,
refs('src', 'base', 'last-committed'),
(md) => md
)
expect(result).toEqual({ markdown: 'last-committed', didSerialize: false })
})
})
@@ -41,12 +41,22 @@ 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: reconcile is best-effort style preservation that already degrades to canonical `edited`
// on any internal mismatch; a *thrown* failure must degrade the same way, not crash Cmd+S or
// silently stall auto-save by leaving the draft un-updated (STA-2027/#9158). Worst case is a
// canonical save (#6080), never lost content.
console.error('[editor] markdown reconcile failed; falling back to canonical output', error)
reconciled = edited
}
refs.originalSourceRef.current = reconciled
// Why: reconciled ≡ edited semantically, so its canonical form is `edited`