Skip to content

fix: crash-proof concurrent PT editing over the SDK operational patch channel (stacks #2974 + #2975 + #2977)#2978

Draft
snorrees wants to merge 11 commits into
mainfrom
fix/concurrent-editing-combined
Draft

fix: crash-proof concurrent PT editing over the SDK operational patch channel (stacks #2974 + #2975 + #2977)#2978
snorrees wants to merge 11 commits into
mainfrom
fix/concurrent-editing-combined

Conversation

@snorrees

Copy link
Copy Markdown
Contributor

Summary

Combined fix for concurrent Portable Text editing over the SDK operational patch
channel
. It stacks four changes as individual merges (attribution preserved; each
underlying PR remains independently mergeable to main):

merge area author what
#2974 apply-operation.ts Ryan route span.marks / block.markDefs remote patches as data patches on the root block
#2975 sync-machine.ts + plugin Snorre crash-proof value sync against a divergent live tree; plugin diff-path resilience
#2977 plugin.sdk-value.tsx Ryan sync through the operational patch channel + repair-divergence after best-effort apply
atomic rollback remote-patches.ts / applyPatch.ts Snorre apply remote patch batches atomically — roll back on failure instead of crashing/partially applying

The load-bearing new piece is the atomic rollback. The operational channel (#2977)
applies incoming remote patches best-effort, one op at a time; when a concurrent
client's patch unsets a span _key this editor already changed, apply-operation
throws node was not found during application — before #2977's plugin-side repair
can run — so the plugin fix alone still crashes. Making the batch atomic (roll the
whole batch back and let the whole-value sync reconcile) removes the crash at the
layer where it actually happens.

Verified — real-dataset, two-session concurrency matrix (@sanity/sdk-react@2.17.0)

scenario before this branch
concurrent typing — disjoint / same span / same caret SAFE SAFE
bold-same-range, link-same-range CRASH SAFE
format-vs-type silent-loss silent-loss (valid)
link-overlap-range CRASH + CORRUPT ⚠️ still CORRUPT (+ a repair-loop stack overflow)
  • The node was not found crash is eliminated across all trials; same-range
    formatting returns to SAFE.
  • Not fixed here — link-overlap-range: two clients annotating the same
    overlapping text simultaneously
    still corrupt the document (orphan mark + duplicated
    text). The operational patches merge at Content Lake with no rebase, so the bad
    merge is server-side — no editor/plugin change undoes it. Separately, feat(plugin-sdk-value): sync through operational patches instead of whole-value diffs #2977's repair
    can hit Maximum call stack size exceeded on this case (an unbounded repair loop).
    This is the remaining operational-rebase work and is the SDK layer's to own; it
    should be tracked as a distinct issue. @sanity/mutator (Studio) is the reference.

Tests

  • Editor: 852 unit + 1723 browser (chromium), 0 failures.
  • Plugin: 30 unit + 20 browser (chromium, incl. the two-client rig), 0 failures.
  • Concurrency matrix + harness (two-session Playwright against a real dataset, scoring
    SAFE / silent-loss / corrupt / crash): available in the sanity-labs
    pte-sdk-concurrent-repro repo (CONCURRENCY-HARNESS.md).

Status

Draft. It is not a complete concurrent-editing fix — it makes the operational
channel crash-free and safe for typing + same-range formatting, and pins the
overlapping-annotation corruption as the open server-side rebase problem. Do not
merge over the individual PRs; this is the combined test/ship surface.

🤖 Generated with Claude Code

ryanbonial and others added 11 commits July 15, 2026 16:01
Guard updateBlock keyed unsets against the live engine tree and widen
the updateValue try/catch so a stale-key removal degrades to a safe
re-sync instead of an unhandled rejection that freezes the sync actor.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…f paths

arrayifyPath returns null instead of throwing on unexpected diff-patch
paths (e.g. array slices from clearing multiple marks); convertPatches
drops unconvertible ops and flags the batch incomplete; applySync then
falls back to an authoritative full value update / resync.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Credits #2974 for the engine routing fix and documents our residual
(updateValue try/catch widening + arrayifyPath/slice resilience).
…pair-divergence

# Conflicts:
#	packages/plugin-sdk-value/src/plugin.sdk-value.tsx
#	packages/plugin-sdk-value/src/plugin.value-sync.browser.test.tsx
Remote patch batches were applied best-effort, one op at a time: a concurrent
client's operational patch that unsets a span _key this editor already changed
throws `node was not found` inside apply-operation, and the batch was applying
partially — diverging the editors. Under the operational patch channel (#2977)
this crashes on concurrent formatting.

Make a remote patch batch atomic: if any op throws, roll the whole batch back
to the pre-batch value and let the whole-value sync reconcile — never a crash,
never a half-applied tree. applyPatch stops swallowing errors so the batch
handler can see the failure.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@changeset-bot

changeset-bot Bot commented Jul 17, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 97ae00e

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-sdk-value Minor
@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-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 17, 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 17, 2026 8:38am
portable-text-example-basic Ready Ready Preview, Comment Jul 17, 2026 8:38am
portable-text-playground Ready Ready Preview, Comment Jul 17, 2026 8:38am

Request Review

@github-actions

github-actions Bot commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

📦 Bundle Stats — @portabletext/editor

Compared against main (46133acc)

@portabletext/editor

Metric Value vs main (46133ac)
Internal (raw) 803.6 KB +3.6 KB, +0.4%
Internal (gzip) 153.9 KB +635 B, +0.4%
Bundled (raw) 1.41 MB +3.6 KB, +0.2%
Bundled (gzip) 318.2 KB +653 B, +0.2%
Import time 99ms +3ms, +2.8%

@portabletext/editor/behaviors

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

@portabletext/editor/plugins

Metric Value vs main (46133ac)
Internal (raw) 2.7 KB -
Internal (gzip) 894 B -
Bundled (raw) 2.5 KB -
Bundled (gzip) 827 B -
Import time 7ms -0ms, -0.0%

@portabletext/editor/selectors

Metric Value vs main (46133ac)
Internal (raw) 81.2 KB -
Internal (gzip) 14.9 KB -
Bundled (raw) 76.9 KB -
Bundled (gzip) 13.8 KB -
Import time 8ms -0ms, -1.0%

@portabletext/editor/traversal

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

@portabletext/editor/utils

Metric Value vs main (46133ac)
Internal (raw) 29.7 KB -
Internal (gzip) 6.2 KB -
Bundled (raw) 27.4 KB -
Bundled (gzip) 5.9 KB -
Import time 6ms -0ms, -2.4%

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

Metric Value vs main (46133ac)
Internal (raw) 53.8 KB -
Internal (gzip) 9.8 KB -
Bundled (raw) 348.9 KB -
Bundled (gzip) 96.3 KB -
Import time 38ms +0ms, +0.7%

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

2 participants