From 210f8681b62999ffa39f12a2a82df959f451a0de Mon Sep 17 00:00:00 2001 From: Alec Geatches Date: Mon, 8 Jun 2026 14:54:27 -0600 Subject: [PATCH 01/10] Use __unstableMarkNextChangeAsCombinedOperation marker and deferred block insert updates to avoid syncing inner block template insertion separately --- .../use-inner-block-template-sync.js | 8 +- .../provider/test/use-block-sync.js | 60 +++++- .../src/components/provider/use-block-sync.js | 101 +++++++++- packages/block-editor/src/store/actions.js | 13 ++ packages/block-editor/src/store/reducer.js | 62 +++++-- packages/block-editor/src/store/selectors.js | 23 +++ .../block-editor/src/store/test/actions.js | 9 + .../block-editor/src/store/test/reducer.js | 173 ++++++++++++++++++ 8 files changed, 424 insertions(+), 25 deletions(-) diff --git a/packages/block-editor/src/components/inner-blocks/use-inner-block-template-sync.js b/packages/block-editor/src/components/inner-blocks/use-inner-block-template-sync.js index 630bb51110364b..c65c0d410c26a4 100644 --- a/packages/block-editor/src/components/inner-blocks/use-inner-block-template-sync.js +++ b/packages/block-editor/src/components/inner-blocks/use-inner-block-template-sync.js @@ -55,8 +55,11 @@ export default function useInnerBlockTemplateSync( getSelectedBlocksInitialCaretPosition, isBlockSelected, } = registry.select( blockEditorStore ); - const { replaceInnerBlocks, __unstableMarkNextChangeAsNotPersistent } = - registry.dispatch( blockEditorStore ); + const { + __unstableMarkNextChangeAsNotPersistent, + __unstableMarkNextChangeAsCombinedOperation, + replaceInnerBlocks, + } = registry.dispatch( blockEditorStore ); // There's an implicit dependency between useInnerBlockTemplateSync and useNestedSettingsUpdate // The former needs to happen after the latter and since the latter is using microtasks to batch updates (performance optimization), @@ -94,6 +97,7 @@ export default function useInnerBlockTemplateSync( __unstableMarkNextChangeAsNotPersistent( { history: 'ignore', } ); + __unstableMarkNextChangeAsCombinedOperation(); replaceInnerBlocks( clientId, nextBlocks, diff --git a/packages/block-editor/src/components/provider/test/use-block-sync.js b/packages/block-editor/src/components/provider/test/use-block-sync.js index 00260264a286d3..2ed48bd8423986 100644 --- a/packages/block-editor/src/components/provider/test/use-block-sync.js +++ b/packages/block-editor/src/components/provider/test/use-block-sync.js @@ -1,12 +1,12 @@ /** * WordPress dependencies */ -import { registerBlockType } from '@wordpress/blocks'; +import { createBlock, registerBlockType } from '@wordpress/blocks'; /** * External dependencies */ -import { render } from '@testing-library/react'; +import { render, waitFor } from '@testing-library/react'; /** * Internal dependencies @@ -351,6 +351,62 @@ describe( 'useBlockSync hook', () => { expect( onInput ).not.toHaveBeenCalled(); } ); + it( 'merges combined-operation block changes into a deferred insertion', async () => { + const onChange = jest.fn(); + const onInput = jest.fn(); + let registry; + const setRegistry = ( reg ) => { + registry = reg; + }; + render( + + ); + onChange.mockClear(); + onInput.mockClear(); + + const parentBlock = createBlock( 'test/test-block' ); + const templateBlock = createBlock( 'test/test-block', { foo: 2 } ); + + registry.dispatch( blockEditorStore ).insertBlock( parentBlock ); + expect( onChange ).not.toHaveBeenCalled(); + registry + .dispatch( blockEditorStore ) + .__unstableMarkNextChangeAsNotPersistent( { + history: 'ignore', + } ); + registry + .dispatch( blockEditorStore ) + .__unstableMarkNextChangeAsCombinedOperation(); + registry + .dispatch( blockEditorStore ) + .replaceInnerBlocks( parentBlock.clientId, [ templateBlock ] ); + + await waitFor( () => expect( onChange ).toHaveBeenCalledTimes( 1 ) ); + + expect( onChange ).toHaveBeenCalledWith( + [ + expect.objectContaining( { + clientId: parentBlock.clientId, + innerBlocks: [ + expect.objectContaining( { + clientId: templateBlock.clientId, + } ), + ], + } ), + ], + expect.objectContaining( { selection: expect.any( Object ) } ) + ); + expect( onChange.mock.calls[ 0 ][ 1 ] ).not.toHaveProperty( + 'undoIgnore' + ); + expect( onInput ).not.toHaveBeenCalled(); + } ); + it( 'avoids updating the parent if there is a pending incoming change', async () => { const replaceInnerBlocks = jest.spyOn( blockEditorActions, diff --git a/packages/block-editor/src/components/provider/use-block-sync.js b/packages/block-editor/src/components/provider/use-block-sync.js index a600de6f5ebe8b..5aafd5fa12d8b9 100644 --- a/packages/block-editor/src/components/provider/use-block-sync.js +++ b/packages/block-editor/src/components/provider/use-block-sync.js @@ -1,7 +1,7 @@ /** * WordPress dependencies */ -import { useContext, useEffect, useRef } from '@wordpress/element'; +import { useContext, useEffect, useRef, useState } from '@wordpress/element'; import { useRegistry } from '@wordpress/data'; import { cloneBlock } from '@wordpress/blocks'; @@ -164,6 +164,12 @@ export default function useBlockSync( { const pendingChangesRef = useRef( { incoming: null, outgoing: [] } ); const subscribedRef = useRef( false ); + // Used to track deferred block syncs. deferredBlockSyncFlushSignal is updated + // to a new value when a deferred block is queued to sync to trigger a useEffect. + const deferredBlockSyncRef = useRef( null ); + const [ deferredBlockSyncFlushSignal, setDeferredBlockSyncFlushSignal ] = + useState( 0 ); + // Mapping between external (original) and internal (cloned) client IDs. // This allows stable external IDs while using unique internal IDs. const idMappingRef = useRef( { @@ -299,6 +305,35 @@ export default function useBlockSync( { onChangeRef.current = onChange; }, [ onInput, onChange ] ); + const flushDeferredBlockSync = () => { + const deferredBlockSync = deferredBlockSyncRef.current; + + // Deferred block syncs are always persistent block changes, so use + // onChangeRef to persist. + deferredBlockSyncRef.current = null; + pendingChangesRef.current.outgoing.push( deferredBlockSync.blocks ); + onChangeRef.current( + deferredBlockSync.blocks, + deferredBlockSync.options + ); + }; + + useEffect( () => { + if ( deferredBlockSyncRef.current ) { + // When we deferredBlockSyncFlushSignal is updated and we have + // a deferred block sync, flush it. + flushDeferredBlockSync(); + } + }, [ deferredBlockSyncFlushSignal ] ); + + const deferBlockSync = ( blocksToSync, options ) => { + deferredBlockSyncRef.current = { + blocks: blocksToSync, + options, + }; + setDeferredBlockSyncFlushSignal( ( current ) => current + 1 ); + }; + // Determine if blocks need to be reset when they change. // Also restores selection from context after blocks are set. useEffect( () => { @@ -343,6 +378,8 @@ export default function useBlockSync( { getSelectedBlocksInitialCaretPosition, isLastBlockChangePersistent, __unstableGetLastBlockChangeHistoryMode, + __unstableDidLastBlockChangeInsertBlocks, + __unstableIsLastBlockChangeCombinedOperation, __unstableIsLastBlockChangeIgnored, areInnerBlocksControlled, getBlockParents, @@ -413,6 +450,11 @@ export default function useBlockSync( { // receives both changes atomically. registry.batch( () => { if ( blocksChanged ) { + const isCombinedOperation = + __unstableIsLastBlockChangeCombinedOperation(); + const didInsertBlocks = + __unstableDidLastBlockChangeInsertBlocks(); + isPersistent = newIsPersistent; blockHistoryMode = newBlockHistoryMode; @@ -437,20 +479,58 @@ export default function useBlockSync( { ) : selectionInfo; - pendingChangesRef.current.outgoing.push( - blocksForParent - ); - - const updateParent = isPersistent - ? onChangeRef.current - : onInputRef.current; const updateOptions = { selection: selectionForParent, }; if ( blockHistoryMode === 'ignore' ) { updateOptions.undoIgnore = true; } - updateParent( blocksForParent, updateOptions ); + + if ( deferredBlockSyncRef.current ) { + if ( isCombinedOperation ) { + // This change was marked as a combined operation with + // __unstableMarkNextChangeAsCombinedOperation. Update the + // deferred payload and return early. It will be flushed + // by the deferredBlockSyncFlushSignal after browser + // microtasks are flushed. + // + // This is used with template insertion in RTC to ensure + // operations like outer block and inner template insertions + // are merged into the same change for peers. + deferredBlockSyncRef.current = { + ...deferredBlockSyncRef.current, + blocks: blocksForParent, + options: { + ...deferredBlockSyncRef.current.options, + selection: selectionForParent, + }, + }; + return; + } + + // A new unrelated block change is starting, so flush + // the deferred block sync before handling it. + flushDeferredBlockSync(); + } + + const shouldDeferBlockSync = + isPersistent && didInsertBlocks; + + if ( shouldDeferBlockSync ) { + // Insert-like persistent changes can trigger a + // template continuation in a browser microtask. + // Delay this payload briefly so that continuation + // can be merged before syncing to the parent. + deferBlockSync( blocksForParent, updateOptions ); + } else { + pendingChangesRef.current.outgoing.push( + blocksForParent + ); + const updateParent = isPersistent + ? onChangeRef.current + : onInputRef.current; + updateParent( blocksForParent, updateOptions ); + } } if ( @@ -506,6 +586,9 @@ export default function useBlockSync( { useEffect( () => { return () => { + if ( deferredBlockSyncRef.current ) { + flushDeferredBlockSync(); + } unsetControlledBlocks(); }; }, [] ); diff --git a/packages/block-editor/src/store/actions.js b/packages/block-editor/src/store/actions.js index 1b6d1a191bc0b5..35ae4fdc60bbe4 100644 --- a/packages/block-editor/src/store/actions.js +++ b/packages/block-editor/src/store/actions.js @@ -1662,6 +1662,19 @@ export function __unstableMarkNextChangeAsNotPersistent( { return { type: 'MARK_NEXT_CHANGE_AS_NOT_PERSISTENT', history }; } +/** + * Action that signals that the next block change should be treated as part of + * the same combined operation as a pending outbound block change. + * + * Used in RTC to combine outer block and inter template insertions into a single + * logical operation to other peers. + * + * @return {Object} Action object. + */ +export function __unstableMarkNextChangeAsCombinedOperation() { + return { type: 'MARK_NEXT_CHANGE_AS_COMBINED_OPERATION' }; +} + /** * Action that marks the last block change as an automatic change, meaning it was not * performed by the user, and can be undone using the `Escape` and `Backspace` keys. diff --git a/packages/block-editor/src/store/reducer.js b/packages/block-editor/src/store/reducer.js index 7e2b6fba128be5..307ba6662e154b 100644 --- a/packages/block-editor/src/store/reducer.js +++ b/packages/block-editor/src/store/reducer.js @@ -420,19 +420,48 @@ const withBlockTree = function withPersistentBlockChange( reducer ) { let lastAction; let nextHistoryMode; + let nextIsCombinedOperation; return ( state, action ) => { const nextState = reducer( state, action ); + const didInsertBlocks = + ( action.type === 'INSERT_BLOCKS' || + action.type === 'REPLACE_BLOCKS' || + action.type === 'REPLACE_BLOCKS_AUGMENTED_WITH_CHILDREN' ) && + !! action.blocks?.length; + const isNotPersistentMarker = + action.type === 'MARK_NEXT_CHANGE_AS_NOT_PERSISTENT'; + const isCombinedOperationMarker = + action.type === 'MARK_NEXT_CHANGE_AS_COMBINED_OPERATION'; + const hasMarker = isNotPersistentMarker || isCombinedOperationMarker; const pendingHistoryMode = nextHistoryMode; - nextHistoryMode = - action.type === 'MARK_NEXT_CHANGE_AS_NOT_PERSISTENT' - ? action.history ?? 'merge' - : undefined; + const pendingIsCombinedOperation = nextIsCombinedOperation; + + // Ensure MARK_NEXT_CHANGE_AS_COMBINED_OPERATION doesn't consume an existing + // MARK_NEXT_CHANGE_AS_NOT_PERSISTENT marker. + nextHistoryMode = undefined; + if ( isNotPersistentMarker ) { + nextHistoryMode = action.history ?? 'merge'; + } else if ( isCombinedOperationMarker ) { + // For combined operations (e.g. inner template sync), keep the + // pending history mode for the next operation. The final combined + // operation should keep the intended history flag. + nextHistoryMode = pendingHistoryMode; + } + + // Also ensure MARK_NEXT_CHANGE_AS_NOT_PERSISTENT does not consume an + // existing MARK_NEXT_CHANGE_AS_COMBINED_OPERATION marker. + nextIsCombinedOperation = false; + if ( isCombinedOperationMarker ) { + nextIsCombinedOperation = true; + } else if ( isNotPersistentMarker ) { + nextIsCombinedOperation = pendingIsCombinedOperation; + } const isExplicitPersistentChange = action.type === 'MARK_LAST_CHANGE_AS_PERSISTENT' || - pendingHistoryMode; + ( ! hasMarker && pendingHistoryMode ); // Defer to previous state value (or default) unless changing or // explicitly marking as persistent. @@ -452,15 +481,24 @@ function withPersistentBlockChange( reducer ) { // have resulted in a changed state. lastAction = action; + // Clear transient metadata from the previous block change. The current + // change adds back only the markers that apply to it below. + const { + lastBlockChangeHistoryMode, + isLastBlockChangeCombinedOperation, + didLastBlockChangeInsertBlocks, + ...blockChange + } = nextState; + if ( pendingHistoryMode === 'ignore' ) { - return { - ...nextState, - isPersistentChange, - lastBlockChangeHistoryMode: 'ignore', - }; + blockChange.lastBlockChangeHistoryMode = 'ignore'; + } + if ( pendingIsCombinedOperation ) { + blockChange.isLastBlockChangeCombinedOperation = true; + } + if ( didInsertBlocks ) { + blockChange.didLastBlockChangeInsertBlocks = true; } - - const { lastBlockChangeHistoryMode, ...blockChange } = nextState; return { ...blockChange, isPersistentChange }; }; diff --git a/packages/block-editor/src/store/selectors.js b/packages/block-editor/src/store/selectors.js index 450223e4107988..38f3a0a17fa689 100644 --- a/packages/block-editor/src/store/selectors.js +++ b/packages/block-editor/src/store/selectors.js @@ -2949,6 +2949,29 @@ export function __unstableGetLastBlockChangeHistoryMode( state ) { return state.blocks.isPersistentChange === false ? 'merge' : 'persistent'; } +/** + * Returns true if the most recent block change should be treated as part of the + * same combined operation as a pending outbound block change. + * + * @param {Object} state Block editor state. + * + * @return {boolean} Whether the most recent block change is part of the combined operation. + */ +export function __unstableIsLastBlockChangeCombinedOperation( state ) { + return !! state.blocks.isLastBlockChangeCombinedOperation; +} + +/** + * Returns true if the most recent block change inserted blocks. + * + * @param {Object} state Block editor state. + * + * @return {boolean} Whether the most recent block change inserted blocks. + */ +export function __unstableDidLastBlockChangeInsertBlocks( state ) { + return !! state.blocks.didLastBlockChangeInsertBlocks; +} + /** * Returns the block list settings for an array of blocks, if any exist. * diff --git a/packages/block-editor/src/store/test/actions.js b/packages/block-editor/src/store/test/actions.js index 8c1cd44087cfb3..5b8f28c90cacc3 100644 --- a/packages/block-editor/src/store/test/actions.js +++ b/packages/block-editor/src/store/test/actions.js @@ -54,6 +54,7 @@ const { updateSettings, validateBlocksToTemplate, __unstableMarkNextChangeAsNotPersistent, + __unstableMarkNextChangeAsCombinedOperation, registerInserterMediaCategory, setBlockEditingMode, unsetBlockEditingMode, @@ -835,6 +836,14 @@ describe( 'actions', () => { } ); } ); + describe( '__unstableMarkNextChangeAsCombinedOperation', () => { + it( 'should mark the next change as a combined operation', () => { + expect( __unstableMarkNextChangeAsCombinedOperation() ).toEqual( { + type: 'MARK_NEXT_CHANGE_AS_COMBINED_OPERATION', + } ); + } ); + } ); + describe( 'mergeBlocks', () => { afterEach( () => { getBlockTypes().forEach( ( block ) => { diff --git a/packages/block-editor/src/store/test/reducer.js b/packages/block-editor/src/store/test/reducer.js index c49c66cefb62bd..e1724fa59aed35 100644 --- a/packages/block-editor/src/store/test/reducer.js +++ b/packages/block-editor/src/store/test/reducer.js @@ -2282,6 +2282,179 @@ describe( 'state', () => { ).toBeUndefined(); } ); + it( 'should flag only the next change as part of a combined operation', () => { + 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', + } ); + original = blocks( original, { + type: 'MARK_NEXT_CHANGE_AS_COMBINED_OPERATION', + } ); + + const nextState = blocks( original, { + type: 'UPDATE_BLOCK_ATTRIBUTES', + clientIds: [ 'kumquat' ], + attributes: { + updated: true, + }, + } ); + + expect( nextState.isPersistentChange ).toBe( false ); + expect( nextState.lastBlockChangeHistoryMode ).toBe( + 'ignore' + ); + expect( nextState.isLastBlockChangeCombinedOperation ).toBe( + true + ); + + const subsequentState = blocks( nextState, { + type: 'UPDATE_BLOCK_ATTRIBUTES', + clientIds: [ 'kumquat' ], + attributes: { + other: true, + }, + } ); + + expect( + subsequentState.isLastBlockChangeCombinedOperation + ).toBeUndefined(); + } ); + + it( 'should preserve combined operation markers when marking the next change as not persistent', () => { + let original = deepFreeze( + blocks( undefined, { + type: 'RESET_BLOCKS', + blocks: [ + { + clientId: 'kumquat', + attributes: {}, + innerBlocks: [], + }, + ], + } ) + ); + original = blocks( original, { + type: 'MARK_NEXT_CHANGE_AS_COMBINED_OPERATION', + } ); + 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' + ); + expect( nextState.isLastBlockChangeCombinedOperation ).toBe( + true + ); + } ); + + it( 'should flag INSERT_BLOCKS-style changes that add blocks', () => { + let original = deepFreeze( + blocks( undefined, { + type: 'RESET_BLOCKS', + blocks: [ + { + clientId: 'kumquat', + attributes: {}, + innerBlocks: [], + }, + ], + } ) + ); + + const insertState = blocks( original, { + type: 'INSERT_BLOCKS', + blocks: [ + { + clientId: 'plum', + attributes: {}, + innerBlocks: [], + }, + ], + } ); + + expect( insertState.didLastBlockChangeInsertBlocks ).toBe( + true + ); + + original = deepFreeze( insertState ); + + const replaceState = blocks( original, { + type: 'REPLACE_BLOCKS', + clientIds: [ 'plum' ], + blocks: [ + { + clientId: 'mango', + attributes: {}, + innerBlocks: [], + }, + ], + } ); + + expect( replaceState.didLastBlockChangeInsertBlocks ).toBe( + true + ); + + const subsequentState = blocks( replaceState, { + type: 'UPDATE_BLOCK_ATTRIBUTES', + clientIds: [ 'mango' ], + attributes: { + updated: true, + }, + } ); + + expect( + subsequentState.didLastBlockChangeInsertBlocks + ).toBeUndefined(); + } ); + + it( 'should not flag replacements that do not add blocks', () => { + const original = deepFreeze( + blocks( undefined, { + type: 'RESET_BLOCKS', + blocks: [ + { + clientId: 'kumquat', + attributes: {}, + innerBlocks: [], + }, + ], + } ) + ); + + const state = blocks( original, { + type: 'REPLACE_BLOCKS', + clientIds: [ 'kumquat' ], + blocks: [], + } ); + + expect( + state.didLastBlockChangeInsertBlocks + ).toBeUndefined(); + } ); + it( 'should retain reference for same state, same persistence', () => { const original = deepFreeze( blocks( undefined, { From c8c0f67e121f591f04e393ff322ad0c472ccd6bd Mon Sep 17 00:00:00 2001 From: Alec Geatches Date: Mon, 8 Jun 2026 14:59:34 -0600 Subject: [PATCH 02/10] Move deferred block marker logic into withDeferredBlockSync reducer --- packages/block-editor/src/store/reducer.js | 87 ++++++++++++++++------ 1 file changed, 66 insertions(+), 21 deletions(-) diff --git a/packages/block-editor/src/store/reducer.js b/packages/block-editor/src/store/reducer.js index 307ba6662e154b..01912c2db28ff7 100644 --- a/packages/block-editor/src/store/reducer.js +++ b/packages/block-editor/src/store/reducer.js @@ -420,23 +420,16 @@ const withBlockTree = function withPersistentBlockChange( reducer ) { let lastAction; let nextHistoryMode; - let nextIsCombinedOperation; return ( state, action ) => { const nextState = reducer( state, action ); - const didInsertBlocks = - ( action.type === 'INSERT_BLOCKS' || - action.type === 'REPLACE_BLOCKS' || - action.type === 'REPLACE_BLOCKS_AUGMENTED_WITH_CHILDREN' ) && - !! action.blocks?.length; const isNotPersistentMarker = action.type === 'MARK_NEXT_CHANGE_AS_NOT_PERSISTENT'; const isCombinedOperationMarker = action.type === 'MARK_NEXT_CHANGE_AS_COMBINED_OPERATION'; const hasMarker = isNotPersistentMarker || isCombinedOperationMarker; const pendingHistoryMode = nextHistoryMode; - const pendingIsCombinedOperation = nextIsCombinedOperation; // Ensure MARK_NEXT_CHANGE_AS_COMBINED_OPERATION doesn't consume an existing // MARK_NEXT_CHANGE_AS_NOT_PERSISTENT marker. @@ -450,15 +443,6 @@ function withPersistentBlockChange( reducer ) { nextHistoryMode = pendingHistoryMode; } - // Also ensure MARK_NEXT_CHANGE_AS_NOT_PERSISTENT does not consume an - // existing MARK_NEXT_CHANGE_AS_COMBINED_OPERATION marker. - nextIsCombinedOperation = false; - if ( isCombinedOperationMarker ) { - nextIsCombinedOperation = true; - } else if ( isNotPersistentMarker ) { - nextIsCombinedOperation = pendingIsCombinedOperation; - } - const isExplicitPersistentChange = action.type === 'MARK_LAST_CHANGE_AS_PERSISTENT' || ( ! hasMarker && pendingHistoryMode ); @@ -481,26 +465,86 @@ function withPersistentBlockChange( reducer ) { // have resulted in a changed state. lastAction = action; + if ( pendingHistoryMode === 'ignore' ) { + return { + ...nextState, + isPersistentChange, + lastBlockChangeHistoryMode: 'ignore', + }; + } + + const { lastBlockChangeHistoryMode, ...blockChange } = nextState; + return { ...blockChange, isPersistentChange }; + }; +} + +/** + * Higher-order reducer intended to augment the blocks reducer with transient + * metadata used by deferred block sync. + * + * @param {Function} reducer Original reducer function. + * + * @return {Function} Enhanced reducer function. + */ +function withDeferredBlockSync( reducer ) { + let nextIsCombinedOperation; + + return ( state, action ) => { + const nextState = reducer( state, action ); + + const isNotPersistentMarker = + action.type === 'MARK_NEXT_CHANGE_AS_NOT_PERSISTENT'; + const isCombinedOperationMarker = + action.type === 'MARK_NEXT_CHANGE_AS_COMBINED_OPERATION'; + const pendingIsCombinedOperation = nextIsCombinedOperation; + + // Ensure MARK_NEXT_CHANGE_AS_NOT_PERSISTENT does not consume an + // existing MARK_NEXT_CHANGE_AS_COMBINED_OPERATION marker. + nextIsCombinedOperation = false; + if ( isCombinedOperationMarker ) { + nextIsCombinedOperation = true; + } else if ( isNotPersistentMarker ) { + nextIsCombinedOperation = pendingIsCombinedOperation; + } + + if ( state === nextState ) { + return state; + } + + const didInsertBlocks = + ( action.type === 'INSERT_BLOCKS' || + action.type === 'REPLACE_BLOCKS' || + action.type === 'REPLACE_BLOCKS_AUGMENTED_WITH_CHILDREN' ) && + !! action.blocks?.length; + + const hasBlockSyncMetadata = + nextState.isLastBlockChangeCombinedOperation !== undefined || + nextState.didLastBlockChangeInsertBlocks !== undefined; + if ( + ! pendingIsCombinedOperation && + ! didInsertBlocks && + ! hasBlockSyncMetadata + ) { + return nextState; + } + // Clear transient metadata from the previous block change. The current // change adds back only the markers that apply to it below. const { - lastBlockChangeHistoryMode, isLastBlockChangeCombinedOperation, didLastBlockChangeInsertBlocks, ...blockChange } = nextState; - if ( pendingHistoryMode === 'ignore' ) { - blockChange.lastBlockChangeHistoryMode = 'ignore'; - } if ( pendingIsCombinedOperation ) { blockChange.isLastBlockChangeCombinedOperation = true; } + if ( didInsertBlocks ) { blockChange.didLastBlockChangeInsertBlocks = true; } - return { ...blockChange, isPersistentChange }; + return blockChange; }; } @@ -819,6 +863,7 @@ export const blocks = pipe( withReplaceInnerBlocks, // Needs to be after withInnerBlocksRemoveCascade. withBlockReset, withPersistentBlockChange, + withDeferredBlockSync, withIgnoredBlockChange, withResetControlledBlocks )( { From e8972b9b65cd8ab4676838a750fd4746957c60ca Mon Sep 17 00:00:00 2001 From: Alec Geatches Date: Tue, 9 Jun 2026 09:22:19 -0600 Subject: [PATCH 03/10] Run deferred block logic in setTimeout instead of microtask so it runs last --- .../src/components/provider/use-block-sync.js | 52 ++++++----- .../collaboration-list-template.spec.ts | 92 +++++++++++++++++++ 2 files changed, 120 insertions(+), 24 deletions(-) create mode 100644 test/e2e/specs/editor/collaboration/websocket-only/collaboration-list-template.spec.ts diff --git a/packages/block-editor/src/components/provider/use-block-sync.js b/packages/block-editor/src/components/provider/use-block-sync.js index 5aafd5fa12d8b9..b06c66a2085ce3 100644 --- a/packages/block-editor/src/components/provider/use-block-sync.js +++ b/packages/block-editor/src/components/provider/use-block-sync.js @@ -1,7 +1,7 @@ /** * WordPress dependencies */ -import { useContext, useEffect, useRef, useState } from '@wordpress/element'; +import { useContext, useEffect, useRef } from '@wordpress/element'; import { useRegistry } from '@wordpress/data'; import { cloneBlock } from '@wordpress/blocks'; @@ -164,11 +164,10 @@ export default function useBlockSync( { const pendingChangesRef = useRef( { incoming: null, outgoing: [] } ); const subscribedRef = useRef( false ); - // Used to track deferred block syncs. deferredBlockSyncFlushSignal is updated - // to a new value when a deferred block is queued to sync to trigger a useEffect. + // Used to track deferred block syncs. The flush timeout gives template + // insertion microtasks a chance to update the deferred payload first. const deferredBlockSyncRef = useRef( null ); - const [ deferredBlockSyncFlushSignal, setDeferredBlockSyncFlushSignal ] = - useState( 0 ); + const deferredBlockSyncFlushTimeoutRef = useRef( null ); // Mapping between external (original) and internal (cloned) client IDs. // This allows stable external IDs while using unique internal IDs. @@ -305,33 +304,38 @@ export default function useBlockSync( { onChangeRef.current = onChange; }, [ onInput, onChange ] ); - const flushDeferredBlockSync = () => { - const deferredBlockSync = deferredBlockSyncRef.current; - - // Deferred block syncs are always persistent block changes, so use - // onChangeRef to persist. - deferredBlockSyncRef.current = null; - pendingChangesRef.current.outgoing.push( deferredBlockSync.blocks ); - onChangeRef.current( - deferredBlockSync.blocks, - deferredBlockSync.options - ); + const clearDeferredBlockSyncFlushTimeout = () => { + if ( deferredBlockSyncFlushTimeoutRef.current !== null ) { + clearTimeout( deferredBlockSyncFlushTimeoutRef.current ); + deferredBlockSyncFlushTimeoutRef.current = null; + } }; - useEffect( () => { + const flushDeferredBlockSync = () => { if ( deferredBlockSyncRef.current ) { - // When we deferredBlockSyncFlushSignal is updated and we have - // a deferred block sync, flush it. - flushDeferredBlockSync(); + const deferredBlockSync = deferredBlockSyncRef.current; + + // Deferred block syncs are always persistent block changes, so use + // onChangeRef to persist. + clearDeferredBlockSyncFlushTimeout(); + deferredBlockSyncRef.current = null; + pendingChangesRef.current.outgoing.push( deferredBlockSync.blocks ); + onChangeRef.current( + deferredBlockSync.blocks, + deferredBlockSync.options + ); } - }, [ deferredBlockSyncFlushSignal ] ); + }; const deferBlockSync = ( blocksToSync, options ) => { deferredBlockSyncRef.current = { blocks: blocksToSync, options, }; - setDeferredBlockSyncFlushSignal( ( current ) => current + 1 ); + clearDeferredBlockSyncFlushTimeout(); + deferredBlockSyncFlushTimeoutRef.current = setTimeout( () => { + flushDeferredBlockSync(); + }, 0 ); }; // Determine if blocks need to be reset when they change. @@ -491,8 +495,8 @@ export default function useBlockSync( { // This change was marked as a combined operation with // __unstableMarkNextChangeAsCombinedOperation. Update the // deferred payload and return early. It will be flushed - // by the deferredBlockSyncFlushSignal after browser - // microtasks are flushed. + // by the scheduled timeout after browser microtasks are + // flushed. // // This is used with template insertion in RTC to ensure // operations like outer block and inner template insertions diff --git a/test/e2e/specs/editor/collaboration/websocket-only/collaboration-list-template.spec.ts b/test/e2e/specs/editor/collaboration/websocket-only/collaboration-list-template.spec.ts new file mode 100644 index 00000000000000..daf9d4d5ff8a59 --- /dev/null +++ b/test/e2e/specs/editor/collaboration/websocket-only/collaboration-list-template.spec.ts @@ -0,0 +1,92 @@ +/** + * WordPress dependencies + */ +import type { Page } from '@playwright/test'; +import type { Editor } from '@wordpress/e2e-test-utils-playwright'; + +/** + * Internal dependencies + */ +import { test, expect } from '../fixtures'; +import type { UserCredentials } from '../fixtures/collaboration-utils'; + +const MIN_REQUIRED_WS_DELAY_MS = 30; +const CONFIGURED_WS_DELAY_MS = + Number.parseInt( process.env.RTC_WS_DELAY || '0', 10 ) || 0; + +type EditorBlock = { + name: string; + innerBlocks?: EditorBlock[]; +}; + +const THIRD_USER: UserCredentials = { + username: 'list_template_collaborator', + email: 'list_template_collaborator@example.com', + firstName: 'List', + lastName: 'Collaborator', + password: 'password', + roles: [ 'editor' ], +}; + +async function getListItemCounts( editor: Editor ) { + const blocks = ( await editor.getBlocks() ) as EditorBlock[]; + + return blocks + .filter( ( block ) => block.name === 'core/list' ) + .map( + ( block ) => + ( block.innerBlocks ?? [] ).filter( + ( innerBlock ) => innerBlock.name === 'core/list-item' + ).length + ); +} + +async function insertListWithSlashInserter( editor: Editor, page: Page ) { + await editor.canvas + .locator( 'role=button[name="Add default block"i]' ) + .click(); + await page.keyboard.type( '/list' ); + await expect( + page.locator( 'role=option[name="List"i][selected]' ) + ).toBeVisible(); + await page.keyboard.press( 'Enter' ); +} + +test.describe( 'Collaboration - WebSocket List Templates', () => { + test.skip( + CONFIGURED_WS_DELAY_MS < MIN_REQUIRED_WS_DELAY_MS, + `Run with RTC_WS_DELAY=${ MIN_REQUIRED_WS_DELAY_MS } or higher to enable the slow WebSocket reproduction.` + ); + + test( 'inserts the list item template once across peers', async ( { + collaborationUtils, + requestUtils, + editor, + page, + } ) => { + const post = await requestUtils.createPost( { + title: 'WebSocket List Template Repro', + status: 'draft', + date_gmt: new Date().toISOString(), + } ); + + await collaborationUtils.openCollaborativeSession( post.id ); + await requestUtils.createUser( THIRD_USER ); + await collaborationUtils.joinUser( post.id, THIRD_USER ); + await collaborationUtils.waitForMutualDiscovery(); + const { editor2 } = collaborationUtils; + const editor3 = collaborationUtils.getEditor( 1 ); + + await insertListWithSlashInserter( editor, page ); + + await expect + .poll( async () => ( await getListItemCounts( editor ) ).length ) + .toBe( 1 ); + + await collaborationUtils.waitForConvergence( { timeout: 5000 } ); + + expect( await getListItemCounts( editor ) ).toEqual( [ 1 ] ); + expect( await getListItemCounts( editor2 ) ).toEqual( [ 1 ] ); + expect( await getListItemCounts( editor3 ) ).toEqual( [ 1 ] ); + } ); +} ); From 474d08c26d8b9ae2e00dfaf99100194605cb24bb Mon Sep 17 00:00:00 2001 From: Alec Geatches Date: Tue, 9 Jun 2026 10:08:07 -0600 Subject: [PATCH 04/10] Remove inner block microtask and setTimeout workaround --- .../use-inner-block-template-sync.js | 97 +++++++++---------- .../use-nested-settings-update.js | 19 ++-- .../src/components/provider/use-block-sync.js | 35 +++---- 3 files changed, 71 insertions(+), 80 deletions(-) diff --git a/packages/block-editor/src/components/inner-blocks/use-inner-block-template-sync.js b/packages/block-editor/src/components/inner-blocks/use-inner-block-template-sync.js index c65c0d410c26a4..86735c0a4dff22 100644 --- a/packages/block-editor/src/components/inner-blocks/use-inner-block-template-sync.js +++ b/packages/block-editor/src/components/inner-blocks/use-inner-block-template-sync.js @@ -14,6 +14,7 @@ import { synchronizeBlocksWithTemplate } from '@wordpress/blocks'; * Internal dependencies */ import { store as blockEditorStore } from '../../store'; +import { flushPendingNestedSettingsUpdates } from './use-nested-settings-update'; /** * This hook makes sure that a block's inner blocks stay in sync with the given @@ -48,8 +49,6 @@ export default function useInnerBlockTemplateSync( const existingTemplateRef = useRef( null ); useLayoutEffect( () => { - let isCancelled = false; - const { getBlocks, getSelectedBlocksInitialCaretPosition, @@ -61,62 +60,54 @@ export default function useInnerBlockTemplateSync( replaceInnerBlocks, } = registry.dispatch( blockEditorStore ); - // There's an implicit dependency between useInnerBlockTemplateSync and useNestedSettingsUpdate - // The former needs to happen after the latter and since the latter is using microtasks to batch updates (performance optimization), - // we need to schedule this one in a microtask as well. - // Example: If you remove queueMicrotask here, ctrl + click to insert quote block won't close the inserter. - window.queueMicrotask( () => { - if ( isCancelled ) { - return; - } + // Only synchronize innerBlocks with template if innerBlocks are empty + // or a locking "all" or "contentOnly" exists directly on the block. + const currentInnerBlocks = getBlocks( clientId ); + const shouldApplyTemplate = + currentInnerBlocks.length === 0 || + templateLock === 'all' || + templateLock === 'contentOnly'; - // Only synchronize innerBlocks with template if innerBlocks are empty - // or a locking "all" or "contentOnly" exists directly on the block. - const currentInnerBlocks = getBlocks( clientId ); - const shouldApplyTemplate = - currentInnerBlocks.length === 0 || - templateLock === 'all' || - templateLock === 'contentOnly'; + const hasTemplateChanged = ! fastDeepEqual( + template, + existingTemplateRef.current + ); - const hasTemplateChanged = ! fastDeepEqual( - template, - existingTemplateRef.current - ); + if ( ! shouldApplyTemplate || ! hasTemplateChanged ) { + return; + } - if ( ! shouldApplyTemplate || ! hasTemplateChanged ) { - return; - } + existingTemplateRef.current = template; + const nextBlocks = synchronizeBlocksWithTemplate( + currentInnerBlocks, + template + ); - existingTemplateRef.current = template; - const nextBlocks = synchronizeBlocksWithTemplate( - currentInnerBlocks, - template + if ( ! fastDeepEqual( nextBlocks, currentInnerBlocks ) ) { + // This hook depends on useNestedSettingsUpdate having applied + // the block-list settings for this InnerBlocks area. Flush pending + // settings before the template replacement to preserve that ordering. + // Example: If you remove flush here, ctrl + click to insert quote + // block won't close the inserter. + flushPendingNestedSettingsUpdates( registry ); + __unstableMarkNextChangeAsNotPersistent( { + history: 'ignore', + } ); + __unstableMarkNextChangeAsCombinedOperation(); + replaceInnerBlocks( + clientId, + nextBlocks, + currentInnerBlocks.length === 0 && + templateInsertUpdatesSelection && + nextBlocks.length !== 0 && + isBlockSelected( clientId ), + // This ensures the "initialPosition" doesn't change when applying the template + // If we're supposed to focus the block, we'll focus the first inner block + // otherwise, we won't apply any auto-focus. + // This ensures for instance that the focus stays in the inserter when inserting the "buttons" block. + getSelectedBlocksInitialCaretPosition() ); - - if ( ! fastDeepEqual( nextBlocks, currentInnerBlocks ) ) { - __unstableMarkNextChangeAsNotPersistent( { - history: 'ignore', - } ); - __unstableMarkNextChangeAsCombinedOperation(); - replaceInnerBlocks( - clientId, - nextBlocks, - currentInnerBlocks.length === 0 && - templateInsertUpdatesSelection && - nextBlocks.length !== 0 && - isBlockSelected( clientId ), - // This ensures the "initialPosition" doesn't change when applying the template - // If we're supposed to focus the block, we'll focus the first inner block - // otherwise, we won't apply any auto-focus. - // This ensures for instance that the focus stays in the inserter when inserting the "buttons" block. - getSelectedBlocksInitialCaretPosition() - ); - } - } ); - - return () => { - isCancelled = true; - }; + } }, [ template, templateLock, diff --git a/packages/block-editor/src/components/inner-blocks/use-nested-settings-update.js b/packages/block-editor/src/components/inner-blocks/use-nested-settings-update.js index 942c78977fca69..c1da548bdc5efb 100644 --- a/packages/block-editor/src/components/inner-blocks/use-nested-settings-update.js +++ b/packages/block-editor/src/components/inner-blocks/use-nested-settings-update.js @@ -16,6 +16,17 @@ import { getLayoutType } from '../../layouts'; const pendingSettingsUpdates = new WeakMap(); +export function flushPendingNestedSettingsUpdates( registry ) { + const settings = pendingSettingsUpdates.get( registry ); + if ( ! settings || ! Object.keys( settings ).length ) { + return; + } + + const { updateBlockListSettings } = registry.dispatch( blockEditorStore ); + updateBlockListSettings( settings ); + pendingSettingsUpdates.set( registry, {} ); +} + // Creates a memoizing caching function that remembers the last value and keeps returning it // as long as the new values are shallowly equal. Helps keep dependencies stable. function createShallowMemo() { @@ -164,13 +175,7 @@ export default function useNestedSettingsUpdate( } pendingSettingsUpdates.get( registry )[ clientId ] = newSettings; window.queueMicrotask( () => { - const settings = pendingSettingsUpdates.get( registry ); - if ( Object.keys( settings ).length ) { - const { updateBlockListSettings } = - registry.dispatch( blockEditorStore ); - updateBlockListSettings( settings ); - pendingSettingsUpdates.set( registry, {} ); - } + flushPendingNestedSettingsUpdates( registry ); } ); }, [ clientId, diff --git a/packages/block-editor/src/components/provider/use-block-sync.js b/packages/block-editor/src/components/provider/use-block-sync.js index b06c66a2085ce3..4d0e85e1acc956 100644 --- a/packages/block-editor/src/components/provider/use-block-sync.js +++ b/packages/block-editor/src/components/provider/use-block-sync.js @@ -1,7 +1,7 @@ /** * WordPress dependencies */ -import { useContext, useEffect, useRef } from '@wordpress/element'; +import { useContext, useEffect, useRef, useState } from '@wordpress/element'; import { useRegistry } from '@wordpress/data'; import { cloneBlock } from '@wordpress/blocks'; @@ -164,10 +164,11 @@ export default function useBlockSync( { const pendingChangesRef = useRef( { incoming: null, outgoing: [] } ); const subscribedRef = useRef( false ); - // Used to track deferred block syncs. The flush timeout gives template - // insertion microtasks a chance to update the deferred payload first. + // Used to track deferred block syncs. The deferred payload is flushed from + // an effect so template insertion layout effects can update it first. const deferredBlockSyncRef = useRef( null ); - const deferredBlockSyncFlushTimeoutRef = useRef( null ); + const [ deferredBlockSyncFlushSignal, setDeferredBlockSyncFlushSignal ] = + useState( 0 ); // Mapping between external (original) and internal (cloned) client IDs. // This allows stable external IDs while using unique internal IDs. @@ -304,20 +305,12 @@ export default function useBlockSync( { onChangeRef.current = onChange; }, [ onInput, onChange ] ); - const clearDeferredBlockSyncFlushTimeout = () => { - if ( deferredBlockSyncFlushTimeoutRef.current !== null ) { - clearTimeout( deferredBlockSyncFlushTimeoutRef.current ); - deferredBlockSyncFlushTimeoutRef.current = null; - } - }; - const flushDeferredBlockSync = () => { if ( deferredBlockSyncRef.current ) { const deferredBlockSync = deferredBlockSyncRef.current; // Deferred block syncs are always persistent block changes, so use // onChangeRef to persist. - clearDeferredBlockSyncFlushTimeout(); deferredBlockSyncRef.current = null; pendingChangesRef.current.outgoing.push( deferredBlockSync.blocks ); onChangeRef.current( @@ -332,12 +325,15 @@ export default function useBlockSync( { blocks: blocksToSync, options, }; - clearDeferredBlockSyncFlushTimeout(); - deferredBlockSyncFlushTimeoutRef.current = setTimeout( () => { - flushDeferredBlockSync(); - }, 0 ); + setDeferredBlockSyncFlushSignal( ( signal ) => signal + 1 ); }; + useEffect( () => { + if ( deferredBlockSyncFlushSignal ) { + flushDeferredBlockSync(); + } + }, [ deferredBlockSyncFlushSignal ] ); + // Determine if blocks need to be reset when they change. // Also restores selection from context after blocks are set. useEffect( () => { @@ -495,8 +491,7 @@ export default function useBlockSync( { // This change was marked as a combined operation with // __unstableMarkNextChangeAsCombinedOperation. Update the // deferred payload and return early. It will be flushed - // by the scheduled timeout after browser microtasks are - // flushed. + // by a React effect after layout effects have run. // // This is used with template insertion in RTC to ensure // operations like outer block and inner template insertions @@ -522,8 +517,8 @@ export default function useBlockSync( { if ( shouldDeferBlockSync ) { // Insert-like persistent changes can trigger a - // template continuation in a browser microtask. - // Delay this payload briefly so that continuation + // template continuation during the same React commit. + // Defer this payload so that continuation // can be merged before syncing to the parent. deferBlockSync( blocksForParent, updateOptions ); } else { From fcebcae355e0d26bbb1ae9ad893af0467b6abec5 Mon Sep 17 00:00:00 2001 From: Alec Geatches Date: Tue, 9 Jun 2026 11:07:26 -0600 Subject: [PATCH 05/10] Remove __unstableMarkNextChangeAsCombinedOperation, withDeferredBlockSync reducer, as we can rely on insertion signals alone --- .../use-inner-block-template-sync.js | 8 +- .../provider/test/use-block-sync.js | 5 +- .../src/components/provider/use-block-sync.js | 18 ++-- packages/block-editor/src/store/actions.js | 13 --- packages/block-editor/src/store/reducer.js | 94 +++---------------- packages/block-editor/src/store/selectors.js | 12 --- .../block-editor/src/store/test/actions.js | 9 -- .../block-editor/src/store/test/reducer.js | 88 ----------------- 8 files changed, 25 insertions(+), 222 deletions(-) diff --git a/packages/block-editor/src/components/inner-blocks/use-inner-block-template-sync.js b/packages/block-editor/src/components/inner-blocks/use-inner-block-template-sync.js index 86735c0a4dff22..0ba774db90baba 100644 --- a/packages/block-editor/src/components/inner-blocks/use-inner-block-template-sync.js +++ b/packages/block-editor/src/components/inner-blocks/use-inner-block-template-sync.js @@ -54,11 +54,8 @@ export default function useInnerBlockTemplateSync( getSelectedBlocksInitialCaretPosition, isBlockSelected, } = registry.select( blockEditorStore ); - const { - __unstableMarkNextChangeAsNotPersistent, - __unstableMarkNextChangeAsCombinedOperation, - replaceInnerBlocks, - } = registry.dispatch( blockEditorStore ); + const { __unstableMarkNextChangeAsNotPersistent, replaceInnerBlocks } = + registry.dispatch( blockEditorStore ); // Only synchronize innerBlocks with template if innerBlocks are empty // or a locking "all" or "contentOnly" exists directly on the block. @@ -93,7 +90,6 @@ export default function useInnerBlockTemplateSync( __unstableMarkNextChangeAsNotPersistent( { history: 'ignore', } ); - __unstableMarkNextChangeAsCombinedOperation(); replaceInnerBlocks( clientId, nextBlocks, diff --git a/packages/block-editor/src/components/provider/test/use-block-sync.js b/packages/block-editor/src/components/provider/test/use-block-sync.js index 2ed48bd8423986..9f1a2cf8114bda 100644 --- a/packages/block-editor/src/components/provider/test/use-block-sync.js +++ b/packages/block-editor/src/components/provider/test/use-block-sync.js @@ -351,7 +351,7 @@ describe( 'useBlockSync hook', () => { expect( onInput ).not.toHaveBeenCalled(); } ); - it( 'merges combined-operation block changes into a deferred insertion', async () => { + it( 'merges history-ignored block changes into a deferred insertion', async () => { const onChange = jest.fn(); const onInput = jest.fn(); let registry; @@ -379,9 +379,6 @@ describe( 'useBlockSync hook', () => { .__unstableMarkNextChangeAsNotPersistent( { history: 'ignore', } ); - registry - .dispatch( blockEditorStore ) - .__unstableMarkNextChangeAsCombinedOperation(); registry .dispatch( blockEditorStore ) .replaceInnerBlocks( parentBlock.clientId, [ templateBlock ] ); diff --git a/packages/block-editor/src/components/provider/use-block-sync.js b/packages/block-editor/src/components/provider/use-block-sync.js index 4d0e85e1acc956..9c667ae1022381 100644 --- a/packages/block-editor/src/components/provider/use-block-sync.js +++ b/packages/block-editor/src/components/provider/use-block-sync.js @@ -379,7 +379,6 @@ export default function useBlockSync( { isLastBlockChangePersistent, __unstableGetLastBlockChangeHistoryMode, __unstableDidLastBlockChangeInsertBlocks, - __unstableIsLastBlockChangeCombinedOperation, __unstableIsLastBlockChangeIgnored, areInnerBlocksControlled, getBlockParents, @@ -450,8 +449,6 @@ export default function useBlockSync( { // receives both changes atomically. registry.batch( () => { if ( blocksChanged ) { - const isCombinedOperation = - __unstableIsLastBlockChangeCombinedOperation(); const didInsertBlocks = __unstableDidLastBlockChangeInsertBlocks(); @@ -487,11 +484,16 @@ export default function useBlockSync( { } if ( deferredBlockSyncRef.current ) { - if ( isCombinedOperation ) { - // This change was marked as a combined operation with - // __unstableMarkNextChangeAsCombinedOperation. Update the - // deferred payload and return early. It will be flushed - // by a React effect after layout effects have run. + if ( + ! isPersistent && + blockHistoryMode === 'ignore' + ) { + // Non-persistent, history-ignored block changes + // can follow a deferred insert before it flushes, + // such as inner template insertion. Update the + // deferred payload and return early. It will be + // flushed by a React effect after layout effects + // have run. // // This is used with template insertion in RTC to ensure // operations like outer block and inner template insertions diff --git a/packages/block-editor/src/store/actions.js b/packages/block-editor/src/store/actions.js index 35ae4fdc60bbe4..1b6d1a191bc0b5 100644 --- a/packages/block-editor/src/store/actions.js +++ b/packages/block-editor/src/store/actions.js @@ -1662,19 +1662,6 @@ export function __unstableMarkNextChangeAsNotPersistent( { return { type: 'MARK_NEXT_CHANGE_AS_NOT_PERSISTENT', history }; } -/** - * Action that signals that the next block change should be treated as part of - * the same combined operation as a pending outbound block change. - * - * Used in RTC to combine outer block and inter template insertions into a single - * logical operation to other peers. - * - * @return {Object} Action object. - */ -export function __unstableMarkNextChangeAsCombinedOperation() { - return { type: 'MARK_NEXT_CHANGE_AS_COMBINED_OPERATION' }; -} - /** * Action that marks the last block change as an automatic change, meaning it was not * performed by the user, and can be undone using the `Escape` and `Backspace` keys. diff --git a/packages/block-editor/src/store/reducer.js b/packages/block-editor/src/store/reducer.js index 01912c2db28ff7..1256c122ac7402 100644 --- a/packages/block-editor/src/store/reducer.js +++ b/packages/block-editor/src/store/reducer.js @@ -426,26 +426,21 @@ function withPersistentBlockChange( reducer ) { const isNotPersistentMarker = action.type === 'MARK_NEXT_CHANGE_AS_NOT_PERSISTENT'; - const isCombinedOperationMarker = - action.type === 'MARK_NEXT_CHANGE_AS_COMBINED_OPERATION'; - const hasMarker = isNotPersistentMarker || isCombinedOperationMarker; const pendingHistoryMode = nextHistoryMode; + const didInsertBlocks = + ( action.type === 'INSERT_BLOCKS' || + action.type === 'REPLACE_BLOCKS' || + action.type === 'REPLACE_BLOCKS_AUGMENTED_WITH_CHILDREN' ) && + !! action.blocks?.length; - // Ensure MARK_NEXT_CHANGE_AS_COMBINED_OPERATION doesn't consume an existing - // MARK_NEXT_CHANGE_AS_NOT_PERSISTENT marker. nextHistoryMode = undefined; if ( isNotPersistentMarker ) { nextHistoryMode = action.history ?? 'merge'; - } else if ( isCombinedOperationMarker ) { - // For combined operations (e.g. inner template sync), keep the - // pending history mode for the next operation. The final combined - // operation should keep the intended history flag. - nextHistoryMode = pendingHistoryMode; } const isExplicitPersistentChange = action.type === 'MARK_LAST_CHANGE_AS_PERSISTENT' || - ( ! hasMarker && pendingHistoryMode ); + ( ! isNotPersistentMarker && pendingHistoryMode ); // Defer to previous state value (or default) unless changing or // explicitly marking as persistent. @@ -465,86 +460,22 @@ function withPersistentBlockChange( reducer ) { // have resulted in a changed state. lastAction = action; - if ( pendingHistoryMode === 'ignore' ) { - return { - ...nextState, - isPersistentChange, - lastBlockChangeHistoryMode: 'ignore', - }; - } - - const { lastBlockChangeHistoryMode, ...blockChange } = nextState; - return { ...blockChange, isPersistentChange }; - }; -} - -/** - * Higher-order reducer intended to augment the blocks reducer with transient - * metadata used by deferred block sync. - * - * @param {Function} reducer Original reducer function. - * - * @return {Function} Enhanced reducer function. - */ -function withDeferredBlockSync( reducer ) { - let nextIsCombinedOperation; - - return ( state, action ) => { - const nextState = reducer( state, action ); - - const isNotPersistentMarker = - action.type === 'MARK_NEXT_CHANGE_AS_NOT_PERSISTENT'; - const isCombinedOperationMarker = - action.type === 'MARK_NEXT_CHANGE_AS_COMBINED_OPERATION'; - const pendingIsCombinedOperation = nextIsCombinedOperation; - - // Ensure MARK_NEXT_CHANGE_AS_NOT_PERSISTENT does not consume an - // existing MARK_NEXT_CHANGE_AS_COMBINED_OPERATION marker. - nextIsCombinedOperation = false; - if ( isCombinedOperationMarker ) { - nextIsCombinedOperation = true; - } else if ( isNotPersistentMarker ) { - nextIsCombinedOperation = pendingIsCombinedOperation; - } - - if ( state === nextState ) { - return state; - } - - const didInsertBlocks = - ( action.type === 'INSERT_BLOCKS' || - action.type === 'REPLACE_BLOCKS' || - action.type === 'REPLACE_BLOCKS_AUGMENTED_WITH_CHILDREN' ) && - !! action.blocks?.length; - - const hasBlockSyncMetadata = - nextState.isLastBlockChangeCombinedOperation !== undefined || - nextState.didLastBlockChangeInsertBlocks !== undefined; - if ( - ! pendingIsCombinedOperation && - ! didInsertBlocks && - ! hasBlockSyncMetadata - ) { - return nextState; - } - - // Clear transient metadata from the previous block change. The current - // change adds back only the markers that apply to it below. const { - isLastBlockChangeCombinedOperation, + lastBlockChangeHistoryMode, didLastBlockChangeInsertBlocks, ...blockChange } = nextState; + const result = { ...blockChange, isPersistentChange }; - if ( pendingIsCombinedOperation ) { - blockChange.isLastBlockChangeCombinedOperation = true; + if ( pendingHistoryMode === 'ignore' ) { + result.lastBlockChangeHistoryMode = 'ignore'; } if ( didInsertBlocks ) { - blockChange.didLastBlockChangeInsertBlocks = true; + result.didLastBlockChangeInsertBlocks = true; } - return blockChange; + return result; }; } @@ -863,7 +794,6 @@ export const blocks = pipe( withReplaceInnerBlocks, // Needs to be after withInnerBlocksRemoveCascade. withBlockReset, withPersistentBlockChange, - withDeferredBlockSync, withIgnoredBlockChange, withResetControlledBlocks )( { diff --git a/packages/block-editor/src/store/selectors.js b/packages/block-editor/src/store/selectors.js index 38f3a0a17fa689..7064383e531135 100644 --- a/packages/block-editor/src/store/selectors.js +++ b/packages/block-editor/src/store/selectors.js @@ -2949,18 +2949,6 @@ export function __unstableGetLastBlockChangeHistoryMode( state ) { return state.blocks.isPersistentChange === false ? 'merge' : 'persistent'; } -/** - * Returns true if the most recent block change should be treated as part of the - * same combined operation as a pending outbound block change. - * - * @param {Object} state Block editor state. - * - * @return {boolean} Whether the most recent block change is part of the combined operation. - */ -export function __unstableIsLastBlockChangeCombinedOperation( state ) { - return !! state.blocks.isLastBlockChangeCombinedOperation; -} - /** * Returns true if the most recent block change inserted blocks. * diff --git a/packages/block-editor/src/store/test/actions.js b/packages/block-editor/src/store/test/actions.js index 5b8f28c90cacc3..8c1cd44087cfb3 100644 --- a/packages/block-editor/src/store/test/actions.js +++ b/packages/block-editor/src/store/test/actions.js @@ -54,7 +54,6 @@ const { updateSettings, validateBlocksToTemplate, __unstableMarkNextChangeAsNotPersistent, - __unstableMarkNextChangeAsCombinedOperation, registerInserterMediaCategory, setBlockEditingMode, unsetBlockEditingMode, @@ -836,14 +835,6 @@ describe( 'actions', () => { } ); } ); - describe( '__unstableMarkNextChangeAsCombinedOperation', () => { - it( 'should mark the next change as a combined operation', () => { - expect( __unstableMarkNextChangeAsCombinedOperation() ).toEqual( { - type: 'MARK_NEXT_CHANGE_AS_COMBINED_OPERATION', - } ); - } ); - } ); - describe( 'mergeBlocks', () => { afterEach( () => { getBlockTypes().forEach( ( block ) => { diff --git a/packages/block-editor/src/store/test/reducer.js b/packages/block-editor/src/store/test/reducer.js index e1724fa59aed35..e3806142e723fc 100644 --- a/packages/block-editor/src/store/test/reducer.js +++ b/packages/block-editor/src/store/test/reducer.js @@ -2282,94 +2282,6 @@ describe( 'state', () => { ).toBeUndefined(); } ); - it( 'should flag only the next change as part of a combined operation', () => { - 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', - } ); - original = blocks( original, { - type: 'MARK_NEXT_CHANGE_AS_COMBINED_OPERATION', - } ); - - const nextState = blocks( original, { - type: 'UPDATE_BLOCK_ATTRIBUTES', - clientIds: [ 'kumquat' ], - attributes: { - updated: true, - }, - } ); - - expect( nextState.isPersistentChange ).toBe( false ); - expect( nextState.lastBlockChangeHistoryMode ).toBe( - 'ignore' - ); - expect( nextState.isLastBlockChangeCombinedOperation ).toBe( - true - ); - - const subsequentState = blocks( nextState, { - type: 'UPDATE_BLOCK_ATTRIBUTES', - clientIds: [ 'kumquat' ], - attributes: { - other: true, - }, - } ); - - expect( - subsequentState.isLastBlockChangeCombinedOperation - ).toBeUndefined(); - } ); - - it( 'should preserve combined operation markers when marking the next change as not persistent', () => { - let original = deepFreeze( - blocks( undefined, { - type: 'RESET_BLOCKS', - blocks: [ - { - clientId: 'kumquat', - attributes: {}, - innerBlocks: [], - }, - ], - } ) - ); - original = blocks( original, { - type: 'MARK_NEXT_CHANGE_AS_COMBINED_OPERATION', - } ); - 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' - ); - expect( nextState.isLastBlockChangeCombinedOperation ).toBe( - true - ); - } ); - it( 'should flag INSERT_BLOCKS-style changes that add blocks', () => { let original = deepFreeze( blocks( undefined, { From bed172564bbba6f6fa845a7a514ccc482bbfeaa0 Mon Sep 17 00:00:00 2001 From: Alec Geatches Date: Tue, 9 Jun 2026 11:07:52 -0600 Subject: [PATCH 06/10] Add specific test for ctrl+clicking quote block in insertion --- test/e2e/specs/editor/blocks/quote.spec.js | 30 ++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/test/e2e/specs/editor/blocks/quote.spec.js b/test/e2e/specs/editor/blocks/quote.spec.js index 156572f479bd8e..073fbd276358b2 100644 --- a/test/e2e/specs/editor/blocks/quote.spec.js +++ b/test/e2e/specs/editor/blocks/quote.spec.js @@ -93,6 +93,36 @@ test.describe( 'Quote', () => { ); } ); + test( 'can be created by modifier-clicking the slash inserter option', async ( { + editor, + page, + } ) => { + const platformModifier = + process.platform === 'darwin' ? 'Meta' : 'Control'; + + await editor.canvas + .locator( 'role=button[name="Add default block"i]' ) + .click(); + await page.keyboard.type( '/quote' ); + + const quoteOption = page.getByRole( 'option', { + name: 'Quote', + exact: true, + } ); + await expect( quoteOption ).toBeVisible(); + await quoteOption.click( { modifiers: [ platformModifier ] } ); + await expect( quoteOption ).toBeHidden(); + + await page.keyboard.type( 'I’m a clicked quote' ); + expect( await editor.getEditedPostContent() ).toBe( + ` +
+

I’m a clicked quote

+
+` + ); + } ); + test( 'can be created by converting a paragraph', async ( { editor, page, From e0c1a97acb5dabf5c5f5902ef89cb724f37bc346 Mon Sep 17 00:00:00 2001 From: Alec Geatches Date: Tue, 9 Jun 2026 12:31:31 -0600 Subject: [PATCH 07/10] Scope defers more narrowly to replace block operations --- .../src/components/provider/test/use-block-sync.js | 9 ++++++--- .../src/components/provider/use-block-sync.js | 12 ++++++------ packages/block-editor/src/store/reducer.js | 11 +++++------ packages/block-editor/src/store/selectors.js | 8 ++++---- packages/block-editor/src/store/test/reducer.js | 12 ++++++------ 5 files changed, 27 insertions(+), 25 deletions(-) diff --git a/packages/block-editor/src/components/provider/test/use-block-sync.js b/packages/block-editor/src/components/provider/test/use-block-sync.js index 9f1a2cf8114bda..feefd74d8edc4c 100644 --- a/packages/block-editor/src/components/provider/test/use-block-sync.js +++ b/packages/block-editor/src/components/provider/test/use-block-sync.js @@ -351,17 +351,18 @@ describe( 'useBlockSync hook', () => { expect( onInput ).not.toHaveBeenCalled(); } ); - it( 'merges history-ignored block changes into a deferred insertion', async () => { + it( 'merges history-ignored block changes into a deferred replacement', async () => { const onChange = jest.fn(); const onInput = jest.fn(); let registry; const setRegistry = ( reg ) => { registry = reg; }; + const initialBlock = createBlock( 'test/test-block' ); render( @@ -372,7 +373,9 @@ describe( 'useBlockSync hook', () => { const parentBlock = createBlock( 'test/test-block' ); const templateBlock = createBlock( 'test/test-block', { foo: 2 } ); - registry.dispatch( blockEditorStore ).insertBlock( parentBlock ); + registry + .dispatch( blockEditorStore ) + .replaceBlock( initialBlock.clientId, parentBlock ); expect( onChange ).not.toHaveBeenCalled(); registry .dispatch( blockEditorStore ) diff --git a/packages/block-editor/src/components/provider/use-block-sync.js b/packages/block-editor/src/components/provider/use-block-sync.js index 9c667ae1022381..a62c90e3a462c3 100644 --- a/packages/block-editor/src/components/provider/use-block-sync.js +++ b/packages/block-editor/src/components/provider/use-block-sync.js @@ -378,7 +378,7 @@ export default function useBlockSync( { getSelectedBlocksInitialCaretPosition, isLastBlockChangePersistent, __unstableGetLastBlockChangeHistoryMode, - __unstableDidLastBlockChangeInsertBlocks, + __unstableDidLastBlockChangeReplaceBlocks, __unstableIsLastBlockChangeIgnored, areInnerBlocksControlled, getBlockParents, @@ -449,8 +449,8 @@ export default function useBlockSync( { // receives both changes atomically. registry.batch( () => { if ( blocksChanged ) { - const didInsertBlocks = - __unstableDidLastBlockChangeInsertBlocks(); + const didReplaceBlocks = + __unstableDidLastBlockChangeReplaceBlocks(); isPersistent = newIsPersistent; blockHistoryMode = newBlockHistoryMode; @@ -489,7 +489,7 @@ export default function useBlockSync( { blockHistoryMode === 'ignore' ) { // Non-persistent, history-ignored block changes - // can follow a deferred insert before it flushes, + // can follow a deferred replacement before it flushes, // such as inner template insertion. Update the // deferred payload and return early. It will be // flushed by a React effect after layout effects @@ -515,10 +515,10 @@ export default function useBlockSync( { } const shouldDeferBlockSync = - isPersistent && didInsertBlocks; + isPersistent && didReplaceBlocks; if ( shouldDeferBlockSync ) { - // Insert-like persistent changes can trigger a + // Replace-block persistent changes can trigger a // template continuation during the same React commit. // Defer this payload so that continuation // can be merged before syncing to the parent. diff --git a/packages/block-editor/src/store/reducer.js b/packages/block-editor/src/store/reducer.js index 1256c122ac7402..107b24c9ff5109 100644 --- a/packages/block-editor/src/store/reducer.js +++ b/packages/block-editor/src/store/reducer.js @@ -427,9 +427,8 @@ function withPersistentBlockChange( reducer ) { const isNotPersistentMarker = action.type === 'MARK_NEXT_CHANGE_AS_NOT_PERSISTENT'; const pendingHistoryMode = nextHistoryMode; - const didInsertBlocks = - ( action.type === 'INSERT_BLOCKS' || - action.type === 'REPLACE_BLOCKS' || + const didReplaceBlocks = + ( action.type === 'REPLACE_BLOCKS' || action.type === 'REPLACE_BLOCKS_AUGMENTED_WITH_CHILDREN' ) && !! action.blocks?.length; @@ -462,7 +461,7 @@ function withPersistentBlockChange( reducer ) { const { lastBlockChangeHistoryMode, - didLastBlockChangeInsertBlocks, + didLastBlockChangeReplaceBlocks, ...blockChange } = nextState; const result = { ...blockChange, isPersistentChange }; @@ -471,8 +470,8 @@ function withPersistentBlockChange( reducer ) { result.lastBlockChangeHistoryMode = 'ignore'; } - if ( didInsertBlocks ) { - result.didLastBlockChangeInsertBlocks = true; + if ( didReplaceBlocks ) { + result.didLastBlockChangeReplaceBlocks = true; } return result; diff --git a/packages/block-editor/src/store/selectors.js b/packages/block-editor/src/store/selectors.js index 7064383e531135..ed0d0a5c17fbf6 100644 --- a/packages/block-editor/src/store/selectors.js +++ b/packages/block-editor/src/store/selectors.js @@ -2950,14 +2950,14 @@ export function __unstableGetLastBlockChangeHistoryMode( state ) { } /** - * Returns true if the most recent block change inserted blocks. + * Returns true if the most recent block change replaced blocks. * * @param {Object} state Block editor state. * - * @return {boolean} Whether the most recent block change inserted blocks. + * @return {boolean} Whether the most recent block change replaced blocks. */ -export function __unstableDidLastBlockChangeInsertBlocks( state ) { - return !! state.blocks.didLastBlockChangeInsertBlocks; +export function __unstableDidLastBlockChangeReplaceBlocks( state ) { + return !! state.blocks.didLastBlockChangeReplaceBlocks; } /** diff --git a/packages/block-editor/src/store/test/reducer.js b/packages/block-editor/src/store/test/reducer.js index e3806142e723fc..6d02c7fbd3c4d6 100644 --- a/packages/block-editor/src/store/test/reducer.js +++ b/packages/block-editor/src/store/test/reducer.js @@ -2282,7 +2282,7 @@ describe( 'state', () => { ).toBeUndefined(); } ); - it( 'should flag INSERT_BLOCKS-style changes that add blocks', () => { + it( 'should flag REPLACE_BLOCKS-style changes that add blocks', () => { let original = deepFreeze( blocks( undefined, { type: 'RESET_BLOCKS', @@ -2307,8 +2307,8 @@ describe( 'state', () => { ], } ); - expect( insertState.didLastBlockChangeInsertBlocks ).toBe( - true + expect( insertState.didLastBlockChangeReplaceBlocks ).toBe( + undefined ); original = deepFreeze( insertState ); @@ -2325,7 +2325,7 @@ describe( 'state', () => { ], } ); - expect( replaceState.didLastBlockChangeInsertBlocks ).toBe( + expect( replaceState.didLastBlockChangeReplaceBlocks ).toBe( true ); @@ -2338,7 +2338,7 @@ describe( 'state', () => { } ); expect( - subsequentState.didLastBlockChangeInsertBlocks + subsequentState.didLastBlockChangeReplaceBlocks ).toBeUndefined(); } ); @@ -2363,7 +2363,7 @@ describe( 'state', () => { } ); expect( - state.didLastBlockChangeInsertBlocks + state.didLastBlockChangeReplaceBlocks ).toBeUndefined(); } ); From c6d29c626604a1e4d0c7644f7127bfc010c685c8 Mon Sep 17 00:00:00 2001 From: Alec Geatches Date: Tue, 9 Jun 2026 12:32:04 -0600 Subject: [PATCH 08/10] Fix block selection after legacy attribute migration for quotes and lists --- packages/block-library/src/list/edit.js | 11 ++++++++++- packages/block-library/src/quote/edit.js | 11 ++++++++++- test/e2e/specs/editor/blocks/list.spec.js | 15 +++++++++++++++ 3 files changed, 35 insertions(+), 2 deletions(-) diff --git a/packages/block-library/src/list/edit.js b/packages/block-library/src/list/edit.js index 73551cd9cdee80..93ad7bc60cc01d 100644 --- a/packages/block-library/src/list/edit.js +++ b/packages/block-library/src/list/edit.js @@ -45,7 +45,7 @@ const NATIVE_MARGIN_SPACING = 8; */ function useMigrateOnLoad( attributes, clientId ) { const registry = useRegistry(); - const { updateBlockAttributes, replaceInnerBlocks } = + const { updateBlockAttributes, replaceInnerBlocks, selectBlock } = useDispatch( blockEditorStore ); useEffect( () => { @@ -63,9 +63,18 @@ function useMigrateOnLoad( attributes, clientId ) { alternative: 'inner blocks', } ); + const { hasSelectedInnerBlock, isBlockSelected } = + registry.select( blockEditorStore ); + const shouldReselectBlock = + isBlockSelected( clientId ) || + hasSelectedInnerBlock( clientId, true ); + registry.batch( () => { updateBlockAttributes( clientId, newAttributes ); replaceInnerBlocks( clientId, newInnerBlocks ); + if ( shouldReselectBlock ) { + selectBlock( clientId ); + } } ); }, [ attributes.values ] ); } diff --git a/packages/block-library/src/quote/edit.js b/packages/block-library/src/quote/edit.js index e26cc4693ed35a..ee1c8252873006 100644 --- a/packages/block-library/src/quote/edit.js +++ b/packages/block-library/src/quote/edit.js @@ -40,7 +40,7 @@ const TEMPLATE = [ [ 'core/paragraph', {} ] ]; */ const useMigrateOnLoad = ( attributes, clientId ) => { const registry = useRegistry(); - const { updateBlockAttributes, replaceInnerBlocks } = + const { updateBlockAttributes, replaceInnerBlocks, selectBlock } = useDispatch( blockEditorStore ); useEffect( () => { // As soon as the block is loaded, migrate it to the new version. @@ -59,9 +59,18 @@ const useMigrateOnLoad = ( attributes, clientId ) => { alternative: 'inner blocks', } ); + const { hasSelectedInnerBlock, isBlockSelected } = + registry.select( blockEditorStore ); + const shouldReselectBlock = + isBlockSelected( clientId ) || + hasSelectedInnerBlock( clientId, true ); + registry.batch( () => { updateBlockAttributes( clientId, newAttributes ); replaceInnerBlocks( clientId, newInnerBlocks ); + if ( shouldReselectBlock ) { + selectBlock( clientId ); + } } ); }, [ attributes.value ] ); }; diff --git a/test/e2e/specs/editor/blocks/list.spec.js b/test/e2e/specs/editor/blocks/list.spec.js index 2e095ecab72572..3a1918fd5034c4 100644 --- a/test/e2e/specs/editor/blocks/list.spec.js +++ b/test/e2e/specs/editor/blocks/list.spec.js @@ -8,6 +8,21 @@ test.describe( 'List (@firefox)', () => { await admin.createNewPost(); } ); + test( 'keeps the list block selected after migrating legacy values', async ( { + editor, + page, + } ) => { + await editor.insertBlock( { + name: 'core/list', + attributes: { values: '
  • List content
  • ' }, + } ); + + await editor.clickBlockToolbarButton( 'List' ); + await expect( + page.getByRole( 'menu', { name: 'List' } ) + ).toBeVisible(); + } ); + test( 'can be copied from multi selection', async ( { editor, page, From 4a6431ff0e3f1acb1f35bdc54b65b77be7fa3336 Mon Sep 17 00:00:00 2001 From: Alec Geatches Date: Tue, 9 Jun 2026 14:42:35 -0600 Subject: [PATCH 09/10] Mark inner-template-looking blocks with __unstableShouldDeferBlockSync to narrow deferred update flag --- .../provider/test/use-block-sync.js | 16 +++--- .../src/components/provider/use-block-sync.js | 16 +++--- packages/block-editor/src/store/actions.js | 22 ++++++++- packages/block-editor/src/store/reducer.js | 12 +++-- packages/block-editor/src/store/selectors.js | 8 +-- .../block-editor/src/store/test/reducer.js | 30 +++++++++--- .../collaboration-list-template.spec.ts | 49 +++++++++++++++++++ 7 files changed, 121 insertions(+), 32 deletions(-) diff --git a/packages/block-editor/src/components/provider/test/use-block-sync.js b/packages/block-editor/src/components/provider/test/use-block-sync.js index feefd74d8edc4c..46102721503c39 100644 --- a/packages/block-editor/src/components/provider/test/use-block-sync.js +++ b/packages/block-editor/src/components/provider/test/use-block-sync.js @@ -45,6 +45,11 @@ describe( 'useBlockSync hook', () => { foo: { type: 'number' }, }, } ); + registerBlockType( 'test/container-block', { + apiVersion: 3, + title: 'Test container block', + allowedBlocks: [ 'test/test-block' ], + } ); } ); afterEach( () => { @@ -351,18 +356,17 @@ describe( 'useBlockSync hook', () => { expect( onInput ).not.toHaveBeenCalled(); } ); - it( 'merges history-ignored block changes into a deferred replacement', async () => { + it( 'merges history-ignored block changes into a deferred block addition', async () => { const onChange = jest.fn(); const onInput = jest.fn(); let registry; const setRegistry = ( reg ) => { registry = reg; }; - const initialBlock = createBlock( 'test/test-block' ); render( @@ -370,12 +374,10 @@ describe( 'useBlockSync hook', () => { onChange.mockClear(); onInput.mockClear(); - const parentBlock = createBlock( 'test/test-block' ); + const parentBlock = createBlock( 'test/container-block' ); const templateBlock = createBlock( 'test/test-block', { foo: 2 } ); - registry - .dispatch( blockEditorStore ) - .replaceBlock( initialBlock.clientId, parentBlock ); + registry.dispatch( blockEditorStore ).insertBlock( parentBlock ); expect( onChange ).not.toHaveBeenCalled(); registry .dispatch( blockEditorStore ) diff --git a/packages/block-editor/src/components/provider/use-block-sync.js b/packages/block-editor/src/components/provider/use-block-sync.js index a62c90e3a462c3..961b700dd418dd 100644 --- a/packages/block-editor/src/components/provider/use-block-sync.js +++ b/packages/block-editor/src/components/provider/use-block-sync.js @@ -378,7 +378,7 @@ export default function useBlockSync( { getSelectedBlocksInitialCaretPosition, isLastBlockChangePersistent, __unstableGetLastBlockChangeHistoryMode, - __unstableDidLastBlockChangeReplaceBlocks, + __unstableDidLastBlockChangeAddBlocks, __unstableIsLastBlockChangeIgnored, areInnerBlocksControlled, getBlockParents, @@ -449,8 +449,8 @@ export default function useBlockSync( { // receives both changes atomically. registry.batch( () => { if ( blocksChanged ) { - const didReplaceBlocks = - __unstableDidLastBlockChangeReplaceBlocks(); + const didAddBlocks = + __unstableDidLastBlockChangeAddBlocks(); isPersistent = newIsPersistent; blockHistoryMode = newBlockHistoryMode; @@ -489,7 +489,7 @@ export default function useBlockSync( { blockHistoryMode === 'ignore' ) { // Non-persistent, history-ignored block changes - // can follow a deferred replacement before it flushes, + // can follow a deferred block addition before it flushes, // such as inner template insertion. Update the // deferred payload and return early. It will be // flushed by a React effect after layout effects @@ -515,12 +515,12 @@ export default function useBlockSync( { } const shouldDeferBlockSync = - isPersistent && didReplaceBlocks; + isPersistent && didAddBlocks; if ( shouldDeferBlockSync ) { - // Replace-block persistent changes can trigger a - // template continuation during the same React commit. - // Defer this payload so that continuation + // Block additions can trigger a inner template + // insertion during the same React commit. + // Defer this block so that block modifications // can be merged before syncing to the parent. deferBlockSync( blocksForParent, updateOptions ); } else { diff --git a/packages/block-editor/src/store/actions.js b/packages/block-editor/src/store/actions.js index 776e888459bd1e..8ce9958e04340c 100644 --- a/packages/block-editor/src/store/actions.js +++ b/packages/block-editor/src/store/actions.js @@ -7,12 +7,12 @@ import { __experimentalCloneSanitizedBlock, createBlock, doBlocksMatchTemplate, + getBlockSupport, getBlockType, getDefaultBlockName, hasBlockSupport, switchToBlockType, synchronizeBlocksWithTemplate, - getBlockSupport, isUnmodifiedDefaultBlock, isUnmodifiedBlock, } from '@wordpress/blocks'; @@ -550,6 +550,20 @@ export function insertBlock( ); } +function shouldDeferBlockSyncForInsertedBlocks( blocks ) { + return blocks.some( ( block ) => { + if ( block.innerBlocks?.length ) { + return false; + } + // Empty blocks with inner block areas can receive default children + // immediately after insertion through useInnerBlockTemplateSync. + return ( + getBlockType( block.name )?.allowedBlocks !== undefined || + getBlockSupport( block.name, 'allowedBlocks' ) !== undefined + ); + } ); +} + /** * Action that inserts an array of blocks, optionally at a specific index respective a root block list. * @@ -599,6 +613,12 @@ export const insertBlocks = } } if ( allowedBlocks.length ) { + if ( shouldDeferBlockSyncForInsertedBlocks( allowedBlocks ) ) { + meta = { + ...meta, + __unstableShouldDeferBlockSync: true, + }; + } dispatch( { type: 'INSERT_BLOCKS', blocks: allowedBlocks, diff --git a/packages/block-editor/src/store/reducer.js b/packages/block-editor/src/store/reducer.js index 107b24c9ff5109..24ea963172708a 100644 --- a/packages/block-editor/src/store/reducer.js +++ b/packages/block-editor/src/store/reducer.js @@ -427,8 +427,10 @@ function withPersistentBlockChange( reducer ) { const isNotPersistentMarker = action.type === 'MARK_NEXT_CHANGE_AS_NOT_PERSISTENT'; const pendingHistoryMode = nextHistoryMode; - const didReplaceBlocks = - ( action.type === 'REPLACE_BLOCKS' || + const didAddBlocks = + ( ( action.type === 'INSERT_BLOCKS' && + action.meta?.__unstableShouldDeferBlockSync ) || + action.type === 'REPLACE_BLOCKS' || action.type === 'REPLACE_BLOCKS_AUGMENTED_WITH_CHILDREN' ) && !! action.blocks?.length; @@ -461,7 +463,7 @@ function withPersistentBlockChange( reducer ) { const { lastBlockChangeHistoryMode, - didLastBlockChangeReplaceBlocks, + didLastBlockChangeAddBlocks, ...blockChange } = nextState; const result = { ...blockChange, isPersistentChange }; @@ -470,8 +472,8 @@ function withPersistentBlockChange( reducer ) { result.lastBlockChangeHistoryMode = 'ignore'; } - if ( didReplaceBlocks ) { - result.didLastBlockChangeReplaceBlocks = true; + if ( didAddBlocks ) { + result.didLastBlockChangeAddBlocks = true; } return result; diff --git a/packages/block-editor/src/store/selectors.js b/packages/block-editor/src/store/selectors.js index 206c2bac42f154..ee13817f9ccd98 100644 --- a/packages/block-editor/src/store/selectors.js +++ b/packages/block-editor/src/store/selectors.js @@ -2941,14 +2941,14 @@ export function __unstableGetLastBlockChangeHistoryMode( state ) { } /** - * Returns true if the most recent block change replaced blocks. + * Returns true if the most recent block change added blocks. * * @param {Object} state Block editor state. * - * @return {boolean} Whether the most recent block change replaced blocks. + * @return {boolean} Whether the most recent block change added blocks. */ -export function __unstableDidLastBlockChangeReplaceBlocks( state ) { - return !! state.blocks.didLastBlockChangeReplaceBlocks; +export function __unstableDidLastBlockChangeAddBlocks( state ) { + return !! state.blocks.didLastBlockChangeAddBlocks; } /** diff --git a/packages/block-editor/src/store/test/reducer.js b/packages/block-editor/src/store/test/reducer.js index 6d02c7fbd3c4d6..c9ca5baddf025d 100644 --- a/packages/block-editor/src/store/test/reducer.js +++ b/packages/block-editor/src/store/test/reducer.js @@ -2282,7 +2282,7 @@ describe( 'state', () => { ).toBeUndefined(); } ); - it( 'should flag REPLACE_BLOCKS-style changes that add blocks', () => { + it( 'should flag deferred changes that add blocks', () => { let original = deepFreeze( blocks( undefined, { type: 'RESET_BLOCKS', @@ -2307,10 +2307,28 @@ describe( 'state', () => { ], } ); - expect( insertState.didLastBlockChangeReplaceBlocks ).toBe( + expect( insertState.didLastBlockChangeAddBlocks ).toBe( undefined ); + const deferredInsertState = blocks( original, { + type: 'INSERT_BLOCKS', + blocks: [ + { + clientId: 'peach', + attributes: {}, + innerBlocks: [], + }, + ], + meta: { + __unstableShouldDeferBlockSync: true, + }, + } ); + + expect( + deferredInsertState.didLastBlockChangeAddBlocks + ).toBe( true ); + original = deepFreeze( insertState ); const replaceState = blocks( original, { @@ -2325,7 +2343,7 @@ describe( 'state', () => { ], } ); - expect( replaceState.didLastBlockChangeReplaceBlocks ).toBe( + expect( replaceState.didLastBlockChangeAddBlocks ).toBe( true ); @@ -2338,7 +2356,7 @@ describe( 'state', () => { } ); expect( - subsequentState.didLastBlockChangeReplaceBlocks + subsequentState.didLastBlockChangeAddBlocks ).toBeUndefined(); } ); @@ -2362,9 +2380,7 @@ describe( 'state', () => { blocks: [], } ); - expect( - state.didLastBlockChangeReplaceBlocks - ).toBeUndefined(); + expect( state.didLastBlockChangeAddBlocks ).toBeUndefined(); } ); it( 'should retain reference for same state, same persistence', () => { diff --git a/test/e2e/specs/editor/collaboration/websocket-only/collaboration-list-template.spec.ts b/test/e2e/specs/editor/collaboration/websocket-only/collaboration-list-template.spec.ts index daf9d4d5ff8a59..91c6e1e86a48b5 100644 --- a/test/e2e/specs/editor/collaboration/websocket-only/collaboration-list-template.spec.ts +++ b/test/e2e/specs/editor/collaboration/websocket-only/collaboration-list-template.spec.ts @@ -52,6 +52,23 @@ async function insertListWithSlashInserter( editor: Editor, page: Page ) { await page.keyboard.press( 'Enter' ); } +async function insertListWithSidebarInserter( editor: Editor, page: Page ) { + await editor.canvas + .locator( 'role=button[name="Add default block"i]' ) + .click(); + await page.keyboard.type( 'abc' ); + + await page.getByLabel( 'Block Inserter' ).click(); + await page + .getByRole( 'region', { name: 'Block Library' } ) + .getByRole( 'searchbox', { name: 'Search' } ) + .fill( 'List' ); + await page + .getByRole( 'listbox', { name: 'Blocks' } ) + .getByRole( 'option', { name: 'List', exact: true } ) + .click(); +} + test.describe( 'Collaboration - WebSocket List Templates', () => { test.skip( CONFIGURED_WS_DELAY_MS < MIN_REQUIRED_WS_DELAY_MS, @@ -89,4 +106,36 @@ test.describe( 'Collaboration - WebSocket List Templates', () => { expect( await getListItemCounts( editor2 ) ).toEqual( [ 1 ] ); expect( await getListItemCounts( editor3 ) ).toEqual( [ 1 ] ); } ); + + test( 'inserts the list item template once when the sidebar inserter inserts a list after text', async ( { + collaborationUtils, + requestUtils, + editor, + page, + } ) => { + const post = await requestUtils.createPost( { + title: 'WebSocket Sidebar List Template Repro', + status: 'draft', + date_gmt: new Date().toISOString(), + } ); + + await collaborationUtils.openCollaborativeSession( post.id ); + await requestUtils.createUser( THIRD_USER ); + await collaborationUtils.joinUser( post.id, THIRD_USER ); + await collaborationUtils.waitForMutualDiscovery(); + const { editor2 } = collaborationUtils; + const editor3 = collaborationUtils.getEditor( 1 ); + + await insertListWithSidebarInserter( editor, page ); + + await expect + .poll( async () => ( await getListItemCounts( editor ) ).length ) + .toBe( 1 ); + + await collaborationUtils.waitForConvergence( { timeout: 5000 } ); + + expect( await getListItemCounts( editor ) ).toEqual( [ 1 ] ); + expect( await getListItemCounts( editor2 ) ).toEqual( [ 1 ] ); + expect( await getListItemCounts( editor3 ) ).toEqual( [ 1 ] ); + } ); } ); From dbec0540bae60704a17280ce5972c206ede3b248 Mon Sep 17 00:00:00 2001 From: Alec Geatches Date: Tue, 9 Jun 2026 14:46:54 -0600 Subject: [PATCH 10/10] Rename didLastBlockChangeAddBlocks to shouldLastBlockChangeDeferBlockSync --- .../src/components/provider/use-block-sync.js | 10 ++++---- packages/block-editor/src/store/reducer.js | 8 +++---- packages/block-editor/src/store/selectors.js | 8 +++---- .../block-editor/src/store/test/reducer.js | 24 ++++++++++--------- 4 files changed, 26 insertions(+), 24 deletions(-) diff --git a/packages/block-editor/src/components/provider/use-block-sync.js b/packages/block-editor/src/components/provider/use-block-sync.js index 961b700dd418dd..80b7c2ecab985f 100644 --- a/packages/block-editor/src/components/provider/use-block-sync.js +++ b/packages/block-editor/src/components/provider/use-block-sync.js @@ -378,7 +378,7 @@ export default function useBlockSync( { getSelectedBlocksInitialCaretPosition, isLastBlockChangePersistent, __unstableGetLastBlockChangeHistoryMode, - __unstableDidLastBlockChangeAddBlocks, + __unstableShouldLastBlockChangeDeferBlockSync, __unstableIsLastBlockChangeIgnored, areInnerBlocksControlled, getBlockParents, @@ -449,8 +449,8 @@ export default function useBlockSync( { // receives both changes atomically. registry.batch( () => { if ( blocksChanged ) { - const didAddBlocks = - __unstableDidLastBlockChangeAddBlocks(); + const shouldLastBlockChangeDeferBlockSync = + __unstableShouldLastBlockChangeDeferBlockSync(); isPersistent = newIsPersistent; blockHistoryMode = newBlockHistoryMode; @@ -515,10 +515,10 @@ export default function useBlockSync( { } const shouldDeferBlockSync = - isPersistent && didAddBlocks; + isPersistent && shouldLastBlockChangeDeferBlockSync; if ( shouldDeferBlockSync ) { - // Block additions can trigger a inner template + // Block additions can trigger an inner template // insertion during the same React commit. // Defer this block so that block modifications // can be merged before syncing to the parent. diff --git a/packages/block-editor/src/store/reducer.js b/packages/block-editor/src/store/reducer.js index 24ea963172708a..74ae0b41e85c63 100644 --- a/packages/block-editor/src/store/reducer.js +++ b/packages/block-editor/src/store/reducer.js @@ -427,7 +427,7 @@ function withPersistentBlockChange( reducer ) { const isNotPersistentMarker = action.type === 'MARK_NEXT_CHANGE_AS_NOT_PERSISTENT'; const pendingHistoryMode = nextHistoryMode; - const didAddBlocks = + const shouldDeferBlockSync = ( ( action.type === 'INSERT_BLOCKS' && action.meta?.__unstableShouldDeferBlockSync ) || action.type === 'REPLACE_BLOCKS' || @@ -463,7 +463,7 @@ function withPersistentBlockChange( reducer ) { const { lastBlockChangeHistoryMode, - didLastBlockChangeAddBlocks, + shouldLastBlockChangeDeferBlockSync, ...blockChange } = nextState; const result = { ...blockChange, isPersistentChange }; @@ -472,8 +472,8 @@ function withPersistentBlockChange( reducer ) { result.lastBlockChangeHistoryMode = 'ignore'; } - if ( didAddBlocks ) { - result.didLastBlockChangeAddBlocks = true; + if ( shouldDeferBlockSync ) { + result.shouldLastBlockChangeDeferBlockSync = true; } return result; diff --git a/packages/block-editor/src/store/selectors.js b/packages/block-editor/src/store/selectors.js index ee13817f9ccd98..efd48d5a32f704 100644 --- a/packages/block-editor/src/store/selectors.js +++ b/packages/block-editor/src/store/selectors.js @@ -2941,14 +2941,14 @@ export function __unstableGetLastBlockChangeHistoryMode( state ) { } /** - * Returns true if the most recent block change added blocks. + * Returns true if the most recent block change should defer block sync. * * @param {Object} state Block editor state. * - * @return {boolean} Whether the most recent block change added blocks. + * @return {boolean} Whether the most recent block change should defer block sync. */ -export function __unstableDidLastBlockChangeAddBlocks( state ) { - return !! state.blocks.didLastBlockChangeAddBlocks; +export function __unstableShouldLastBlockChangeDeferBlockSync( state ) { + return !! state.blocks.shouldLastBlockChangeDeferBlockSync; } /** diff --git a/packages/block-editor/src/store/test/reducer.js b/packages/block-editor/src/store/test/reducer.js index c9ca5baddf025d..a407b03ba4d259 100644 --- a/packages/block-editor/src/store/test/reducer.js +++ b/packages/block-editor/src/store/test/reducer.js @@ -2282,7 +2282,7 @@ describe( 'state', () => { ).toBeUndefined(); } ); - it( 'should flag deferred changes that add blocks', () => { + it( 'should flag changes that should defer block sync', () => { let original = deepFreeze( blocks( undefined, { type: 'RESET_BLOCKS', @@ -2307,9 +2307,9 @@ describe( 'state', () => { ], } ); - expect( insertState.didLastBlockChangeAddBlocks ).toBe( - undefined - ); + expect( + insertState.shouldLastBlockChangeDeferBlockSync + ).toBeUndefined(); const deferredInsertState = blocks( original, { type: 'INSERT_BLOCKS', @@ -2326,7 +2326,7 @@ describe( 'state', () => { } ); expect( - deferredInsertState.didLastBlockChangeAddBlocks + deferredInsertState.shouldLastBlockChangeDeferBlockSync ).toBe( true ); original = deepFreeze( insertState ); @@ -2343,9 +2343,9 @@ describe( 'state', () => { ], } ); - expect( replaceState.didLastBlockChangeAddBlocks ).toBe( - true - ); + expect( + replaceState.shouldLastBlockChangeDeferBlockSync + ).toBe( true ); const subsequentState = blocks( replaceState, { type: 'UPDATE_BLOCK_ATTRIBUTES', @@ -2356,11 +2356,11 @@ describe( 'state', () => { } ); expect( - subsequentState.didLastBlockChangeAddBlocks + subsequentState.shouldLastBlockChangeDeferBlockSync ).toBeUndefined(); } ); - it( 'should not flag replacements that do not add blocks', () => { + it( 'should not flag replacements that do not defer block sync', () => { const original = deepFreeze( blocks( undefined, { type: 'RESET_BLOCKS', @@ -2380,7 +2380,9 @@ describe( 'state', () => { blocks: [], } ); - expect( state.didLastBlockChangeAddBlocks ).toBeUndefined(); + expect( + state.shouldLastBlockChangeDeferBlockSync + ).toBeUndefined(); } ); it( 'should retain reference for same state, same persistence', () => {