Skip to content

RTC: Fix undo breaking for non-synced entities - #83888

Merged
alecgeatches merged 14 commits into
trunkfrom
fix/rtc-entity-undo
Oct 1, 2026
Merged

alecgeatches merged 14 commits into
trunkfrom
fix/rtc-entity-undo

Conversation

@alecgeatches

@alecgeatches alecgeatches commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

What?

Fixes #80722. Replaces earlier draft PR #81063.

Fixes undo when some synced and non-synced entities are loaded in the same editor.

When RTC is disabled for some entity types (e.g. a custom post type), but not completely disabled, this can cause the Yjs undo manager to incorrectly take control of the undo stack for all entities. Below, a post type has been excluded from sync using a filter. Undo works until a synced entity (a category) is loaded via the publish panel, which breaks the undo manager for the post on trunk:

undo-mixed-entity-trunk.mov

With this fix, only synced entities delegate to the Yjs undo manager:

undo-mixed-entity-fix.mov

Why?

When collaboration is enabled, the Yjs undo manager replaces core-data's undo manager after any synced entity is loaded. When a entity type has been excluded from syncing but the same page includes other synced entities, the undo manager for both is replaced incorrectly.

How?

core-data's undo manager stays in charge of the whole undo history, and now only delegates to Yjs on synced entities:

  • editEntityRecord records every edit in core-data's undo manager as before, except edits to a record the sync manager reports as synced (isSynced). Yjs tracks those, and the record's new onUndoLevelOpened handler tells core-data each time Yjs opens a new level. core-data then adds a placeholder record for that level to its undo manager (recordSyncUndoLevel), so synced and non-synced levels sit in the same ordered history. Yjs used to manage undo levels between different entities with a YMultiDocUndoManager.

  • undo and redo pop from core-data's undo manager. Placeholder undo levels are delegated to the sync manager's undoHistory, but everything else is applied to the store. Interleaved edits are undone in the order they were made. Levels whose entities are gone (e.g. an unloaded document) are skipped. The logic lives in packages/core-data/src/utils/sync-undo-levels.js.

  • When core-data records an edit itself, it closes the sync manager's current level and drops its redo levels (stopCapturing, clearRedo), so a later synced change cannot merge into a level that is no longer on top and a new edit ends the redo history of both.

  • @wordpress/sync: the undo manager exposes undo()/redo() returning whether a level moved, stopCapturing(), and clearRedo(). Reading or moving the history flushes deferred document updates first. The sync manager exposes isLoaded(), and onUndoStackChange is replaced by onUndoLevelOpened. The sync manager creates its undo manager when it is created, instead of on the first entity load.

  • core-data no longer mirrors the sync manager's undo state: syncUndoManagerState and __unstableNotifySyncUndoManagerChange are gone. hasUndo/hasRedo read the undo manager, and a placeholder added outside of an entity edit changes undoManagerReference so selectors run again.

Example

There is no new stack. core-data's regular undo manager (state.undoManager) record keeps the whole ordered history, and each Yjs level is represented in it by a placeholder record with id :'core/entity-sync-undo-level'. The placeholder's changes are a counter so the record is never considered empty. After typing in a synced post, editing a non-synced custom entity, then typing in the post again, the undo manager holds:

[
	[ { id: 'core/entity-sync-undo-level', changes: { level: { from: 0, to: 1 } } } ],
	[ { id: { kind: 'root', name: 'table', recordId: 7 }, changes: { title: { from: 'a', to: 'b' } } } ],
	[ { id: 'core/entity-sync-undo-level', changes: { level: { from: 1, to: 2 } } } ],
]

and the Yjs undo stack holds two items. When undo pops the last record, the placeholder is delegated to undoHistory.undo(), which pops the matching Yjs item and updates the entity through its document. Anything else in the record is dispatched as a normal UNDO. So the three undos above revert the second typing, then the custom entity edit, then the first typing. A placeholder whose Yjs item is gone (its document was unloaded) applies nothing and is skipped.

The level: { from: 0, to: 1 } records saved in the undo manager are needed because the undo manager checks that from/to are not equal values, and level is a random attribute that represents "undo level" but otherwise has no meaning. Overall, this is just an incrementing ID and ID + 1, with a pointer to the Yjs stack using the core/entity-sync-undo-level ID.

Testing Instructions

