Skip to content
Merged
3 changes: 3 additions & 0 deletions backport-changelog/6.9/9992.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
https://github.com/WordPress/wordpress-develop/pull/9992

* https://github.com/WordPress/gutenberg/pull/71820
100 changes: 69 additions & 31 deletions lib/compat/wordpress-6.9/block-bindings.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,21 @@ function ( $attributes, $block_type ) {
2
);

// The following filter can be removed once the minimum required WordPress version is 6.9 or newer.
add_filter(
'block_editor_settings_all',
function ( $editor_settings ) {
$editor_settings['__experimentalBlockBindingsSupportedAttributes'] = array();
foreach ( array_keys( WP_Block_Type_Registry::get_instance()->get_all_registered() ) as $block_type ) {
$supported_block_attributes = gutenberg_get_block_bindings_supported_attributes( $block_type );
if ( ! empty( $supported_block_attributes ) ) {
$editor_settings['__experimentalBlockBindingsSupportedAttributes'][ $block_type ] = $supported_block_attributes;
}
}
return $editor_settings;
}
);

/**
* Callback function for the render_block filter.
*
Expand Down Expand Up @@ -86,6 +101,59 @@ function gutenberg_block_bindings_render_block( $block_content, $block, $instanc
}
add_filter( 'render_block', 'gutenberg_block_bindings_render_block', 10, 3 );

/**
* Retrieves the list of block attributes supported by block bindings.
*
* @since 6.9.0
*
* @param string $block_type The block type whose supported attributes are being retrieved.
* @return array The list of block attributes that are supported by block bindings.
*/
function gutenberg_get_block_bindings_supported_attributes( $block_type ) {
// List of block attributes supported by Block Bindings in WP 6.8.
$block_bindings_supported_attributes_6_8 = array(
'core/paragraph' => array( 'content' ),
'core/heading' => array( 'content' ),
'core/image' => array( 'id', 'url', 'title', 'alt' ),
'core/button' => array( 'url', 'text', 'linkTarget', 'rel' ),
);

$supported_block_attributes =
$block_bindings_supported_attributes_6_8[ $block_type ] ??
array();

/**
* Filters the supported block attributes for block bindings.
*
* @since 6.9.0
*
* @param string[] $supported_block_attributes The block's attributes that are supported by block bindings.
* @param string $block_type The block type whose attributes are being filtered.
*/
$supported_block_attributes = apply_filters(
'block_bindings_supported_attributes',
$supported_block_attributes,
$block_type
);

/**
* Filters the supported block attributes for block bindings.
*
* The dynamic portion of the hook name, `$block_type`, refers to the block type
* whose attributes are being filtered.
*
* @since 6.9.0
*
* @param string[] $supported_block_attributes The block's attributes that are supported by block bindings.
*/
$supported_block_attributes = apply_filters(
"block_bindings_supported_attributes_{$block_type}",
$supported_block_attributes
);

return $supported_block_attributes;
}

/**
* Processes the block bindings and updates the block attributes with the values from the sources.
*
Expand Down Expand Up @@ -138,38 +206,8 @@ function gutenberg_process_block_bindings( $instance ) {
'core/image' => array( 'id', 'url', 'title', 'alt' ),
'core/button' => array( 'url', 'text', 'linkTarget', 'rel' ),
);
$supported_block_attributes =
$block_bindings_supported_attributes_6_8[ $block_type ] ??
array();

/**
* Filters the supported block attributes for block bindings.
*
* @since 6.9.0
*
* @param string[] $supported_block_attributes The block's attributes that are supported by block bindings.
* @param string $block_type The block type whose attributes are being filtered.
*/
$supported_block_attributes = apply_filters(
'block_bindings_supported_attributes',
$supported_block_attributes,
$block_type
);

/**
* Filters the supported block attributes for block bindings.
*
* The dynamic portion of the hook name, `$block_type`, refers to the block type
* whose attributes are being filtered.
*
* @since 6.9.0
*
* @param string[] $supported_block_attributes The block's attributes that are supported by block bindings.
*/
$supported_block_attributes = apply_filters(
"block_bindings_supported_attributes_{$block_type}",
$supported_block_attributes
);
$supported_block_attributes = gutenberg_get_block_bindings_supported_attributes( $block_type );

