From 643cbe43585e950955a36bb655ca779473761e51 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 21 Jul 2026 00:04:44 -0700 Subject: [PATCH] fix(editor): fail safe when rich-markdown reconcile throws (#9685) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- .../components/editor/RichMarkdownEditor.tsx | 6 ++--- .../editor/rich-markdown-editor-config.ts | 6 ++--- ...rich-markdown-serialization-commit.test.ts | 27 +++++++++++++++++++ .../rich-markdown-serialization-commit.ts | 24 ++++++++++++----- .../editor/rich-markdown-source-reconcile.ts | 4 +++ 5 files changed, 54 insertions(+), 13 deletions(-) diff --git a/src/renderer/src/components/editor/RichMarkdownEditor.tsx b/src/renderer/src/components/editor/RichMarkdownEditor.tsx index 2f499ba6b..dc2b9885a 100644 --- a/src/renderer/src/components/editor/RichMarkdownEditor.tsx +++ b/src/renderer/src/components/editor/RichMarkdownEditor.tsx @@ -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]) 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 5877d9eb3..09f1479e9 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,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) }, diff --git a/src/renderer/src/components/editor/rich-markdown-serialization-commit.test.ts b/src/renderer/src/components/editor/rich-markdown-serialization-commit.test.ts index e08b7a08b..008f751b5 100644 --- a/src/renderer/src/components/editor/rich-markdown-serialization-commit.test.ts +++ b/src/renderer/src/components/editor/rich-markdown-serialization-commit.test.ts @@ -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)', () => { 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 d35e9bde4..dcebdf60d 100644 --- a/src/renderer/src/components/editor/rich-markdown-serialization-commit.ts +++ b/src/renderer/src/components/editor/rich-markdown-serialization-commit.ts @@ -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` diff --git a/src/renderer/src/components/editor/rich-markdown-source-reconcile.ts b/src/renderer/src/components/editor/rich-markdown-source-reconcile.ts index 9a047992b..f04134100 100644 --- a/src/renderer/src/components/editor/rich-markdown-source-reconcile.ts +++ b/src/renderer/src/components/editor/rich-markdown-source-reconcile.ts @@ -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.