Skip to content

fix: rename colliding keys before a range delete merges sibling blocks - #3187

Merged
christianhg merged 2 commits into
mainfrom
range-delete-merge-renames
Aug 27, 2026
Merged

fix: rename colliding keys before a range delete merges sibling blocks#3187
christianhg merged 2 commits into
mainfrom
range-delete-merge-renames

Conversation

@christianhg

@christianhg christianhg commented Aug 27, 2026

Copy link
Copy Markdown
Member

Deleting a range that spans two sibling text blocks merges the end block into the start block. When the blocks share a child _key (legal across blocks, and a shape real documents have), the merge comes out wrong twice over: the merged text is reordered, and a colliding markDefs key makes the end block's def replace the start block's, so the start block's surviving annotated spans point at the wrong annotation. Both corruptions are silent on the wire: the colliding keys are re-minted with no rename, so receivers read destroy-and-create for content that was merely merged, and carets, comments, and decorations tracking those spans lose them.

The fix mirrors what #3182 gave the collapsed backspace/forward-delete merges: mergeBlock plans _key renames before the merge and applies them to the still-living end block, so keyed set patches land on the wire ahead of the merge's unset and inserts. The plan is computed against the start block's mid-delete state, where the span holding the range's start point still exists as an empty span and is a genuine collision source. The rename planner is extracted from the delete behaviors into a shared helper in its own behavior-preserving commit. markDef renames apply as direct engine sets, since setNodeProperties cannot address keyed markDefs descents and would silently no-op (caught in review; the wire test now asserts the markDef rename patch explicitly). applyMergeNode also anchors each child insert on the post-apply key, since the engine's collision backstop re-mints keys in place; the stale pre-apply anchor was the reorder mechanism.

Byte-identical markDefs under the same _key dedupe instead of renaming: the merge keeps one def under the original key and the migrated spans resolve to it, preserving annotation identity the way the old merge did for identical defs. Only same-key-different-content defs rename. The dedupe is scoped to the range-delete path: the collapsed merges keep renaming, because their output feeds insert.block, where the block is parsed standalone and any mark that doesn't resolve in the block's own markDefs is stripped, so a deduped def's spans would lose the annotation entirely. A dedicated test pins that the collapsed path keeps renaming identical defs (it goes red if the dedupe flag is flipped there).

Pinned red-on-old: the reorder, the markDef overwrite, the markDef rename reaching the wire before the block unset, the identical-def dedupe (no rename patch on the wire), the empty-span collision (with the caret collapsing to the range start), an inline-object collision, and undo round-trips restoring every original _key. New wire-catalogue row: range-delete-duplicate-keys.


Note

Medium Risk
Touches core delete/merge and patch ordering for collaboration, but the change is scoped to duplicate-key merges with broad regression coverage.

Overview
Fixes range deletes that merge two sibling blocks when those blocks reuse the same child _key or markDefs key—cases that could scramble merged text, overwrite annotations, and emit patches that look like destroy/create instead of moves.

Range-delete merge (mergeBlock in delete-internal.ts) now runs the same pre-merge rename plan as collapsed backspace/forward merges: colliding keys on the end block are updated with explicit set patches before unset/insert, using the start block’s mid-delete children (including the surviving empty span at the range anchor) as the collision set. Byte-identical colliding markDefs are deduped on this path; differing defs are renamed and span marks are rewritten.

Rename planning is extracted to plan-merge-key-renames.ts; delete behaviors keep a thin planMergeKeyRenameActions wrapper that raises behavior events (dedupeEqualMarkDefs: false for insert.block).

applyMergeNode chains child inserts using each insert operation’s post-apply _key, so engine collision re-minting no longer mis-orders siblings.

Tests cover wire order, markDef dedupe vs rename, inline-object collisions, undo/redo, and a new wire-catalogue scenario range-delete-duplicate-keys.

Reviewed by Cursor Bugbot for commit 108c2d3. Bugbot is set up for automated code reviews on this repo. Configure here.

@vercel

vercel Bot commented Aug 27, 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 Aug 27, 2026 1:14pm
portable-text-example-basic Ready Ready Preview Aug 27, 2026 1:14pm
portable-text-playground Ready Ready Preview Aug 27, 2026 1:14pm

Request Review

@changeset-bot

changeset-bot Bot commented Aug 27, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 108c2d3

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-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-sdk-value 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

@github-actions

github-actions Bot commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Bundle Stats

✅ No significant changes.

All scenario measurements (7)

🗺️ @portabletext/editor / @portabletext/editor · @portabletext/editor / @portabletext/editor/behaviors · @portabletext/editor / @portabletext/editor/plugins · @portabletext/editor / @portabletext/editor/selectors · @portabletext/editor / @portabletext/editor/traversal · @portabletext/editor / @portabletext/editor/utils · @portabletext/markdown / @portabletext/markdown · Artifacts

