Skip to content

Conversation

@alecgeatches
Copy link
Contributor

@alecgeatches alecgeatches commented Nov 6, 2025

What?

Improve undo positioning in real-time collaborative documents.

Our current implementation of the UndoStack with real-time collaboration undoes stack operations but does not manage the user's cursor position within the undo stack. This leads to a lot of unexpected jumps, selection loss, or relocation to the beginning of the line when changes are undone.

Undo in the current wpvip/rtc-plugin branch:

Screen.Recording.2025-11-06.at.2.37.15.PM.mov

The user's cursor can jump to an unexpected place or lose focus when an undo operation happens. This is caused from restoring content but not the user's cursor.

The changes in this PR use Y.UndoManager's stack-item-added and stack-item-popped events to store the user's RelativePosition on the stack. This fixes regular undo behavior, and also accounts for position changes that might have been made by another user in the CRDT doc. Here are the same tests above in this branch:

Undo in this PR (fix/relative-position-undo):

Screen.Recording.2025-11-06.at.2.34.12.PM.mov

Why?

Because our real-time undo stack relies on Y.UndoManager, we need to give it information on the user's cursor when we save items to the stack, or change the user's cursor when we pop items off. We store RelativePosition objects instead of absolute offsets which allow cursor positions to move if the underlying content has changed.

How?

This PR relies on a couple of important pieces:

  • Viewing the user's current selection, and restoring selection in the sync's UndoManager is difficult without introducing circular dependencies. To work around this, we pass addUndoMeta() and restoreUndoMeta() record handlers to core data, which the sync manager uses to pass undo stack meta and activate it during an undo operation.

  • The BlockSelectionHistory lives in core data class keeps a recent set of selected blocks, bucketed by start and end block clientId, which was found in practice to be a good way to store recent selections without duplicated data. For example, here's a user typing in two blocks:

    b1-b2

    The de-duplicated selection updates from the block editor store give these set of selection updates:

    { "selectionStart": {}, "selectionEnd": {} }
    {
        "selectionStart": { "clientId": "<block1>", "attributeKey": "content", "offset": 0 },
        "selectionEnd":   { "clientId": "<block1>", "attributeKey": "content", "offset": 0 }
    }
    {
        "selectionStart": { "clientId": "<block1>", "attributeKey": "content", "offset": 1 },
        "selectionEnd":   { "clientId": "<block1>", "attributeKey": "content", "offset": 1 }
    }
    {
        "selectionStart": { "clientId": "<block1>", "attributeKey": "content", "offset": 2 },
        "selectionEnd":   { "clientId": "<block1>", "attributeKey": "content", "offset": 2 }
    }
    {
        "selectionStart": { "clientId": "<block2>" },
        "selectionEnd":   { "clientId": "<block2>" }
    }
    {
        "selectionStart": { "clientId": "<block2>", "attributeKey": "content" },
        "selectionEnd":   { "clientId": "<block2>", "attributeKey": "content" }
    }
    {
        "selectionStart": { "clientId": "<block2>", "attributeKey": "content", "offset": 0 },
        "selectionEnd":   { "clientId": "<block2>", "attributeKey": "content", "offset": 0 }
    }
    {
        "selectionStart": { "clientId": "<block2>", "attributeKey": "content", "offset": 1 },
        "selectionEnd":   { "clientId": "<block2>", "attributeKey": "content", "offset": 1 }
    }
    {
        "selectionStart": { "clientId": "<block2>", "attributeKey": "content", "offset": 2 },
        "selectionEnd":   { "clientId": "<block2>", "attributeKey": "content", "offset": 2 }
    }

    <block1> and <block2> are shortened block clientIds.

    There's a lot of noise in here, especially when blocks are created like <block2> which send an update with just theclientId, then the clientId and attributeKey, and finally the location of the cursor when the block is done being created with an offset. It can be tricky to determine which of these represent a valid state for the user's selection to return to.

    BlockSelectionHistory processes these updates to bucket them by clientId. At the end of the actions above, here's what BlockSelectionHistory has stored:

    {
        "currentSelection": {
            "start": {
                "type": "RelativeSelection",
                "attributeKey": "content",
                "relativePosition": { /* ... */ },
                "clientId":"<block2>",
                "offset":2
            },
            "end": { /** same as start */ },
        }
        "history": [
            {
                "start": {
                    "type": "RelativeSelection",
                    "attributeKey": "content",
                    "relativePosition": { /* ... */ },
                    "clientId":"<block1>",
                    "offset":2
                },
                "end": { /** same as start */ },
            }
        ]
    }

    where the relativePosition property is a Y.RelativePosition type.

    Now when we process an undo stack being added, it's easy to grab the current relative position and some recent backups and push them onto the stack. The current implementation saves the current selection along with the last three unique block selections made before it.

    When the user undoes an item in the undo stack, we iterate through the positions on that item and move to the first valid one. In testing, especially in a multi-user environment, that was found to be a robust way to handle fallback positions when the underlying document can change.

Selection ranges

We also store the start and end for each selection, which allows us to restore selected content made before a change:

restore-selected-content

This is change from non-collaborative Gutenberg, which does not keep selection state in the undo stack.

What's missing?

There are still some features we can enhance to improve the undo stack or match WordPress behavior better:

1. Match WordPress behavior by creating an undo stack item when a block is inserted

WordPress will create a new undo location at the beginning of each block:

wordpress-line-undo

When undoing, the cursor stops at each new block. However, Y.UndoManager instead uses a captureTimeout of 500ms to decide when new stack items are added. If typed quickly enough, all 3 blocks are grouped together in one stack item instead:

rtc-line-undo

This may be possible to fix, but in the current behavior Y.UndoManager is fully in charge of when stack items are committed and we don't have specific say other than captureTimeout.

2. Block lookup inefficiencies

In order to convert a cursor offset to a RelativePosition, we need access to the underlying Y.Text object that represents the block's RichText content. This currently relies on the findBlockByClientIdInDoc() function, which recursively scans a document's entire content to find the associated Y.Text object. This works fine for small document tests but may be a bottleneck in larger documents.

This is the same inefficient approach we use within the vip-real-time-collaboration plugin, but it should be changed in both to a better system. I think a top level map of clientId -> attributeName -> Y.Text would be a much more efficient way to store Y.Text values for lookup and would solve the O(N) runtime of the current implementation, but this will require changes to attribute storage.

Testing Instructions

  1. Check out this PR.
  2. As a single user, type things in the editor, create new blocks, and move the cursor around. Try undoing and redoing actions, an ensure that the cursor position returns to an intuitive position.
  3. Try selecting a range within a single block, type over the selected content, and then undo. The selection should be restored. Note that this behavior doesn't work when restoring a selection across multiple blocks, which is a limitation of selection state in Gutenberg.
  4. Open another tab with another user and edit the document at the same time. Ensure that undo and redo operations continue to work, even if blocks are changed. Since we store a RelativePosition, the user's cursor should still return to the correct logical spot in a previous paragraph even if the offset has changed.

There are also a set of tests in block-selection-history.test.ts:

npm run test:unit -- --testPathPattern=block-selection-history

@alecgeatches alecgeatches force-pushed the fix/relative-position-undo branch from fee51f8 to 33375d4 Compare November 7, 2025 18:06
@alecgeatches alecgeatches marked this pull request as ready for review November 7, 2025 20:38
@alecgeatches alecgeatches requested a review from nerrad as a code owner November 7, 2025 20:38
@github-actions
Copy link

github-actions bot commented Nov 7, 2025

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

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

}
}

// Convenience types to manage block values with a clientId, attributes, and innerBlocks.
Copy link
Contributor Author

Choose a reason for hiding this comment

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

@chriszarate I ended up copying over some of the convenience types from our plugin to implement findBlockByClientIdInDoc(). I know you had some recent type improvement changes for Y.Map, could those be useful here as well? Can we share them in a sane way?

Copy link
Contributor

@chriszarate chriszarate left a comment

Choose a reason for hiding this comment

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

My initial question is: Why can’t this be handled by getChangesFrom / applyChangesToCRDTDoc (with some refactoring)? It’s very attractive to keep those functions in full control of the sync behavior. Splitting the logic brings complications and hidden dependencies between the packages.

Even though we don’t sync selection, could those mentioned functions read and mutate the selection in the way you’ve implemented here? Could applyChangesToCRDTDoc provide some selection hints that can be read by a naïve undo manager? I’m reading this PR quickly on mobile, so I’m sure I’m missing something.

@chriszarate chriszarate added [Feature] Real-time Collaboration Phase 3 of the Gutenberg roadmap around real-time collaboration [Type] Experimental Experimental feature or API. labels Nov 7, 2025
@alecgeatches alecgeatches force-pushed the fix/relative-position-undo branch from faf2b2e to 4b30e72 Compare November 24, 2025 17:50
@chriszarate chriszarate force-pushed the wpvip/rtc-plugin branch 3 times, most recently from de365b7 to f7977ca Compare December 1, 2025 23:56
@alecgeatches alecgeatches force-pushed the fix/relative-position-undo branch from d90bd82 to df407f5 Compare December 2, 2025 19:55
chriszarate and others added 4 commits December 12, 2025 11:12
- Remove count from getSelectionHistory(), replace existing calls
- Remove use of "position" and replace with "selection" in comments
- Update incorrect comments
}

/**
* This class is used to track recent block selections to help in restoring
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
* This class is used to track recent block selections to help in restoring
* This function is used to track recent block selections to help in restoring

/**
* Get the current selection (most recent selection in the current block).
*/
const getCurrentSelection = () => {
Copy link
Contributor

Choose a reason for hiding this comment

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

Would you mind adding return type annotations to all functions in this file? Most are covered by the BlockSelectionHistory type, but I think it's always good practice to avoid inferred return types. It helps prevent unintentional changes to the return type, which can lead to type bugs in consuming code.

Copy link
Contributor

@chriszarate chriszarate left a comment

Choose a reason for hiding this comment

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

I added a few (hopefully) simplifying commits. This looks great!

@chriszarate chriszarate merged commit 13a111e into WordPress:wpvip/rtc-plugin Dec 12, 2025
33 checks passed
@chriszarate chriszarate deleted the fix/relative-position-undo branch December 12, 2025 22:42
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 [Type] Experimental Experimental feature or API.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants