Skip to content

fix: keep normalization fallout of remote changes local (normalization echo corruption)#2999

Closed
ryanbonial wants to merge 1 commit into
mainfrom
fix-remote-normalization-echo
Closed

fix: keep normalization fallout of remote changes local (normalization echo corruption)#2999
ryanbonial wants to merge 1 commit into
mainfrom
fix-remote-normalization-echo

Conversation

@ryanbonial

@ryanbonial ryanbonial commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Draft on purpose. The live fix works, but it is blunt and conflicts with the self-solving documents contract. It needs a call on the right mechanism before it can land. Everything here (repro, Studio comparison, traces, regression test, breakage analysis) is meant to hand you the problem fully cornered.

The bug (SDK-path concurrent corruption; not observed in Studio)

Reported on @portabletext/plugin-sdk-value@7.1.2 + @portabletext/editor@7.10.10 after the two typing fixes landed: bolding/italicizing selected text "works briefly, but then the formatting is lost and all of the content from the selected text onward is duplicated and appended to the end"; repeated pastes while another user types multiply.

Reproduced on demand with a two-client human-gesture driver (duo-typing.mjs format in sanity-labs/pte-sdk-concurrent-repro): user A keyboard-selects a word and toggles bold while user B types in another block.

Head-to-head on the same editor version (@portabletext/editor@7.10.10):

Target format mode (A bold while B types)
Native Studio FormBuilder PTE (sanity@6.5.0, Structure form, no SDK) SAFE (4/4) — block A text intact, correct bold split
SDK plugin path (plugin-sdk-value@7.1.2) MANGLED (6/6) — block A truncated/scrambled (…in the r, …rrant and it wants a taco estau)

So this is not a latent Studio PTE bug that somehow went unreported. Studio's FormBuilder + @sanity/mutator path does not reproduce the Axios format-corruption class under the same workload. The SDK value-plugin sync path does.

That also fits "why after 7.1.0": the operational patch channel made fine-grained normalize echoes into real competing writes. Pre-7.1.0 whole-value LWW mostly hid them.

Harness: studio-native.mjs format (Studio) vs duo-typing.mjs format (SDK), same seed/oracle.

Root cause (from SDK-store-level traces of a corrupted trial)

Every receiving editor on the SDK path pushes its own normalization of a collaborator's formatting. The smoking gun, client B (typing only in block B) pushing edits to block A:

4.16 B PUSH: dmp spanA.text "+it wants ", unset children[71bc...]   ← merging A's fragments
5.70 B PUSH: dmp spanA.text "+rant and ", unset children[4ad6...]
7.44 B PUSH: dmp spanA.text "+estau",     unset children[36fb...]

