From 8b632c94a58516e75de2be4553763ceee7dacb72 Mon Sep 17 00:00:00 2001 From: Joe McGill Date: Tue, 18 Mar 2025 12:41:31 +0000 Subject: [PATCH] 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 --- src/wp-includes/block-supports/layout.php | 57 ++++-- src/wp-includes/functions.php | 30 +++ .../blocks/fixtures/core__columns.server.html | 2 +- .../core__columns__deprecated.server.html | 2 +- tests/phpunit/tests/block-supports/layout.php | 176 +++++++++++++++++- .../tests/functions/wpUniqueIdFromValues.php | 172 +++++++++++++++++ 6 files changed, 425 insertions(+), 14 deletions(-) create mode 100644 tests/phpunit/tests/functions/wpUniqueIdFromValues.php diff --git a/src/wp-includes/block-supports/layout.php b/src/wp-includes/block-supports/layout.php index d3718930d5..71ade08f83 100644 --- a/src/wp-includes/block-supports/layout.php +++ b/src/wp-includes/block-supports/layout.php @@ -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, diff --git a/src/wp-includes/functions.php b/src/wp-includes/functions.php index fcb60e6eb3..3eb9e89255 100644 --- a/src/wp-includes/functions.php +++ b/src/wp-includes/functions.php @@ -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; +} diff --git a/tests/phpunit/data/blocks/fixtures/core__columns.server.html b/tests/phpunit/data/blocks/fixtures/core__columns.server.html index e35ab2763e..02d855cbd6 100644 --- a/tests/phpunit/data/blocks/fixtures/core__columns.server.html +++ b/tests/phpunit/data/blocks/fixtures/core__columns.server.html @@ -1,5 +1,5 @@ -
+
diff --git a/tests/phpunit/data/blocks/fixtures/core__columns__deprecated.server.html b/tests/phpunit/data/blocks/fixtures/core__columns__deprecated.server.html index c61aabf682..6b695d1596 100644 --- a/tests/phpunit/data/blocks/fixtures/core__columns__deprecated.server.html +++ b/tests/phpunit/data/blocks/fixtures/core__columns__deprecated.server.html @@ -1,5 +1,5 @@ -
+

Column One, Paragraph One

diff --git a/tests/phpunit/tests/block-supports/layout.php b/tests/phpunit/tests/block-supports/layout.php index de6d9e6e76..38c9cce4af 100644 --- a/tests/phpunit/tests/block-supports/layout.php +++ b/tests/phpunit/tests/block-supports/layout.php @@ -272,7 +272,47 @@ class Tests_Block_Supports_Layout extends WP_UnitTestCase { ), ), ), - 'expected_output' => '

Some text.

', // The generated classname number assumes `wp_unique_prefixed_id( 'wp-container-content-' )` will not have run previously in this test. + 'expected_output' => '

Some text.

', + ), + 'single wrapper block layout with flex type' => array( + 'args' => array( + 'block_content' => '
', + 'block' => array( + 'blockName' => 'core/group', + 'attrs' => array( + 'layout' => array( + 'type' => 'flex', + 'orientation' => 'horizontal', + 'flexWrap' => 'nowrap', + ), + ), + 'innerBlocks' => array(), + 'innerHTML' => '
', + 'innerContent' => array( + '
', + ), + ), + ), + 'expected_output' => '
', + ), + 'single wrapper block layout with grid type' => array( + 'args' => array( + 'block_content' => '
', + 'block' => array( + 'blockName' => 'core/group', + 'attrs' => array( + 'layout' => array( + 'type' => 'grid', + ), + ), + 'innerBlocks' => array(), + 'innerHTML' => '
', + 'innerContent' => array( + '
', + ), + ), + ), + 'expected_output' => '
', ), '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 = '
'; + $block = array( + 'blockName' => 'core/group', + 'innerBlocks' => array(), + 'innerHTML' => '
', + 'innerContent' => array( + '
', + ), + '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', + ), + ); + } } diff --git a/tests/phpunit/tests/functions/wpUniqueIdFromValues.php b/tests/phpunit/tests/functions/wpUniqueIdFromValues.php new file mode 100644 index 0000000000..391b1ff9e8 --- /dev/null +++ b/tests/phpunit/tests/functions/wpUniqueIdFromValues.php @@ -0,0 +1,172 @@ +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' => '', + ), + ); + } +}