First, reproduce the issue on trunk. Enable the real-time collaboration experiment in Gutenberg, and add this code to the bottom of gutenberg.php:

add_filter(
      'wp_is_post_type_collaboration_disabled',
      function ( $disabled, $post_type ) {
              return 'post' === $post_type ? true : $disabled;
      },
      10,
      2
);

This will enable Yjs syncing for every type that RTC supports, except post. Next:

  1. As an admin user, create a new post, and add a bit of content. Check that undo and redo work as expected.
  2. Click the "Publish" button to open the pre-publish panel, which starts syncing a taxonomy entity. Click "Cancel".
  3. Notice that the undo/redo buttons are canceled out, and cmd/ctrl+z doesn't work anymore.

On fix/rtc-entity-undo, try the same steps with post excluded from syncing using the same code. You should see that undo continues to work after the pre-publish panel opens.

Automated tests:

npm run test:unit -- packages/sync/src/test/undo-manager.test.ts packages/core-data/src/utils/test/sync-undo-levels.test.js
npm run test:e2e -- test/e2e/specs/editor/collaboration/collaboration-undo-non-synced-entities.spec.ts

Use of AI Tools

Claude code for the whole process.

@alecgeatches alecgeatches self-assigned this Sep 30, 2026
@github-actions github-actions Bot added [Package] Core data /packages/core-data [Package] Sync /packages/sync labels Sep 30, 2026
@github-actions

github-actions Bot commented Sep 30, 2026 •

Copy link
Copy Markdown

🤖 PR meta 🤖

🎉 Props

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: alecgeatches <alecgeatches@git.wordpress.org>
Co-authored-by: chriszarate <czarate@git.wordpress.org>
Co-authored-by: giteshsarvaiya <giteshsarvaiya@git.wordpress.org>
Co-authored-by: gschaub <myfamilyweb@git.wordpress.org>

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

Updated as activity occurs, without notifying anyone named here. Add the props-bot label to refresh.

📦 Bundle size

Size Change: -2 B (0%)

Total Size: 8.25 MB

📦 View Changed
Filename Size Change
build/scripts/core-data/index.min.js 38.5 kB +409 B (+1.07%)
build/scripts/sync/index.min.js 41.9 kB -411 B (-0.97%)

cee1d35 Run

⚡ Performance

Show the results

Client side metrics exclude the server response time.

front-end-block-theme

Metric b89d380 trunk % Change
timeToFirstByte 58.4 ms +5.91% -3.08% 59.05 ms +8.38% -2.37% -1.1%
largestContentfulPaint 98 ms +4.08% -6.12% 92 ms +10.87% -0% 6.52%
lcpMinusTtfb 36.6 ms +16.26% -4.1% 34.75 ms +2.59% -3.31% 5.32%
wpBeforeTemplate 29.09 ms +4.98% -1.2% 29.63 ms +14.38% -2.5% -1.82%
wpTemplate 25.13 ms +3.26% -4.5% 25.51 ms +1.92% -3.92% -1.49%
wpTotal 54.55 ms +5.32% -3.04% 55.32 ms +8.5% -2.17% -1.39%
wpMemoryUsage 7.62 MB +0% -0% 7.59 MB +0% -0% 0.46%
wpDbQueries 17 +0% -0% 17 +0% -0% 0%

front-end-classic-theme

Metric b89d380 trunk % Change
timeToFirstByte 44.15 ms +7.7% -1.47% 48.7 ms +7.39% -0.41% -9.34%
largestContentfulPaint 96 ms +2.08% -4.17% 104 ms +5.77% -0% -7.69%
lcpMinusTtfb 51.1 ms +2.35% -4.4% 55.5 ms +4.14% -0.81% -7.93%
wpBeforeTemplate 27 ms +7.78% -1.41% 26.85 ms +2.5% -1.64% 0.56%
wpTemplate 14.31 ms +4.33% -2.38% 18.95 ms +2.96% -0.95% -24.49%
wpTotal 41.22 ms +8.61% -1.09% 45.69 ms +7.11% -0.7% -9.78%
wpMemoryUsage 6.11 MB +0% -0% 6.20 MB +0% -0% -1.57%
wpDbQueries 10 +0% -0% 14 +0% -0% -28.57%

media-processing

Metric b89d380 trunk % Change
mediaProcessingJpeg 419.02 ms +1.1% -0.95% 418.82 ms +0.46% -1.2% 0.05%
mediaProcessingAvif 6157.32 ms +0.27% -0.68% 6100.32 ms +0.5% -0.06% 0.93%
mediaProcessingJpegToAvif 4275.43 ms +0.54% -0.83% 4226.85 ms +0.12% -0.24% 1.15%

media-upload

Metric b89d380 trunk % Change
jpegUploadProcessing 1511.75 ms +29.61% -6.58% 1435.01 ms +0.69% -0.68% 5.35%
pngUploadProcessing 184.5 ms +22.15% -6.67% 213.86 ms +8.95% -12.98% -13.73%
largeJpegUploadProcessing 1422.61 ms +0.3% -0.34% 1425.16 ms +0.8% -0.97% -0.18%
multipleImageUploadProcessing 1643.49 ms +25.7% -2.86% 2017.94 ms +4.45% -20.07% -18.56%

post-editor

Metric b89d380 trunk % Change
serverResponse 430.36 ms +1.49% -5.37% 426.9 ms +4.17% -7.34% 0.81%
firstPaint 221.61 ms +45.2% -11.96% 213.83 ms +30.42% -13.01% 3.64%
domContentLoaded 1033.79 ms +0.85% -4.28% 1027.82 ms +2.19% -1.12% 0.58%
loaded 1035.21 ms +0.82% -4.3% 1029.08 ms +2.18% -1.13% 0.6%
firstContentfulPaint 435.82 ms +3.46% -9.15% 435.19 ms +3.83% -5.44% 0.14%
firstBlock 2969.6 ms +1.29% -1.46% 2935.52 ms +0.84% -1.23% 1.16%
type 18.98 ms +6.27% -2.95% 17.96 ms +6.4% -1.61% 5.68%
typeWithoutInspector 17.83 ms +4.82% -3.93% 17.63 ms +7.94% -1.47% 1.13%
typeWithTopToolbar 23.72 ms +2.53% -3.84% 24.52 ms +6.24% -3.22% -3.26%
typeContainer 8.92 ms +2.35% -3.36% 9.08 ms +4.63% -6.72% -1.76%
focus 73.94 ms +7.4% -2.62% 72.88 ms +1.07% -1.74% 1.45%
firstFocus 196.98 ms +0% -0% 196.88 ms +0% -0% 0.05%
selectAll 422.4 ms +0.06% -0.52% 402.86 ms +6.45% -1.08% 4.85%
listViewOpen 62.22 ms +8.53% -15.57% 58.92 ms +4.06% -4.09% 5.6%
inserterOpen 23.07 ms +11.23% -8.45% 23.57 ms +15.06% -1.1% -2.12%
inserterHover 2.21 ms +12.67% -6.33% 2.29 ms +14.41% -5.68% -3.49%
inserterSearch 9.29 ms +3.23% -7.86% 8.55 ms +6.2% -2.69% 8.65%
loadPatterns 679.32 ms +5.42% -7.46% 676.47 ms +5.81% -5.88% 0.42%
wpTotal 420.23 ms +1.43% -5.47% 416.73 ms +4.17% -7.56% 0.84%
wpMemoryUsage 13.17 MB +0% -0% 13.13 MB +0% -0% 0.28%
wpDbQueries 54 +0% -1.85% 54 +0% -0% 0%

site-editor

Metric b89d380 trunk % Change
serverResponse 399.72 ms +3.36% -5.01% 411.18 ms +3.73% -6.81% -2.79%
firstPaint 212.85 ms +23.67% -8.19% 220.76 ms +11.55% -5.35% -3.58%
domContentLoaded 1126.43 ms +1.58% -0.73% 1132.04 ms +1.83% -0.4% -0.5%
loaded 1127.73 ms +1.59% -0.74% 1133.26 ms +1.83% -0.39% -0.49%
firstContentfulPaint 457.92 ms +1.1% -1.8% 459.9 ms +1.67% -2.38% -0.43%
firstBlock 3987.62 ms +0.97% -0.59% 3959.16 ms +1.04% -0.53% 0.72%
type 19.84 ms +5.8% -4.08% 19.25 ms +1.82% -3.38% 3.06%
navigate 107.99 ms +3.37% -3.32% 121.13 ms +5.13% -8.13% -10.85%
loadPatterns 1292.85 ms +7.35% -5.66% 1292.11 ms +19.1% -4.56% 0.06%
loadPages 1089.35 ms +2.15% -1.65% 1079.57 ms +2.97% -1.16% 0.91%
wpTotal 389.07 ms +3.36% -5.07% 400.44 ms +3.86% -7.08% -2.84%
wpMemoryUsage 12.14 MB +0% -0% 12.10 MB +0% -0% 0.41%
wpDbQueries 43 +2.33% -0% 43.5 +1.15% -1.15% -1.15%

cee1d35 Run

@chriszarate chriszarate added the [Type] Bug An existing feature does not function as intended label Sep 30, 2026
@alecgeatches alecgeatches added [Type] Task Issues or PRs that have been broken down into an individual action to take [Feature] Real-time Collaboration Phase 3 of the Gutenberg roadmap around real-time collaboration labels Sep 30, 2026
@chriszarate chriszarate removed the [Type] Task Issues or PRs that have been broken down into an individual action to take label Sep 30, 2026
Comment thread packages/core-data/src/sync.ts Outdated
return Boolean( getSyncManager() && getSyncConfig( kind, name ) );
},

isSynced( kind, name, recordId ) {

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.

Can we call this isLoaded to mirror the SyncManager method? isSynced implies sync state to me

*
* @return {Array} The record.
*/
export function createSyncUndoLevelRecord() {

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.

Can this file be TypeScript?

Comment thread packages/sync/src/undo-manager.ts Outdated
// events must not be mistaken for new levels.
let isApplyingHistory = false;

const yUndoManager = new YMultiDocUndoManager( [], {

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.

Now that the OG UndoManager is back in control and delegates undo/redo per-entity, we no longer have a need for the YMultiDocUndoManager. We can just create a Y.UndoManager for each entity and store a reference. This should simplify things a bit

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.

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.

Maybe we could go further and just attach an UndoManager to each EntityState, leave that to you

@alecgeatches alecgeatches Oct 1, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good thinking! YMultiDocUndoManager managed undoes across all synced entities, but core-data can be more specific than telling Yjs to handle whatever undo is next. I think I'll bring your commits in here and try the EntityState changes too.

chriszarate and others added 7 commits October 1, 2026 10:47
A placeholder in core-data's undo history now names the record whose Yjs
level it stands for, and the sync manager keeps one Yjs undo manager per
entity instead of a YMultiDocUndoManager. Undoing a placeholder moves that
entity's level only.

Before, a placeholder meant "whatever is on top of the Yjs stack". After a
synced record was unloaded (for example deleted), its placeholder undid
another entity's level out of order. Now a level whose record is no longer
synced is skipped.

When one entity opens a level, the other entities stop capturing and drop
their redo levels, so a later change cannot merge into an older level.
# Conflicts:
#	packages/core-data/src/test/private-selectors.jsdom.test.js
@alecgeatches

Copy link
Copy Markdown
Contributor Author

@chriszarate Your comments above have been addressed, please review again when you can. Thanks!

The sync manager defers local changes to a CRDT document while editing
alone, and a change's undo level, with its placeholder in core-data's
history, opens only when the deferred change is applied. applyUndoLevel
took the top level off the history before anything applied them, so an
undo right after typing in a synced record moved the edit before it
instead, and the typing's placeholder then dropped the redo level.

Close the sync manager's level first, which applies deferred changes, as
recordEntityEdit already does before it records an edit.
@chriszarate

Copy link
Copy Markdown
Contributor

@alecgeatches I pushed a bug fix that makes conceptual sense but could use some eyes and testing. Revert if it looks wrong.

2e10c58

@alecgeatches

Copy link
Copy Markdown
Contributor Author

AI development test failures seem to be unrelated and also failing on trunk, so going to merge. Thanks @chriszarate!

@alecgeatches
alecgeatches merged commit a2660ae into trunk Oct 1, 2026
119 of 131 checks passed
@alecgeatches
alecgeatches deleted the fix/rtc-entity-undo branch October 1, 2026 21:18
@github-actions github-actions Bot added this to the Gutenberg 24.2 milestone Oct 1, 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 [Package] Core data /packages/core-data [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: Editing a non-synced custom core-data entity leaves hasUndo() false

3 participants