Skip to content

fix(plugin-sdk-value): only repair divergence that persists, never transient store states#2998

Merged
christianhg merged 1 commit into
mainfrom
fix-duo-repair-whipsaw
Jul 21, 2026
Merged

fix(plugin-sdk-value): only repair divergence that persists, never transient store states#2998
christianhg merged 1 commit into
mainfrom
fix-duo-repair-whipsaw

Conversation

@ryanbonial

Copy link
Copy Markdown
Contributor

The bug

With two users typing simultaneously, even in separate blocks (the common "two editors, two paragraphs" case, which involves no genuine conflict), one user's words could end up scrambled mid-word: "newsroom " becoming "nom ewsro", "editors" becoming "edit… ors" with another word's restore spliced in between. Letters survive; placement breaks. Found by a new two-client human-typing driver in the external harness (sanity-labs/pte-sdk-concurrent-repro, duo-typing.mjs): roughly 3 of 8 aggressive trials scrambled on @portabletext/plugin-sdk-value@7.1.1.

Root cause (from instrumented sync traces)

When a remote transaction arrives interleaved with the listener echoes of this client's own recent edits, the SDK store value is transiently wrong until the rebase corrects it moments later. The plugin's whole-value repair fired inside exactly that window:

49.83  A types, then backspaces; deletions pushed        (editor correct)
50.670 B's remote patches arrive (none touch A's block)
50.671 repair diff: store transiently wrong → repair DELETES "oom publi" from A's span
50.675 store corrected → second repair RE-ADDS "oom publi"
54.501 same whipsaw: repair deletes "sanity "
55.754 re-adds "sanity " nine characters later, INSIDE the word "editors" A typed meanwhile

The repair wasn't repairing; it was photographing the store mid-blink, then un-photographing it at a drifted offset because the user kept typing between the two writes.

The fix

Repairs only act on divergence that persists:

  • applySync is now two-phase: it records the exact (editor, store) state pair and applies the repair only after a confirmation window (1s in production, sized to outlast a slow echo round trip) if that pair is byte-for-byte unchanged. Transients self-correct within the window and produce zero editor writes; genuine divergence is stable and gets repaired one beat later, losing nothing.
  • Repairs never run while local keystrokes are unflushed (the store lags the editor by design then; a repair would delete them).
  • Tests use a 150ms window (their mock stores are synchronous, echo transients cannot occur).

Tests

  • New browser regression test: a store value that blinks wrong and self-corrects must produce zero sync writes into the editor. Gate-checked: with the confirmation window removed it fails in chromium, firefox, and webkit, catching exactly the whipsaw's two-write signature. A companion test pins that persistent divergence still gets repaired.
  • Full package suite green: 205 tests across all three browsers; types, lint, build pass. Changeset included (patch).

Verification against real Content Lake (external harness)

Scenario 7.1.1 with this fix
Duo human typing, separate blocks ~3/8 trials scrambled 10/10 clean
Solo typing (delete-and-retype) 6/6 clean 6/6 clean (unchanged)
Discrete collision matrix soft-loss rates baseline unchanged

Related but independent: #2933 fixes an adjacent caret-jump (remote mark toggles replacing a block's children wholesale). Tested in combination: the two compose cleanly (10/10 with both). This fix does not require it, and neither supersedes the other; notably, this plugin's update value escalation path is exactly the kind of producer whose replacements #2933 teaches transformPoint to tolerate.

@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 6:31pm
portable-text-example-basic Ready Ready Preview, Comment Jul 21, 2026 6:31pm
portable-text-playground Ready Ready Preview, Comment Jul 21, 2026 6:31pm

Request Review

@changeset-bot

changeset-bot Bot commented Jul 21, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: f03fb03

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

This PR includes changesets to release 1 package
Name Type
@portabletext/plugin-sdk-value 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

@ryanbonial
ryanbonial requested a review from christianhg July 21, 2026 16:57
…ansient store states

With two users typing simultaneously (even in different blocks), a
remote transaction arriving interleaved with the listener echoes of
this client's own recent edits leaves the store value transiently
wrong until the rebase corrects it moments later. The whole-value
repair fired inside that window: it copied the transient into the
editor (deleting text the user had legitimately typed), and a
follow-up repair restored the text at a drifted offset because the
user had kept typing, scrambling words ('newsroom ' becoming
'nom ewsro').

The repair now confirms divergence before acting:

- applySync becomes two-phase: it records the exact (editor, store)
  state pair and only applies the repair after a confirmation window
  (1s in production, sized to outlast a slow echo round trip) if that
  pair is byte-for-byte unchanged. Transients self-correct within the
  window and produce zero editor writes; genuine divergence is stable
  and gets repaired one beat later, losing nothing.
- Repairs never run while local keystrokes are unflushed: unflushed
  edits mean the store lags the editor by design, and a repair would
  delete them.

Verified with the external two-client human-typing harness: the
separate-blocks duo scenario went from ~3-in-8 trials scrambled to
10/10 clean at aggressive typing rates, solo typing stays 6/6 clean,
and soft-loss rates in the discrete collision matrix are unchanged.

The regression tests pin both sides: a transiently wrong store value
must produce zero sync writes into the editor (fails on the previous
behavior in all three browsers with exactly the whipsaw's two-write
signature), and persistent divergence still gets repaired.
@github-actions

github-actions Bot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

📦 Bundle Stats — @portabletext/editor

Compared against main (bbce83b8)

@portabletext/editor

Metric Value vs main (bbce83b)
Internal (raw) 803.9 KB -
Internal (gzip) 154.3 KB -
Bundled (raw) 1.41 MB -
Bundled (gzip) 318.5 KB -
Import time 99ms +1ms, +0.8%

@portabletext/editor/behaviors

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

@portabletext/editor/plugins

Metric Value vs main (bbce83b)
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 (bbce83b)
Internal (raw) 81.8 KB -
Internal (gzip) 15.2 KB -
Bundled (raw) 77.6 KB -
Bundled (gzip) 14.1 KB -
Import time 8ms -0ms, -0.8%

@portabletext/editor/traversal

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

@portabletext/editor/utils

Metric Value vs main (bbce83b)
Internal (raw) 29.7 KB -
Internal (gzip) 6.2 KB -
Bundled (raw) 27.4 KB -
Bundled (gzip) 5.9 KB -
Import time 6ms +0ms, +1.0%

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

Metric Value vs main (bbce83b)
Internal (raw) 53.8 KB -
Internal (gzip) 9.8 KB -
Bundled (raw) 348.9 KB -
Bundled (gzip) 96.3 KB -
Import time 40ms +1ms, +1.4%

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

@christianhg
christianhg merged commit 2a71da1 into main Jul 21, 2026
17 checks passed
@christianhg
christianhg deleted the fix-duo-repair-whipsaw branch July 21, 2026 18:34
@ecoscript ecoscript Bot mentioned this pull request Jul 21, 2026
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.

2 participants