From 1df653c38e12caef282d1c6537fcfda0995d2660 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 20 Jul 2026 21:29:29 -0700 Subject: [PATCH] fix(editor): fail safe when rich-markdown reconcile throws MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../components/editor/RichMarkdownEditor.tsx | 8 ++- .../editor/rich-markdown-editor-config.ts | 8 ++- ...alization-commit-reconcile-failure.test.ts | 62 +++++++++++++++++++ .../rich-markdown-serialization-commit.ts | 22 +++++-- 4 files changed, 88 insertions(+), 12 deletions(-) create mode 100644 src/renderer/src/components/editor/rich-markdown-serialization-commit-reconcile-failure.test.ts diff --git a/src/renderer/src/components/editor/RichMarkdownEditor.tsx b/src/renderer/src/components/editor/RichMarkdownEditor.tsx index 2f499ba6b65..caad26fc26e 100644 --- a/src/renderer/src/components/editor/RichMarkdownEditor.tsx +++ b/src/renderer/src/components/editor/RichMarkdownEditor.tsx @@ -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]) diff --git a/src/renderer/src/components/editor/rich-markdown-editor-config.ts b/src/renderer/src/components/editor/rich-markdown-editor-config.ts index 5877d9eb31e..279270ee7ae 100644 --- a/src/renderer/src/components/editor/rich-markdown-editor-config.ts +++ b/src/renderer/src/components/editor/rich-markdown-editor-config.ts @@ -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) }, diff --git a/src/renderer/src/components/editor/rich-markdown-serialization-commit-reconcile-failure.test.ts b/src/renderer/src/components/editor/rich-markdown-serialization-commit-reconcile-failure.test.ts new file mode 100644 index 00000000000..a8ede169638 --- /dev/null +++ b/src/renderer/src/components/editor/rich-markdown-serialization-commit-reconcile-failure.test.ts @@ -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 | 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 }) + }) +}) diff --git a/src/renderer/src/components/editor/rich-markdown-serialization-commit.ts b/src/renderer/src/components/editor/rich-markdown-serialization-commit.ts index d35e9bde42b..9d93f1581dc 100644 --- a/src/renderer/src/components/editor/rich-markdown-serialization-commit.ts +++ b/src/renderer/src/components/editor/rich-markdown-serialization-commit.ts @@ -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`