Editor: Fix layout support classes to be generated with a stable ID.

This fixes a bug reported in https://github.com/WordPress/gutenberg/issues/67308 related to the Interactivity API's client-side navigation feature by replacing the incrementally generated IDs with stable hashes derived from the block's layout style definition.

Fixes #62985.
Props darerodz.


git-svn-id: https://develop.svn.wordpress.org/trunk@60038 602fd350-edb4-49c9-b593-d223f7449a82
This commit is contained in:
Joe McGill 2025-03-18 12:41:31 +00:00
parent 79fd25fa56
commit 8b632c94a5
6 changed files with 425 additions and 14 deletions

View File

@ -580,7 +580,33 @@ function wp_render_layout_support_flag( $block_content, $block ) {
// Child layout specific logic.
if ( $child_layout ) {
$container_content_class = wp_unique_prefixed_id( 'wp-container-content-' );
/*
* Generates a unique class for child block layout styles.
*
* To ensure consistent class generation across different page renders,
* only properties that affect layout styling are used. These properties
* come from `$block['attrs']['style']['layout']` and `$block['parentLayout']`.
*
* As long as these properties coincide, the generated class will be the same.
*/
$container_content_class = wp_unique_id_from_values(
array(
'layout' => array_intersect_key(
$block['attrs']['style']['layout'] ?? array(),
array_flip(
array( 'selfStretch', 'flexSize', 'columnStart', 'columnSpan', 'rowStart', 'rowSpan' )
)
),
'parentLayout' => array_intersect_key(
$block['parentLayout'] ?? array(),
array_flip(
array( 'minimumColumnWidth', 'columnCount' )
)
),
),
'wp-container-content-'
);
$child_layout_declarations = array();
$child_layout_styles = array();
@ -706,16 +732,6 @@ function wp_render_layout_support_flag( $block_content, $block ) {
$class_names = array();
$layout_definitions = wp_get_layout_definitions();
/*
* Uses an incremental ID that is independent per prefix to make sure that
* rendering different numbers of blocks doesn't affect the IDs of other
* blocks. Makes the CSS class names stable across paginations
* for features like the enhanced pagination of the Query block.
*/
$container_class = wp_unique_prefixed_id(
'wp-container-' . sanitize_title( $block['blockName'] ) . '-is-layout-'
);
// Set the correct layout type for blocks using legacy content width.
if ( isset( $used_layout['inherit'] ) && $used_layout['inherit'] || isset( $used_layout['contentSize'] ) && $used_layout['contentSize'] ) {
$used_layout['type'] = 'constrained';
@ -806,6 +822,25 @@ function wp_render_layout_support_flag( $block_content, $block ) {
: null;
$has_block_gap_support = isset( $block_gap );
/*
* Generates a unique ID based on all the data required to obtain the
* corresponding layout style. Keeps the CSS class names the same
* even for different blocks on different places, as long as they have
* the same layout definition. Makes the CSS class names stable across
* paginations for features like the enhanced pagination of the Query block.
*/
$container_class = wp_unique_id_from_values(
array(
$used_layout,
$has_block_gap_support,
$gap_value,
$should_skip_gap_serialization,
$fallback_gap_value,
$block_spacing,
),
'wp-container-' . sanitize_title( $block['blockName'] ) . '-is-layout-'
);
$style = wp_get_layout_style(
".$container_class",
$used_layout,

View File

@ -9174,3 +9174,33 @@ function wp_verify_fast_hash(
return hash_equals( $hash, wp_fast_hash( $message ) );
}
/**
* Generates a unique ID based on the structure and values of a given array.
*
* This function serializes the array into a JSON string and generates a hash
* that serves as a unique identifier. Optionally, a prefix can be added to
* the generated ID for context or categorization.
*
* @since 6.8.0
*
* @param array $data The input array to generate an ID from.
* @param string $prefix Optional. A prefix to prepend to the generated ID. Default ''.
*
* @return string The generated unique ID for the array.
*/
function wp_unique_id_from_values( array $data, string $prefix = '' ): string {
if ( empty( $data ) ) {
_doing_it_wrong(
__FUNCTION__,
sprintf(
__( 'The $data argument must not be empty.' ),
gettype( $data )
),
'6.8.0'
);
}
$serialized = wp_json_encode( $data );
$hash = substr( md5( $serialized ), 0, 8 );
return $prefix . $hash;
}

View File

@ -1,5 +1,5 @@
<div class="wp-block-columns has-3-columns is-layout-flex wp-container-1 wp-block-columns-is-layout-flex">
<div class="wp-block-columns has-3-columns is-layout-flex wp-container-1d6595d7 wp-block-columns-is-layout-flex">
<div class="wp-block-column is-layout-flow wp-block-column-is-layout-flow">

View File

@ -1,5 +1,5 @@
<div class="wp-block-columns has-3-columns is-layout-flex wp-container-1 wp-block-columns-is-layout-flex">
<div class="wp-block-columns has-3-columns is-layout-flex wp-container-1d6595d7 wp-block-columns-is-layout-flex">
<p class="layout-column-1">Column One, Paragraph One</p>

View File

@ -272,7 +272,47 @@ class Tests_Block_Supports_Layout extends WP_UnitTestCase {
),
),
),
'expected_output' => '<p class="wp-container-content-1">Some text.</p>', // The generated classname number assumes `wp_unique_prefixed_id( 'wp-container-content-' )` will not have run previously in this test.
'expected_output' => '<p class="wp-container-content-b7aa651c">Some text.</p>',
),
'single wrapper block layout with flex type' => array(
'args' => array(
'block_content' => '<div class="wp-block-group"></div>',
'block' => array(
'blockName' => 'core/group',
'attrs' => array(
'layout' => array(
'type' => 'flex',
'orientation' => 'horizontal',
'flexWrap' => 'nowrap',
),
),
'innerBlocks' => array(),
'innerHTML' => '<div class="wp-block-group"></div>',
'innerContent' => array(
'<div class="wp-block-group"></div>',
),
),
),
'expected_output' => '<div class="wp-block-group is-horizontal is-nowrap is-layout-flex wp-container-core-group-is-layout-67f0b8e2 wp-block-group-is-layout-flex"></div>',
),
'single wrapper block layout with grid type' => array(
'args' => array(
'block_content' => '<div class="wp-block-group"></div>',
'block' => array(
'blockName' => 'core/group',
'attrs' => array(
'layout' => array(
'type' => 'grid',
),
),
'innerBlocks' => array(),
'innerHTML' => '<div class="wp-block-group"></div>',
'innerContent' => array(
'<div class="wp-block-group"></div>',
),
),
),
'expected_output' => '<div class="wp-block-group is-layout-grid wp-container-core-group-is-layout-9649a0d9 wp-block-group-is-layout-grid"></div>',
),
'skip classname output if block does not support layout and there are no child layout classes to be output' => array(
'args' => array(
@ -463,4 +503,138 @@ class Tests_Block_Supports_Layout extends WP_UnitTestCase {
),
);
}
/**
* Check that wp_render_layout_support_flag() renders consistent hashes
* for the container class when the relevant layout properties are the same.
*
* @dataProvider data_layout_support_flag_renders_consistent_container_hash
*
* @covers ::wp_render_layout_support_flag
*
* @param array $block_attrs Dataset to test.
* @param array $expected_class Class generated for the passed dataset.
*/
public function test_layout_support_flag_renders_consistent_container_hash( $block_attrs, $expected_class ) {
switch_theme( 'default' );
$block_content = '<div class="wp-block-group"></div>';
$block = array(
'blockName' => 'core/group',
'innerBlocks' => array(),
'innerHTML' => '<div class="wp-block-group"></div>',
'innerContent' => array(
'<div class="wp-block-group"></div>',
),
'attrs' => $block_attrs,
);
/*
* The `appearance-tools` theme support is temporarily added to ensure
* that the block gap support is enabled during rendering, which is
* necessary to compute styles for layouts with block gap values.
*/
add_theme_support( 'appearance-tools' );
$output = wp_render_layout_support_flag( $block_content, $block );
remove_theme_support( 'appearance-tools' );
// Process the output and look for the expected class in the first rendered element.
$processor = new WP_HTML_Tag_Processor( $output );
$processor->next_tag();
$this->assertTrue(
$processor->has_class( $expected_class ),
"Expected class '$expected_class' not found in the rendered output, probably because of a different hash."
);
}
/**
* Data provider for test_layout_support_flag_renders_consistent_container_hash.
*
* @return array
*/
public function data_layout_support_flag_renders_consistent_container_hash() {
return array(
'default type block gap 12px' => array(
'block_attributes' => array(
'layout' => array(
'type' => 'default',
),
'style' => array(
'spacing' => array(
'blockGap' => '12px',
),
),
),
'expected_class' => 'wp-container-core-group-is-layout-c5c7d83f',
),
'default type block gap 24px' => array(
'block_attributes' => array(
'layout' => array(
'type' => 'default',
),
'style' => array(
'spacing' => array(
'blockGap' => '24px',
),
),
),
'expected_class' => 'wp-container-core-group-is-layout-634f0b9d',
),
'constrained type justified left' => array(
'block_attributes' => array(
'layout' => array(
'type' => 'constrained',
'justifyContent' => 'left',
),
),
'expected_class' => 'wp-container-core-group-is-layout-12dd3699',
),
'constrained type justified right' => array(
'block_attributes' => array(
'layout' => array(
'type' => 'constrained',
'justifyContent' => 'right',
),
),
'expected_class' => 'wp-container-core-group-is-layout-f1f2ed93',
),
'flex type horizontal' => array(
'block_attributes' => array(
'layout' => array(
'type' => 'flex',
'orientation' => 'horizontal',
'flexWrap' => 'nowrap',
),
),
'expected_class' => 'wp-container-core-group-is-layout-2487dcaa',
),
'flex type vertical' => array(
'block_attributes' => array(
'layout' => array(
'type' => 'flex',
'orientation' => 'vertical',
),
),
'expected_class' => 'wp-container-core-group-is-layout-fe9cc265',
),
'grid type' => array(
'block_attributes' => array(
'layout' => array(
'type' => 'grid',
),
),
'expected_class' => 'wp-container-core-group-is-layout-478b6e6b',
),
'grid type 3 columns' => array(
'block_attributes' => array(
'layout' => array(
'type' => 'grid',
'columnCount' => 3,
),
),
'expected_class' => 'wp-container-core-group-is-layout-d3b710ac',
),
);
}
}

View File

@ -0,0 +1,172 @@
<?php
/**
* Test cases for the `wp_unique_id_from_values()` function.
*
* @package WordPress\UnitTests
*
* @since 6.8.0
*
* @group functions.php
* @covers ::wp_unique_id_from_values
*/
class Tests_Functions_WpUniqueIdFromValues extends WP_UnitTestCase {
/**
* Test that the function returns consistent ids for the passed params.
*
* @ticket 62985
*
* @dataProvider data_wp_unique_id_from_values
*
* @since 6.8.0
*/
public function test_wp_unique_id_from_values( $expected, $data, $prefix ) {
$output1 = wp_unique_id_from_values( $data );
$output2 = wp_unique_id_from_values( $data, $prefix );
$this->assertSame( $expected, $output1 );
$this->assertSame( $prefix . $expected, $output2 );
}
/**
* Data provider.
*
* @return array[]
*/
public function data_wp_unique_id_from_values() {
return array(
'string' => array(
'expected' => '469f5989',
'data' => array(
'value' => 'text',
),
'prefix' => 'my-prefix-',
),
'integer' => array(
'expected' => 'b2f0842e',
'data' => array(
'value' => 123,
),
'prefix' => 'my-prefix-',
),
'float' => array(
'expected' => 'a756f54d',
'data' => array(
'value' => 1.23,
),
'prefix' => 'my-prefix-',
),
'boolean' => array(
'expected' => 'bdae8be3',
'data' => array(
'value' => true,
),
'prefix' => 'my-prefix-',
),
'object' => array(
'expected' => '477bd670',
'data' => array(
'value' => new StdClass(),
),
'prefix' => 'my-prefix-',
),
'null' => array(
'expected' => 'a860dd95',
'data' => array(
'value' => null,
),
'prefix' => 'my-prefix-',
),
'multiple values' => array(
'expected' => 'ef258a5d',
'data' => array(
'value1' => 'text',
'value2' => 123,
'value3' => 1.23,
'value4' => true,
'value5' => new StdClass(),
'value6' => null,
),
'prefix' => 'my-prefix-',
),
'nested arrays' => array(
'expected' => '4345cae5',
'data' => array(
'list1' => array(
'value1' => 'text',
'value2' => 123,
'value3' => 1.23,
),
'list2' => array(
'value4' => true,
'value5' => new StdClass(),
'value6' => null,
),
),
'prefix' => 'my-prefix-',
),
);
}
/**
* Test that passing an empty array is not allowed.
*
* @ticket 62985
*
* @expectedIncorrectUsage wp_unique_id_from_values
*
* @since 6.8.0
*/
public function test_wp_unique_id_from_values_empty_array() {
wp_unique_id_from_values( array(), 'my-prefix-' );
}
/**
* Test that passing non-array data throws an error.
*
* @ticket 62985
*
* @dataProvider data_wp_unique_id_from_values_invalid_data
*
* @since 6.8.0
*/
public function test_wp_unique_id_from_values_invalid_data( $data, $prefix ) {
$this->expectException( TypeError::class );
wp_unique_id_from_values( $data, $prefix );
}
/**
* Data provider.
*
* @return array[]
*/
public function data_wp_unique_id_from_values_invalid_data() {
return array(
'string' => array(
'data' => 'text',
'prefix' => '',
),
'integer' => array(
'data' => 123,
'prefix' => '',
),
'float' => array(
'data' => 1.23,
'prefix' => '',
),
'boolean' => array(
'data' => true,
'prefix' => '',
),
'object' => array(
'data' => new StdClass(),
'prefix' => '',
),
'null' => array(
'data' => null,
'prefix' => '',
),
);
}
}