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. *