Skip to content

fix: cancel net-zero insert/unset pairs when flushing mutations#3018

Draft
christianhg wants to merge 2 commits into
mainfrom
cancel-net-zero-patches-at-mutation-assembly
Draft

fix: cancel net-zero insert/unset pairs when flushing mutations#3018
christianhg wants to merge 2 commits into
mainfrom
cancel-net-zero-patches-at-mutation-assembly

Conversation

@christianhg

Copy link
Copy Markdown
Member

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 a MutationObserver on 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.

cancelNetZeroPatches runs 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 in event.mutation.test.tsx asserts 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 patch events keep the full narration; only the mutation stream, which is what channels push to remotes, carries the collapsed form. All existing suites pass unmodified, editor and plugin-sdk-value alike, 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".

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-bot

changeset-bot Bot commented Jul 23, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 59fa2d4

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 14 packages
Name Type
@portabletext/editor Patch
@portabletext/plugin-character-pair-decorator Patch
@portabletext/plugin-dnd Patch
@portabletext/plugin-emoji-picker Patch
@portabletext/plugin-input-rule Patch
@portabletext/plugin-list-index Patch
@portabletext/plugin-markdown-shortcuts Patch
@portabletext/plugin-one-line Patch
@portabletext/plugin-paste-link Patch
@portabletext/plugin-sdk-value Patch
@portabletext/plugin-table Patch
@portabletext/plugin-typeahead-picker Patch
@portabletext/plugin-typography Patch
@portabletext/toolbar Patch

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

@vercel

vercel Bot commented Jul 23, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
portable-text-editor-documentation Ready Ready Preview, Comment Jul 23, 2026 6:22pm
portable-text-example-basic Ready Ready Preview, Comment Jul 23, 2026 6:22pm
portable-text-playground Ready Ready Preview, Comment Jul 23, 2026 6:22pm

Request Review

@github-actions

github-actions Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

📦 Bundle Stats — @portabletext/editor

Compared against main (02d5023b)

@portabletext/editor

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.

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.

1 participant