Scenario Kind Bundle (raw / gzip) Gzip change Import time Import change
⚪ @portabletext/editor / @portabletext/editor export 1.09 MB / 253.2 KB +543 B, +0.2% 62 ms +0 ms, +0.2%
⚪ @portabletext/editor / @portabletext/editor/behaviors export 4.0 KB / 1.4 KB None 2 ms +0 ms, +4.2%
⚪ @portabletext/editor / @portabletext/editor/plugins export 5.1 KB / 1.8 KB None 7 ms +0 ms, +3.8%
⚪ @portabletext/editor / @portabletext/editor/selectors export 93.4 KB / 21.3 KB None 7 ms +0 ms, +0.6%
⚪ @portabletext/editor / @portabletext/editor/traversal export 41.4 KB / 10.7 KB None 5 ms +0 ms, +4.1%
⚪ @portabletext/editor / @portabletext/editor/utils export 32.8 KB / 8.6 KB None 5 ms -0 ms, -4.7%
⚪ @portabletext/markdown / @portabletext/markdown export 272.2 KB / 79.6 KB None 32 ms -0 ms, -0.4%

Significant means at least 1.0 KB and 1% gzip, or at least 5 ms and 10% import time.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 4c6b289. Configure here.

Comment thread packages/editor/src/internal-utils/delete-internal.ts
The backspace and forward-delete merge behaviors planned their
colliding-key renames inside `behavior.abstract.delete.ts`, coupled to
behavior actions (`raise`). The planning itself — which keys collide,
what they become, which `marks` follow a renamed markDef — is
layer-neutral, and the range-delete merge at the operation layer needs
the same plan.

Move the pure planner to `internal-utils/plan-merge-key-renames.ts`,
returning the renamed block plus neutral child and markDef rename
lists; the behavior builds its `raise` actions from those lists.

Net behavior unchanged: colliding and non-colliding merges emit
byte-identical patches, pinned by the existing
`block-merge-duplicate-keys` suite and wire-catalogue fixtures, both
untouched.
A range delete spanning two sibling text blocks merges the end block
into the start block (`mergeBlock` → `applyMergeNode`). When the
blocks shared a child `_key` (legal across blocks), two mechanisms
corrupted the merge. `applyMergeNode` anchored each child insert
after the previous child's pre-apply `_key`, but the engine's
collision backstop re-mints a colliding key at apply time and writes
it back onto the operation, so the stale anchor resolved to the
destination's own twin span and the next child landed in the wrong
position: the merged text came out reordered. And `mergeBlock` merged
the two `markDefs` arrays through a last-wins map keyed on `_key`,
so the end block's def silently replaced the start block's, repointing
the start block's surviving annotated spans at the wrong annotation.
Both re-mints were silent on the wire: receivers saw fresh-key inserts
with no rename — destroy-and-create for content the user merely
merged.

`mergeBlock` now plans `_key` renames against the start block's
current children before the merge — including the span holding the
range's start point, which survives as an empty span until deferred
normalization and is a genuine collision source — and applies them to
the still-living end block, so keyed `set` patches for every rename
precede the merge's `unset` and inserts on the wire, mirroring the
collapsed backspace/forward-delete merges. markDef renames apply as
direct engine `set`s (`setNodeProperties` cannot address keyed
`markDefs` descents and would silently no-op). The markDefs carry
over as a plain append (collisions are renamed away, so the dedupe map
is gone), and `applyMergeNode` anchors each insert on the post-apply
key, immunizing it against any collision that still reaches the
backstop.

Byte-identical markDefs under the same `_key` dedupe instead of
renaming: the merge keeps the destination's def and the migrated
spans' `marks` resolve to it, preserving annotation identity the way
the old map did for identical defs. Only the range-delete path
dedupes: the collapsed merges keep renaming, because their
`renamedBlock` feeds `insert.block`, where `parseBlock` parses the
block standalone and strips any mark that doesn't resolve in the
block's own `markDefs` — a deduped def's spans would lose the
annotation entirely.

Pinned red-on-old: child reorder, markDef overwrite, the markDef
rename reaching the wire before the merge's `unset`, identical-def
dedupe with no rename patch, the collapsed path still renaming
identical defs (red under a flipped `dedupeEqualMarkDefs`),
trimmed-empty-span collision (with the caret collapsing to the range
start), inline-object collision, and merge-undo round-trips restoring
every original `_key`. New wire-catalogue row
`range-delete-duplicate-keys`.
@christianhg
christianhg deleted the range-delete-merge-renames branch August 27, 2026 14:00
@ecoscript ecoscript Bot mentioned this pull request Aug 27, 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.

1 participant