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:
parent
58bcfe2a33
commit
643cbe4358
|
|
@ -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])
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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)', () => {
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in New Issue