Repository navigation
RTC: Preserve unsaved blocks when reconciling a persisted CRDT doc - #80011
giteshsarvaiya wants to merge 2 commits into
Conversation
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
|
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. |
|
👋 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. |
|
@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 Here's the reproduction steps on autosave-trunk.movNote that I've set On 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 autosave-pr-branch.movThis seems to match your before and after videos. In the "before" video, after reloading, the In short, |
|
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). |
|
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 |
This is in my priority list right now, will check out the possibilities, and keep the thread updated. |
Fixes #80004
What
_applyPersistedCrdtDocinpackages/sync/src/manager.tscompares thepersisted CRDT doc against the real entity record to find invalidated keys,
then builds a
changesobject mapping each invalidated key torecord[key].When
'blocks'is an invalidated key,record['blocks']is alwaysundefined— REST post records never expose ablocksproperty, onlycontent(serialized HTML). ThatundefinedreachedapplyPostChangesToCRDTDoc's'blocks'case inpackages/core-data/src/utils/crdt.tswithout an accompanyingcontentvalue, so it took the "wipe blocks" branch instead of the existing
mergeContentWithoutBlocksmerge 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 carryrecord.contentalongin the
changespayload, so the existing (already correct)mergeContentWithoutBlockspath incrdt.tsgets what it needs toreconcile blocks from content instead of wiping them. No changes needed in
crdt.ts.Repro
hello.123so it readshello 123. Do not click Save.recent autosave" notice appears.
Before:
hello 123becomeshello 123 123(repeating the cycle keepsstacking duplicates).
After: stays
hello 123.Video Before
80004.issue.mp4
Video After
80004.pr.mp4
Testing
packages/sync/src/test/manager.tsassertingapplyChangesToCRDTDocis called with{ blocks: undefined, content: mockRecord.content }whenblocksis invalidated (previously it wouldonly receive
{ blocks: undefined }).npm run test:unit -- packages/sync: 224/224 passing.npm run lint:js/npm run formaton changed files: clean.Design note (open question for reviewers)
packages/syncis otherwise a fully entity-agnostic package — nothing elsein
manager.tsreferences WordPress-post-specific field names, and the samemanager code is reused as-is for non-post entities (e.g. taxonomies via
defaultSyncConfig). This fix introduces the first WP-post-specificspecial case (
key === 'blocks' && record.content) directly into thatgeneric file, duplicating knowledge that already lives correctly in the
post-specific
applyPostChangesToCRDTDoc/mergeContentWithoutBlocksinpackages/core-data/src/utils/crdt.ts.Possible future enhancement
A cleaner long-term shape would be a new optional
SyncConfighook that letseach entity-specific config own this kind of "field A needs field B to
reconstruct" logic, keeping
manager.tsitself free of entity-specificliterals — consistent with how
applyChangesToCRDTDoc/getChangesFromCRDTDocalready delegate entity-specific behavior today.Not proposing this be built now — it's a bigger surface change (new
interface, new implementation in
core-data, new tests) for what'scurrently 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/contentcoupling issues werehandled previously in this exact code — the established pattern is narrow,
inline fixes, not new abstractions:
50b0a31ec0, "Apply only detected changes from the persistedCRDT document") introduced the very
invalidatedKeys.reduce(...)line thisPR touches. It shipped as a fully generic, naive per-key copy from day
one — no entity-specific hook.
blocksvalue #75437 (001a256148, "Sync post content and undefinedblocksvalue") introduced the
blocks-needs-contentreconciliation logic thisPR's fix relies on (
mergeContentWithoutBlocks). It landed as an inlinecase 'blocks'special case directly incrdt.ts, not through anygeneric mechanism.
1263f906e3, "Sync the content even if its a function")landed another narrow, inline fix straight into
crdt.ts's existingswitch statement, again with no new abstraction.
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
getChangesForInvalidatedKeyshooksketched above be added now, deferred to a follow-up PR, or skipped
entirely given the precedent above?
Related / not in scope
saving) is a separate, larger root cause — not addressed here.
but distinct save-time bug in
packages/core-data/src/actions.js— nooverlap with this fix.
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.