Skip to content

RTC: Preserve unsaved blocks when reconciling a persisted CRDT doc - #80011

Closed
giteshsarvaiya wants to merge 2 commits into
WordPress:trunkfrom
giteshsarvaiya:fix/80004-rtc-crdt-blocks-reconciliation
Closed

giteshsarvaiya wants to merge 2 commits into
WordPress:trunkfrom
giteshsarvaiya:fix/80004-rtc-crdt-blocks-reconciliation

Conversation

@giteshsarvaiya

Copy link
Copy Markdown
Contributor

Fixes #80004

What

_applyPersistedCrdtDoc in packages/sync/src/manager.ts compares the
persisted CRDT doc against the real entity record to find invalidated keys,
then builds a changes object mapping each invalidated key to
record[key].

When 'blocks' is an invalidated key, record['blocks'] is always
undefined — REST post records never expose a blocks property, only
content (serialized HTML). That undefined reached
applyPostChangesToCRDTDoc's 'blocks' case in
packages/core-data/src/utils/crdt.ts without an accompanying content
value, so it took the "wipe blocks" branch instead of the existing
mergeContentWithoutBlocks merge path — losing stable block identities and,
on repeated reconciliation (e.g. viewing a server-side autosave), stacking
duplicate content (hello 123 → hello 123 123 → ...).

The fix: when 'blocks' is invalidated, also carry record.content along
in the changes payload, so the existing (already correct)
mergeContentWithoutBlocks path in crdt.ts gets what it needs to
reconcile blocks from content instead of wiping them. No changes needed in
crdt.ts.

Repro

  1. Enable Real-Time Collaboration.
  2. Open a published post with a paragraph that says hello.
  3. Type 123 so it reads hello 123. Do not click Save.
  4. Wait ~1 minute for WordPress's server-side autosave to fire.
  5. Leave the editor without saving, then reopen the post. A "there is a more
    recent autosave" notice appears.
  6. Click "View the autosave".

Before: hello 123 becomes hello 123 123 (repeating the cycle keeps
stacking duplicates).
After: stays hello 123.

Video Before

80004.issue.mp4

Video After

80004.pr.mp4

Testing

  • Added a regression test in packages/sync/src/test/manager.ts asserting
    applyChangesToCRDTDoc is called with { blocks: undefined, content: mockRecord.content } when blocks is invalidated (previously it would
    only receive { blocks: undefined }).
  • npm run test:unit -- packages/sync: 224/224 passing.
  • npm run lint:js / npm run format on changed files: clean.

Design note (open question for reviewers)

packages/sync is otherwise a fully entity-agnostic package — nothing else
in manager.ts references WordPress-post-specific field names, and the same
manager code is reused as-is for non-post entities (e.g. taxonomies via
defaultSyncConfig). This fix introduces the first WP-post-specific
special case (key === 'blocks' && record.content) directly into that
generic file, duplicating knowledge that already lives correctly in the
post-specific applyPostChangesToCRDTDoc/mergeContentWithoutBlocks in
packages/core-data/src/utils/crdt.ts.

Possible future enhancement

A cleaner long-term shape would be a new optional SyncConfig hook that lets
each entity-specific config own this kind of "field A needs field B to
reconstruct" logic, keeping manager.ts itself free of entity-specific
literals — consistent with how applyChangesToCRDTDoc/
getChangesFromCRDTDoc already delegate entity-specific behavior today.

// packages/sync/src/types.ts
export interface SyncConfig {
    applyChangesToCRDTDoc: ( ydoc: Y.Doc, changes: Partial< ObjectData > ) => void;
    getChangesFromCRDTDoc: ( ydoc: Y.Doc, editedRecord: ObjectData ) => ObjectData;
    // New: build the `changes` payload for a set of invalidated keys. Lets an
    // entity-specific config express "key X needs record[Y] to reconcile
    // correctly" without the generic manager knowing about entity field names.
    getChangesForInvalidatedKeys?: (
        invalidatedKeys: string[],
        record: ObjectData
    ) => Partial< ObjectData >;
    // ...existing hooks
}
// packages/sync/src/manager.ts — _applyPersistedCrdtDoc
const changes = syncConfig.getChangesForInvalidatedKeys
    ? syncConfig.getChangesForInvalidatedKeys( invalidatedKeys, record )
    : invalidatedKeys.reduce< Partial< ObjectData > >( ( acc, key ) => {
          acc[ key ] = record[ key ];
          return acc;
      }, {} );
// packages/core-data/src/entities.js — post syncConfig
getChangesForInvalidatedKeys( invalidatedKeys, record ) {
    const changes = invalidatedKeys.reduce( ( acc, key ) => {
        acc[ key ] = record[ key ];
        return acc;
    }, {} );
    if ( invalidatedKeys.includes( 'blocks' ) && record.content ) {
        changes.content = record.content;
    }
    return changes;
}

Not proposing this be built now — it's a bigger surface change (new
interface, new implementation in core-data, new tests) for what's
currently a single call site. Sketching it here so the tradeoff is visible
and the follow-up is easy to pick up if a second entity type ever needs
similar treatment.

Precedent: this is how the codebase already handles this class of fix

Checked git history for how similar blocks/content coupling issues were
handled previously in this exact code — the established pattern is narrow,
inline fixes, not new abstractions:

Given that track record, an inline special case here is consistent with how
this part of the codebase has always evolved — generalize later, if/when a
second real case shows up.

Question for reviewers: should the getChangesForInvalidatedKeys hook
sketched above be added now, deferred to a follow-up PR, or skipped
entirely given the precedent above?

Related / not in scope


This PR was developed with AI assistance (Claude Code, opus 4.8) for
investigation, implementation, and testing. All changes were reviewed and
verified by me before submission.

