mirror of
https://github.com/stablyai/orca.git
synced 2026-10-19 08:03:13 +00:00
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:
@@ -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)
|
||||
},
|
||||
|
||||
+62
@@ -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`
|
||||
|
||||
Reference in New Issue
Block a user