Skip to content

RTC: Fix title divergence between users on page refresh after title update#77666

Merged
alecgeatches merged 4 commits into
WordPress:trunkfrom
danluu:try/rtc-title-reload-pr
May 8, 2026
Merged

RTC: Fix title divergence between users on page refresh after title update#77666
alecgeatches merged 4 commits into
WordPress:trunkfrom
danluu:try/rtc-title-reload-pr

Conversation

@danluu
Copy link
Copy Markdown
Contributor

@danluu danluu commented Apr 25, 2026

What?

This is part of a series of bug reports and PRs from an AI fuzzing project. See #77532 for more details on that.

This PR fixes an issue where users see different titles after a page refresh. See the following videos for what happens before and after the fix:

rtc-title-reload-bug.mp4
rtc-title-reload-fixed.mp4

BEGIN AI GENERATED TEXT

If two collaborators are editing the same post, an unsaved title edit can sync to both users and then be undone for one collaborator when the other collaborator reloads. The collaboration room remains connected: later block edits and later fresh title edits still sync. The lost value is specifically the already-synced, unsaved title value.

The PR branch for review is try/rtc-title-reload-pr. It has the requested commit shape:

User-visible Behavior

Expected behavior:

  • User A changes the title.
  • User B sees the changed title.
  • User A reloads before saving.
  • Both users continue to see the changed title.

Actual behavior:

  • User A changes the title.
  • User B sees the changed title.
  • User A reloads before saving.
  • One user falls back to the original saved title.
  • The other user still shows the unsaved edited title.

The strongest reproduction regresses the non-reloading collaborator, which rules out a simple local page-state explanation.

Confirmed Mechanism

The bug is caused by the CRDT persistence save path replaying a stale REST save response back into the live sync document.

The failure sequence is:

  1. A collaborator edits the title. The title edit syncs through the RTC Y.Doc, but it is not saved to the post record yet.
  2. The persisted server-side post title is still the original title. That is normal before an explicit save.
  3. A collaborator reloads. During sync bootstrap, the sync manager asks core-data to persist the current CRDT document to post meta.
  4. The persistence callback in packages/core-data/src/resolvers.js calls saveEntityRecord so __unstablePrePersist can write _crdt_document.
  5. That save is intended to persist the CRDT document, not to make the stale saved title authoritative.
  6. The REST API response is still a full post record. In the failing case, its title is the stale saved title.
  7. The generic synced-save path in packages/core-data/src/actions.js applies the full save response to the sync manager with LOCAL_UNDO_IGNORED_ORIGIN and { isSave: true }.
  8. That turns the stale saved title into a live CRDT update, so the other collaborator can receive an update that changes the title back to the original value.

This matches the deeper instrumentation:

  • The stale title update originated from the save-response path, not from normal editor typing.
  • The update origin was the undo-ignored save origin, which explains why it did not behave like a user edit in undo history.
  • Sync storage showed the reloaded client emitting a title update back to the original title after it had already received the synced edited title.
  • After the split, new block edits and new title edits still sync, so the transport and room membership are not the underlying failure.

Repros And Checks

There are three committed repro levels on the PR branch.

The lowest-level repro is in packages/core-data/src/test/actions.js. The test named preserves the live sync title when a CRDT persistence save returns stale post fields models the sync document as already holding the unsaved title, then runs saveEntityRecord with a stale REST save response. On the buggy path, the stale title is replayed into the sync document.

The middle-level repro is in packages/core-data/src/test/resolvers.js. The test named persistCRDTDoc does not replay a stale save response into the sync document wires the real persistCRDTDoc resolver callback into the real saveEntityRecord action. This reproduces the exact internal path used during reload/bootstrap, without needing a browser.

The top-level product repro is the browser test test/e2e/specs/editor/collaboration/collaboration-title-reload.spec.ts. It uses two real editor pages, a real title edit, a real reload, core-data entity resolution, the sync manager, REST save response handling, and the collaboration provider.

To rerun the browser repro on the PR branch:

git fetch danluu try/rtc-title-reload-pr
git switch try/rtc-title-reload-pr
npm install
npx wp-env --config .wp-env.test.json start
npm run build -- --skip-types
WP_BASE_URL=http://localhost:8889 npm run test:e2e -- test/e2e/specs/editor/collaboration/collaboration-title-reload.spec.ts --project=chromium

