fix: emit reliable patches after clearing the editor - #3236
Conversation
🦋 Changeset detectedLatest commit: 946c2ca The changes in this PR will be included in the next version bump. This PR includes changesets to release 14 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Bundle Stats✅ No significant changes. All scenario measurements (7)🗺️
Significant means at least 1.0 KB and 1% gzip, or at least 5 ms and 10% import time. |
12cb766 to
89f74b2
Compare
89f74b2 to
677a27b
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 677a27b. Configure here.
677a27b to
ec1480a
Compare
ec1480a to
c80781e
Compare
c80781e to
384ca70
Compare
384ca70 to
b772de3
Compare
b772de3 to
c004dd0
Compare
c004dd0 to
644bee2
Compare
644bee2 to
b800c69
Compare
…ring host A host following the documented mirror pattern (send `mutation.value` back as `update value`) echoes the editor's own placeholder-bearing engine value after a clear. The retype must still emit `setIfMissing([], [])` and the block `insert` before its `diffMatchPatch`es: the clear flush ended with `unset([])`, so the patch stream's document has no field to receive naked text diffs. Two scenarios, two poisoning paths. A single-flush clear (word delete) echoes a value equal to the engine, which only a no-op sync recording turns into false persistence evidence. A character-by-character clear spreads across flushes, so the echoes lag the ongoing edits: each stale echo differs from the engine by the time it syncs, writes as a genuine remote change, and the last one records the editor's cleared state as synced content. The second scenario waits for that cascade to settle (a remote-origin operation observed, engine back at the placeholder) before retyping, so the poisoned recording is deterministically in place. Red before the fix in both scenarios: the placeholder passes as persisted content, re-materialization is suppressed, and the retype flush carries only `diffMatchPatch`es that no patch-applying store can apply.
`lastSyncedValue` exists to tell a synced-in pristine block (persisted content) apart from the editor's local placeholder. Two paths let the placeholder itself get recorded as that evidence when a host mirrors `mutation.value` back as `update value`. The sync machine's `record synced value on engine` recorded every `done syncing` value, including no-op syncs whose value the engine already embodied, so a mirror echo after a single-flush clear recorded the placeholder directly. And when a clear spreads across flushes, the echoes lag the edits: each stale echo differs from the engine by sync time and writes as a genuine remote change, so even a correctly guarded recording ends up holding the editor's cleared state. Either way, the placeholder then compared equal to `lastSyncedValue` in `subscriber.patch-generation.ts`, the empty-editor transitions were suppressed, and the retype after a clear emitted naked `diffMatchPatch`es against a field its own stream had just `unset([])`. Two mechanisms, one per path. `updateValue` already computes `isChanged`; it rides on `done syncing` as `changed`, and `lastSyncedValue` is recorded only when the sync actually wrote, since an echo of the editor's own state asserts nothing about persistence. And patch generation now tracks its own field-level statement on the engine (`valueUnsetEmitted`): set when the became-empty `unset([])` is emitted, cleared when the rebuild is emitted, and while set, `editorWasEmpty` ignores the `lastSyncedValue` comparison entirely. The emitted stream stays self-consistent regardless of what lagging syncs recorded in between: after destroying the field it always rebuilds it before targeting it again. Rebuilding over a recorded value equal to the placeholder also clears the recording: the rebuild proves it was a stale echo, and keeping it would make the next became-empty transition skip its `unset([])`. Recording now skips every no-op sync, not only mirror echoes. Emitted patches only change after a became-empty `unset([])`: the next content-producing edit re-materializes the field again. Genuinely synced-in empty blocks still suppress re-materialization (no self-emitted unset precedes them), pinned by the existing pristine-block scenarios.
b800c69 to
946c2ca
Compare

Clearing a Portable Text field and typing into it again could silently lose the new text, or crash the consumer applying the emitted patches (
Cannot apply deep operations on primitive values): the retype flush emitted onlydiffMatchPatches against a field the same patch stream had justunset([]). It reproduces with any host that mirrorsmutation.valueback intoupdate value, which is the documented wiring (the playground uses it), and it is live in the playground onmain.The editor tells a synced-in pristine block (persisted content) apart from its own local placeholder by remembering the last synced value. Two paths let the placeholder itself become that evidence. First, the sync machine recorded every completed sync, including no-op syncs whose value the engine already embodied, so the mirror's echo after a single-flush clear recorded the placeholder directly. Second, and surviving any recording guard: when a clear spans several flushes (character-by-character backspace), the mirrored echoes lag the ongoing edits, so each stale echo differs from the engine by the time it syncs, writes as a genuine remote change, and the last one legitimately records the cleared state.
Two mechanisms, one per path. Recording now happens only when a sync actually wrote into the engine, since an echo of the editor's own state asserts nothing about persistence. And patch generation tracks its own field-level statement: after it emits the became-empty
unset([]), the next content-producing edit always rebuilds the field, regardless of what lagging syncs recorded in between. Rebuilding over a recorded value equal to the placeholder also clears the recording (the rebuild proves it was a stale echo), so a repeated clear unsets the field again instead of leaving an empty block behind. The emitted stream stays self-consistent: destroy, then rebuild, never text diffs into the void.One narrow trade rides along, disclosed in the changeset: a genuine empty block arriving through
update valuein the window between the editor's own unset and its next keystroke is now rebuilt (re-inserting a block the document already holds) where value sync previously suppressed correctly; the same window reached through remote patches misbehaved identically before this change. A stale echo and a genuine pristine block are indistinguishable by value in that window; distinguishing them takes provenance metadata, which is follow-up work. Both regression scenarios from the persisted-content fix this builds on remain pinned and green; the two new scenarios were proven red five-for-five without each mechanism and stable across twenty runs with them.Note
Medium Risk
Changes patch emission and value-sync bookkeeping on the critical clear→retype path; behavior is narrow but affects how hosts apply local patches.
Overview
Fixes a bug where clearing the editor and typing again could emit only
diffMatchPatchpatches against a field the same stream had just removed withunset([]), so consumers lost the new text or threw when applying patches. This showed up with the common host pattern that mirrorsmutation.valueback intoupdate value.Patch generation now tracks
valueUnsetEmittedafter a became-emptyunset([]). The next local edit that produces patches always re-materializes the field (setIfMissing, blockinsert, then text diffs), even if a lagging mirrored sync made the placeholder look like persisted content. Rebuilding over a recorded value equal to that placeholder clearslastSyncedValueso a second clear still emitsunset([]).Value sync only updates
lastSyncedValuewhen a completed sync actually wrote into the engine (done syncingcarries achangedflag), so no-op echoes of the editor’s own state are not treated as host persistence claims.Regression coverage adds scenarios for single-flush and character-by-character clears with mirrored values.
Reviewed by Cursor Bugbot for commit 946c2ca. Bugbot is set up for automated code reviews on this repo. Configure here.