Skip to content

fix: emit item-keyed markDefs patches instead of whole-array sets#2982

Draft
christianhg wants to merge 3 commits into
mainfrom
fix-markdefs-item-patches
Draft

fix: emit item-keyed markDefs patches instead of whole-array sets#2982
christianhg wants to merge 3 commits into
mainfrom
fix-markdefs-item-patches

Conversation

@christianhg

@christianhg christianhg commented Jul 20, 2026

Copy link
Copy Markdown
Member

Stacks on #2983 (undo-step boundaries) and #2984 (plugin guard). The first two commits here are those PRs verbatim and disappear on rebase once they merge; review only the last commit in this PR. Both are extracted because they merge independently: #2983 fixes latent undo bugs reachable on main today, and #2984 must release ahead of this PR's editor change (the plugin's published ^7.x peer range accepts the new emission, so a consumer upgrading the editor without the plugin would otherwise run the orphan race unguarded).

Two clients annotating the same block concurrently silently destroy each other's work: client A adds a comment annotation, client B links a word in the same block, and whoever flushes last wins. The loser's definition vanishes from the stored document while both clients' span marks references survive, leaving marks pointing at definitions that no longer exist. The concrete driver is the operational patch channel in #2977, which compensates for this outgoing shape channel-side (toMergeableMarkDefsPatches); fixing the emission in the engine pays for every multi-writer consumer at once, including two Studio users on the same block.

