Skip to content

RTC: add regression tests for data corruption bug due to cursor scope issue#77662

Merged
alecgeatches merged 2 commits into
WordPress:trunkfrom
danluu:try/rtc-cursor-scope-pr
May 7, 2026
Merged

RTC: add regression tests for data corruption bug due to cursor scope issue#77662
alecgeatches merged 2 commits into
WordPress:trunkfrom
danluu:try/rtc-cursor-scope-pr

Conversation

@danluu

@danluu danluu commented Apr 24, 2026

Copy link
Copy Markdown
Contributor

What?

Follow up to #77532, #77658

This is part of an AI fuzzing project which has uncovered a number of real bugs (#77532, and also #77631 independently of @alecgeatches finding the repro for that bug) and has not yet had a false positive (I'm sure there will be some in the future).

The fuzzer found another data corruption bug that's independent from #77532 but is also fixed by #77658. The PR for #77658 was created by a subagent narrowly looking at #77532, so it does not include regression tests for this bug. This PR has two purposes:

Why?

  1. Add regression tests for this other data corruption issue
  2. Document what the issue was, for future reference. This is both useful for the AI to automatically direct itself to do more fuzzing in high-risk areas. And, as a human who's worked on projects like this before, I know I would want this documented in the pre-AI days.

Here's a video of the issue:

core-file-cursor-scope-real-user-annotated.mp4

Codex's description of the bug:

BEGIN AI GENERATED TEXT

The RTC block-merge write path throws away rich-text field scope and keeps only
one bare cursor offset. If a block update changes a rich-text field that is not
the selected field, RTC can reuse the selected field's cursor while diffing the
wrong rich-text value.

The confirmed high-level repro uses the bundled core/file block:

  1. Put the caret inside the File block's Download button text.
  2. Toggle the normal File block setting Open in new tab.
  3. Replace the file through Replace -> Open Media Library.

The author sees the new file name correctly as manual, but the collaborator
sees the corrupted file name manuaa.

This is a real user-visible RTC bug in a bundled core block. It is not just a
synthetic custom-block platform issue.

Root Cause

WPBlockSelection preserves field scope:

  • the selection carries clientId
  • the selection carries attributeKey
  • the selection carries the offset within that selected field

For the core/file repro, the selected field is downloadButtonText and the
selection offset is 2.

The RTC write path collapses that scoped selection to just a number before
block merging:

  • applyPostChangesToCRDTDoc() reads
    changes.selection?.selectionStart?.offset ?? null
  • mergeCrdtBlocks() receives only that bare number
  • that same number is reused for rich-text merges elsewhere in the block

When the later File block replacement changes fileName from gamma to
manual, the merge code still has the old downloadButtonText cursor offset.
It no longer knows that the cursor belongs to downloadButtonText, so it uses
that offset while diffing fileName. That corrupts the collaborator's
fileName to manuaa.

The selected field in the repro is plain text, so offset 2 is already a valid
HTML index for downloadButtonText. The failure is not the offset-space bug.
The corruption occurs because a cursor from one field is reused for another
field.

Confirmed User Repro

The confirmed bundled-core browser repro is committed here:

The user-visible sequence is:

  1. Start a collaborative session with a post containing a File block whose file
    name is gamma and whose button text is Download.
  2. In the author editor, click inside the visible Download button text.
  3. Move the caret to offset 2 inside Download.
  4. In the File block sidebar, enable Open in new tab.
  5. Use the File block toolbar: Replace -> Open Media Library.
  6. Select a different existing file named manual.

Expected result on both editors:

manual

Actual result:

author:       manual
collaborator: manuaa

The Playwright test also saves the draft after the visible corruption and
checks that the collaborator remains corrupted in that same live session.

Why The Intermediate Update Matters

The one-step file replacement path did not reliably reproduce the bug by
itself. The confirmed repro needs the extra user action while the caret remains
inside downloadButtonText.

That extra action is not synthetic. It is a normal File block control:

Open in new tab

Clicking it updates the block's textLinkTarget while the editor selection is
still scoped to downloadButtonText at offset 2. The following file
replacement changes the other rich-text field, fileName, while that stale
bare offset is still available to RTC. Because RTC has already dropped
attributeKey, it cannot tell that the cursor does not belong to fileName.

Pre-Fix Production Path

The confirmed browser path is:

  1. A real core/file block is edited in the browser.
  2. The selection is inside downloadButtonText.
  3. Toggling Open in new tab sends a block update while preserving that
    selection.
  4. Replacing the file sends another block update that changes fileName.
  5. editEntityRecord() forwards blocks plus selection.
  6. SyncManager.update() applies the change to the live Yjs document.
  7. applyPostChangesToCRDTDoc() drops attributeKey and keeps only offset.
  8. mergeCrdtBlocks() reuses that offset while merging fileName.
  9. The collaborator renders corrupted fileName content.

Evidence And Regression Coverage

The regression tests cover multiple levels of the production write path. On the
pre-fix implementation, these tests reproduce the corruption; with the scoped
cursor fix, they pass:

The lower-level tests use a two-rich-text block to isolate the cursor-scope
failure and include negative controls:

  • the same sequence stays correct when merged with null cursor hints
  • the same non-selected-field values stay correct when applied without
    cross-field cursor reuse
  • the selected field is plain text, so the selected cursor offsets are not in
    the wrong coordinate space

Those controls are also linked directly:

The browser repro then confirms the same class of bug in a bundled core block
with normal user interactions. The author and collaborator diverge only after
the RTC merge path runs.

Re-Running The Repros

Run these commands from the repo root with Node 20 active.

mergeCrdtBlocks() regression

npm run test:unit -- --runInBand \
  packages/core-data/src/utils/test/rtc-rich-text-cursor-scope.test.js \
  --testNamePattern="mergeCrdtBlocks"

applyPostChangesToCRDTDoc() regression

npm run test:unit -- --runInBand \
  packages/core-data/src/utils/test/rtc-rich-text-cursor-scope.test.js \
  --testNamePattern="applyPostChangesToCRDTDoc"

SyncManager.update() regression

npm run test:unit -- --runInBand \
  packages/core-data/src/utils/test/rtc-rich-text-cursor-scope.test.js \
  --testNamePattern="SyncManager.update"

custom-block browser regression with programmatic insertion

npm run wp-env-test start
WP_BASE_URL=http://localhost:8889 \
npm run test:e2e -- \
  test/e2e/specs/editor/collaboration/collaboration-rich-text-cursor-scope.spec.ts \
  --project=chromium \
  --grep="keeps collaborator rich text correct$"

custom-block browser regression with the real inserter and typing interactions

npm run wp-env-test start
WP_BASE_URL=http://localhost:8889 \
npm run test:e2e -- \
  test/e2e/specs/editor/collaboration/collaboration-rich-text-cursor-scope.spec.ts \
  --project=chromium \
  --grep="real inserter and typing interactions"

core/file browser regression with real browser UI interactions

npm run wp-env-test start
WP_BASE_URL=http://localhost:8889 \
npm run test:e2e -- \
  test/e2e/specs/editor/collaboration/collaboration-rich-text-cursor-scope.spec.ts \
  --project=chromium \
  --grep="core/file"

The full committed cursor-scope test suite is:

npm run test:unit -- --runInBand \
  packages/core-data/src/utils/test/rtc-rich-text-cursor-scope.test.js

WP_BASE_URL=http://localhost:8889 \
npm run test:e2e -- \
  test/e2e/specs/editor/collaboration/collaboration-rich-text-cursor-scope.spec.ts \
  --project=chromium

How The Bug Was Introduced

The current top-level-field bug was introduced on January 14, 2026 by commit
30c040ca841 ("Real-time collaboration: Use alternative diff in quill-delta,
provide incremental text updates") from
PR #73699.

That change:

  • started forwarding changes.selection?.selectionStart?.offset ?? null
    into mergeCrdtBlocks()
  • reduced the block merge cursor to a bare number
  • removed the selected field scope before rich-text merging happened

Later, on April 2, 2026, commit 09a21c64b5b
("RTC: Fix core/table cell merging") from
PR #76913 generalized
the same cursor plumbing into schema-aware array and object merges. That did
not create the top-level core/file bug shown here, but it widened the same
scope-loss problem to nested rich-text structures.

Impact Bounds

What is currently proven:

  • a bundled core block can hit the bug
  • the trigger uses normal browser UI interactions
  • the bug is visible to a collaborator in a live browser session
  • the visible corruption survives saveDraft() in that same session
  • lower-level controls isolate the cause to cross-field cursor reuse

What is not currently proven:

  • the visible core/file corruption survives a fresh reload
  • the saved canonical post content is permanently corrupted across sessions

That narrows severity, but it does not make the bug a false positive. The bug
causes real in-session collaborator-visible corruption.

END AI GENERATED TEXT

Note that the above was with respect to this branch in my fork, not this PR.

Use of AI Tools

As noted above, this is an AI generated PR coming out of an AI fuzzing project. We've had no false positives so far, but of course the false positive rate isn't zero and I expect false positives at some point. In this case, since this is adding regression tests, a false positive would just mean that we're adding some tests for a bug that was somehow not real. While this can happen when a fuzz test uncovers a bug with respect to a function call, this particular bug was reproduced with real user actions inside playwright and should not be a false positive.

@danluu danluu requested a review from nerrad as a code owner April 24, 2026 23:07
@github-actions

github-actions Bot commented Apr 24, 2026

Copy link
Copy Markdown

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 props-bot label.

If you're merging code through a pull request on GitHub, copy and paste the following into the bottom of the merge commit message.

Co-authored-by: danluu <danluu@git.wordpress.org>
Co-authored-by: alecgeatches <alecgeatches@git.wordpress.org>

To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook.

@github-actions github-actions Bot added [Package] Core data /packages/core-data First-time Contributor Pull request opened by a first-time contributor to Gutenberg repository labels Apr 24, 2026
@github-actions

Copy link
Copy Markdown

👋 Thanks for your first Pull Request and for helping build the future of Gutenberg and WordPress, @danluu! In case you missed it, we'd love to have you join us in our Slack community.

If you want to learn more about WordPress development in general, check out the Core Handbook full of helpful information.

@t-hamano t-hamano added [Type] Code Quality Issues or PRs that relate to code quality [Feature] Real-time Collaboration Phase 3 of the Gutenberg roadmap around real-time collaboration labels Apr 27, 2026
@dmsnell dmsnell mentioned this pull request Apr 27, 2026
@danluu danluu force-pushed the try/rtc-cursor-scope-pr branch from 9426641 to cdc44aa Compare May 5, 2026 06:12

@alecgeatches alecgeatches left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good additional coverage! Merging.

@alecgeatches alecgeatches merged commit 86d1b67 into WordPress:trunk May 7, 2026
40 checks passed
@github-actions github-actions Bot added this to the Gutenberg 23.2 milestone May 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

[Feature] Real-time Collaboration Phase 3 of the Gutenberg roadmap around real-time collaboration First-time Contributor Pull request opened by a first-time contributor to Gutenberg repository [Package] Core data /packages/core-data [Type] Code Quality Issues or PRs that relate to code quality

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants