Skip to content

[Bug]: Rich markdown editor silently drops saves in non-ASCII documents — applyPatches() reads UCS-2 offsets as UTF-8 byte offsets #9158

Description

@JGRhee

Operating system: Windows
Orca version: 1.4.143

Short summary

Since #8862 (2026-07-15), saving a .md file in the rich markdown editor throws an uncaught Failed to determine byte offset and the save is silently dropped — no error reaches the UI. It only triggers on documents containing multi-byte UTF-8 characters, and whether a given keystroke triggers it is essentially a coin flip on the byte alignment of the edit position. So for Korean/Japanese/Chinese users, saving works sometimes, which reads as flaky behavior rather than a bug.

What happened?

Editing a Korean .md file and pressing Ctrl+S sometimes writes the file and sometimes does nothing at all — no error, no toast, no dirty-state change. Reduced to a deterministic case on one real file:

Line under edit (an H2 heading):

## 터미널 1: 텔레그램 (터미널 1로 통합 실행)
  • Append one - → Ctrl+S → saves
  • Append a second - → Ctrl+S → silently does not save

Same file, same line, one byte apart. main.trace.ndjson shows the corresponding uncaught error each time a save is dropped:

Uncaught Error: Failed to determine byte offset
  at advanceTo
  at adjustIndiciesToUcs2
  at apply
  at reconcileSerializedMarkdown

Root cause

reconcileSerializedMarkdown() calls @sanity/diff-match-patch like this:

const patches = make(baseLf, diffs);                              // makePatches
const [reconciledLf, results] = apply(patches, originalSourceLf); // applyPatches

That is a documented, type-correct use of the public API — makePatches returns Patch[] and applyPatches accepts Patch[].

But makePatches populates two coordinate systems on each patch:

start1     = 114   // UCS-2 code unit offset
utf8Start1 = 210   // UTF-8 byte offset

and applyPatchesadjustIndiciesToUcs2(patches, text) reads patch.start1 / patch.start2 and treats them as UTF-8 byte offsets, walking the base string by utf8len(codePoint) until it lands exactly on the target:

if (!options.allowExceedingIndices && byteOffset !== target)
  throw new Error("Failed to determine byte offset");

For ASCII, 1 char = 1 byte = 1 code unit, so start1 === utf8Start1 and the misread is a harmless identity — this can never throw. For Korean (3 bytes/char) the two diverge (114 vs 210), and the walk can only stop on real character boundaries. If the misread offset happens to coincide with a boundary the call survives; otherwise it overshoots and throws.

That is exactly the one-dash/two-dash split above:

one dash : start1=113 → is a byte boundary in the base → saves
two dashes: start1=114 → is not a byte boundary        → throws

The throw escapes #8862's safety net

The reconcile has a fallback for a failed patch:

const [reconciledLf, results] = apply(patches, originalSourceLf);
if (results.some((applied) => !applied)) {
  return restoreEol(editedLf, eol);   // never reached
}

but apply() calls adjustIndiciesToUcs2 on its first statement, before results exists. There is no try/catch, so a thrown patch is not a failed patch and the whole save unwinds instead of falling back. #8862 states:

On any mismatch, failed patch, oversize input, or no-op → fall back to today's canonical output. So it can never corrupt or relocate content — worst case is exactly today's behavior.

The invariant holds for content correctness, but not for liveness: the save never happens at all.

It then disappears completely, because handleSaveForFile swallows the rejection:

try {
  await requestEditorFileSave({ fileId: saveTargetFile.id, fallbackContent: content });
} catch {
}

How can we reproduce it?

Standalone, no Orca needed (@sanity/diff-match-patch@3.2.0):

import { makePatches, applyPatches } from '@sanity/diff-match-patch'

const base   = '## 터미널 1: 텔레그램 (터미널 1로 통합 실행)-\n'
const edited = '## 터미널 1: 텔레그램 (터미널 1로 통합 실행)--\n'

applyPatches(makePatches(base, edited), base)
// => Error: Failed to determine byte offset

// identical edit, ASCII document:
const enBase   = '## Terminal 1: Telegram (unified run)-\n'
const enEdited = '## Terminal 1: Telegram (unified run)--\n'
applyPatches(makePatches(enBase, enEdited), enBase)
// => fine, always

In the app:

  1. Open any .md containing Korean/Japanese/Chinese text in the rich editor (the default for .md).
  2. Type single characters at the end of a line, pressing Ctrl+S after each.
  3. Some keystrokes save; others silently do not. The error appears only in main.trace.ndjson.

Suggested fix

Three options, all verified locally against the repro above:

// 1. round-trip through the serialized patch format, whose offsets are UTF-8
//    (the coordinate system applyPatches actually expects)
apply(parsePatch(stringifyPatches(patches)), originalSourceLf)     // works

// 2. disable the exactness assertion
apply(patches, originalSourceLf, { allowExceedingIndices: true })  // works

// 3. make the existing fallback reachable
let reconciledLf, results;
try {
  [reconciledLf, results] = apply(patches, originalSourceLf);
} catch {
  return restoreEol(editedLf, eol);   // same path as a failed patch
}

Both (1) and (2) produce the correct reconciled output for the Korean case. (3) is the smallest safe change and routes through already-reviewed behavior; (1) is the most correct if the reconcile should keep working for CJK rather than degrading to canonical output on every CJK edit.

This also looks like an upstream footgun in @sanity/diff-match-patchapplyPatches(makePatches(a, b), a) type-checks and is the obvious usage, but is only correct for ASCII. I could not find an existing issue on either side.

Anything else that might help

  • The empty catch {} in handleSaveForFile turns every save rejection into silence. Even with this bug fixed, a failed write deserves surfacing — it took reading main.trace.ndjson to discover the file simply was not being written.
  • Reconcile tests presumably all pass today because ASCII makes the offset misread an accidental identity. A CJK fixture would have caught this immediately.
  • Severity reads higher than a typical editor bug: from the user's side this is silent data loss. The editor accepts the edit, Ctrl+S gives no feedback, the file never changes, and closing the tab loses the work with no warning.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions