Version
@tiptap/y-tiptap@3.0.8 (same behaviour in 3.0.6 and 3.0.7), yjs@13.6.30, prosemirror-model@1.25.9, @tiptap/core@3.27.1.
Summary
When a remote peer edits the block the local caret is in, and another top-level block has the text the caret's block had before that edit, the caret is moved into that sibling block.
Empty paragraphs make this easy to hit, because every empty block has textContent === ''. Any document with more than one empty line is exposed. Blocks that share non-empty text hit the same path.
Reproduction
Setup for each case: one editor with a caret, a second peer that appends two characters to one block, then the update is applied to the first editor. The caret should stay in its own block.
| Blocks |
Caret in block |
Remote edits block |
Caret block after (expected) |
Caret block after (actual) |
['', '', ''] |
1 (anchor 3) |
1 |
1 |
2 (anchor 7) |
['H', 'A', '', 'B', ''] |
2 (anchor 7) |
2 |
2 |
4 (anchor 14) |
['same', 'same'] |
0 (anchor 5) |
0 |
0 |
1 (anchor 13) |
The exact anchor offset inside the block is not the point of this report; the block the caret lands in is.
Control case: with ['AAA', '', 'BBB'] and the caret in block 1, no sibling matches the old text, the fallback returns null, the Yjs resolution is used, and the caret stays in block 1. So for these documents the fallback lands the caret in a worse place than its own null path does.
We also confirmed the ['H', 'A', '', 'B', ''] case in a browser: two machines editing the same document, caret on the third line (empty), the other machine typing on that same third line, caret jumps to the fifth line.
This is not a regression from a recent release. 3.0.6 and 3.0.8 behave identically here.
Mechanism
In findAbsolutePositionAfterStructuralChange (src/lib.js), pass 1 (byAll) and pass 2 (byText) call findByPredicate without requireUnique. findByPredicate counts how many blocks in oldDoc up to and including the caret's block satisfy the predicate, and then picks the block at that same ordinal in newDoc.
When the remote edited the caret's own block, that block no longer carries oldText in newDoc. The ordinal is then satisfied by a later sibling that still has the old text, and the caret is placed there.
Pass 4 already encodes the rule that empty text must not identify a block:
oldText !== '' && child.textContent !== '' &&
with the comment "Empty text is a prefix of everything and must never match." Passes 1 and 2 have neither that guard nor a uniqueness requirement, so an empty caret block matches every other empty block by position alone.
Related
#48 reports a different defect in the same recovery path, an unclamped oldDoc.resolve in isMisresolvedAfterStructuralChange.
Version
@tiptap/y-tiptap@3.0.8(same behaviour in 3.0.6 and 3.0.7),yjs@13.6.30,prosemirror-model@1.25.9,@tiptap/core@3.27.1.Summary
When a remote peer edits the block the local caret is in, and another top-level block has the text the caret's block had before that edit, the caret is moved into that sibling block.
Empty paragraphs make this easy to hit, because every empty block has
textContent === ''. Any document with more than one empty line is exposed. Blocks that share non-empty text hit the same path.Reproduction
Setup for each case: one editor with a caret, a second peer that appends two characters to one block, then the update is applied to the first editor. The caret should stay in its own block.
['', '', '']['H', 'A', '', 'B', '']['same', 'same']The exact anchor offset inside the block is not the point of this report; the block the caret lands in is.
Control case: with
['AAA', '', 'BBB']and the caret in block 1, no sibling matches the old text, the fallback returnsnull, the Yjs resolution is used, and the caret stays in block 1. So for these documents the fallback lands the caret in a worse place than its ownnullpath does.We also confirmed the
['H', 'A', '', 'B', '']case in a browser: two machines editing the same document, caret on the third line (empty), the other machine typing on that same third line, caret jumps to the fifth line.This is not a regression from a recent release. 3.0.6 and 3.0.8 behave identically here.
Mechanism
In
findAbsolutePositionAfterStructuralChange(src/lib.js), pass 1 (byAll) and pass 2 (byText) callfindByPredicatewithoutrequireUnique.findByPredicatecounts how many blocks inoldDocup to and including the caret's block satisfy the predicate, and then picks the block at that same ordinal innewDoc.When the remote edited the caret's own block, that block no longer carries
oldTextinnewDoc. The ordinal is then satisfied by a later sibling that still has the old text, and the caret is placed there.Pass 4 already encodes the rule that empty text must not identify a block:
with the comment "Empty text is a prefix of everything and must never match." Passes 1 and 2 have neither that guard nor a uniqueness requirement, so an empty caret block matches every other empty block by position alone.
Related
#48 reports a different defect in the same recovery path, an unclamped
oldDoc.resolveinisMisresolvedAfterStructuralChange.