Skip to content

🐛 Stop diff() emitting lone surrogates after semantic cleanup - #116

Open
alecgibson wants to merge 1 commit into
slab:mainfrom
reedsy:diff-lone-surrogates
Open

alecgibson wants to merge 1 commit into
slab:mainfrom
reedsy:diff-lone-surrogates

Conversation

@alecgibson

Copy link
Copy Markdown
Contributor

Fixes #115

diff() runs fast-diff with semantic cleanup enabled. fast-diff only applies its unicode-aware merge in the pass that runs before cleanup: diff_cleanupSemantic() then merges again without the flag, re-splitting the surrogate pairs the first pass protected.

The result is ops holding half an astral character. Composing them still gives the right document, since the halves rejoin across op boundaries, but a lone surrogate has no UTF-8 encoding, so anything that serialises an op on its own to bytes mangles or rejects it.

This change falls back to the uncleaned diff when the cleaned one splits a pair, since the plain diff is surrogate-safe by construction. Those rare diffs then come back character-aligned rather than word-aligned. Fixing this in fast-diff would be preferable, but it hasn't had a release since May 2023.

🤖 Generated with Claude Code

Co-Authored-By: Claude noreply@anthropic.com

Fixes slab#115

`diff()` runs `fast-diff` with semantic cleanup enabled. `fast-diff`
only applies its unicode-aware merge in the pass that runs *before*
cleanup: `diff_cleanupSemantic()` then merges again without the flag,
re-splitting the surrogate pairs the first pass protected.

The result is ops holding half an astral character. Composing them
still gives the right document, since the halves rejoin across op
boundaries, but a lone surrogate has no UTF-8 encoding, so anything
that serialises an op on its own to bytes mangles or rejects it.

This change falls back to the uncleaned diff when the cleaned one
splits a pair, since the plain diff is surrogate-safe by construction.
Those rare diffs then come back character-aligned rather than
word-aligned. Fixing this in `fast-diff` would be preferable, but it
hasn't had a release since May 2023.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
alecgibson added a commit to reedsy/delta that referenced this pull request Sep 9, 2026
Refs reedsy/reedsy-editor#14888

`Delta#diff()` calls `fast-diff` with semantic cleanup enabled. Under
the hood, `fast-diff` runs `diff_cleanupMerge()` with its `fix_unicode`
flag, which shaves stray surrogates off the boundaries of equalities so
that no tuple ever ends up holding half an astral character. However, it
only does this in the merge pass that runs *before* the optional
semantic cleanup, and `diff_cleanupSemantic()` calls
`diff_cleanupMerge()` again without the flag, happily re-splitting the
pairs the first pass protected.

The result is that a small fraction of diffs between documents
containing emoji come back with lone surrogates in their ops, e.g.:

```
new Delta().insert('x🌀').diff(new Delta().insert('x🏆🌀'))
// [{retain: 2}, {insert: '\uDFC6\uD83C'}]
```

Composing such a delta still produces the right document — the halves
rejoin across op boundaries — so this doesn't corrupt anything locally.
But a lone surrogate is legal in a JS string while having no UTF-8
encoding, so anything that serialises the op to bytes either mangles it
to U+FFFD or rejects it. In our case ShareDB stores it happily in Mongo,
and then
BigQuery's Storage Write API rejects the archived row, which aborts the
commit and blocks the user's save.

This change checks the cleaned-up diff for lone surrogates and, if it
finds any, re-runs the diff without semantic cleanup. `fast-diff`'s
plain diff is surrogate-safe by construction, so the fallback can never
split a pair. The only cost is that these rare diffs come out
character-aligned rather than word-aligned, which is cosmetic for us:
our callers use the result to derive ops, not to show them to a human.

The proper fix would be for `fast-diff` to run a final unicode-aware
merge after semantic cleanup, but it hasn't had a release since 2023, so
we work around it here rather than forking it. The bug is [reported][1]
and the same workaround [proposed][2] upstream, so we can drop ours if
it lands there.

Semantic cleanup only splits a pair for particular alignments of the two
documents, which is far too sparse a target to hit by hand, so the tests
include a seeded fuzz over a few thousand random emoji-laden document
pairs alongside the hand-written cases.

[1]: slab#115
[2]: slab#116

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
@alecgibson
alecgibson marked this pull request as ready for review September 9, 2026 07:37
alecgibson added a commit to reedsy/delta that referenced this pull request Sep 9, 2026
Refs reedsy/reedsy-editor#14888

`fast-diff` shaves stray surrogates off its equality boundaries, but
only in the merge pass that runs *before* semantic cleanup, which then
happily re-splits the pairs that pass protected. A lone surrogate is
legal in a JS string while having no UTF-8 encoding, so diffs of
emoji-laden documents come back with ops that ShareDB stores happily in
Mongo and BigQuery's Storage Write API then rejects, aborting the commit
and blocking the user's save.

So when the cleaned-up diff contains a lone surrogate, we re-run the
diff without semantic cleanup, which is surrogate-safe by construction.
Those rare diffs then come out character- rather than word-aligned,
which is cosmetic for us: we derive ops from the result, we never show
it to anyone.

`fast-diff` should really run a final unicode-aware merge itself, but it
hasn't had a release since 2023, so we work around it here rather than
forking it. The bug is [reported][1] and this workaround [proposed][2]
upstream, so we can drop ours if it lands there.

[1]: slab#115
[2]: slab#116

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
alecgibson added a commit to reedsy/delta that referenced this pull request Sep 9, 2026
Refs reedsy/reedsy-editor#14888

`fast-diff` shaves stray surrogates off its equality boundaries, but
only in the merge pass that runs *before* semantic cleanup, which then
happily re-splits the pairs that pass protected:

```
new Delta().insert('x🌀').diff(new Delta().insert('x🏆🌀'))
// [{retain: 2}, {insert: '\uDFC6\uD83C'}]
```

A lone surrogate is legal in a JS string while having no UTF-8
encoding, so ShareDB stores these ops happily in Mongo and BigQuery's
Storage Write API then rejects the archived row, aborting the commit
and blocking the user's save.

So when the cleaned-up diff contains a lone surrogate, we re-run the
diff without semantic cleanup, which is surrogate-safe by construction.
Those rare diffs then come out character- rather than word-aligned,
which is cosmetic for us: we derive ops from the result, we never show
it to anyone.

`fast-diff` should really run a final unicode-aware merge itself, but it
hasn't had a release since 2023, so we work around it here rather than
forking it. The bug is [reported][1] and this workaround [proposed][2]
upstream, so we can drop ours if it lands there.

[1]: slab#115
[2]: slab#116

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

diff() emits lone surrogates when semantic cleanup splits a pair

1 participant