When 'blocks' is an invalidated key during CRDT doc reconciliation,
record['blocks'] is always undefined since REST post records only
expose 'content'. Without an accompanying content value, the CRDT
apply logic wiped the blocks key instead of merging via the existing
mergeContentWithoutBlocks path, causing duplicated content on repeated
reconciliation (e.g. viewing a server-side autosave).

Carry record.content along whenever blocks is invalidated so the
existing merge path in crdt.ts gets what it needs.

Fixes WordPress#80004
@github-actions github-actions Bot added the [Package] Sync /packages/sync label Jul 8, 2026
@giteshsarvaiya
giteshsarvaiya marked this pull request as ready for review July 8, 2026 16:27
@github-actions

github-actions Bot commented Jul 8, 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: giteshsarvaiya <giteshsarvaiya@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 the First-time Contributor Pull request opened by a first-time contributor to Gutenberg repository label Jul 8, 2026
@github-actions

github-actions Bot commented Jul 8, 2026

Copy link
Copy Markdown

👋 Thanks for your first Pull Request and for helping build the future of Gutenberg and WordPress, @giteshsarvaiya! 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.

@alecgeatches

alecgeatches commented Jul 14, 2026 •

Copy link
Copy Markdown
Contributor

@giteshsarvaiya I'm able to reproduce this problem, and it's a good find, but I'm not sure if the fix here works. In my testing, content duplication seems to happen only when another user is present in the post (to hold the original 123) addition. Without another user, the problem doesn't reproduce. This is true both on trunk and in this PR branch.

Here's the reproduction steps on trunk with one user:

autosave-trunk.mov

Note that I've set "config": { "AUTOSAVE_INTERVAL": 20 } in .wp-env.json to reduce the autosave time for the video, but it works the same with a 60s autosave.

On trunk, if we have just one user, I can't reproduce the bug. You'll notice when the post reloads the 123 text isn't included in the CRDT doc, because it was not persisted and there is no other user holding that live CRDT change. Then the restore works just fine as expected and doesn't duplicate content.

If another user is present in the document, I can reproduce content duplication, but the changes in this PR don't appear to fix it. Below, I repeated the same steps on the current PR branch fix/80004-rtc-crdt-blocks-reconciliation, freshly built, with another user present in the post:

autosave-pr-branch.mov

This seems to match your before and after videos. In the "before" video, after reloading, the 123 text re-appears which sort of looks like an active session is syncing that content, but in the "after" video the 123 text doesn't appear on reload. I was only able to reproduce this by having additional users in my reproduction.

In short, trunk and this PR branch don't reproduce the issue with a single user, but both reproduce the issue with a collaborator present. The problem here seems real, but I'm not sure the fix is working. Am I missing something? Thanks!

@alecgeatches alecgeatches added [Feature] Real-time Collaboration Phase 3 of the Gutenberg roadmap around real-time collaboration [Type] Bug An existing feature does not function as intended labels Jul 14, 2026
@giteshsarvaiya

giteshsarvaiya commented Jul 15, 2026 •

Copy link
Copy Markdown
Contributor Author

Hi @alecgeatches

Really appreciate you taking the time to dig into this and record those videos. Your finding about the collaborator-present case was a really useful lead and pushed me to test this a lot more rigorously than I had before raising the PR.

I ran the repro roughly 20-30 times today, varying a few conditions (with/without another collaborator, immediate vs. delayed navigation back into the post, hard reload vs. in-app navigation). It reproduced about 7-10 of those times, so it's real, but noticeably flaky rather than deterministic, and I was able to get it solo (no second person) a few times too, not just with a collaborator present.

I don't yet have a confirmed root cause for what makes it reproduce on some attempts and not others. I have a few leads (something related to timing around navigation/reconnection seems to matter) but nothing solid enough to state as the actual mechanism yet.

Given that, I don't think this PR is ready to merge as-is. The fix I proposed may not be addressing the real trigger. Going to keep digging and will follow up here once I have a clearer picture. Thanks again for the thorough testing and for sharing your repro, genuinely helpful in narrowing this down.

P.S. I am afk on 16th and 17th (IST).
Will be back after that and contribute further on issues and PRs.

@alecgeatches

Copy link
Copy Markdown
Contributor

Thank you for digging into it, @giteshsarvaiya! I'm not sure why I wasn't able to reproduce without a collaborator. If you need to do any weird steps like adding a sleep somewhere or a breakpoint to reproduce consistently, that's totally fine. There are a lot of possibilities for race conditions in RTC. Any fairly-consistent reproduction, even if it needs to change some conditions to make it consistent, it totally fine. Let me know if you find one!

@giteshsarvaiya

Copy link
Copy Markdown
Contributor Author

Thank you for digging into it, @giteshsarvaiya! I'm not sure why I wasn't able to reproduce without a collaborator. If you need to do any weird steps like adding a sleep somewhere or a breakpoint to reproduce consistently, that's totally fine. There are a lot of possibilities for race conditions in RTC. Any fairly-consistent reproduction, even if it needs to change some conditions to make it consistent, it totally fine. Let me know if you find one!

This is in my priority list right now, will check out the possibilities, and keep the thread updated.

@giteshsarvaiya
giteshsarvaiya marked this pull request as draft July 28, 2026 17:33
@giteshsarvaiya
giteshsarvaiya deleted the fix/80004-rtc-crdt-blocks-reconciliation branch August 7, 2026 08:16
@giteshsarvaiya
giteshsarvaiya restored the fix/80004-rtc-crdt-blocks-reconciliation branch August 7, 2026 08:16
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] Sync /packages/sync [Type] Bug An existing feature does not function as intended

Projects

None yet

Development

Successfully merging this pull request may close these issues.

RTC: restoring a server-side autosave duplicates block content instead of merging it

2 participants