A's bold toggle splits a span into keyed fragments. Those arrive at B as remote patches; B's normalizer merges the adjacent same-mark spans back together (correct local hygiene), but the merge operations are emitted as local patches and the plugin pushes them. A performs the same merge with different fragment keys. The interleaved competing merges (overlapping dmp inserts, unsets of each other's fragments) corrupt the server: fragments unset after their text already moved (truncation) or text applied twice (the duplication users see).

Mechanically, the leak is scope nesting: remote application runs under withoutPatching, but withoutNormalizing was outermost, and its exit normalize pass (plus the explicit normalize in remote-patches.ts) ran after patching was restored.

Studio likely survives the same editor behavior because its BufferedDocument / mutator path buffers, squashes, and rebases pending local mutations over arriving remotes before commit, so a receiver's normalize fallout often never lands as a competing write. The SDK plugin, by design after the 7.1.1 typing fix, pushes every mutation flush immediately and has no equivalent "hold normalize echo until rebase proves it is still needed" layer.

What this draft does

Makes withoutPatching outermost at every remote-application site (remote-patches.ts and the five sync-machine.ts sites), so normalization fallout of remote changes stays local. The originator still pushes its own merge as a genuine local edit, so the server converges to the normalized form.

This stops the echo at the editor. It is one valid fix site, but given the Studio comparison it should be framed as protecting the SDK sync path (and any other consumer that eagerly commits every mutation), not as "Studio has this bug too."

Verification (external harness, real Content Lake, local builds)

Scenario main / released this branch
SDK format mode (select + bold toggle vs typing) 6/6 corrupted SAFE
Native Studio format mode (same gestures) SAFE (no change expected) n/a
solo typing / duo separate blocks / duo same block clean clean (unchanged)

The new regression test (remote-patches-normalization.test.tsx) pins the leak: remote patches creating adjacent same-mark spans must produce zero locally emitted patches/mutations. It fails on main in all three browsers with exactly the two-write merge signature.

Why it's a draft: the self-solving collision

Against a clean-main baseline (10 pre-existing browser-suite failures on my machine), this suppression newly breaks ~21 pinned tests, and they're not collateral: they're the self-solving documents contract (normalization.test.tsx, self-solving.test.tsx, container-normalization.test.tsx, event.patches.test.tsx normalization emissions, and the collaborative-editing.test.tsx deferred-normalization scenarios). Those tests deliberately assert that normalization fix-ups ARE emitted so broken documents heal.

The existing architecture already has the right idea for the pristine case: normalization patches are held in pendingEvents and discard conflicting pending patches drops them when a conflicting remote patch arrives. The failure mode fixed here is specifically remote-triggered normalization during an already-dirty session, which bypasses the hold and emits with the next flush.

So the likely correct mechanism, rather than this blanket suppression: extend the hold-and-discard treatment to normalization patches generated while isProcessingRemoteChanges (regardless of dirty state), so the originator's own merge ops discard them on arrival, and they still emit eventually if no one else fixes the document (self-solving preserved). That reaches into the deferral design in editor-machine.ts, which is why I'd rather hand it over than guess.

Alternative framing (plugin / SDK, not core)

Because Studio does not reproduce this, another valid fix site is plugin-sdk-value: do not push mutations that are only the normalize fallout of a remote apply (or hold them until a mutator-style rebase shows they are still needed). That would mirror Studio's "buffer and rebase away" behavior without teaching the core editor to suppress self-solving emissions. Happy to pursue either site.

The driver reproduces the corruption in about 30 seconds per trial.

When a collaborator's formatting toggle splits a span, every receiving
editor's normalizer merges the resulting adjacent same-mark spans back
together, and those merge operations were emitted as local patches and
pushed to the server. Each receiving client pushed its own competing
cleanup of the same structure (different fragment keys, overlapping dmp
inserts, unsets of each other's fragments), and the interleaved merges
corrupted the shared document. Field signature: bold/italic appears to
work briefly, then the formatted region's text is duplicated at the end
of the block with the marks gone; repeated pastes while another user
types multiply.

Mechanism: the suppression scopes were nested with withoutNormalizing
outermost, so its exit normalize pass ran after patching was restored,
and the explicit normalize after remote application was likewise
unsuppressed. This draft makes withoutPatching outermost at every remote
application site (remote-patches.ts and the five sync-machine sites), so
remote-triggered normalization stays local. The originating client still
pushes its own merge as a genuine local edit, so the server converges to
the normalized form.

DRAFT: this blanket suppression conflicts with the self-solving
documents contract (pinned normalization-emission tests fail; see the PR
description for the enumeration). The likely correct mechanism is
extending the existing pendingEvents hold-and-discard machinery to
remote-triggered normalization during dirty sessions instead of
suppressing emission entirely; needs a design call.

The new regression test pins the leak: applying remote patches that
create adjacent same-mark spans must produce zero locally-emitted
patches or mutations. It fails on main in all three browsers with
exactly the whipsaw's two-write signature.
@changeset-bot

changeset-bot Bot commented Jul 21, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: c750fd3

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@vercel

vercel Bot commented Jul 21, 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 21, 2026 10:31pm
portable-text-example-basic Ready Ready Preview, Comment Jul 21, 2026 10:31pm
portable-text-playground Ready Ready Preview, Comment Jul 21, 2026 10:31pm

Request Review

@github-actions

github-actions Bot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

📦 Bundle Stats — @portabletext/editor

Compared against main (11d72d02)

@portabletext/editor

Metric Value vs main (11d72d0)
Internal (raw) 806.6 KB +9 B, +0.0%
Internal (gzip) 154.9 KB +6 B, +0.0%
Bundled (raw) 1.42 MB +9 B, +0.0%
Bundled (gzip) 319.2 KB +6 B, +0.0%
Import time 95ms +0ms, +0.1%

@portabletext/editor/behaviors

Metric Value vs main (11d72d0)
Internal (raw) 467 B -
Internal (gzip) 207 B -
Bundled (raw) 424 B -
Bundled (gzip) 171 B -
Import time 2ms +0ms, +4.8%

@portabletext/editor/plugins

Metric Value vs main (11d72d0)
Internal (raw) 2.7 KB -
Internal (gzip) 894 B -
Bundled (raw) 2.5 KB -
Bundled (gzip) 827 B -
Import time 7ms +0ms, +0.4%

@portabletext/editor/selectors

Metric Value vs main (11d72d0)
Internal (raw) 82.7 KB -
Internal (gzip) 15.4 KB -
Bundled (raw) 78.4 KB -
Bundled (gzip) 14.3 KB -
Import time 8ms +0ms, +0.5%

@portabletext/editor/traversal

Metric Value vs main (11d72d0)
Internal (raw) 28.1 KB -
Internal (gzip) 5.6 KB -
Bundled (raw) 28.1 KB -
Bundled (gzip) 5.5 KB -
Import time 6ms -0ms, -2.6%

@portabletext/editor/utils

Metric Value vs main (11d72d0)
Internal (raw) 30.6 KB -
Internal (gzip) 6.4 KB -
Bundled (raw) 28.2 KB -
Bundled (gzip) 6.1 KB -
Import time 6ms -0ms, -0.8%

🗺️ . · ./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 (11d72d02)

Metric Value vs main (11d72d0)
Internal (raw) 53.8 KB -
Internal (gzip) 9.8 KB -
Bundled (raw) 348.9 KB -
Bundled (gzip) 96.3 KB -
Import time 36ms -3ms, -7.1%

🗺️ 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.

@ryanbonial

Copy link
Copy Markdown
Contributor Author

closing in favor of a SDK plugin fix

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