Skip to content

Selection restore picks a same-text sibling block when the remote edits the caret's own block #51

Description

@miz-k

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions