Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,9 @@ export default function useInnerBlockTemplateSync(
);

if ( ! fastDeepEqual( nextBlocks, currentInnerBlocks ) ) {
__unstableMarkNextChangeAsNotPersistent();
__unstableMarkNextChangeAsNotPersistent( {
history: 'ignore',
} );
replaceInnerBlocks(
clientId,
nextBlocks,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,46 @@ describe( 'useBlockSync hook', () => {
expect( onChange ).not.toHaveBeenCalled();
} );

it( 'passes undoIgnore when a non-persistent block change ignores history', async () => {
const onChange = jest.fn();
const onInput = jest.fn();
const value1 = [
{ clientId: 'a', innerBlocks: [], attributes: { foo: 1 } },
];
let registry;
const setRegistry = ( reg ) => {
registry = reg;
};
render(
<TestWrapper
setRegistry={ setRegistry }
value={ value1 }
onChange={ onChange }
onInput={ onInput }
/>
);
onChange.mockClear();
onInput.mockClear();

registry
.dispatch( blockEditorStore )
.__unstableMarkNextChangeAsNotPersistent( {
history: 'ignore',
} );
registry
.dispatch( blockEditorStore )
.updateBlockAttributes( 'a', { foo: 2 } );

expect( onInput ).toHaveBeenCalledWith(
[ { clientId: 'a', innerBlocks: [], attributes: { foo: 2 } } ],
expect.objectContaining( {
selection: expect.any( Object ),
undoIgnore: true,
} )
);
expect( onChange ).not.toHaveBeenCalled();
} );

it( 'calls onChange if a persistent change occurs', async () => {
const onChange = jest.fn();
const onInput = jest.fn();
Expand Down
14 changes: 12 additions & 2 deletions packages/block-editor/src/components/provider/use-block-sync.js
Original file line number Diff line number Diff line change
Expand Up @@ -342,13 +342,15 @@ export default function useBlockSync( {
const {
getSelectedBlocksInitialCaretPosition,
isLastBlockChangePersistent,
__unstableGetLastBlockChangeHistoryMode,
__unstableIsLastBlockChangeIgnored,
areInnerBlocksControlled,
getBlockParents,
} = registry.select( blockEditorStore );

let blocks = getBlocks( clientId );
let isPersistent = isLastBlockChangePersistent();
let blockHistoryMode = __unstableGetLastBlockChangeHistoryMode();
let previousAreBlocksDifferent = false;
let prevSelectionStart = getSelectionStart();
let prevSelectionEnd = getSelectionEnd();
Expand All @@ -367,6 +369,8 @@ export default function useBlockSync( {
}

const newIsPersistent = isLastBlockChangePersistent();
const newBlockHistoryMode =
__unstableGetLastBlockChangeHistoryMode();
const newBlocks = getBlocks( clientId );
const areBlocksDifferent = newBlocks !== blocks;
blocks = newBlocks;
Expand All @@ -377,6 +381,7 @@ export default function useBlockSync( {
) {
pendingChangesRef.current.incoming = null;
isPersistent = newIsPersistent;
blockHistoryMode = newBlockHistoryMode;
return;
}

Expand Down Expand Up @@ -409,6 +414,7 @@ export default function useBlockSync( {
registry.batch( () => {
if ( blocksChanged ) {
isPersistent = newIsPersistent;
blockHistoryMode = newBlockHistoryMode;

// For inner block controllers (clientId is set), restore external IDs
// before passing blocks to the parent.
Expand Down Expand Up @@ -438,9 +444,13 @@ export default function useBlockSync( {
const updateParent = isPersistent
? onChangeRef.current
: onInputRef.current;
updateParent( blocksForParent, {
const updateOptions = {
selection: selectionForParent,
} );
};
if ( blockHistoryMode === 'ignore' ) {
updateOptions.undoIgnore = true;
}
updateParent( blocksForParent, updateOptions );
}

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.

I don't understand why there are changes to the block-editor package and how they relate to the current PR?

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 question, I didn't explain the flow.

useBlockSync is the place where block-editor changes are converted into the onInput / onChange callbacks that eventually call editEntityRecord. This is part of the plumbing into core data. Here's the flow:

  1. We insert an async template and don't want it to create an undo item. The "ignore history in this undo" starts in useInnerBlockTemplateSync, which calls __unstableMarkNextChangeAsNotPersistent( { history: 'ignore' } );.

  2. The block-editor store records that as the next block change’s history mode, and exposes it through a selector.

  3. For the line you commented on above, useBlockSync bridges from block-editor to the core-data onInput callback. It reads the history mode and adds undoIgnore: true when the mode is history: 'ignore' to the onInput callback.

  4. useEntityBlockEditor.onInput forwards those options into editEntityRecord.

  5. editEntityRecord understands undoIgnore, and merges into the CRDT with LOCAL_UNDO_IGNORED_ORIGIN, so the Yjs document still receives the template change but the Yjs undo manager does not capture it.

The undoIgnore flag plumbing is used communicate to core-data to treat the template insertion differently than a normal cached block edit and have it avoid creating an undo item.

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.

Sorry for the confusion, I was not pointing that line specifically, It's just that in my mind, the bug that I highlighted before has nothing to do with block-editor store, it was a core-data selector, so I don't understand how the changes to block-editor are related to that bug fix.

@alecgeatches alecgeatches Jun 3, 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.

Got it. In my mind, the connection to the original comment you left is this:

Should we try to address it or should we assume that if there's an undo or redo that is created something else in the "state" of core-data is probably changing at the same time (which triggers the selectors again). (Basically accept that the bug exists but is hidden, or hard to reproduce)

In dependent PR #78756, we made a small timing adjustment to CRDT updates which caused hasUndo and hasRedo to update in-time with the actual Yjs undo stack, which it was not doing before. This revealed that Yjs was incorrectly adding an undo stack item, but it wasn't reflected in the UI for the test because "core-data is probably changing at the same time" wasn't actually happening. Once we started correctly reflecting the undo state in the UI, the test promptly failed because we weren't supposed to be creating an undo item. The behavior was always wrong, but we didn't see it because undo stack UI updates weren't being dispatched. That's the problem you described.

This PR address both your comment and the break:

  1. We're now ensuring that stack updates, regardless of timing, actually update UI state instead of relying on "probably changing" second-order effects.

  2. One of the consequences of correct undo stack UI display is the failure of the inner-blocks-templates.spec.js test because of an incorrectly-created undo item. The rest of the changes in this PR are to address the underlying issue to allow Yjs to correctly skip an undo item for this particular case.

    The changes are largely in the block editor as the originator of the __unstableMarkNextChangeAsNotPersistent signal, and the place where we can understand the intended meaning of that signal.


if (
Expand Down
14 changes: 11 additions & 3 deletions packages/block-editor/src/store/actions.js
Original file line number Diff line number Diff line change
Expand Up @@ -1646,12 +1646,20 @@ export function __unstableMarkLastChangeAsPersistent() {
}

/**
* Action that signals that the next block change should be marked explicitly as not persistent.
* Action that signals that the next block change should be marked explicitly
* as not persistent.
*
* By default, non-persistent changes may still merge into undo history. Use
* `history: 'ignore'` for derived changes that should never be captured by undo.

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.

I'm having a hard time understanding the difference between "non persistent" change and a change that is ignored from history. My memory might be failing me a bit.

@alecgeatches alecgeatches Jun 3, 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 question, I think the terminology is confusing. My apologies in advance for the wall of text.

"Non-persistent" is the block-editor distinction between sending a block-editor change through onInput versus onChange (trunk link). A non-persistent/onInput change does not create a new undo stack item by itself, but can still end up in history. The most obvious use is in typing, where each character should update the editor immediately but should not create a separate undo level. Those are staged and later converted into a persistent change after a timeout so they create a new undo stack item.

However, these "non-persistent" markers are used with different purposes. Edit from the PR description:

Typically __unstableMarkNextChangeAsNotPersistent() calls are proceeded by an onChange() call, and the flag is used to mark "merge the next operation into the prior item". However in useInnerBlockTemplateSync() the non-persistent call is NOT paired with an existing change. On a fresh page load, which happens in the test, there is no prior undo item to roll changes into, and so the Yjs undo manager creates a fresh undo for the entity change anyway.

In other uses of __unstableMarkNextChangeAsNotPersistent(), the history behavior is implicitly merge, and the change is treated as an update that should be combined into another change.

Before this PR, both typing and template sync reach the undo layer with the same shape, non-persistent or isNewUndoLevel: false in the sync path. That is enough for the built-in WordPress undo manager because it has a staged-record concept. Cached records are held in stagedRecord and do not affect hasUndo() until they are later merged into history.

Yjs works differently. It observes CRDT transactions as they happen and modifies the local undo stack outside of the editor. We have tools to stopCapturing() and use local transactions to avoid creating undo items in Yjs (like in this PR), but we need enough information to avoid undo stack manipulation when we're transacting document changes. If a non-persistent change is initiated by the editor and there is no prior stack item to merge into (as in the test), without other context, Yjs will still create an undo stack item.

That is why this template still case can be fixed with the history: 'ignore' mode. The template update should still be applied and synced, but it has no user edit to merge into, so it needs to use an untracked origin and be skipped by undo history entirely. Implementing true WordPress-style staging for Yjs would require a forked undo-manager layer, or some rough manipulation of Yjs stack internals.

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.

Ok I'm going to ping @ellatrix here just for awareness. This feels a bit complex and I just to want make sure we're not shooting ourselves in the foot by this extra complexity.

*
* @param {Object} options Options object.
* @param {string} options.history How the change should interact with history.
* @return {Object} Action object.
*/
export function __unstableMarkNextChangeAsNotPersistent() {
return { type: 'MARK_NEXT_CHANGE_AS_NOT_PERSISTENT' };
export function __unstableMarkNextChangeAsNotPersistent( {
history = 'merge',
} = {} ) {
return { type: 'MARK_NEXT_CHANGE_AS_NOT_PERSISTENT', history };
}

/**
Expand Down
26 changes: 19 additions & 7 deletions packages/block-editor/src/store/reducer.js
Original file line number Diff line number Diff line change
Expand Up @@ -419,18 +419,20 @@ const withBlockTree =
*/
function withPersistentBlockChange( reducer ) {
let lastAction;
let markNextChangeAsNotPersistent = false;
let nextHistoryMode;

return ( state, action ) => {
const nextState = reducer( state, action );

const wasMarkedAsNotPersistent = markNextChangeAsNotPersistent;
markNextChangeAsNotPersistent =
action.type === 'MARK_NEXT_CHANGE_AS_NOT_PERSISTENT';
const pendingHistoryMode = nextHistoryMode;
nextHistoryMode =
action.type === 'MARK_NEXT_CHANGE_AS_NOT_PERSISTENT'
? action.history ?? 'merge'
: undefined;

const isExplicitPersistentChange =
action.type === 'MARK_LAST_CHANGE_AS_PERSISTENT' ||
wasMarkedAsNotPersistent;
pendingHistoryMode;

// Defer to previous state value (or default) unless changing or
// explicitly marking as persistent.
Expand All @@ -442,15 +444,25 @@ function withPersistentBlockChange( reducer ) {
}

const isPersistentChange = isExplicitPersistentChange
? ! wasMarkedAsNotPersistent
? ! pendingHistoryMode
: ! isUpdatingSameBlockAttribute( action, lastAction );

// In comparing against the previous action, consider only those which
// would have qualified as one which would have been ignored or not
// have resulted in a changed state.
lastAction = action;

return { ...nextState, isPersistentChange };
if ( pendingHistoryMode === 'ignore' ) {
return {
...nextState,
isPersistentChange,
lastBlockChangeHistoryMode: 'ignore',
};
}

const { lastBlockChangeHistoryMode, ...blockChange } = nextState;

return { ...blockChange, isPersistentChange };
};
}

Expand Down
19 changes: 19 additions & 0 deletions packages/block-editor/src/store/selectors.js
Original file line number Diff line number Diff line change
Expand Up @@ -2930,6 +2930,25 @@ export function isLastBlockChangePersistent( state ) {
return state.blocks.isPersistentChange;
}

/**
* Returns how the most recent block change interacts with undo history.
*
* - `persistent` changes create a new undo level.
* - `merge` changes do not create a new undo level, but may merge into the
* prior stack item history.
* - `ignore` changes should never be captured by undo history.
*
* @param {Object} state Block editor state.
*
* @return {'persistent'|'merge'|'ignore'} Block change history behavior.
*/
export function __unstableGetLastBlockChangeHistoryMode( state ) {
if ( state.blocks.lastBlockChangeHistoryMode ) {
return state.blocks.lastBlockChangeHistoryMode;
}
return state.blocks.isPersistentChange === false ? 'merge' : 'persistent';
}

/**
* Returns the block list settings for an array of blocks, if any exist.
*
Expand Down
21 changes: 21 additions & 0 deletions packages/block-editor/src/store/test/actions.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ const {
updateBlockListSettings,
updateSettings,
validateBlocksToTemplate,
__unstableMarkNextChangeAsNotPersistent,
registerInserterMediaCategory,
setBlockEditingMode,
unsetBlockEditingMode,
Expand Down Expand Up @@ -814,6 +815,26 @@ describe( 'actions', () => {
} );
} );

describe( '__unstableMarkNextChangeAsNotPersistent', () => {
it( 'should use merge history by default', () => {
expect( __unstableMarkNextChangeAsNotPersistent() ).toEqual( {
type: 'MARK_NEXT_CHANGE_AS_NOT_PERSISTENT',
history: 'merge',
} );
} );

it( 'should allow ignoring history for the next change', () => {
expect(
__unstableMarkNextChangeAsNotPersistent( {
history: 'ignore',
} )
).toEqual( {
type: 'MARK_NEXT_CHANGE_AS_NOT_PERSISTENT',
history: 'ignore',
} );
} );
} );

describe( 'mergeBlocks', () => {
afterEach( () => {
getBlockTypes().forEach( ( block ) => {
Expand Down
46 changes: 46 additions & 0 deletions packages/block-editor/src/store/test/reducer.js
Original file line number Diff line number Diff line change
Expand Up @@ -2236,6 +2236,52 @@ describe( 'state', () => {
expect( subsequentState.isPersistentChange ).toBe( true );
} );

it( 'should flag ignored history for an explicitly marked not persistent change', () => {
let original = deepFreeze(
blocks( undefined, {
type: 'RESET_BLOCKS',
blocks: [
{
clientId: 'kumquat',
attributes: {},
innerBlocks: [],
},
],
} )
);
original = blocks( original, {
type: 'MARK_NEXT_CHANGE_AS_NOT_PERSISTENT',
history: 'ignore',
} );

const nextState = blocks( original, {
type: 'UPDATE_BLOCK_ATTRIBUTES',
clientIds: [ 'kumquat' ],
attributes: {
updated: true,
},
} );

expect( nextState.isPersistentChange ).toBe( false );
expect( nextState.lastBlockChangeHistoryMode ).toBe(
'ignore'
);

// A subsequent change should clear the ignored history.
const subsequentState = blocks( nextState, {
type: 'UPDATE_BLOCK_ATTRIBUTES',
clientIds: [ 'kumquat' ],
attributes: {
other: true,
},
} );

expect( subsequentState.isPersistentChange ).toBe( true );
expect(
subsequentState.lastBlockChangeHistoryMode
).toBeUndefined();
} );

it( 'should retain reference for same state, same persistence', () => {
const original = deepFreeze(
blocks( undefined, {
Expand Down
18 changes: 18 additions & 0 deletions packages/core-data/src/private-actions.js
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,24 @@ export function receiveViewConfig( kind, name, config ) {
};
}

/**
* Returns an action object used to notify core-data that the sync undo manager
* state changed outside of the core-data reducer, e.g. The Yjs UndoManager
* captured an undo level.
*
* @param {Object} state The sync undo stack state.
* @param {boolean} state.hasRedo Whether there are changes to redo.
* @param {boolean} state.hasUndo Whether there are changes to undo.
*
* @return {Object} Action object.
*/
export function __unstableNotifySyncUndoManagerChange( state ) {
return {
type: 'SYNC_UNDO_MANAGER_CHANGE',
...state,
};
}

/**
* Returns an action object used to set the sync connection status for an entity or collection.
*
Expand Down
12 changes: 0 additions & 12 deletions packages/core-data/src/private-selectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,18 +21,6 @@ const EMPTY_OBJECT = {};
* Returns the previous edit from the current undo offset
* for the entity records edits history, if any.
*
* Known Issue: Every-time state.undoManager changes, the getUndoManager
* private selector is called (if used within useSelect and things like that)
* which ensures the UI is always properly reactive. But, it's not the case with
* the custom "sync" undo manager.
*
* Assumption: When an undo/redo is created, other parts of the core-data state
* are likely changing simultaneously, which will trigger the selectors again.
*
* This issue is acceptable based on the assumption above.
*
* @see https://github.com/WordPress/gutenberg/pull/72407/files#r2580214235 for more details.
*
* @param state State tree.
*
* @return The undo manager.
Expand Down
17 changes: 17 additions & 0 deletions packages/core-data/src/reducer.js
Original file line number Diff line number Diff line change
Expand Up @@ -460,6 +460,22 @@ export function undoManager( state = createUndoManager() ) {
return state;
}

// Stores a snapshot of the sync undo manager's undo/redo availability so
// core-data selectors can react to undo stack changes.
export function syncUndoManagerState(
state = { hasRedo: false, hasUndo: false },
action
) {
switch ( action.type ) {
case 'SYNC_UNDO_MANAGER_CHANGE':
return {
hasRedo: action.hasRedo,
hasUndo: action.hasUndo,
};
}
return state;
}

export function editsReference( state = {}, action ) {
switch ( action.type ) {
case 'EDIT_ENTITY_RECORD':
Expand Down Expand Up @@ -750,6 +766,7 @@ export default combineReducers( {
themeGlobalStyleRevisions,
entities,
editsReference,
syncUndoManagerState,
undoManager,
embedPreviews,
userPermissions,
Expand Down
5 changes: 5 additions & 0 deletions packages/core-data/src/resolvers.js
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,11 @@ export const getEntityRecord =
);
}
},
onUndoStackChange: ( undoState ) => {
dispatch.__unstableNotifySyncUndoManagerChange(
undoState
);
},
restoreUndoMeta: ( ydoc, meta ) => {
const selectionHistory =
meta.get( 'selectionHistory' );
Expand Down
Loading
Loading