RTC: fix 9 cursor bugs that can occur in various corner case scenarios#78251
RTC: fix 9 cursor bugs that can occur in various corner case scenarios#78251danluu wants to merge 11 commits into
Conversation
|
The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs. You can also manually ask me to refresh this list by adding the If you're merging code through a pull request on GitHub, copy and paste the following into the bottom of the merge commit message. To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook. |
|
As a quick summary, some of these changes seem good, but there's one main path that appears to be unreachable code, and the test was removed in the most recent commit after failing. I think the AI gave up and decided to just not test the feature because it wasn't working. I'd like the ostensibly dead-code portion of this PR to be removed and then continue reviewing.
If I'm correct that the above changes are not reachable code, I'd prefer that the code be removed. The bug described is that selections between multiple rich-texts are not displayed as multiple selections in RTC, but Gutenberg doesn't currently support that selection state. This could be treated as a new feature, but in the current implementation it's also unreachable. It may be reasonable to reach for AI to throw more code into this PR until the desired outcome is possible (increase scope), but in the interest of merging more bug fixes, I'd prefer we remove unused code from this PR and focus on the other fixes. Thank you! |
|
One more bit of feedback from my AI to yours:
|

This is part of an AI fuzzing project, where an AI wrote a fuzzer and then triages bugs from the fuzzer and creates fixes. See #77716 for the tracking issue. As of this writing, there have been no known false positives from this project, but there have been some issues, which are documented in #77716. I expect we’ll see false positives at some point (and may even have one that’s been filed in a PR that hasn’t been inspected by a code owner yet).
What?
This is a follow-up to #77673 that came out of fuzzing that fix for more cursor issues. @alecgeatches suggested splitting the tests for the new bugs found out into a separate PR. See #77673 (comment) and the subsequent comments for more details on that discussion as well as videos for repros of two of the issues. This PR also includes the AI's proposed fix, but it's possible another fix or set of fixes is better.
AI TEXT
This plan covers the cursor-awareness bugs found while reviewing PR 77673,
including the cases already covered by the PR tests and the additional failing
tests added during follow-up review.
The central invariant is:
Remote text cursor identity is the live
Y.RelativePositioninto a liveY.Text. A WordPressattributeKeyis useful to find thatY.Textwhen thelocal user creates the selection, but on a receiver it is only a current
RichText address for DOM targeting. For positional nested attributes such as
table cells, it must be derived from the resolved Yjs object at read time.
Relevant PRs
introduced the first RTC awareness payload containing user and selection
information.
relative positions to selection history and the undo stack.
client IDs from awareness and made receiver-side block lookup depend on the
Yjs block tree.
collaborator rich-text selection overlay.
#76913, and
#77164 extended RTC
serialization and merge behavior for nested attributes, especially table
data.
block-editor selection observer path for edge selections inside one block.
awareness PR under review. It starts carrying
attributeKeyfor nestedRichText targeting, which fixes the initial missing-target bug but also
exposes stale-address and strict-targeting bugs.
Known bugs
Initial nested RichText cursors can target the wrong editor element.
Before PR 77673, awareness resolution carried a block clientId and text
offset, but not the specific RichText target inside the block. Blocks such
as
core/tablehave many RichText instances inside one block. Rendering aremote cursor against the block element can place the cursor in the wrong
cell or first matching text root.
This was introduced by the original awareness and overlay split:
#74728 added
selection awareness around block-level identity, and
#76107 rendered
remote rich-text cursors from that model. Those PRs did not need to
distinguish multiple RichText roots inside one block. PR
#77673 is the first
pass at adding that missing RichText address.
Stale table-cell
attributeKeyafter row or column structure changes.PR 77673 passes the sender's
attributeKeythrough to the receiver. Thatfixes the initial non-first-cell case, but treats a positional key such as
body.1.cells.1.contentas stable identity. Table cell RichText identifiersare regenerated from current row and column indexes. After another user
inserts or deletes a preceding row, the same
Y.Textmay now have a newpath.
This was introduced by PR
#77673's
attribute-key pass-through fix: it added the right kind of data to the wire
shape, but reused the sender's old address on the read path instead of
deriving the current address from the resolved
Y.Text. The failure is mostvisible in the table paths that became RTC-editable through
#76913 and more stable
under structural edits in
#77164.
Deleted or missing keyed RichText targets can fall back to the whole block.
resolveTargetElement()narrows to[data-wp-block-attribute-key]when akey is present, but falls back to the block element when no matching keyed
node exists. That produces plausible but wrong cursors for removed captions,
stale table-cell paths, and temporarily absent RichText targets.
This was introduced when PR
#77673 layered keyed
nested-RichText lookup onto the older overlay fallback from
#76107. The fallback
is valid only for legacy unkeyed selections, not for keyed text selections
whose offsets are local to one RichText field.
Block-shaped nested attribute objects can be misclassified as real blocks.
getContainingBlockYMap()currently identifies a block-shapedY.Mapbychecking for
clientId,innerBlocks, and a parentY.Array. Nestedattribute data can legally have those keys. For example,
attributes.cards[0] = { clientId, innerBlocks, content: Y.Text }can betreated as a document block and resolve to the wrong local block path.
This was introduced by the generic nested-RichText support in PR
#77673. It correctly
stopped assuming a fixed parent depth, but replaced that with shape-based
block detection rather than membership in the actual block tree. That became
dangerous because
#75590 intentionally
removed awareness block client IDs and made receivers recover the local
block by walking the Yjs block tree.
Same-block selections across different RichText fields render as one text
root.
SelectionInOneBlockcurrently means same block clientId, but the overlayalso treats it as same DOM text root. A selection from one table cell to
another can have one block clientId and two different
attributeKeys. Thecurrent single-block rendering path applies both offsets to the start
element.
This comes from the selection model introduced by
#74728 and the overlay
renderer added by
#76107: both treated
"same block" as enough information to render one continuous text selection.
PR #77673 adds
endpoint
attributeKeys, but the rendering branch still conflates sameblock with same RichText root.
Same-block browser selections may lose independent start/end RichText keys.
The block-editor selection observer has a singular-selection branch that
chooses one RichText element from either endpoint and uses that key for both
start and end. If a natural drag stays inside one block but crosses two
RichText fields, this can collapse the sender-side selection before
awareness sees it.
This was exposed by
#77136, which fixed a
writing-flow toolbar case by choosing a single RichText element for a
singular same-block selection. That is reasonable for toolbar focus, but RTC
awareness now needs independent start and end RichText identities when a
natural selection crosses table cells or other same-block fields.
Nested RichText cursor scope in CRDT merges is top-level only.
updateYBlockAttribute()seedscursorScope.attributeKeywith thetop-level attribute name, such as
body. The merge code descends throughquery arrays and maps, but does not accumulate the full RichText path such
as
body.1.cells.1.content. Cursor-scoped rich-text updates therefore donot reliably match nested RichText fields.
This was introduced in stages. The cursor-aware rich-text merge path was
designed around top-level RichText attributes. Then
#76597,
#76913, and
#77164 extended RTC
support into nested attributes and table structures without carrying the
accumulated nested RichText path through the cursor scope.
Selection history and restore paths can replay stale
attributeKeys.Selection history stores a relative position plus the
attributeKey, thenconverts the relative position back to a
WPBlockSelectionusing the storedkey. If the underlying
Y.Textmoved to a new nested path, undo/redo orrestored selections can resurrect the old table-cell path.
This was introduced by
#74878, which stores a
Y.RelativePositionplus anattributeKeyin selection history. That issafe while the key is a stable top-level address. PR
#77673 makes nested
positional keys part of cursor awareness, so the same stale-address bug now
applies to undo/redo and selection restore.
Overlay DOM lookup is too broad and too selector-dependent.
The overlay builds selectors from
localClientIdandattributeKey, andthen uses
blockElement.querySelector()for the keyed RichText target. Thatcan match descendant child blocks with common keys such as
content, andlocalClientIdis not escaped. The safer contract is exact attributecomparison scoped to the resolved block element.
This was introduced by the selector-based lookup in
#76107, which was
adequate when the overlay only needed to find a block-level target. PR
#77673 extends that
pattern to nested
data-wp-block-attribute-keyvalues, where child-blockscoping and selector escaping become correctness issues.
Fix plan
Harden Yjs block membership.
A real block map must be in the root
blocksarray, or in the exactinnerBlocksarray of another real block. UpdategetContainingBlockYMap()and
getBlockPathInYdoc()to validate that ancestry by object identity.Do not accept arbitrary maps just because they have
clientIdandinnerBlocks.Add reverse
Y.TexttoattributeKeyresolution.Add a helper that starts from a resolved
Y.Text, walks parent links backto the containing block's
attributesmap, and builds the current dottedpath by map keys and array indexes. It should return
nullif the text isdeleted, outside
attributes, or ambiguous. Preserve the existing rule thatdirect top-level keys win before dotted-path traversal.
Use the derived key in awareness resolution.
In
PostEditorAwareness.convertSelectionStateToAbsolute(), resolve therelative position, find the containing real block, derive the current key,
and return that key in
ResolvedSelection. The sender key should be kept asa compatibility or debugging hint only, and used as a fallback only when it
still resolves to the same live
Y.Text.Use the same derivation in selection restore paths.
Update
crdt-selectionandblock-selection-historyconversions so astored relative position is restored with the current RichText key, not the
stale saved address.
Make keyed overlay lookup strict and scoped.
If a resolved selection has an
attributeKey, the overlay must find thatexact RichText node inside the resolved block or render nothing. Only
unkeyed legacy selections may fall back to the block element. Filter keyed
candidates so their closest
[data-block]is the resolved block element.Prefer exact attribute comparison over selector construction.
Add same-block, multi-RichText rendering.
Keep awareness selection types stable. In the overlay, split rendering into:
cursor, same RichText range, same block with different RichText targets, and
cross-block range. For same block plus different keys, resolve both endpoint
RichText elements, order by DOM position, draw endpoint partial ranges and
any intermediate RichText fields as needed, and anchor the cursor to the
active endpoint based on direction.
Verify and, if needed, fix sender selection capture.
Before changing the block-editor observer, test whether natural same-block
drags across table cells produce distinct
getSelectionStart()andgetSelectionEnd()keys. If not, update the singular-selection branch inthe writing-flow observer to record independent start and end RichText
elements and offsets.
Thread full nested cursor scope through CRDT merges.
When
mergeYValue()descends through maps and arrays, carry the accumulatedattribute path. RichText cursor scope should compare against
body.1.cells.1.content, not justbody.Add redraw liveness without awareness feedback.
Once keyed misses hide cursors, the overlay needs a deterministic recompute
when block or RichText DOM changes. Add a debounced redraw-only trigger from
block/content DOM changes or relevant store updates. It must not publish
awareness or edit entity state.
Validation plan
caption removal.
selector-hostile keys.
backward selection.
the row-following behavior is fully correct under ambiguous table merges.
END AI TEXT