/*
* Remove attributes that we know are processed by WP 6.8 from the list,
Expand Down
28 changes: 23 additions & 5 deletions packages/block-editor/src/components/block-edit/edit.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,10 @@ import { useCallback, useContext, useMemo } from '@wordpress/element';
import BlockContext from '../block-context';
import isURLLike from '../link-control/is-url-like';
import {
canBindAttribute,
hasPatternOverridesDefaultBinding,
replacePatternOverridesDefaultBinding,
} from '../../utils/block-bindings';
import { store as blockEditorStore } from '../../store';
import { unlock } from '../../lock-unlock';

/**
Expand Down Expand Up @@ -56,6 +56,8 @@ const Edit = ( props ) => {

const EditWithFilters = withFilters( 'editor.BlockEdit' )( Edit );

const EMPTY_ARRAY = [];

const EditWithGeneratedProps = ( props ) => {
const { name, clientId, attributes, setAttributes } = props;
const registry = useRegistry();
Expand All @@ -66,6 +68,17 @@ const EditWithGeneratedProps = ( props ) => {
unlock( select( blocksStore ) ).getAllBlockBindingsSources(),
[]
);
const bindableAttributes = useSelect(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking at code vitals, this PR made the "hover" metric slower. My guess is it's because of this addition here. I think it's also responsible of a small impact on the "type" metrics (even though it's hard to see there)

I think the reason (a guess, not 100% certain) is that we prefer to avoid adding new useSelect calls as much as possible to these components that run for every block. Now, it's possible to solve this as we have a "private edit context" (or something like that) to solve this. Basically we group all the useSelect calls that we need for a block at a single place.

Can we try moving this there to see?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(Just saw the previous related discussion)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@cbravobernal, is that on your radar for 6.9?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've added it to the "Bugfixes" section in #67520.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@youknowriad Can you help me figure out where to put this specifically? The selector checks what attributes are supported by block bindings, which is information passed by the server (via getSettings()). Potentially, any block can have such attributes, which is why we're running this code for all blocks...

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe in this useSelect

and pass the info down in the privateContext variable.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@cbravobernal has filed a PR to address this: #72351

( select ) => {
const { __experimentalBlockBindingsSupportedAttributes } =
select( blockEditorStore ).getSettings();
return (
__experimentalBlockBindingsSupportedAttributes?.[ name ] ||
EMPTY_ARRAY
);
},
[ name ]
);

const { blockBindings, context, hasPatternOverrides } = useMemo( () => {
// Assign context values using the block type's declared context needs.
Expand All @@ -90,8 +103,8 @@ const EditWithGeneratedProps = ( props ) => {
}
return {
blockBindings: replacePatternOverridesDefaultBinding(
name,
attributes?.metadata?.bindings
attributes?.metadata?.bindings,
bindableAttributes
),
context: computedContext,
hasPatternOverrides: hasPatternOverridesDefaultBinding(
Expand Down Expand Up @@ -120,7 +133,10 @@ const EditWithGeneratedProps = ( props ) => {
) ) {
const { source: sourceName, args: sourceArgs } = binding;
const source = registeredSources[ sourceName ];
if ( ! source || ! canBindAttribute( name, attributeName ) ) {
if (
! source ||
! bindableAttributes.includes( attributeName )

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Getting errors in the site editor
image

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Uncaught TypeError: Cannot read properties of undefined (reading 'includes')

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fix: #72401
Revert: #72400

) {
continue;
}

Expand Down Expand Up @@ -172,6 +188,7 @@ const EditWithGeneratedProps = ( props ) => {
},
[
attributes,
bindableAttributes,
blockBindings,
clientId,
context,
Expand All @@ -197,7 +214,7 @@ const EditWithGeneratedProps = ( props ) => {
) ) {
if (
! blockBindings[ attributeName ] ||
! canBindAttribute( name, attributeName )
! bindableAttributes.includes( attributeName )
) {
continue;
}
Expand Down Expand Up @@ -250,6 +267,7 @@ const EditWithGeneratedProps = ( props ) => {
} );
},
[
bindableAttributes,
blockBindings,
clientId,
context,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@ import { useBlockRefProvider } from './use-block-refs';
import { useIntersectionObserver } from './use-intersection-observer';
import { useScrollIntoView } from './use-scroll-into-view';
import { useFlashEditableBlocks } from '../../use-flash-editable-blocks';
import { canBindBlock } from '../../../utils/block-bindings';
import { useFirefoxDraggableCompatibility } from './use-firefox-draggable-compatibility';

/**
Expand Down Expand Up @@ -128,14 +127,13 @@ export function useBlockProps( props = {}, { __unstableIsHtml } = {} ) {

const blockEditContext = useBlockEditContext();
const hasBlockBindings = !! blockEditContext[ blockBindingsKey ];
const bindingsStyle =
hasBlockBindings && canBindBlock( name )
? {
'--wp-admin-theme-color': 'var(--wp-block-synced-color)',
'--wp-admin-theme-color--rgb':
'var(--wp-block-synced-color--rgb)',
}
: {};
const bindingsStyle = hasBlockBindings

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I recall correctly, the check was in place to cover for the case when someone adds bindings in metadata, but the block doesn't support bindings.

@ockham ockham Sep 25, 2025

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, that makes sense. This is the only part where I had to change the logic.

useBlockProps doesn't use useSelect (nor does it read from any stores at all AFAICS); this makes sense to me, since it's a fairly low-level tool. I didn't want to introduce a dependency on a store, even if that means that the block could be shown with a purple border in the editor because it has bindings in the metadata even though the block doesn't support bindings.

TBH I'm not even sure is useBlockProps (and a modification of the block style, rather than e.g. adding a class name) is that right place for this. I looked around a bit, and other code that highlights reusable blocks (e.g. patterns or template parts) tends to do that quite a bit differently.

I'm willing to look into tightening the logic so it'll really only highlight the block if it does support bindings, but it looked like it needed further research how to best do this, and it seemed like a small enough edge case that I didn't want to block this PR by it.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't consider it a blocker. If you find a better place to inject this special styling, that would be the best solution for me. It can be handled seperately.

? {
'--wp-admin-theme-color': 'var(--wp-block-synced-color)',
'--wp-admin-theme-color--rgb':
'var(--wp-block-synced-color--rgb)',
}
: {};

// Ensures it warns only inside the `edit` implementation for the block.
if ( blockApiVersion < 2 && clientId === blockEditContext.clientId ) {
Expand Down
8 changes: 6 additions & 2 deletions packages/block-editor/src/components/rich-text/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,6 @@ import FormatEdit from './format-edit';
import { getAllowedFormats } from './utils';
import { Content, valueToHTMLString } from './content';
import { withDeprecations } from './with-deprecations';
import { canBindBlock } from '../../utils/block-bindings';
import BlockContext from '../block-context';

export const keyboardShortcutContext = createContext();
Expand Down Expand Up @@ -177,9 +176,14 @@ export function RichTextWrapper(

const { disableBoundBlock, bindingsPlaceholder, bindingsLabel } = useSelect(
( select ) => {
const { __experimentalBlockBindingsSupportedAttributes } =
select( blockEditorStore ).getSettings();

if (
! blockBindings?.[ identifier ] ||
! canBindBlock( blockName )
! (
blockName in __experimentalBlockBindingsSupportedAttributes
)
) {
return {};
}
Expand Down
92 changes: 49 additions & 43 deletions packages/block-editor/src/hooks/block-bindings.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,7 @@ import { useViewportMatch } from '@wordpress/compose';
/**
* Internal dependencies
*/
import {
canBindAttribute,
getBindableAttributes,
useBlockBindingsUtils,
} from '../utils/block-bindings';
import { useBlockBindingsUtils } from '../utils/block-bindings';
import { unlock } from '../lock-unlock';
import InspectorControls from '../components/inspector-controls';
import BlockContext from '../components/block-context';
Expand Down Expand Up @@ -205,52 +201,62 @@ function EditableBlockBindingsPanelItems( {
export const BlockBindingsPanel = ( { name: blockName, metadata } ) => {
const blockContext = useContext( BlockContext );
const { removeAllBlockBindings } = useBlockBindingsUtils();
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(
( select ) => {
if ( ! bindableAttributes || bindableAttributes.length === 0 ) {
return EMPTY_OBJECT;
}
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 { bindableAttributes, fieldsList, canUpdateBlockBindings } =
useSelect(
( select ) => {
const { __experimentalBlockBindingsSupportedAttributes } =
select( blockEditorStore ).getSettings();
const _bindableAttributes =
__experimentalBlockBindingsSupportedAttributes?.[
blockName
];
if (
! _bindableAttributes ||
_bindableAttributes.length === 0
) {
return EMPTY_OBJECT;
}
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( {
select,
context,
} );
// Only add source if the list is not empty.
if ( Object.keys( sourceList || {} ).length ) {
_fieldsList[ sourceName ] = { ...sourceList };
}
}
const sourceList = getFieldsList( {
select,
context,
} );
// Only add source if the list is not empty.
if ( Object.keys( sourceList || {} ).length ) {
_fieldsList[ sourceName ] = { ...sourceList };
}
}
}
);
return {
fieldsList:
Object.values( _fieldsList ).length > 0
? _fieldsList
: EMPTY_OBJECT,
canUpdateBlockBindings:
select( blockEditorStore ).getSettings()
.canUpdateBlockBindings,
};
},
[ blockContext, bindableAttributes ]
);
);
return {
bindableAttributes: _bindableAttributes,
fieldsList:
Object.values( _fieldsList ).length > 0
? _fieldsList
: EMPTY_OBJECT,
canUpdateBlockBindings:
select( blockEditorStore ).getSettings()
.canUpdateBlockBindings,
};
},
[ blockContext ]
);
// Return early if there are no bindable attributes.
if ( ! bindableAttributes || bindableAttributes.length === 0 ) {
return null;
Expand All @@ -260,7 +266,7 @@ export const BlockBindingsPanel = ( { name: blockName, metadata } ) => {
const filteredBindings = { ...bindings };
Object.keys( filteredBindings ).forEach( ( key ) => {
if (
! canBindAttribute( blockName, key ) ||
! bindableAttributes.includes( key ) &&

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@talldan, all e2e tests pass, but I would still appreciate some confirmation that this change doesn't introduce regressions. I noticed it when performing a post-merge check on trunk.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@gziolo Do you mean the change from || to &&? That does look kinda wrong now 🤔

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, it wasn't immediately apparent.

@talldan talldan Sep 30, 2025

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The || makes more sense to me. || would delete anything that's either a non-bindable attribute or has pattern overrides, which is what the comment above the code says.

With this change you only delete the attribute if it has a pattern overrides binding.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll file a fix for this tomorrow.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, looks like the second part of the criterion was already removed altogether: 2a9598c#diff-bff98e322c4269c50aa13e4b251beb9d53d2d0c3d4066406f735d2d49bed83a1L269-L270

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure the new version is quite correct TBH:

// Filter bindings to only show bindable attributes.
const { bindings } = metadata || {};
const filteredBindings = { ...bindings };
Object.keys( filteredBindings ).forEach( ( key ) => {
if ( ! bindableAttributes.includes( key ) ) {
delete filteredBindings[ key ];
}

Maybe we did this accidentally, since the && was giving the wrong behavior? We might want to revisit this and bring back the previous logic.

diff --git a/packages/block-editor/src/hooks/block-bindings.js b/packages/block-editor/src/hooks/block-bindings.js
index 87ab4e3632..a8cc0d369f 100644
--- a/packages/block-editor/src/hooks/block-bindings.js
+++ b/packages/block-editor/src/hooks/block-bindings.js
@@ -436,11 +436,14 @@ export const BlockBindingsPanel = ( { name: blockName, metadata } ) => {
        if ( ! bindableAttributes || bindableAttributes.length === 0 ) {
                return null;
        }
-       // Filter bindings to only show bindable attributes.
+       // Filter bindings to only show bindable attributes and remove pattern overrides.
        const { bindings } = metadata || {};
        const filteredBindings = { ...bindings };
        Object.keys( filteredBindings ).forEach( ( key ) => {
-               if ( ! bindableAttributes.includes( key ) ) {
+               if (
+                       ! bindableAttributes.includes( key ) ||
+                       filteredBindings[ key ].source === 'core/pattern-overrides'
+               ) {
                        delete filteredBindings[ key ];
                }
        } );

I'll be AFK on Thu and Fri; @gziolo @cbravobernal Maybe y'all could look into this?

@cbravobernal cbravobernal Oct 10, 2025

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Edit again:

The pattern overrides should never appear. As it doesn't contain an editorUI attribute. And when a block contains a pattern override, the __default attribute won't appear on the UI, as is not a "supported" attribute.

That check is not needed. I found another bug if there are no sources. We show the panel without a readonly attributes panel, which is not ideal.

<!-- wp:image {"metadata":{"bindings":{"url":{"source":"core/undefined"},"alt":{"source":"core/undefined"},"title":{"source":"core/post-data", "args":{"key":"date"}}}}} -->
<figure class="wp-block-image"><img alt=""/></figure>
<!-- /wp:image -->

Drafted a PR: #72253

filteredBindings[ key ].source === 'core/pattern-overrides'
) {
delete filteredBindings[ key ];
Expand Down
Loading
Loading