diff --git a/packages/block-editor/src/hooks/block-bindings.js b/packages/block-editor/src/hooks/block-bindings.js index 5c002613831ce..ad6211bdfbc78 100644 --- a/packages/block-editor/src/hooks/block-bindings.js +++ b/packages/block-editor/src/hooks/block-bindings.js @@ -34,8 +34,6 @@ import { store as blockEditorStore } from '../store'; const { DropdownMenuV2 } = unlock( componentsPrivateApis ); -const EMPTY_OBJECT = {}; - const useToolsPanelDropdownMenuProps = () => { const isMobile = useViewportMatch( 'medium', '<' ); return ! isMobile @@ -192,46 +190,52 @@ export const BlockBindingsPanel = ( { name: blockName, metadata } ) => { const bindableAttributes = getBindableAttributes( blockName ); const dropdownMenuProps = useToolsPanelDropdownMenuProps(); - // `useSelect` is used purposely here to ensure `getFieldsList` - // is updated whenever there are updates in block context. - // `source.getFieldsList` may also call a selector via `select`. - const _fieldsList = {}; - const { fieldsList, canUpdateBlockBindings } = useSelect( + const { canUpdateBlockBindings } = useSelect( ( select ) => { + return { + canUpdateBlockBindings: + select( blockEditorStore ).getSettings().canUpdateBlockBindings, + }; + }, [] ); + + /** + * Create new selector for fieldsList to avoid unnecessary re-renders. + * See: https://github.com/WordPress/gutenberg/pull/64072#discussion_r1764693730 + * + * `useSelect` is used purposely here to ensure `getFieldsList` is updated + * whenever there are updates in block context. + * `source.getFieldsList` may also call a selector via `registry.select`. + */ + // A constant object needs to be used to avoid unnecessary re-renders. + const context = {}; + const fieldsList = useSelect( ( select ) => { if ( ! bindableAttributes || bindableAttributes.length === 0 ) { - return EMPTY_OBJECT; + return; } + const _fieldsList = {}; const registeredSources = getBlockBindingsSources(); Object.entries( registeredSources ).forEach( ( [ sourceName, { getFieldsList, usesContext } ] ) => { if ( getFieldsList ) { // Populate context. - const context = {}; if ( usesContext?.length ) { for ( const key of usesContext ) { context[ key ] = blockContext[ key ]; } } - const sourceList = getFieldsList( { + _fieldsList[ sourceName ] = getFieldsList( { select, context, } ); - // Only add source if the list is not empty. - if ( Object.keys( sourceList || {} ).length ) { - _fieldsList[ sourceName ] = { ...sourceList }; - } + + // Clean `context` variable for next iterations. + Object.keys( context ).forEach( ( key ) => { + delete context[ key ]; + } ); } } ); - return { - fieldsList: - Object.values( _fieldsList ).length > 0 - ? _fieldsList - : EMPTY_OBJECT, - canUpdateBlockBindings: - select( blockEditorStore ).getSettings() - .canUpdateBlockBindings, - }; + return _fieldsList; }, [ blockContext, bindableAttributes ] ); @@ -251,9 +255,16 @@ export const BlockBindingsPanel = ( { name: blockName, metadata } ) => { } } ); + // Remove empty sources from the list of fields. + Object.entries( fieldsList || {} ).forEach( ( [ key, value ] ) => { + if ( ! Object.keys( value || {} ).length ) { + delete fieldsList[ key ]; + } + } ); + // Lock the UI when the user can't update bindings or there are no fields to connect to. const readOnly = - ! canUpdateBlockBindings || ! Object.keys( fieldsList ).length; + ! canUpdateBlockBindings || ! Object.keys( fieldsList || {} ).length; if ( readOnly && Object.keys( filteredBindings ).length === 0 ) { return null; diff --git a/packages/block-editor/src/hooks/use-bindings-attributes.js b/packages/block-editor/src/hooks/use-bindings-attributes.js index fdc617fda20c0..68c302eb06681 100644 --- a/packages/block-editor/src/hooks/use-bindings-attributes.js +++ b/packages/block-editor/src/hooks/use-bindings-attributes.js @@ -1,7 +1,7 @@ /** * WordPress dependencies */ -import { store as blocksStore } from '@wordpress/blocks'; +import { getBlockBindingsSources } from '@wordpress/blocks'; import { createHigherOrderComponent } from '@wordpress/compose'; import { useRegistry, useSelect } from '@wordpress/data'; import { useCallback, useMemo, useContext } from '@wordpress/element'; @@ -11,7 +11,6 @@ import { addFilter } from '@wordpress/hooks'; * Internal dependencies */ import isURLLike from '../components/link-control/is-url-like'; -import { unlock } from '../lock-unlock'; import BlockContext from '../components/block-context'; /** @typedef {import('@wordpress/compose').WPHigherOrderComponent} WPHigherOrderComponent */ @@ -100,36 +99,24 @@ export const withBlockBindingSupport = createHigherOrderComponent( ( BlockEdit ) => ( props ) => { const registry = useRegistry(); const blockContext = useContext( BlockContext ); - const sources = useSelect( ( select ) => - unlock( select( blocksStore ) ).getAllBlockBindingsSources() - ); + const sources = getBlockBindingsSources(); const { name, clientId, context, setAttributes } = props; - const blockBindings = useMemo( - () => - replacePatternOverrideDefaultBindings( + const { blockBindings, blockBindingsBySource, updatedContext } = + useMemo( () => { + const _blockBindings = replacePatternOverrideDefaultBindings( name, props.attributes.metadata?.bindings - ), - [ props.attributes.metadata?.bindings, name ] - ); + ); + const _updatedContext = {}; - // While this hook doesn't directly call any selectors, `useSelect` is - // used purposely here to ensure `boundAttributes` is updated whenever - // there are attribute updates. - // `source.getValues` may also call a selector via `registry.select`. - const updatedContext = {}; - const boundAttributes = useSelect( - ( select ) => { - if ( ! blockBindings ) { - return; + if ( ! _blockBindings ) { + return { updatedContext: _updatedContext }; } - const attributes = {}; - - const blockBindingsBySource = new Map(); + const _blockBindingsBySource = new Map(); for ( const [ attributeName, binding ] of Object.entries( - blockBindings + _blockBindings ) ) { const { source: sourceName, args: sourceArgs } = binding; const source = sources[ sourceName ]; @@ -142,18 +129,38 @@ export const withBlockBindingSupport = createHigherOrderComponent( // Populate context. for ( const key of source.usesContext || [] ) { - updatedContext[ key ] = blockContext[ key ]; + _updatedContext[ key ] = blockContext[ key ]; } - blockBindingsBySource.set( source, { - ...blockBindingsBySource.get( source ), + _blockBindingsBySource.set( source, { + ..._blockBindingsBySource.get( source ), [ attributeName ]: { args: sourceArgs, }, } ); } - if ( blockBindingsBySource.size ) { + return { + blockBindings: _blockBindings, + updatedContext: _updatedContext, + blockBindingsBySource: _blockBindingsBySource, + }; + }, [ + props.attributes.metadata?.bindings, + name, + context, + blockContext, + sources, + ] ); + + // While this hook doesn't directly call any selectors, `useSelect` is + // used purposely here to ensure `boundAttributes` is updated whenever + // there are attribute updates. + // `source.getValues` may also call a selector via `registry.select`. + const boundAttributes = useSelect( + ( select ) => { + const attributes = {}; + if ( blockBindingsBySource?.size ) { for ( const [ source, bindings, @@ -188,10 +195,9 @@ export const withBlockBindingSupport = createHigherOrderComponent( } } } - return attributes; }, - [ blockBindings, name, clientId, updatedContext, sources ] + [ blockBindingsBySource, clientId, updatedContext ] ); const hasParentPattern = !! updatedContext[ 'pattern/overrides' ]; @@ -208,7 +214,6 @@ export const withBlockBindingSupport = createHigherOrderComponent( } const keptAttributes = { ...nextAttributes }; - const blockBindingsBySource = new Map(); // Loop only over the updated attributes to avoid modifying the bound ones that haven't changed. for ( const [ attributeName, newValue ] of Object.entries( @@ -226,17 +231,20 @@ export const withBlockBindingSupport = createHigherOrderComponent( if ( ! source?.setValues ) { continue; } + // Add the new value to the existing source bindings. blockBindingsBySource.set( source, { ...blockBindingsBySource.get( source ), [ attributeName ]: { - args: binding.args, + ...blockBindingsBySource.get( source )?.[ + attributeName + ], newValue, }, } ); delete keptAttributes[ attributeName ]; } - if ( blockBindingsBySource.size ) { + if ( blockBindingsBySource?.size ) { for ( const [ source, bindings, @@ -272,6 +280,7 @@ export const withBlockBindingSupport = createHigherOrderComponent( [ registry, blockBindings, + blockBindingsBySource, name, clientId, updatedContext, diff --git a/packages/editor/src/bindings/post-meta.js b/packages/editor/src/bindings/post-meta.js index 9198ac0fe41e1..1b48739cbec66 100644 --- a/packages/editor/src/bindings/post-meta.js +++ b/packages/editor/src/bindings/post-meta.js @@ -2,6 +2,7 @@ * WordPress dependencies */ import { store as coreDataStore } from '@wordpress/core-data'; +import { createSelector } from '@wordpress/data'; /** * Internal dependencies @@ -34,42 +35,56 @@ import { unlock } from '../lock-unlock'; * } * ``` */ -function getPostMetaFields( select, context ) { - const { getEditedEntityRecord } = select( coreDataStore ); - const { getRegisteredPostMeta } = unlock( select( coreDataStore ) ); +const getPostMetaFields = createSelector( + ( select, context ) => { + const { getEditedEntityRecord } = select( coreDataStore ); + const { getRegisteredPostMeta } = unlock( select( coreDataStore ) ); - let entityMetaValues; - // Try to get the current entity meta values. - if ( context?.postType && context?.postId ) { - entityMetaValues = getEditedEntityRecord( - 'postType', - context?.postType, - context?.postId - ).meta; - } - - const registeredFields = getRegisteredPostMeta( context?.postType ); - const metaFields = {}; - Object.entries( registeredFields || {} ).forEach( ( [ key, props ] ) => { - // Don't include footnotes or private fields. - if ( key !== 'footnotes' && key.charAt( 0 ) !== '_' ) { - metaFields[ key ] = { - label: props.title || key, - value: - // When using the entity value, an empty string IS a valid value. - entityMetaValues?.[ key ] ?? - // When using the default, an empty string IS NOT a valid value. - ( props.default || undefined ), - }; + let entityMetaValues; + // Try to get the current entity meta values. + if ( context?.postType && context?.postId ) { + entityMetaValues = getEditedEntityRecord( + 'postType', + context?.postType, + context?.postId + ).meta; } - } ); - if ( ! Object.keys( metaFields || {} ).length ) { - return null; - } + const registeredFields = getRegisteredPostMeta( context?.postType ); + const metaFields = {}; + Object.entries( registeredFields || {} ).forEach( + ( [ key, props ] ) => { + // Don't include footnotes or private fields. + if ( key !== 'footnotes' && key.charAt( 0 ) !== '_' ) { + metaFields[ key ] = { + label: props.title || key, + value: + // When using the entity value, an empty string IS a valid value. + entityMetaValues?.[ key ] ?? + // When using the default, an empty string IS NOT a valid value. + ( props.default || undefined ), + }; + } + } + ); - return metaFields; -} + if ( ! Object.keys( metaFields || {} ).length ) { + return null; + } + + return metaFields; + }, + ( select, context ) => [ + select( coreDataStore ).getEditedEntityRecord( + 'postType', + context.postType, + context.postId + ).meta, + unlock( select( coreDataStore ) ).getRegisteredPostMeta( + context.postType + ), + ] +); export default { name: 'core/post-meta', diff --git a/test/e2e/specs/editor/various/block-bindings/post-meta.spec.js b/test/e2e/specs/editor/various/block-bindings/post-meta.spec.js index d82def6feb66b..d64e813c21db1 100644 --- a/test/e2e/specs/editor/various/block-bindings/post-meta.spec.js +++ b/test/e2e/specs/editor/various/block-bindings/post-meta.spec.js @@ -144,29 +144,25 @@ test.describe( 'Post Meta source', () => { editor, page, } ) => { - /** - * Create connection manually until this issue is solved: - * https://github.com/WordPress/gutenberg/pull/65604 - * - * Once solved, block with the binding can be directly inserted. - */ await editor.insertBlock( { name: 'core/paragraph', + attributes: { + content: 'fallback content', + metadata: { + bindings: { + content: { + source: 'core/post-meta', + args: { + key: 'movie_field', + }, + }, + }, + }, + }, } ); - await page.getByLabel( 'Attributes options' ).click(); - await page - .getByRole( 'menuitemcheckbox', { - name: 'Show content', - } ) - .click(); const contentBinding = page.getByRole( 'button', { name: 'content', } ); - await contentBinding.click(); - await page - .getByRole( 'menuitemradio' ) - .filter( { hasText: 'Movie field label' } ) - .click(); await expect( contentBinding ).toContainText( 'Movie field label' ); @@ -175,29 +171,25 @@ test.describe( 'Post Meta source', () => { editor, page, } ) => { - /** - * Create connection manually until this issue is solved: - * https://github.com/WordPress/gutenberg/pull/65604 - * - * Once solved, block with the binding can be directly inserted. - */ await editor.insertBlock( { name: 'core/paragraph', + attributes: { + content: 'fallback content', + metadata: { + bindings: { + content: { + source: 'core/post-meta', + args: { + key: 'field_without_label_or_default', + }, + }, + }, + }, + }, } ); - await page.getByLabel( 'Attributes options' ).click(); - await page - .getByRole( 'menuitemcheckbox', { - name: 'Show content', - } ) - .click(); const contentBinding = page.getByRole( 'button', { name: 'content', } ); - await contentBinding.click(); - await page - .getByRole( 'menuitemradio' ) - .filter( { hasText: 'field_without_label_or_default' } ) - .click(); await expect( contentBinding ).toContainText( 'field_without_label_or_default' );