From 78d1ffc7eadccc77e38e32196aa36fed50e8db32 Mon Sep 17 00:00:00 2001 From: George Mamadashvili Date: Mon, 21 Sep 2026 21:43:22 +0400 Subject: [PATCH 1/6] Inserter: Resolve the insertion destination when inserting `useInsertionPoint` derived the destination in `useSelect` and closed over it in `onInsertBlocks` and `onToggleInsertionPoint`. The destination follows the block selection, so both callbacks changed identity on every block click and everything the inserter passed them to re-rendered. Read the destination from the store when the callbacks run instead. Prerequisite for keeping the inserter list stable while the selection moves. Refs https://github.com/WordPress/gutenberg/issues/67805 Co-Authored-By: Claude Fable 5.1 --- .../inserter/hooks/use-insertion-point.js | 136 ++++++++++-------- 1 file changed, 80 insertions(+), 56 deletions(-) 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..476267df905fe8 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 @@ -34,6 +34,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 +118,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 ] ); @@ -139,6 +151,13 @@ 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 } + ); + const selectedBlock = getSelectedBlock(); if ( @@ -157,15 +176,14 @@ function useInsertionPoint( { insertBlocks( blocks, isAppender || _rootClientId === undefined - ? destinationIndex + ? destination.destinationIndex : getIndex( { - destinationRootClientId, - destinationIndex, + ...destination, rootClientId: _rootClientId, registry, } ), isAppender || _rootClientId === undefined - ? destinationRootClientId + ? destination.destinationRootClientId : _rootClientId, selectBlockOnInsert, shouldFocusBlock || shouldForceFocusBlock ? 0 : null, @@ -185,12 +203,13 @@ function useInsertionPoint( { } }, [ + rootClientId, + insertionIndex, + clientId, isAppender, getSelectedBlock, replaceBlocks, insertBlocks, - destinationRootClientId, - destinationIndex, onSelect, shouldFocusBlock, selectBlockOnInsert, @@ -202,17 +221,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 +250,14 @@ function useInsertionPoint( { } }, [ + rootClientId, + insertionIndex, + clientId, + isAppender, getClosestAllowedInsertionPoint, getBlockInsertionPoint, showInsertionPoint, hideInsertionPoint, - destinationRootClientId, - destinationIndex, registry, ] ); From d280eab9715c19d1963c344d772615ffa76103a2 Mon Sep 17 00:00:00 2001 From: George Mamadashvili Date: Mon, 21 Sep 2026 21:43:26 +0400 Subject: [PATCH 2/6] Block Editor: Return the same inserter items for every root `getInserterItems` rebuilt every item object per root, so a selection change handed the inserter a new item list and defeated the `memo` on its list items. Build the block type and reusable block items once, independent of the root, and only filter them per root. The copies that carry `isAllowedInCurrentRoot` are cached per item and flag so they keep their identity too. `useBlockTypesState` returns the array bare so `useSelect` can hand back the previous one when the items match, and `BlockTypesTab` memoizes its split. Selector output is unchanged. Refs https://github.com/WordPress/gutenberg/issues/67805 Co-Authored-By: Claude Fable 5.1 --- .../components/inserter/block-types-tab.jsx | 40 +-- .../inserter/hooks/use-block-types-state.js | 6 +- packages/block-editor/src/store/selectors.js | 237 +++++++++++------- .../src/store/test/selectors.jsdom.test.jsx | 51 ++++ 4 files changed, 225 insertions(+), 109 deletions(-) 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..b50edf22473c12 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 @@ -26,13 +26,13 @@ const useBlockTypesState = ( rootClientId, onInsert, isQuick ) => { () => ( { [ isFiltered ]: !! isQuick } ), [ isQuick ] ); - const [ items ] = useSelect( - ( select ) => [ + // 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( diff --git a/packages/block-editor/src/store/selectors.js b/packages/block-editor/src/store/selectors.js index 1c61fbc232a232..ae02512cdb1301 100644 --- a/packages/block-editor/src/store/selectors.js +++ b/packages/block-editor/src/store/selectors.js @@ -2414,6 +2414,141 @@ 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. + */ + +/** + * 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(), + 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 +2581,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,53 +2640,17 @@ 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(), 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( [ { From b3b1663f9867f5f4570bf30e4c0798123816c925 Mon Sep 17 00:00:00 2001 From: George Mamadashvili Date: Mon, 21 Sep 2026 21:43:30 +0400 Subject: [PATCH 3/6] Inserter: Resolve the allowed container when inserting `useBlockTypesState` resolved the closest allowed container from the rendered root inside `onSelectItem`, so the callback changed identity on every selection change and the memoized list items re-rendered anyway. Move that resolution and the "can't be inserted" notice into `onInsertBlocks`, next to where the hover cue already resolves it. Blocks inserted from the media tab now land in the closest container that accepts them, or show the notice, instead of being dropped silently by `insertBlocks`. Open-inserter click penalty against the closed baseline of the same run: +26ms -> +6ms, and +109ms -> +36ms at 4x CPU throttle. Item renders per click: 224 -> 6. Refs https://github.com/WordPress/gutenberg/issues/67805 Co-Authored-By: Claude Fable 5.1 --- packages/block-editor/CHANGELOG.md | 1 + .../inserter/hooks/use-block-types-state.js | 44 ++----------------- .../inserter/hooks/use-insertion-point.js | 35 +++++++++++++-- 3 files changed, 35 insertions(+), 45 deletions(-) diff --git a/packages/block-editor/CHANGELOG.md b/packages/block-editor/CHANGELOG.md index 1cba91476c58e7..5047bd1997d454 100644 --- a/packages/block-editor/CHANGELOG.md +++ b/packages/block-editor/CHANGELOG.md @@ -45,6 +45,7 @@ - In-between inserter: Show the inserter between blocks on a wrapped line of a horizontal container, such as the Buttons, Row, Gallery and Navigation blocks ([#83276](https://github.com/WordPress/gutenberg/pull/83276)). - In-between inserter: Resolve the hovered block through the block refs rather than the container's DOM children, so the inserter also appears in lists whose blocks apply their block props to an inner element, such as Social Icons ([#83327](https://github.com/WordPress/gutenberg/pull/83327)). - In HEIC-only upload mode, route files to the HEIC conversion by what they contain rather than by the MIME type the browser infers from their name, so a HEIC photo saved as `.jpg` or `.png` is no longer handed to a server-side path that cannot convert it either ([#81737](https://github.com/WordPress/gutenberg/pull/81737)). +- 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)). ### Internal 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 b50edf22473c12..207a3481c08e14 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,13 @@ import { - getBlockType, createBlock, createBlocksFromInnerBlocksTemplate, store as blocksStore, parse, } from '@wordpress/blocks'; -import { useSelect, useDispatch } from '@wordpress/data'; +import { useSelect } from '@wordpress/data'; import { useCallback, useMemo } from '@wordpress/element'; -import { store as noticesStore } from '@wordpress/notices'; -import { __, sprintf } from '@wordpress/i18n'; import { store as blockEditorStore } from '../../../store'; import { isFiltered } from '../../../store/utils'; -import { unlock } from '../../../lock-unlock'; /** * Retrieves the block types inserter state. @@ -35,10 +31,6 @@ const useBlockTypesState = ( rootClientId, onInsert, isQuick ) => { ), [ rootClientId, options ] ); - const { getClosestAllowedInsertionPoint } = unlock( - useSelect( blockEditorStore ) - ); - const { createErrorNotice } = useDispatch( noticesStore ); const [ categories, collections ] = useSelect( ( select ) => { const { getCategories, getCollections } = select( blocksStore ); @@ -57,26 +49,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 +60,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 476267df905fe8..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'; @@ -136,6 +137,7 @@ function useInsertionPoint( { hideInsertionPoint, setLastFocus, } = unlock( useDispatch( blockEditorStore ) ); + const { createErrorNotice } = useDispatch( noticesStore ); const onInsertBlocks = useCallback( ( blocks, meta, shouldForceFocusBlock = false, _rootClientId ) => { @@ -158,6 +160,29 @@ function useInsertionPoint( { { 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 ( @@ -175,14 +200,14 @@ function useInsertionPoint( { } else { insertBlocks( blocks, - isAppender || _rootClientId === undefined + isAppender ? destination.destinationIndex : getIndex( { ...destination, rootClientId: _rootClientId, registry, } ), - isAppender || _rootClientId === undefined + isAppender ? destination.destinationRootClientId : _rootClientId, selectBlockOnInsert, @@ -208,6 +233,8 @@ function useInsertionPoint( { clientId, isAppender, getSelectedBlock, + getClosestAllowedInsertionPoint, + createErrorNotice, replaceBlocks, insertBlocks, onSelect, From c3738145b939cad5f091c58e9866500f1ad22416 Mon Sep 17 00:00:00 2001 From: George Mamadashvili Date: Wed, 23 Sep 2026 13:09:50 +0400 Subject: [PATCH 4/6] Inserter: Share the selector options across mounts `useBlockTypesState` and `usePatternsState` built a new `options` object per mount, so `getInserterItems` and `getAllowedPatterns` missed their cache every time the inserter opened. Use module-level constants. Reopen with 1500 extra block types: `getInserterItems` 27.8 -> 0.1ms, first frame 47.8 -> 28.6ms (p<0.001). Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/block-editor/CHANGELOG.md | 2 +- .../inserter/hooks/use-block-types-state.js | 11 ++++++----- .../components/inserter/hooks/use-patterns-state.js | 9 +++++---- 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/packages/block-editor/CHANGELOG.md b/packages/block-editor/CHANGELOG.md index 5047bd1997d454..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) @@ -45,7 +46,6 @@ - In-between inserter: Show the inserter between blocks on a wrapped line of a horizontal container, such as the Buttons, Row, Gallery and Navigation blocks ([#83276](https://github.com/WordPress/gutenberg/pull/83276)). - In-between inserter: Resolve the hovered block through the block refs rather than the container's DOM children, so the inserter also appears in lists whose blocks apply their block props to an inner element, such as Social Icons ([#83327](https://github.com/WordPress/gutenberg/pull/83327)). - In HEIC-only upload mode, route files to the HEIC conversion by what they contain rather than by the MIME type the browser infers from their name, so a HEIC photo saved as `.jpg` or `.png` is no longer handed to a server-side path that cannot convert it either ([#81737](https://github.com/WordPress/gutenberg/pull/81737)). -- 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)). ### Internal 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 207a3481c08e14..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 @@ -5,10 +5,14 @@ import { parse, } from '@wordpress/blocks'; import { useSelect } from '@wordpress/data'; -import { useCallback, useMemo } from '@wordpress/element'; +import { useCallback } from '@wordpress/element'; import { store as blockEditorStore } from '../../../store'; 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 types inserter state. * @@ -18,10 +22,7 @@ import { isFiltered } from '../../../store/utils'; * @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 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 ) => 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 From 317d6e1a9e4ac5c3b6befe7690ef1ace2ec400e0 Mon Sep 17 00:00:00 2001 From: George Mamadashvili Date: Thu, 24 Sep 2026 14:05:46 +0400 Subject: [PATCH 5/6] Inserter: Refresh items when block variations change The inserter item caches did not depend on block variations, so an added or removed variation stayed stale. Trunk hid this because each inserter mount passed new options and missed the cache. Add a private `getBlockVariationsRaw` selector to the blocks store and use it as a dependency of `getBlockTypeInserterItems` and `getInserterItems`. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/block-editor/src/store/selectors.js | 12 +++++++- .../test/registry-selectors.jsdom.test.js | 29 ++++++++++++++++++- .../blocks/src/store/private-selectors.ts | 16 ++++++++++ 3 files changed, 55 insertions(+), 2 deletions(-) diff --git a/packages/block-editor/src/store/selectors.js b/packages/block-editor/src/store/selectors.js index ae02512cdb1301..632ec34f4736fc 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, @@ -2450,6 +2454,10 @@ const buildReusableBlockInserterItem = ( state ) => ( reusableBlock ) => { * 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. @@ -2501,6 +2509,7 @@ const getBlockTypeInserterItems = createSelector( }, ( state ) => [ getBlockTypes(), + getBlockVariationsRaw(), state.blocks.order, state.preferences.insertUsage, ] @@ -2654,6 +2663,7 @@ export const getInserterItems = createRegistrySelector( ( select ) => }, ( state, rootClientId ) => [ getBlockTypes(), + getBlockVariationsRaw(), 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/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. * From 38c6e54ec239b925c2a0dfdd8629a613cbe5f57d Mon Sep 17 00:00:00 2001 From: George Mamadashvili Date: Thu, 24 Sep 2026 14:09:07 +0400 Subject: [PATCH 6/6] Inserter: Depend on the block type items in getInserterItems `getInserterItems` repeated the inputs of `getBlockTypeInserterItems` in its dependencies. Depend on its result instead, so a new input to the inner selector reaches the outer cache. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/block-editor/src/store/selectors.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/block-editor/src/store/selectors.js b/packages/block-editor/src/store/selectors.js index 632ec34f4736fc..145368e17876fc 100644 --- a/packages/block-editor/src/store/selectors.js +++ b/packages/block-editor/src/store/selectors.js @@ -2662,8 +2662,7 @@ export const getInserterItems = createRegistrySelector( ( select ) => return [ ...blockTypeInserterItems, ...patternInserterItems ]; }, ( state, rootClientId ) => [ - getBlockTypes(), - getBlockVariationsRaw(), + getBlockTypeInserterItems( state ), unlock( select( STORE_NAME ) ).getReusableBlocks(), state.blocks.order, state.preferences.insertUsage,