The granularity was lost at the operation level: every markDefs mutation funneled through setNodeProperties, which applies one set operation carrying the whole array, and patch generation echoed it verbatim. An earlier iteration of this PR fixed the symptom in the operation-to-patch layer by diffing the pre-apply value, but that reconstructs intent the operation sites had precisely and then erased, a translation layer compensating for the layer above it. The shipped shape moves the fix to the sites: annotation.add and the block-merge path in insert.block apply keyed insert operations, and the normalizer's unused-definition prune applies keyed unsets. The engine's sidecar routing (shipped in #2974 for remote patches) applies these locally unchanged, and now also computes item-keyed inverses so undo emits mergeable patches too instead of restoring whole arrays. Patch generation shrinks to mechanical representation changes, with the invariant pinned in a header comment: never diff sibling state to reconstruct intent here; if a patch needs more than the operation expresses, the operation site is emitting the wrong operation. One translation remains by necessity: normalization's ensure-markDefs-exists set([], ...) becomes setIfMissing, because no operation vocabulary carries ensure-exists intent, and a whole-array set of [] would clobber concurrent writers on documents that lack the property.

Inserts anchor on index 0 rather than on an existing definition's key: a keyed anchor diffed against this engine's state may no longer exist at the server by application time (the same divergence lesson as the repair-path discussion on #2977), while index 0 always resolves and markDefs order carries no meaning. New definitions therefore prepend instead of append, a value-visible order change pinned by re-pinned tests in overlapping-annotations and unique-sibling-keys. Explicit whole-array writes through block.set still emit a whole-array set (whole-property intent from a public API, and duplicate-key arrays, which the dedupe rule exists for, are inexpressible item-level).

Removing the always-applied whole-array set unmasked two latent history bugs, fixed in their own commit. The isNoOp early-return in the history subscriber advanced previousUndoStepId, so a no-op leading a new undo step consumed the step boundary and a single undo reverted more than one step's worth of changes. And a step's pre-state selection was captured from the first history-affecting operation, by which time operation implementations may have parked the selection on transient nodes their own step removes again; the engine now snapshots undoStepSelection when the undo step ID is minted. Both were masked on main because the redundant markDefs set reached the history subscriber first, creating the boundary and capturing the selection while it was still resolvable. The event.history.undo/event.history.redo suites pin the corrected behavior.

The third commit extends plugin-sdk-value's referenced-keys guard to the new patch shape. The editor's keyed prune unsets passed through toMergeableMarkDefsPatches untouched, bypassing the guard that previously protected decomposed whole-array sets, and a diverged client could prune a definition the store's spans still reference, exactly the orphaned-mark failure the collision-matrix link scenarios caught (orphan mark (no markDef)). Keyed markDefs unsets now go through the same store-side check; a dropped prune leaves an unused definition behind at worst, which a later converged session's normalization removes. The full collision matrix and the two-client rig pass on the new emission.

The emit-to-apply round trip is pinned by the browser scenario "Emitted patches converge another editor on the same value" in event.patch.markdefs.test.tsx, and the ensure-exists translation by the setNodePatch unit matrix. The 12 existing tests pinning whole-array shapes are re-pinned; the remaining editor suites (857 unit, 1733 browser) and all plugin-sdk-value suites (192 across chromium, firefox and webkit) pass.

The one deliberately unsolved residual: the prune-vs-concurrent-reference race is inherent to marks being string references into a separate array, and fully closing it engine-side needs store knowledge the engine lacks. The channel-side guard covers the SDK path; deferred pruning as a general mechanism is tracked separately as a follow-up.

@changeset-bot

changeset-bot Bot commented Jul 20, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 6301a18

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

This PR includes changesets to release 14 packages
Name Type
@portabletext/plugin-sdk-value Patch
@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-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 20, 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 8:32am
portable-text-example-basic Ready Ready Preview, Comment Jul 21, 2026 8:32am
portable-text-playground Ready Ready Preview, Comment Jul 21, 2026 8:32am

Request Review

@github-actions

github-actions Bot commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

📦 Bundle Stats — @portabletext/editor

Compared against main (04b79737)

@portabletext/editor

Metric Value vs main (04b7973)
Internal (raw) 806.6 KB +2.6 KB, +0.3%
Internal (gzip) 154.8 KB +525 B, +0.3%
Bundled (raw) 1.42 MB +2.6 KB, +0.2%
Bundled (gzip) 319.1 KB +520 B, +0.2%
Import time 95ms -3ms, -2.9%

@portabletext/editor/behaviors

Metric Value vs main (04b7973)
Internal (raw) 467 B -
Internal (gzip) 207 B -
Bundled (raw) 424 B -
Bundled (gzip) 171 B -
Import time 2ms -0ms, -0.5%

@portabletext/editor/plugins

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

@portabletext/editor/selectors

Metric Value vs main (04b7973)
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.9%

@portabletext/editor/traversal

Metric Value vs main (04b7973)
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.1%

@portabletext/editor/utils

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

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

Metric Value vs main (04b7973)
Internal (raw) 53.8 KB -
Internal (gzip) 9.8 KB -
Bundled (raw) 348.9 KB -
Bundled (gzip) 96.3 KB -
Import time 38ms +1ms, +1.5%

🗺️ 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 force-pushed the fix-markdefs-item-patches branch from 52e7eba to e956c3b Compare July 20, 2026 15:18
@christianhg
christianhg force-pushed the fix-markdefs-item-patches branch from e956c3b to 0495e57 Compare July 20, 2026 15:25
@christianhg
christianhg force-pushed the fix-markdefs-item-patches branch from 0495e57 to 0009730 Compare July 20, 2026 16:01
@christianhg
christianhg force-pushed the fix-markdefs-item-patches branch from 0009730 to 1d51829 Compare July 20, 2026 16:15
@christianhg
christianhg force-pushed the fix-markdefs-item-patches branch from 1d51829 to 248c9cd Compare July 20, 2026 16:26
The history subscriber's `isNoOp` early-return advanced
`previousUndoStepId` before bailing. A no-op (an operation without an
inverse, or a zero-length text operation) leading a new undo step then
consumed the step boundary: the next history-affecting operation saw
`currentUndoStepId === previousUndoStepId` and merged into the
previous step, so a single undo reverted more than the last step's
worth of changes. `previousUndoStepId` now only advances for
operations that reach `createUndoSteps`, since it tracks the last
operation that affected history.

Relatedly, a step's pre-state selection was captured from the first
history-affecting operation's `beforeSelection`. Operation
implementations may park the selection on transient nodes (created and
removed within the same step) before that operation applies, and undo
then restored a selection that no longer resolves, dropping the cursor.
The engine now snapshots its selection as `undoStepSelection` when
`editor.undoStepId` is minted, and the history subscriber uses that
snapshot as the step's pre-state selection when an operation opens a
new step.

Both defects are masked on `main` in the annotation and block-merge
flows by an unconditionally applied whole-array `markDefs` set
operation that reached `createUndoSteps` first, creating the boundary
and capturing the selection before it moved. Emitting item-level
`markDefs` operations removes that accidental mask, which is what
surfaced both.
…ferenced definitions

An upcoming editor change emits normalization's unused-definition
prunes as item-keyed `unset` patches instead of whole-array sets.
`toMergeableMarkDefsPatches` only decomposes whole-array sets, so
those unsets would pass through without the referenced-keys guard: a
diverged client (its span `marks` overwritten by the other client's
flush, its own definition now locally unused) would prune a definition
the store's spans still reference, orphaning the other client's
annotation at the server.

Keyed `markDefs` unsets now go through the same store-side
referenced-keys check as decomposed sets and are dropped while the
store's spans reference the definition. A dropped prune leaves an
unused definition behind at worst; normalization in a later converged
session removes it for real. Against the current editor's emission
(whole-array sets only) the guard is inert; it ships ahead so a
consumer upgrading the editor without upgrading this plugin is never
exposed.
Every `markDefs` mutation funneled through `setNodeProperties`,
which applied one `set` operation carrying the whole array, and
patch generation echoed it verbatim. Under concurrency this is last
writer wins at the server: client A adds an annotation, client B
formats the same block, both flush `set(block.markDefs, [...])`, the
loser's definition vanishes while both clients' span `marks` string
references survive, stranding marks without definitions.

The operation sites now carry their intent item-level instead of the
patch layer reconstructing it. `annotation.add` and the block-merge
path in `insert.block` apply keyed `insert` operations; the
normalizer's unused-definition prune applies keyed `unset`s. The
engine's sidecar routing (shipped for remote patches) applies these
locally unchanged; it now also computes item-keyed inverses (a keyed
`unset` for a keyed insert, a keyed re-insert for a keyed unset) so
undo emits mergeable patches too instead of whole-array sets.

Patch generation shrinks to mechanical representation changes:
`insertNodePatch` already emits `setIfMissing` plus `insert` for
any array-field insert, keyed `unset`s pass through verbatim, and
`setNodePatch` keeps one translation with no operation vocabulary:
normalization's ensure-`markDefs`-exists `set([], ...)` becomes
`setIfMissing`, creating the property on the stored document without
clobbering concurrent writers. Inserts anchor on index 0 rather than
on an existing definition's key: a keyed anchor diffed against this
engine's state may no longer exist at the server by application time,
and `markDefs` order carries no meaning.

Behavioral deltas: new definitions prepend instead of append (order
tests re-pinned); whole-array `markDefs` writes through `block.set`
still emit a whole-array `set` (explicit whole-property intent, and
duplicate-key arrays are inexpressible item-level); dedupe
normalization likewise keeps the whole-array set. Emitted patch shapes
change for annotation add/remove, block merges and undo; consumers of
`patch`/`mutation` events see `setIfMissing`/`insert`/keyed
`unset` where they previously saw one whole-array `set`.
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