Skip to content

fix: rename colliding keys before a text block merge deletes the merging block - #3182

Merged
christianhg merged 3 commits into
mainfrom
merge-rekey-main
Aug 26, 2026
Merged

fix: rename colliding keys before a text block merge deletes the merging block#3182
christianhg merged 3 commits into
mainfrom
merge-rekey-main

Conversation

@christianhg

@christianhg christianhg commented Aug 26, 2026

Copy link
Copy Markdown
Member

Merging a text block into its neighbor (backspace at its start, or forward delete at the end of the block before it) re-keys any child span or annotation whose _key collides with one in the destination block. Today those fresh keys are minted silently before insertion, so the wire reads "block deleted, unrelated spans created": receivers can connect nothing through the merge, and anything tracking those nodes (carets, comments, decorations) loses them.

The fix makes both merge gestures say what they did. Before deleting the merging block, the merge behaviors rename the colliding keys in place, set patches on keyed _key paths, rewriting marks references when a markDef is renamed, and insert the children under their final keys. Rename-first matters: renaming after insertion would put twin keys among siblings. Non-colliding merges emit byte-identical patches to before.

Two engine fixes ride along in their own commits, both latent bugs this work surfaced and both reachable today without the merge change:

  • The rekey point transform rewrote positions by key value alone, so renaming a node dragged carets and tracked ranges sitting on a same-keyed twin elsewhere in the document. It now rewrites only at the renamed node's own path.
  • Undoing any _key rename silently dropped the rename: the auto-generated inverse addressed the node by a path that stopped resolving once the rename applied. Inverses now target the post-rename key, pinned by a merge-undo-redo-undo round-trip.

Receiver-side caret following through this new wire shape (the transaction interpreter recognizing renamed, reinserted children) lands separately once #3154 merges; this PR is emission and engine correctness only.


Note

Medium Risk
Touches core delete/merge behaviors, patch emission order, selection transformation, and undo inverses—high user impact for editing and collaboration, but changes are targeted with broad test and wire-catalogue coverage.

Overview
Block merges (backspace at block start or forward delete at the prior block’s end) now rename colliding _keys on the merging block before it is removed and re-inserted. Collisions on child spans and markDefs are handled via explicit set / child.set actions (including remapping marks when an annotation key changes), so emitted patches describe rename then move instead of silent re-minting that looked like destroy-and-create on the wire. Non-colliding merges are unchanged.

Selection and point mapping for _key renames is path-scoped: rekey steps carry the parent path and only rewrite the renamed node’s segment at that depth, so carets on another node that shares the same key (legal across blocks) are not pulled along.

Undo of a _key rename now builds inverse set/unset paths against the new key, so reverting a merge (or any rename in the same undo batch) restores the original keys instead of leaving nodes stuck under renamed keys.

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

@changeset-bot

changeset-bot Bot commented Aug 26, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: c9a16d7

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

@vercel

vercel Bot commented Aug 26, 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 Building Building Preview Aug 26, 2026 11:17am
portable-text-example-basic Ready Ready Preview Aug 26, 2026 11:17am
portable-text-playground Ready Ready Preview Aug 26, 2026 11:17am

Request Review

@github-actions

github-actions Bot commented Aug 26, 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 / 252.4 KB +891 B, +0.3% 68 ms -0 ms, -0.5%
⚪ @portabletext/editor / @portabletext/editor/behaviors export 4.0 KB / 1.4 KB None 2 ms +0 ms, +0.4%
⚪ @portabletext/editor / @portabletext/editor/plugins export 5.1 KB / 1.8 KB None 7 ms -0 ms, -0.1%
⚪ @portabletext/editor / @portabletext/editor/selectors export 93.4 KB / 21.3 KB None 8 ms -0 ms, -0.4%
⚪ @portabletext/editor / @portabletext/editor/traversal export 41.4 KB / 10.7 KB None 6 ms +0 ms, +1.0%
⚪ @portabletext/editor / @portabletext/editor/utils export 32.8 KB / 8.6 KB None 6 ms +0 ms, +4.3%
⚪ @portabletext/markdown / @portabletext/markdown export 272.2 KB / 79.6 KB None 38 ms -2 ms, -5.8%

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

…key` changes

The `rekey` step rewrote any point path segment bearing the old key,
path-blind. Keys are only unique among siblings, so a node elsewhere
in the document can legally carry the same key, and a rename would
drag positions on that twin to the renamed node's location. The step
now carries the renamed node's container path, and the mapper
rewrites only the segment at that exact location. `transform-point`'s
own `rekey` construction for local `_key` sets supplies the same
path. Pinned by a unit test placing a same-valued key at a different
depth and asserting it stays put.
…ing block

Backspace at the start of a text block and forward delete at the end
of the block before it both merge by raising `delete.block` +
`insert.block`, and `adjustFragmentKeys` silently minted fresh keys
for any child or markDef colliding with the destination block, so the
wire read destroy-and-create and receivers could connect nothing
through the merge. Both merge behaviors now plan the renames first:
`set` patches on keyed `_key` paths (markDef renames rewrite the
referencing `marks`), raised while the merging block is still
addressable, and the captured node passed to `insert.block` carries
the renamed keys, so no collision handling fires downstream. The wire
becomes rename, unset, reinsert under final keys. Rename-first is
load-bearing: renaming after insertion would put twin keys among
siblings. Non-colliding merges emit byte-identical patches to before.

Pinned by three wire-catalogue rows (`block-merge-duplicate-keys`,
`-forward`, `-markdefs`) and two-editor browser tests covering both
gestures' emission shapes and the markDef rename rewrites.
The auto-generated inverse for a `set` on a node's own `_key`
addressed the node by its pre-rename keyed path, which stops
resolving the moment the rename applies, so undo silently dropped the
rename while reverting everything else in the step. Both inverse
constructions (`setNodeProperties` and the engine's `set` apply path)
re-target the post-rename key. Redo is unaffected: it replays the
original operations, whose pre-rename paths resolve again after undo.

Pinned by a merge-undo-redo-undo round-trip over the colliding-keys
merge, asserting the exact full value, every `_key` included, at each
stop.
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