To see the repros fail before the fix, check out the tests commit and run either the unit repros or the browser repro:

git checkout dcb258e2f7f01dc64c39820782c5b743a8127d66
npm run test:unit packages/core-data/src/test/actions.js packages/core-data/src/test/resolvers.js -- --runInBand
npm run build -- --skip-types
WP_BASE_URL=http://localhost:8889 npm run test:e2e -- test/e2e/specs/editor/collaboration/collaboration-title-reload.spec.ts --project=chromium

To rerun just the lower-level repros on the PR branch:

npm run test:unit packages/core-data/src/test/actions.js packages/core-data/src/test/resolvers.js -- --runInBand

The audit branch also contains a broader diagnostic repro suite in collaboration-title-reload-repro.spec.ts. That file is intentionally not part of the PR branch; it contains exploratory checks that are useful for investigation but too broad for the small PR.

Environment Used

  • WordPress core in wp-env: 7.1-alpha-62260
  • Gutenberg package version: 23.0.1
  • PR base: 1642980d599, RTC: Fix "Connection Lost" dialog when too many entities are loaded (#77631)
  • Node: v20.19.0
  • npm: 10.8.2
  • Browser runner: Playwright Chromium against WP_BASE_URL=http://localhost:8889

Why This Is Not A False Positive

The bug does not depend on fuzz-only hooks, injected faults, or mocked browser state. It reproduces in normal Playwright browser sessions with two real editor pages.

The bug also survives deeper checks:

  • Before reload, both collaborators show the synced unsaved title.
  • After reload, one collaborator can lose that title without making a local title edit.
  • Waiting longer does not restore the lost pre-reload title.
  • The room remains live enough to sync later block edits.
  • The room remains live enough to sync later fresh title edits.
  • Saving can repair convergence, which is consistent with the bug being a stale pre-save response replay rather than a permanent transport failure.

One subtle non-bug is worth separating: the saved post record remaining on the initial title before explicit save is normal. The production bug is that the stale saved title is replayed into the live CRDT document during an automatic CRDT persistence save.

Introduction Analysis

The strongest direct introduction candidate is ea2cfb87be4, from PR #75841, RTC: Fix entity save call / initial persistence.

That change altered the CRDT persistence callback from saving only edited fields to resolving the full edited entity record and calling saveEntityRecord. The change was made so CRDT persistence could trigger even when there were no ordinary unsaved entity edits. That solved the initial-persistence problem, but it also meant an automatic CRDT persistence save could now receive a full stale REST response and feed that response into the generic synced-save path.

Relevant history:

  • PR #72373 added CRDT persistence. It is a prerequisite, but its original callback used saveEditedEntityRecord.
  • PR #75448 moved title/content/excerpt to Y.Text. It is a prerequisite for this title-specific symptom, but it is not the most direct stale-save-response introduction point.
  • PR #75560 fixed a separate auto-draft issue in the Y.Text title path, which is corroborating evidence that this area was still settling.
  • PR #75975 fixed stale CRDT document serialization on save by waiting for deferred Y.Doc updates before serialization. That is adjacent but not this bug: it does not prevent the REST save response from being replayed into the sync document.
  • PR #76017 fixed a generic refresh sync issue. This title bug is narrower because later block edits and later title edits still sync after the split.
  • PR #77658, the offset-space fix, is a different RTC rich-text bug and does not address this title save-response replay path.

Fix Plan And Rationale

The fix should not add sleeps, retries, or reload-specific special cases. The failure is deterministic once the automatic CRDT persistence save replays a stale full REST response.

The proposed fix is the one in the PR branch:

  1. Keep normal saveEntityRecord behavior unchanged by default.
  2. Add an internal __unstableSkipSyncUpdate option to saveEntityRecord.
  3. When the option is false, continue applying the REST save response to the sync manager as before.
  4. When the option is true, call the sync manager with an empty change object and { isSave: true }.
  5. Use that option only from persistCRDTDoc, where the save exists to persist _crdt_document and should not make stale REST fields authoritative.

The rationale is that the sync manager still needs to mark the document as saved, but it must not treat the REST response title/content fields as new collaborative edits for this internal persistence save. Passing {} preserves the save marker path without applying stale post fields.

Minimum verification for the fix:

  • The unit repros and browser repro fail at the tests commit and pass at the fix commit.
  • The focused browser test passes after a fresh build.
  • The core-data unit tests pass.
  • Targeted JS lint passes for the touched source and test files.

Verification run on the PR branch:

npm run test:unit packages/core-data/src/test/actions.js packages/core-data/src/test/resolvers.js -- --runInBand
npm run lint:js -- packages/core-data/src/actions.js packages/core-data/src/resolvers.js packages/core-data/src/test/actions.js packages/core-data/src/test/resolvers.js test/e2e/specs/editor/collaboration/collaboration-title-reload.spec.ts test/e2e/specs/editor/collaboration/fixtures/collaboration-utils.ts
npm run build -- --skip-types
WP_BASE_URL=http://localhost:8889 npm run test:e2e -- test/e2e/specs/editor/collaboration/collaboration-title-reload.spec.ts --project=chromium

END AI GENERATED TEXT

Use of AI Tools

The bug finding as well as the fix are AI generated. So far, of the manually verified (verified by humans) bugs the fuzzer has found, we have 2 real bugs and 0 false positives (a number of bugs have not been checked by someone who's familiar with Gutenberg). I'm not very familiar with Gutenberg and am not a good judge of whether or not something is a false positive, but the divergence between one user's title and the other seems like something that shouldn't happen?

@danluu danluu requested a review from nerrad as a code owner April 25, 2026 02:40
@github-actions github-actions Bot added the [Package] Core data /packages/core-data label Apr 25, 2026
@github-actions
Copy link
Copy Markdown

github-actions Bot commented Apr 25, 2026

Warning: Type of PR label mismatch

To merge this PR, it requires exactly 1 label indicating the type of PR. Other labels are optional and not being checked here.

  • Required label: Any label starting with [Type].
  • Labels found: [Package] Core data, First-time Contributor, [Feature] Real-time Collaboration.

Read more about Type labels in Gutenberg. Don't worry if you don't have the required permissions to add labels; the PR reviewer should be able to help with the task.

@github-actions
Copy link
Copy Markdown

github-actions Bot commented Apr 25, 2026

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 <[email protected]>
Co-authored-by: alecgeatches <[email protected]>

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 the First-time Contributor Pull request opened by a first-time contributor to Gutenberg repository label Apr 25, 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.

@danluu danluu force-pushed the try/rtc-title-reload-pr branch from 0ff6ab3 to 573b567 Compare April 25, 2026 02:44
@danluu danluu changed the title Fix title divergence between users on page refresh after title update RTC: Fix title divergence between users on page refresh after title update Apr 25, 2026
@dmsnell dmsnell mentioned this pull request Apr 27, 2026
@dmsnell dmsnell added the [Feature] Real-time Collaboration Phase 3 of the Gutenberg roadmap around real-time collaboration label Apr 27, 2026
@alecgeatches
Copy link
Copy Markdown
Contributor

@danluu This fix looks good! I just wanted to note that the blast radius on this is a bit smaller than the description looks like. I spent some time trying to reproduce in a local post, but had no luck:

manual-title-change-failure.mov

However, the end-to-end test replicates every time so I knew that I was missing something. I also tried some variations like using data APIs directly like wp.data.dispatch( 'core/editor' ).editPost( { title } ); to match the reproduction steps, but no luck there either:

command-title-change-failure.mov

After some more testing, I found this was only possible if the post is created via API like the reproduction steps. The reason this reproduces is that when a user creates a post via the UI, the fixed logic doesn't run because there's already an existing doc stored with the post and we instead return in a different section of code.

Either way, even if the reproduction is tricky this is still a valid bug for some scenarios and I'm glad to have it fixed! Just adding the context above for why the reproduction is difficult, and that this applies to posts created outside of the UI (e.g. via WP-CLI) and then edited and exited in the UI before the first save.

Copy link
Copy Markdown
Contributor

@alecgeatches alecgeatches left a comment

Choose a reason for hiding this comment

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

Thank you!

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants