diff --git a/packages/block-editor/CHANGELOG.md b/packages/block-editor/CHANGELOG.md
index 1cba91476c58e7..b8aa496c46f731 100644
--- a/packages/block-editor/CHANGELOG.md
+++ b/packages/block-editor/CHANGELOG.md
@@ -9,6 +9,7 @@
### Bug Fixes
- `BlockCompare`: Show whitespace differences in the Resolve Block dialog. The markup's spaces and blank lines are kept instead of collapsed by the browser, and each added or removed line break gets a visible marker, so a diff made only of whitespace no longer looks identical on both sides. The dialog also shows the markup of a converted Custom HTML block, which is kept in its `innerContent` rather than produced by `save`, instead of an empty diff ([#82397](https://github.com/WordPress/gutenberg/pull/82397)).
+- Inserter: Stop re-rendering the block list when the block selection changes while the inserter is open ([#83284](https://github.com/WordPress/gutenberg/pull/83284)).
## 18.0.0 (2026-09-23)
diff --git a/packages/block-editor/src/components/inserter/block-types-tab.jsx b/packages/block-editor/src/components/inserter/block-types-tab.jsx
index edd3a42ac465af..7e001c2f7664bc 100644
--- a/packages/block-editor/src/components/inserter/block-types-tab.jsx
+++ b/packages/block-editor/src/components/inserter/block-types-tab.jsx
@@ -166,29 +166,33 @@ export function BlockTypesTab(
onInsert
);
- if ( ! items.length ) {
- return ;
- }
+ const [ itemsForCurrentRoot, itemsRemaining ] = useMemo( () => {
+ const forCurrentRoot = [];
+ const remaining = [];
+
+ for ( const item of items ) {
+ // Skip reusable blocks, they moved to the patterns tab.
+ if ( item.category === 'reusable' ) {
+ continue;
+ }
- const itemsForCurrentRoot = [];
- const itemsRemaining = [];
+ // Skip search-only items from browse view (they're still searchable).
+ if ( item.isSearchOnly ) {
+ continue;
+ }
- for ( const item of items ) {
- // Skip reusable blocks, they moved to the patterns tab.
- if ( item.category === 'reusable' ) {
- continue;
+ if ( item.isAllowedInCurrentRoot ) {
+ forCurrentRoot.push( item );
+ } else {
+ remaining.push( item );
+ }
}
- // Skip search-only items from browse view (they're still searchable).
- if ( item.isSearchOnly ) {
- continue;
- }
+ return [ forCurrentRoot, remaining ];
+ }, [ items ] );
- if ( item.isAllowedInCurrentRoot ) {
- itemsForCurrentRoot.push( item );
- } else {
- itemsRemaining.push( item );
- }
+ if ( ! items.length ) {
+ return ;
}
return (
diff --git a/packages/block-editor/src/components/inserter/hooks/use-block-types-state.js b/packages/block-editor/src/components/inserter/hooks/use-block-types-state.js
index e7868a68d8ef4a..b63a90212af141 100644
--- a/packages/block-editor/src/components/inserter/hooks/use-block-types-state.js
+++ b/packages/block-editor/src/components/inserter/hooks/use-block-types-state.js
@@ -1,17 +1,17 @@
import {
- getBlockType,
createBlock,
createBlocksFromInnerBlocksTemplate,
store as blocksStore,
parse,
} from '@wordpress/blocks';
-import { useSelect, useDispatch } from '@wordpress/data';
-import { useCallback, useMemo } from '@wordpress/element';
-import { store as noticesStore } from '@wordpress/notices';
-import { __, sprintf } from '@wordpress/i18n';
+import { useSelect } from '@wordpress/data';
+import { useCallback } from '@wordpress/element';
import { store as blockEditorStore } from '../../../store';
import { isFiltered } from '../../../store/utils';
-import { unlock } from '../../../lock-unlock';
+
+// Shared so the selector cache survives the inserter closing and reopening.
+const FILTERED_OPTIONS = { [ isFiltered ]: true };
+const UNFILTERED_OPTIONS = { [ isFiltered ]: false };
/**
* Retrieves the block types inserter state.
@@ -22,23 +22,16 @@ import { unlock } from '../../../lock-unlock';
* @return {Array} Returns the block types state. (block types, categories, collections, onSelect handler)
*/
const useBlockTypesState = ( rootClientId, onInsert, isQuick ) => {
- const options = useMemo(
- () => ( { [ isFiltered ]: !! isQuick } ),
- [ isQuick ]
- );
- const [ items ] = useSelect(
- ( select ) => [
+ const options = isQuick ? FILTERED_OPTIONS : UNFILTERED_OPTIONS;
+ // Not wrapped in a tuple, so `useSelect` can return the previous array when the items match.
+ const items = useSelect(
+ ( select ) =>
select( blockEditorStore ).getInserterItems(
rootClientId,
options
),
- ],
[ rootClientId, options ]
);
- const { getClosestAllowedInsertionPoint } = unlock(
- useSelect( blockEditorStore )
- );
- const { createErrorNotice } = useDispatch( noticesStore );
const [ categories, collections ] = useSelect( ( select ) => {
const { getCategories, getCollections } = select( blocksStore );
@@ -57,26 +50,6 @@ const useBlockTypesState = ( rootClientId, onInsert, isQuick ) => {
},
shouldFocusBlock
) => {
- const destinationClientId = getClosestAllowedInsertionPoint(
- name,
- rootClientId
- );
- if ( destinationClientId === null ) {
- const title = getBlockType( name )?.title ?? name;
- createErrorNotice(
- sprintf(
- /* translators: %s: block pattern title. */
- __( 'Block "%s" can\'t be inserted.' ),
- title
- ),
- {
- type: 'snackbar',
- id: 'inserter-notice',
- }
- );
- return;
- }
-
const insertedBlock =
syncStatus === 'unsynced'
? parse( content, {
@@ -88,19 +61,9 @@ const useBlockTypesState = ( rootClientId, onInsert, isQuick ) => {
createBlocksFromInnerBlocksTemplate( innerBlocks ),
innerContent
);
- onInsert(
- insertedBlock,
- undefined,
- shouldFocusBlock,
- destinationClientId
- );
+ onInsert( insertedBlock, undefined, shouldFocusBlock );
},
- [
- getClosestAllowedInsertionPoint,
- rootClientId,
- onInsert,
- createErrorNotice,
- ]
+ [ onInsert ]
);
return [ items, categories, collections, onSelectItem ];
diff --git a/packages/block-editor/src/components/inserter/hooks/use-insertion-point.js b/packages/block-editor/src/components/inserter/hooks/use-insertion-point.js
index 740b47a210ba1c..eac798b69bf67e 100644
--- a/packages/block-editor/src/components/inserter/hooks/use-insertion-point.js
+++ b/packages/block-editor/src/components/inserter/hooks/use-insertion-point.js
@@ -1,6 +1,7 @@
import { useDispatch, useRegistry, useSelect } from '@wordpress/data';
-import { isUnmodifiedDefaultBlock } from '@wordpress/blocks';
-import { _n, sprintf } from '@wordpress/i18n';
+import { getBlockType, isUnmodifiedDefaultBlock } from '@wordpress/blocks';
+import { __, _n, sprintf } from '@wordpress/i18n';
+import { store as noticesStore } from '@wordpress/notices';
import { speak } from '@wordpress/a11y';
import { useCallback } from '@wordpress/element';
import { store as blockEditorStore } from '../../../store';
@@ -34,6 +35,55 @@ function getIndex( {
.length;
}
+/**
+ * Resolves where an insertion from this inserter lands.
+ *
+ * @param {Object} selectors Unlocked `core/block-editor` selectors.
+ * @param {Object} config Inserter config.
+ * @param {string} config.rootClientId Root the inserter belongs to.
+ * @param {number=} config.insertionIndex Explicit index to insert at.
+ * @param {string=} config.clientId Block to insert after.
+ * @param {boolean=} config.isAppender Whether the inserter is an appender.
+ * @return {{ destinationRootClientId: string, destinationIndex: number }} Destination.
+ */
+function getDestination(
+ selectors,
+ { rootClientId, insertionIndex, clientId, isAppender }
+) {
+ const {
+ getSelectedBlockClientId,
+ getBlockRootClientId,
+ getBlockIndex,
+ getBlockOrder,
+ getInsertionPoint,
+ } = selectors;
+ const selectedBlockClientId = getSelectedBlockClientId();
+ let destinationRootClientId = rootClientId;
+ let destinationIndex;
+ const insertionPoint = getInsertionPoint();
+
+ if ( insertionIndex !== undefined ) {
+ // Insert into a specific index.
+ destinationIndex = insertionIndex;
+ } else if ( insertionPoint && insertionPoint.hasOwnProperty( 'index' ) ) {
+ destinationRootClientId = insertionPoint?.rootClientId
+ ? insertionPoint.rootClientId
+ : rootClientId;
+ destinationIndex = insertionPoint.index;
+ } else if ( clientId ) {
+ // Insert after a specific client ID.
+ destinationIndex = getBlockIndex( clientId );
+ } else if ( ! isAppender && selectedBlockClientId ) {
+ destinationRootClientId = getBlockRootClientId( selectedBlockClientId );
+ destinationIndex = getBlockIndex( selectedBlockClientId ) + 1;
+ } else {
+ // Insert at the end of the list.
+ destinationIndex = getBlockOrder( destinationRootClientId ).length;
+ }
+
+ return { destinationRootClientId, destinationIndex };
+}
+
/**
* @typedef WPInserterConfig
*
@@ -69,51 +119,14 @@ function useInsertionPoint( {
getClosestAllowedInsertionPoint,
getBlockInsertionPoint,
} = unlock( useSelect( blockEditorStore ) );
- const { destinationRootClientId, destinationIndex } = useSelect(
- ( select ) => {
- const {
- getSelectedBlockClientId,
- getBlockRootClientId,
- getBlockIndex,
- getBlockOrder,
- getInsertionPoint,
- } = unlock( select( blockEditorStore ) );
- const selectedBlockClientId = getSelectedBlockClientId();
- let _destinationRootClientId = rootClientId;
- let _destinationIndex;
- const insertionPoint = getInsertionPoint();
-
- if ( insertionIndex !== undefined ) {
- // Insert into a specific index.
- _destinationIndex = insertionIndex;
- } else if (
- insertionPoint &&
- insertionPoint.hasOwnProperty( 'index' )
- ) {
- _destinationRootClientId = insertionPoint?.rootClientId
- ? insertionPoint.rootClientId
- : rootClientId;
- _destinationIndex = insertionPoint.index;
- } else if ( clientId ) {
- // Insert after a specific client ID.
- _destinationIndex = getBlockIndex( clientId );
- } else if ( ! isAppender && selectedBlockClientId ) {
- _destinationRootClientId = getBlockRootClientId(
- selectedBlockClientId
- );
- _destinationIndex = getBlockIndex( selectedBlockClientId ) + 1;
- } else {
- // Insert at the end of the list.
- _destinationIndex = getBlockOrder(
- _destinationRootClientId
- ).length;
- }
-
- return {
- destinationRootClientId: _destinationRootClientId,
- destinationIndex: _destinationIndex,
- };
- },
+ const { destinationRootClientId } = useSelect(
+ ( select ) =>
+ getDestination( unlock( select( blockEditorStore ) ), {
+ rootClientId,
+ insertionIndex,
+ clientId,
+ isAppender,
+ } ),
[ rootClientId, insertionIndex, clientId, isAppender ]
);
@@ -124,6 +137,7 @@ function useInsertionPoint( {
hideInsertionPoint,
setLastFocus,
} = unlock( useDispatch( blockEditorStore ) );
+ const { createErrorNotice } = useDispatch( noticesStore );
const onInsertBlocks = useCallback(
( blocks, meta, shouldForceFocusBlock = false, _rootClientId ) => {
@@ -139,6 +153,36 @@ function useInsertionPoint( {
setLastFocus( null );
}
+ // Resolved at call time: the destination follows the selection, and
+ // closing over it would give this callback a new identity per click.
+ const destination = getDestination(
+ unlock( registry.select( blockEditorStore ) ),
+ { rootClientId, insertionIndex, clientId, isAppender }
+ );
+
+ // No root given: use the closest container that accepts the blocks,
+ // as the hover cue does.
+ if ( _rootClientId === undefined ) {
+ const names = (
+ Array.isArray( blocks ) ? blocks : [ blocks ]
+ ).map( ( block ) => block.name );
+ _rootClientId = getClosestAllowedInsertionPoint(
+ names,
+ destination.destinationRootClientId
+ );
+ if ( _rootClientId === null ) {
+ createErrorNotice(
+ sprintf(
+ /* translators: %s: block title. */
+ __( 'Block "%s" can\'t be inserted.' ),
+ getBlockType( names[ 0 ] )?.title ?? names[ 0 ]
+ ),
+ { type: 'snackbar', id: 'inserter-notice' }
+ );
+ return;
+ }
+ }
+
const selectedBlock = getSelectedBlock();
if (
@@ -156,16 +200,15 @@ function useInsertionPoint( {
} else {
insertBlocks(
blocks,
- isAppender || _rootClientId === undefined
- ? destinationIndex
+ isAppender
+ ? destination.destinationIndex
: getIndex( {
- destinationRootClientId,
- destinationIndex,
+ ...destination,
rootClientId: _rootClientId,
registry,
} ),
- isAppender || _rootClientId === undefined
- ? destinationRootClientId
+ isAppender
+ ? destination.destinationRootClientId
: _rootClientId,
selectBlockOnInsert,
shouldFocusBlock || shouldForceFocusBlock ? 0 : null,
@@ -185,12 +228,15 @@ function useInsertionPoint( {
}
},
[
+ rootClientId,
+ insertionIndex,
+ clientId,
isAppender,
getSelectedBlock,
+ getClosestAllowedInsertionPoint,
+ createErrorNotice,
replaceBlocks,
insertBlocks,
- destinationRootClientId,
- destinationIndex,
onSelect,
shouldFocusBlock,
selectBlockOnInsert,
@@ -202,17 +248,20 @@ function useInsertionPoint( {
const onToggleInsertionPoint = useCallback(
( item ) => {
if ( item ) {
+ const destination = getDestination(
+ unlock( registry.select( blockEditorStore ) ),
+ { rootClientId, insertionIndex, clientId, isAppender }
+ );
const allowedDestinationRootClientId =
getClosestAllowedInsertionPoint(
item.name,
- destinationRootClientId
+ destination.destinationRootClientId
);
if ( allowedDestinationRootClientId !== null ) {
showInsertionPoint(
allowedDestinationRootClientId,
getIndex( {
- destinationRootClientId,
- destinationIndex,
+ ...destination,
rootClientId: allowedDestinationRootClientId,
registry,
} )
@@ -228,12 +277,14 @@ function useInsertionPoint( {
}
},
[
+ rootClientId,
+ insertionIndex,
+ clientId,
+ isAppender,
getClosestAllowedInsertionPoint,
getBlockInsertionPoint,
showInsertionPoint,
hideInsertionPoint,
- destinationRootClientId,
- destinationIndex,
registry,
]
);
diff --git a/packages/block-editor/src/components/inserter/hooks/use-patterns-state.js b/packages/block-editor/src/components/inserter/hooks/use-patterns-state.js
index 9be211804ed29e..af0e15d223b8e6 100644
--- a/packages/block-editor/src/components/inserter/hooks/use-patterns-state.js
+++ b/packages/block-editor/src/components/inserter/hooks/use-patterns-state.js
@@ -12,6 +12,10 @@ import {
import { INSERTER_PATTERN_TYPES } from '../block-patterns-tab/utils';
import { isFiltered } from '../../../store/utils';
+// Shared so the selector cache survives the inserter closing and reopening.
+const FILTERED_OPTIONS = { [ isFiltered ]: true };
+const UNFILTERED_OPTIONS = { [ isFiltered ]: false };
+
/**
* Retrieves the block patterns inserter state.
*
@@ -28,10 +32,7 @@ const usePatternsState = (
selectedCategory,
isQuick
) => {
- const options = useMemo(
- () => ( { [ isFiltered ]: !! isQuick } ),
- [ isQuick ]
- );
+ const options = isQuick ? FILTERED_OPTIONS : UNFILTERED_OPTIONS;
// Check if we're editing a navigation-overlay template part.
// This information is passed through block editor settings to avoid
diff --git a/packages/block-editor/src/store/selectors.js b/packages/block-editor/src/store/selectors.js
index 1c61fbc232a232..145368e17876fc 100644
--- a/packages/block-editor/src/store/selectors.js
+++ b/packages/block-editor/src/store/selectors.js
@@ -13,7 +13,11 @@ import { applyFilters } from '@wordpress/hooks';
import { symbol } from '@wordpress/icons';
import { create, remove, toHTMLString } from '@wordpress/rich-text';
import deprecated from '@wordpress/deprecated';
-import { createSelector, createRegistrySelector } from '@wordpress/data';
+import {
+ createSelector,
+ createRegistrySelector,
+ select as globalSelect,
+} from '@wordpress/data';
import {
isFiltered,
checkAllowListRecursive,
@@ -2414,6 +2418,146 @@ const buildBlockVariationItem = ( state, item ) => ( variation ) => {
};
};
+const buildReusableBlockInserterItem = ( state ) => ( reusableBlock ) => {
+ const icon = ! reusableBlock.wp_pattern_sync_status
+ ? {
+ src: symbol,
+ foreground: 'var(--wp-block-synced-color)',
+ }
+ : symbol;
+ const userPattern = mapUserPattern( reusableBlock );
+ const { time, count = 0 } = getInsertUsage( state, userPattern.name ) || {};
+ const frecency = calculateFrecency( time, count );
+
+ return {
+ id: userPattern.name,
+ name: 'core/block',
+ initialAttributes: { ref: reusableBlock.id },
+ title: userPattern.title,
+ icon,
+ category: 'reusable',
+ keywords: [ 'reusable' ],
+ isDisabled: false,
+ utility: 1, // Deprecated.
+ frecency,
+ content: userPattern.content,
+ get blocks() {
+ return getParsedPattern( userPattern ).blocks;
+ },
+ syncStatus: userPattern.syncStatus,
+ };
+};
+
+/*
+ * The two lists below are built independently of the root, so an item is the
+ * same object for every root and `getInserterItems` only filters. The nesting
+ * is for identity, not speed: the per-root cache cannot keep the objects.
+ */
+
+// Read from the default registry, like `getBlockTypes()`.
+const getBlockVariationsRaw = () =>
+ unlock( globalSelect( blocksStore ) ).getBlockVariationsRaw();
+
+/**
+ * Returns an inserter item for every registered block type that supports the
+ * inserter, variations expanded and core blocks first.
+ *
+ * @param {Object} state Editor state.
+ *
+ * @return {WPEditorInserterItem[]} Block type inserter items.
+ */
+const getBlockTypeInserterItems = createSelector(
+ ( state ) => {
+ const buildBlockTypeInserterItem = buildBlockTypeItem( state, {
+ buildScope: 'inserter',
+ } );
+
+ const items = getBlockTypes()
+ .filter( ( blockType ) =>
+ hasBlockSupport( blockType, 'inserter', true )
+ )
+ .map( buildBlockTypeInserterItem )
+ .reduce( ( accumulator, item ) => {
+ const { variations = [] } = item;
+ // Exclude any block type item that is to be replaced by a default variation.
+ if ( ! variations.some( ( { isDefault } ) => isDefault ) ) {
+ accumulator.push( item );
+ }
+ if ( variations.length ) {
+ const variationMapper = getItemFromVariation( state, item );
+ accumulator.push( ...variations.map( variationMapper ) );
+ }
+ return accumulator;
+ }, [] );
+
+ // Ensure core blocks are prioritized in the returned results,
+ // because third party blocks can be registered earlier than
+ // the core blocks (usually by using the `init` action),
+ // thus affecting the display order.
+ const groupByType = ( blocks, block ) => {
+ const { core, noncore } = blocks;
+ const type = block.name.startsWith( 'core/' ) ? core : noncore;
+
+ type.push( block );
+ return blocks;
+ };
+ const { core: coreItems, noncore: nonCoreItems } = items.reduce(
+ groupByType,
+ { core: [], noncore: [] }
+ );
+ return [ ...coreItems, ...nonCoreItems ];
+ },
+ ( state ) => [
+ getBlockTypes(),
+ getBlockVariationsRaw(),
+ state.blocks.order,
+ state.preferences.insertUsage,
+ ]
+);
+
+/**
+ * Returns an inserter item for every reusable block.
+ *
+ * @param {Object} state Editor state.
+ * @param {Array} reusableBlocks Reusable blocks, from `getReusableBlocks`.
+ *
+ * @return {WPEditorInserterItem[]} Reusable block inserter items.
+ */
+const getReusableBlockInserterItems = createSelector(
+ ( state, reusableBlocks ) =>
+ reusableBlocks.map( buildReusableBlockInserterItem( state ) ),
+ ( state, reusableBlocks ) => [
+ reusableBlocks,
+ state.preferences.insertUsage,
+ ]
+);
+
+const itemsWithRootFlag = new WeakMap();
+
+/**
+ * Returns a copy of the item carrying `isAllowedInCurrentRoot`, cached per
+ * item and flag so it is the same object for every root.
+ *
+ * @param {WPEditorInserterItem} item Inserter item.
+ * @param {boolean} isAllowedInCurrentRoot Whether the item can be inserted in the root.
+ *
+ * @return {WPEditorInserterItem} The flagged item.
+ */
+function withIsAllowedInCurrentRoot( item, isAllowedInCurrentRoot ) {
+ let variants = itemsWithRootFlag.get( item );
+ if ( ! variants ) {
+ variants = new Map();
+ itemsWithRootFlag.set( item, variants );
+ }
+ if ( ! variants.has( isAllowedInCurrentRoot ) ) {
+ variants.set( isAllowedInCurrentRoot, {
+ ...item,
+ isAllowedInCurrentRoot,
+ } );
+ }
+ return variants.get( isAllowedInCurrentRoot );
+}
+
/**
* Determines the items that appear in the inserter. Includes both static
* items (e.g. a regular block type) and dynamic items (e.g. a reusable block).
@@ -2446,63 +2590,25 @@ const buildBlockVariationItem = ( state, item ) => ( variation ) => {
export const getInserterItems = createRegistrySelector( ( select ) =>
createSelector(
( state, rootClientId = null, options = DEFAULT_INSERTER_OPTIONS ) => {
- const buildReusableBlockInserterItem = ( reusableBlock ) => {
- const icon = ! reusableBlock.wp_pattern_sync_status
- ? {
- src: symbol,
- foreground: 'var(--wp-block-synced-color)',
- }
- : symbol;
- const userPattern = mapUserPattern( reusableBlock );
- const { time, count = 0 } =
- getInsertUsage( state, userPattern.name ) || {};
- const frecency = calculateFrecency( time, count );
-
- return {
- id: userPattern.name,
- name: 'core/block',
- initialAttributes: { ref: reusableBlock.id },
- title: userPattern.title,
- icon,
- category: 'reusable',
- keywords: [ 'reusable' ],
- isDisabled: false,
- utility: 1, // Deprecated.
- frecency,
- content: userPattern.content,
- get blocks() {
- return getParsedPattern( userPattern ).blocks;
- },
- syncStatus: userPattern.syncStatus,
- };
- };
-
const patternInserterItems = canInsertBlockTypeUnmemoized(
state,
'core/block',
rootClientId
)
- ? unlock( select( STORE_NAME ) )
- .getReusableBlocks()
- .map( buildReusableBlockInserterItem )
+ ? getReusableBlockInserterItems(
+ state,
+ unlock( select( STORE_NAME ) ).getReusableBlocks()
+ )
: [];
- const buildBlockTypeInserterItem = buildBlockTypeItem( state, {
- buildScope: 'inserter',
- } );
-
- let blockTypeInserterItems = getBlockTypes()
- .filter( ( blockType ) =>
- hasBlockSupport( blockType, 'inserter', true )
- )
- .map( buildBlockTypeInserterItem );
+ let blockTypeInserterItems = getBlockTypeInserterItems( state );
if ( options[ isFiltered ] !== false ) {
blockTypeInserterItems = blockTypeInserterItems.filter(
- ( blockType ) =>
+ ( item ) =>
canIncludeBlockTypeInInserter(
state,
- blockType,
+ item,
rootClientId
)
);
@@ -2543,56 +2649,20 @@ export const getInserterItems = createRegistrySelector( ( select ) =>
) {
continue;
}
- allowedItems.push( {
- ...blockType,
- isAllowedInCurrentRoot,
- } );
+ allowedItems.push(
+ withIsAllowedInCurrentRoot(
+ blockType,
+ isAllowedInCurrentRoot
+ )
+ );
}
blockTypeInserterItems = allowedItems;
}
- const items = blockTypeInserterItems.reduce(
- ( accumulator, item ) => {
- const { variations = [] } = item;
- // Exclude any block type item that is to be replaced by a default variation.
- if ( ! variations.some( ( { isDefault } ) => isDefault ) ) {
- accumulator.push( item );
- }
- if ( variations.length ) {
- const variationMapper = getItemFromVariation(
- state,
- item
- );
- accumulator.push(
- ...variations.map( variationMapper )
- );
- }
- return accumulator;
- },
- []
- );
-
- // Ensure core blocks are prioritized in the returned results,
- // because third party blocks can be registered earlier than
- // the core blocks (usually by using the `init` action),
- // thus affecting the display order.
- // We don't sort reusable blocks as they are handled differently.
- const groupByType = ( blocks, block ) => {
- const { core, noncore } = blocks;
- const type = block.name.startsWith( 'core/' ) ? core : noncore;
-
- type.push( block );
- return blocks;
- };
- const { core: coreItems, noncore: nonCoreItems } = items.reduce(
- groupByType,
- { core: [], noncore: [] }
- );
- const sortedBlockTypes = [ ...coreItems, ...nonCoreItems ];
- return [ ...sortedBlockTypes, ...patternInserterItems ];
+ return [ ...blockTypeInserterItems, ...patternInserterItems ];
},
( state, rootClientId ) => [
- getBlockTypes(),
+ getBlockTypeInserterItems( state ),
unlock( select( STORE_NAME ) ).getReusableBlocks(),
state.blocks.order,
state.preferences.insertUsage,
diff --git a/packages/block-editor/src/store/test/registry-selectors.jsdom.test.js b/packages/block-editor/src/store/test/registry-selectors.jsdom.test.js
index 6a602e043595c3..485ad8ac0445d9 100644
--- a/packages/block-editor/src/store/test/registry-selectors.jsdom.test.js
+++ b/packages/block-editor/src/store/test/registry-selectors.jsdom.test.js
@@ -7,9 +7,15 @@ import {
expect,
it,
} from 'vitest';
-import { registerBlockType, unregisterBlockType } from '@wordpress/blocks';
+import {
+ registerBlockType,
+ registerBlockVariation,
+ unregisterBlockType,
+ unregisterBlockVariation,
+} from '@wordpress/blocks';
import { select, dispatch } from '@wordpress/data';
import { store } from '../';
+import { isFiltered } from '../utils';
describe( 'selectors', () => {
beforeEach( () => {
@@ -432,4 +438,25 @@ describe( 'selectors', () => {
} );
} );
} );
+
+ describe( 'getInserterItems', () => {
+ const options = { [ isFiltered ]: false };
+ const getItemIds = () =>
+ select( store )
+ .getInserterItems( null, options )
+ .map( ( { id } ) => id );
+
+ it( 'should reflect registered and unregistered variations', () => {
+ expect( getItemIds() ).not.toContain( 'core/test-block-a/foo' );
+
+ registerBlockVariation( 'core/test-block-a', {
+ name: 'foo',
+ title: 'Foo',
+ } );
+ expect( getItemIds() ).toContain( 'core/test-block-a/foo' );
+
+ unregisterBlockVariation( 'core/test-block-a', 'foo' );
+ expect( getItemIds() ).not.toContain( 'core/test-block-a/foo' );
+ } );
+ } );
} );
diff --git a/packages/block-editor/src/store/test/selectors.jsdom.test.jsx b/packages/block-editor/src/store/test/selectors.jsdom.test.jsx
index 76d6c391e1f559..87db2eace65262 100644
--- a/packages/block-editor/src/store/test/selectors.jsdom.test.jsx
+++ b/packages/block-editor/src/store/test/selectors.jsdom.test.jsx
@@ -25,6 +25,7 @@ import * as selectors from '../selectors';
import { store } from '../';
import { lock } from '../../lock-unlock';
import { sectionRootClientIdKey } from '../private-keys';
+import { isFiltered } from '../utils';
const {
getBlockName,
@@ -3934,6 +3935,56 @@ describe( 'selectors', () => {
);
} );
+ it( 'should reuse item objects across roots', async () => {
+ await dispatch( store ).resetBlocks( [
+ {
+ clientId: 'block3',
+ name: 'core/test-block-a',
+ innerBlocks: [],
+ },
+ {
+ clientId: 'block4',
+ name: 'core/test-block-a',
+ innerBlocks: [],
+ },
+ ] );
+ await dispatch( store ).updateBlockListSettings( 'block3', {} );
+ await dispatch( store ).updateBlockListSettings( 'block4', {} );
+
+ const forFirstRoot = select( store ).getInserterItems( 'block3' );
+ const forSecondRoot = select( store ).getInserterItems( 'block4' );
+ expect( forFirstRoot ).not.toBe( forSecondRoot );
+ expect( forFirstRoot.length ).toBeGreaterThan( 0 );
+ forFirstRoot.forEach( ( item, index ) => {
+ expect( item ).toBe( forSecondRoot[ index ] );
+ } );
+
+ // The copies carrying `isAllowedInCurrentRoot` are shared too.
+ const allForFirstRoot = select( store ).getInserterItems(
+ 'block3',
+ {
+ [ isFiltered ]: false,
+ }
+ );
+ const allForSecondRoot = select( store ).getInserterItems(
+ 'block4',
+ { [ isFiltered ]: false }
+ );
+ allForFirstRoot.forEach( ( item, index ) => {
+ expect( item ).toBe( allForSecondRoot[ index ] );
+ } );
+
+ // A change under another root does not rebuild the items either.
+ await dispatch( store ).updateBlockListSettings( 'block4', {
+ allowedBlocks: [ 'core/test-block-b' ],
+ } );
+ const afterChange = select( store ).getInserterItems( 'block3' );
+ afterChange.forEach( ( item, index ) => {
+ expect( item ).toBe( forFirstRoot[ index ] );
+ } );
+ await dispatch( store ).updateBlockListSettings( 'block4', {} );
+ } );
+
it( 'should set isDisabled when a block with `multiple: false` has been used', async () => {
await dispatch( store ).resetBlocks( [
{
diff --git a/packages/blocks/src/store/private-selectors.ts b/packages/blocks/src/store/private-selectors.ts
index d8ca62e047771b..2cf94258f16fb6 100644
--- a/packages/blocks/src/store/private-selectors.ts
+++ b/packages/blocks/src/store/private-selectors.ts
@@ -9,6 +9,7 @@ import type {
BlockBindingsSource,
BlockShortcut,
BlockType,
+ BlockVariation,
} from '../types';
/**
@@ -245,6 +246,21 @@ export function getUnprocessedBlockTypes(
return state.unprocessedBlockTypes;
}
+/**
+ * Returns the registered block variations of every block type, keyed by block
+ * name. The reference changes whenever a variation is added or removed, so it
+ * can serve as a cache dependency.
+ *
+ * @param state Data state.
+ *
+ * @return Block variations keyed by block name.
+ */
+export function getBlockVariationsRaw(
+ state: BlockStoreState
+): Record< string, BlockVariation[] > {
+ return state.blockVariations;
+}
+
/**
* Returns all the block bindings sources registered.
*