Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Block Bindings: Keep bindings editor calls in their own logic useSelect #65604

Draft
wants to merge 5 commits into
base: trunk
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 35 additions & 24 deletions packages/block-editor/src/hooks/block-bindings.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 ]
);
Expand All @@ -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;
Expand Down
75 changes: 42 additions & 33 deletions packages/block-editor/src/hooks/use-bindings-attributes.js
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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 */
Expand Down Expand Up @@ -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 ];
Expand All @@ -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,
Expand Down Expand Up @@ -188,10 +195,9 @@ export const withBlockBindingSupport = createHigherOrderComponent(
}
}
}

return attributes;
},
[ blockBindings, name, clientId, updatedContext, sources ]
[ blockBindingsBySource, clientId, updatedContext ]
);

const hasParentPattern = !! updatedContext[ 'pattern/overrides' ];
Expand All @@ -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(
Expand All @@ -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,
Expand Down Expand Up @@ -272,6 +280,7 @@ export const withBlockBindingSupport = createHigherOrderComponent(
[
registry,
blockBindings,
blockBindingsBySource,
name,
clientId,
updatedContext,
Expand Down
79 changes: 47 additions & 32 deletions packages/editor/src/bindings/post-meta.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
* WordPress dependencies
*/
import { store as coreDataStore } from '@wordpress/core-data';
import { createSelector } from '@wordpress/data';

/**
* Internal dependencies
Expand Down Expand Up @@ -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',
Expand Down
Loading
Loading