Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/atomic-remote-patch-apply.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@portabletext/editor': patch
---

Apply remote patch batches atomically. Previously a batch of remote patches was
applied best-effort, one operation at a time: when a concurrent client's
operational patch addressed a span `_key` this editor had already changed,
`apply-operation` threw `node was not found` and the batch was left partially
applied, diverging the two editors. Under the operational patch channel this
surfaced as a crash under concurrent formatting. Now, if any operation in a
remote batch fails, the whole batch is rolled back to the pre-batch value and
the whole-value sync reconciles — never a crash, never a half-applied tree.
28 changes: 28 additions & 0 deletions .changeset/concurrent-crashproof.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
'@portabletext/editor': patch
'@portabletext/plugin-sdk-value': patch
---

fix: crash-proof concurrent value sync against divergent live trees

Builds on the engine routing fix in #2974 (which stops remote sidecar-array
patches — `span.marks[n]`, `block.markDefs[_key==...]` — from being routed
through structural child insertion/removal, the root cause of the sync-killing
`unset` throw and the bogus-`children` document corruption). This changeset adds
the residual client-side resilience so a divergent live tree degrades safely
instead of crashing:

- `@portabletext/editor`: keyed `updateBlock` unsets are guarded against the
live engine tree, and the `updateValue` try/catch is widened so a stale-key
removal degrades to a safe re-sync instead of surfacing an unhandled rejection
that freezes the sync actor. Some of the per-`updateBlock` unset guards now
overlap with the engine fix in #2974 and are kept as defense-in-depth.
- `@portabletext/plugin-sdk-value`: `arrayifyPath` returns `null` instead of
throwing on diff-patch paths it cannot convert (e.g. array slices produced
when clearing multiple `marks`); `convertPatches` drops the unconvertible ops
and flags the batch incomplete, and `applySync` then falls back to an
authoritative full value update / resync.

Together this stops the crash and the document corruption. It does NOT fix the
underlying SDK concurrency clobber (two editors racing on the same block can
still overwrite each other's changes); that remains to be addressed separately.
9 changes: 9 additions & 0 deletions .changeset/plugin-sdk-value-patch-channel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@portabletext/plugin-sdk-value': minor
---

Sync through operational patches instead of whole-value diffs when the SDK supports it.

`SDKValuePlugin` now subscribes to the SDK's `remote-patches` document events and applies patches from other clients directly to the editor, and pushes the editor's own patches back through `editDocument` with `preserveOperations`. This lets two people edit the same Portable Text field concurrently without overwriting each other. Whole-value sync remains as a fallback for SDK versions without the patch channel (`@sanity/sdk-react` < 2.17) and for patches that cannot be scoped to the field.

`ValueSyncPlugin` accepts optional `onRemotePatches` and `pushPatches` config to drive the same behavior with a custom store.
9 changes: 9 additions & 0 deletions .changeset/remote-patch-repair-sync.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@portabletext/plugin-sdk-value': patch
---

Repair editor divergence after best-effort remote patch application.

Applying remote patches is best-effort: keyed operations produced by another client against a different view of the document (for example both clients splitting the same span to format overlapping ranges) can fail to resolve and are skipped, leaving the editor diverged from the stored document. `ValueSyncPlugin` now follows every remote patch application with a whole-value repair sync, immediately when no local edits are in flight or after the next mutation flush when they are, and escalates to the full value sync machinery when the diff-based repair cannot converge.

The repair diff also no longer emits operations addressing items inside sidecar arrays (`markDefs`, `marks`), which the engine cannot resolve; those are coalesced into whole-property sets taken from the target value.
24 changes: 24 additions & 0 deletions .changeset/sidecar-array-remote-patches.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
'@portabletext/editor': patch
---

fix: apply remote patches addressing `marks` and `markDefs` elements as data patches

Remote patches produced by diffing tools (e.g. `@sanity/diff-patch` in
`@portabletext/plugin-sdk-value`) can address elements of sidecar
arrays: `span.marks[0]`, `span.marks[-1]`, `block.markDefs[_key==...]`.
These paths end in a keyed or numeric segment just like structural node
paths, so the engine routed them through structural child
insertion/removal. Removals threw `Cannot apply an "unset" (node
removal) operation ... because the node was not found.` and killed the
consumer's sync, while inserts silently wrote a bogus `children` array
onto the span, corrupting the document once the value was pushed back
to the datastore. In practice this broke concurrent editing whenever a
collaborator toggled a decorator or annotation.

The engine now detects that such a path does not target the owning
node's structural child array and applies the operation as a plain data
patch on the root block, matching what the datastore computed.
`diffMatchPatch` patches on strings outside span text (e.g. a swapped
decorator inside `marks`) are applied the same way instead of being
ignored.
42 changes: 34 additions & 8 deletions packages/editor/src/editor/remote-patches.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,31 +35,57 @@ export function setupRemotePatches({
const patches = bufferedPatches
bufferedPatches = []
let changed = false
let failed = false

// Remote patch batches are applied atomically. Operational patches from a
// concurrent editor can reference a node this client has already changed —
// e.g. a keyed `unset` for a span the sibling tab already removed throws
// `node not found`. Dropping just that op and applying the rest of the
// batch (the previous behaviour) leaves the tree inconsistent: the editors
// diverge and broken Portable Text (an orphaned mark, a stale span) can be
// persisted. So if any patch throws we discard the WHOLE batch and restore
// the pre-batch value; the whole-value sync channel then reconciles to the
// authoritative value — last-writer-wins for that edit, but never a crash
// or a half-applied batch.
//
// Only thrown patches trigger a rollback. A patch that applies cleanly but
// yields a value this client's schema doesn't recognise (an unknown
// decorator or object `_type` from another client / a newer schema) is
// valid remote data and is intentionally kept.
const valueBefore = editor.snapshot.context.value

withRemoteChanges(editor, () => {
withoutNormalizing(editor, () => {
withoutPatching(editor, () => {
pluginWithoutHistory(editor, () => {
for (const patch of patches) {
try {
changed = applyPatch(editor, patch)
if (applyPatch(editor, patch)) {
changed = true
}

if (debug.syncPatch.enabled) {
if (changed) {
debug.syncPatch(`(applied) ${safeStringify(patch, 2)}`)
} else {
debug.syncPatch(`(ignored) ${safeStringify(patch, 2)}`)
}
debug.syncPatch(`(applied) ${safeStringify(patch, 2)}`)
}
} catch (error) {
console.error(
`Applying patch ${safeStringify(patch)} failed due to: ${error instanceof Error ? error.message : error}`,
failed = true
debug.syncPatch(
`(failed — rolling back batch) ${safeStringify(patch)}: ${
error instanceof Error ? error.message : error
}`,
)
break
}
}
})
})
})

if (failed) {
editor.snapshot.context.value = valueBefore
return
}

if (changed) {
normalize(editor)
editor.onChange()
Expand Down
Loading
Loading