fix: cancel net-zero insert/unset pairs when flushing mutations#3018
Draft
christianhg wants to merge 2 commits into
Draft
fix: cancel net-zero insert/unset pairs when flushing mutations#3018christianhg wants to merge 2 commits into
christianhg wants to merge 2 commits into
Conversation
A merged `insert.blocks` stages temporary spans (the pasted span and an empty split remainder) and merges them away within the same flush, yet the emitted mutation carries all eight patches: two keyed inserts, the neighbor's `diffMatchPatch`, and two unsets that retract the inserts. The intermediate patches sum to nothing, and receivers whose delivery slices a transaction render the transient spans (a remote paste briefly showing its text twice). Red until the mutation batcher cancels net-zero insert/unset pairs at flush time.
A merged `insert.blocks` stages temporary spans and merges them away within the same flush, and the mutation batcher emitted every patch of that narration: keyed inserts, the neighbor's `diffMatchPatch`, and the unsets retracting the inserts. The store's final state is identical either way, but receivers whose delivery slices a transaction apply the patches step by step and render the intermediate states, which is how a remote paste briefly shows its text twice (mitigated receiver-side for the SDK in #3017). `cancelNetZeroPatches` runs when a bulk flushes: a keyed item both inserted and unset within the flush is cancelled along with every patch scoped inside it, pairing sequentially so a re-insert of the same key survives. It bails per key when the key anchors another insert or is referenced outside the insert/unset window, and it assumes flush-created keys are fresh, which the key generator guarantees. A bulk whose patches all cancel is not emitted. Undo is unaffected: history is built from engine operations, not emitted patches. Individual `patch` events keep the full narration; only the `mutation` stream, which is what channels push to remotes, carries the collapsed form.
🦋 Changeset detectedLatest commit: 59fa2d4 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.
|
Contributor
📦 Bundle Stats —
|
| Metric | Value | vs main (02d5023) |
|---|---|---|
| Internal (raw) | 811.2 KB | +3.7 KB, +0.5% |
| Internal (gzip) | 156.1 KB | +941 B, +0.6% |
| Bundled (raw) | 1.42 MB | +3.7 KB, +0.3% |
| Bundled (gzip) | 320.3 KB | +954 B, +0.3% |
| Import time | 97ms | +1ms, +0.5% |
@portabletext/editor/behaviors
| Metric | Value | vs main (02d5023) |
|---|---|---|
| Internal (raw) | 467 B | - |
| Internal (gzip) | 207 B | - |
| Bundled (raw) | 424 B | - |
| Bundled (gzip) | 171 B | - |
| Import time | 2ms | -0ms, -0.0% |
@portabletext/editor/plugins
| Metric | Value | vs main (02d5023) |
|---|---|---|
| Internal (raw) | 2.7 KB | - |
| Internal (gzip) | 894 B | - |
| Bundled (raw) | 2.5 KB | - |
| Bundled (gzip) | 827 B | - |
| Import time | 7ms | -0ms, -0.2% |
@portabletext/editor/selectors
| Metric | Value | vs main (02d5023) |
|---|---|---|
| Internal (raw) | 82.7 KB | - |
| Internal (gzip) | 15.4 KB | - |
| Bundled (raw) | 78.4 KB | - |
| Bundled (gzip) | 14.3 KB | - |
| Import time | 8ms | +0ms, +1.0% |
@portabletext/editor/traversal
| Metric | Value | vs main (02d5023) |
|---|---|---|
| Internal (raw) | 28.1 KB | - |
| Internal (gzip) | 5.6 KB | - |
| Bundled (raw) | 28.1 KB | - |
| Bundled (gzip) | 5.5 KB | - |
| Import time | 6ms | +0ms, +0.8% |
@portabletext/editor/utils
| Metric | Value | vs main (02d5023) |
|---|---|---|
| Internal (raw) | 30.6 KB | - |
| Internal (gzip) | 6.4 KB | - |
| Bundled (raw) | 28.2 KB | - |
| Bundled (gzip) | 6.1 KB | - |
| Import time | 6ms | -0ms, -1.9% |
🗺️ . · ./behaviors · ./plugins · ./selectors · ./traversal · ./utils · Artifacts
Details
- Import time regressions over 10% are flagged with
⚠️ - Sizes shown as raw / gzip 🗜️. Internal bytes = own code only. Total bytes = with all dependencies. Import time = Node.js cold-start median.
📦 Bundle Stats — @portabletext/markdown
Compared against main (02d5023b)
| Metric | Value | vs main (02d5023) |
|---|---|---|
| Internal (raw) | 53.8 KB | - |
| Internal (gzip) | 9.8 KB | - |
| Bundled (raw) | 348.9 KB | - |
| Bundled (gzip) | 96.3 KB | - |
| Import time | 38ms | -1ms, -3.6% |
🗺️ View treemap · Artifacts
Details
- Import time regressions over 10% are flagged with
⚠️ - Sizes shown as raw / gzip 🗜️. Internal bytes = own code only. Total bytes = with all dependencies. Import time = Node.js cold-start median.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
When someone pastes text into a paragraph, remote peers can briefly see the pasted text twice before it snaps back to normal. #3017 fixed this receiver-side for SDK consumers by filtering incoming inserts absent from the store's settled value; this PR fixes the sender so the transient content is never published in the first place, for every consumer.
The emission is the problem. A paste whose text merges into an existing same-mark span stages temporary spans and merges them away within one flush: the mutation carried all eight patches of that narration, two keyed inserts (the pasted span and an empty split remainder), the neighbor's
diffMatchPatch, and two unsets retracting the inserts. Those pairs sum to nothing, and the store's final state is identical with or without them. But a receiver applies patches against its own engine state in the order delivered, and when delivery slices the transaction it renders the intermediate states. A probe with aMutationObserveron a receiving editor confirmed the split of responsibility: handed all the patches in one send, the DOM passes through exactly one state; handed them across sends, it paints the duplicate. The editor renders atomically per batch, so the only defense that works for every delivery is to not emit the scratch work.cancelNetZeroPatchesruns where a bulk flushes in the mutation batcher: a keyed item that is both inserted and unset within the flush is cancelled, together with every patch scoped inside it, and pairing is sequential so a re-insert of the same key survives as the net change. Two conservative guards keep it honest, pinned by unit tests over synthetic flushes: it bails per key when the key anchors another insert (cancelling an anchor target would dangle the reference) and when anything references the key outside the insert/unset window (a remove-then-recreate nets to a replacement, not to nothing). It assumes flush-created keys are fresh, which the key generator guarantees. A bulk whose patches all cancel is not emitted. The browser pin inevent.mutation.test.tsxasserts the paste emission collapses to the single text diff, with the engine value agreeing.Blast radius: document state is byte-identical by construction, undo is untouched (history is built from engine operations, not emitted patches), and individual
patchevents keep the full narration; only themutationstream, which is what channels push to remotes, carries the collapsed form. All existing suites pass unmodified, editor andplugin-sdk-valuealike, including the #3017 test: no existing pin ever asserted a mutation containing transient content. The one observable delta beyond fewer patches is that an edit whose patches fully cancel emits no mutation at all, which is the correct statement of "nothing changed".