Skip to content

fix: rename colliding keys before backspace merges a block into its predecessor - #3179

Closed
christianhg wants to merge 2 commits into
feat/transaction-interpreterfrom
merge-rekey-renames
Closed

fix: rename colliding keys before backspace merges a block into its predecessor#3179
christianhg wants to merge 2 commits into
feat/transaction-interpreterfrom
merge-rekey-renames

Conversation

@christianhg

Copy link
Copy Markdown
Member

Stacked on #3154 (it supplies the transaction interpreter this change extends).

Merging a block into its predecessor with backspace 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": a receiving editor can connect nothing, and a collaborator whose caret sat in the merged-away block loses it (reproduced with two editors: the published selection ends up pointing into the removed block).

The fix makes the merge say what it did. Before deleting the merging block, the boundary-merge behavior renames the colliding keys in place, set patches on keyed _key paths, rewriting marks references when a markDef is renamed, and inserts 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.

The interpreter follows the new shape by identity, never by key value: renamed nodes are tracked by their recorded path, a rename shadowing a pre-existing twin key pairs nothing, and rekey steps are path-scoped so a destination-block caret sitting on a twin key stays put. Adversarial probes pin each of those refusals.

One fix rides along in its own commit: undoing any _key rename silently dropped the rename, because 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.

…redecessor

Backspace at the start of a text block raises `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 follow nothing
through the merge. The boundary-merge behavior now plans 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; non-colliding
merges emit byte-identical patches to before.

On the receiving side, `interpretTransaction` records each renamed
node's path in `renamedKeyPaths` at rename time and registers exactly
those descendants of a whole-block `unset` as move candidates,
identity-matched by path, never by key value (a rename shadowing a
pre-existing twin key pairs nothing). The container's `remove.node`
survives descendant moves, relocated after them, so never-renamed
siblings invalidate instead of dangling. `rekey` steps carry the
renamed node's container path and the mapper rewrites only the
segment at that exact location, keeping destination-block carets on
twin keys untouched.

Pinned by the `block-merge-duplicate-keys` wire-catalogue row,
interpreter probes (shadowed rename, partial collision, chained
rename, rename-then-container-hop, destination-caret stability), and
two-editor browser tests for emission shape, caret follow-through,
and 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.
@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 Ready Ready Preview Aug 26, 2026 9:43am
portable-text-example-basic Ready Ready Preview Aug 26, 2026 9:43am
portable-text-playground Ready Ready Preview Aug 26, 2026 9:43am

Request Review

@changeset-bot

changeset-bot Bot commented Aug 26, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: e03a169

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 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.35 MB / 330.6 KB +1.2 KB, +0.4% 90 ms +0 ms, +0.3%
⚪ @portabletext/editor / @portabletext/editor/behaviors export 4.0 KB / 1.4 KB None 2 ms -0 ms, -3.6%
⚪ @portabletext/editor / @portabletext/editor/plugins export 5.1 KB / 1.8 KB None 7 ms -0 ms, -3.9%
⚪ @portabletext/editor / @portabletext/editor/selectors export 93.4 KB / 21.3 KB None 7 ms +0 ms, +0.7%
⚪ @portabletext/editor / @portabletext/editor/traversal export 41.4 KB / 10.7 KB None 5 ms -0 ms, -0.8%
⚪ @portabletext/editor / @portabletext/editor/utils export 32.8 KB / 8.6 KB None 6 ms +0 ms, +2.9%
⚪ @portabletext/markdown / @portabletext/markdown export 272.2 KB / 79.6 KB None 34 ms -3 ms, -7.8%

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

@christianhg

christianhg commented Aug 26, 2026

Copy link
Copy Markdown
Member Author

Superseded by the un-stacked version: the emission change and both engine fixes now target main directly in #3182; the interpreter extension and the caret-follow test follow separately once #3154 merges (the branch here remains their source).

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