diff --git a/src/wp-includes/block-supports/layout.php b/src/wp-includes/block-supports/layout.php index 4c1f4c2fe7..0e22dded74 100644 --- a/src/wp-includes/block-supports/layout.php +++ b/src/wp-includes/block-supports/layout.php @@ -630,7 +630,16 @@ function wp_render_layout_support_flag( $block_content, $block ) { $class_names = array(); $layout_definitions = wp_get_layout_definitions(); - $container_class = wp_unique_id( 'wp-container-' ); + + /* + * 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'] ) . '-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'] ) { diff --git a/src/wp-includes/functions.php b/src/wp-includes/functions.php index c467961c98..cb490ee176 100644 --- a/src/wp-includes/functions.php +++ b/src/wp-includes/functions.php @@ -7830,6 +7830,40 @@ function wp_unique_id( $prefix = '' ) { return $prefix . (string) ++$id_counter; } +/** + * Generates an incremental ID that is independent per each different prefix. + * + * It is similar to `wp_unique_id`, but each prefix has its own internal ID + * counter to make each prefix independent from each other. The ID starts at 1 + * and increments on each call. The returned value is not universally unique, + * but it is unique across the life of the PHP process and it's stable per + * prefix. + * + * @since 6.4.0 + * + * @param string $prefix Optional. Prefix for the returned ID. Default empty string. + * @return string Incremental ID per prefix. + */ +function wp_unique_prefixed_id( $prefix = '' ) { + static $id_counters = array(); + + if ( ! is_string( $prefix ) ) { + wp_trigger_error( + __FUNCTION__, + sprintf( 'The prefix must be a string. "%s" data type given.', gettype( $prefix ) ) + ); + $prefix = ''; + } + + if ( ! isset( $id_counters[ $prefix ] ) ) { + $id_counters[ $prefix ] = 0; + } + + $id = ++$id_counters[ $prefix ]; + + return $prefix . (string) $id; +} + /** * Gets last changed date for the specified cache group. * diff --git a/tests/phpunit/tests/blocks/render.php b/tests/phpunit/tests/blocks/render.php index 632298186e..cb06030282 100644 --- a/tests/phpunit/tests/blocks/render.php +++ b/tests/phpunit/tests/blocks/render.php @@ -218,9 +218,9 @@ class Tests_Blocks_Render extends WP_UnitTestCase { $html = do_blocks( self::strip_r( file_get_contents( $html_path ) ) ); // If blocks opt into Gutenberg's layout implementation - // the container will receive an added classname of `wp_unique_id( 'wp-container-' )` + // the container will receive an additional, unique classname based on "wp-container-[blockname]-layout" // so we need to normalize the random id. - $normalized_html = preg_replace( '/wp-container-\d+/', 'wp-container-1', $html ); + $normalized_html = preg_replace( '/wp-container-[a-z-]+\d+/', 'wp-container-1', $html ); // The gallery block uses a unique class name of `wp_unique_id( 'wp-block-gallery-' )` // so we need to normalize the random id. diff --git a/tests/phpunit/tests/functions/wpUniquePrefixedId.php b/tests/phpunit/tests/functions/wpUniquePrefixedId.php new file mode 100644 index 0000000000..64a6a955a1 --- /dev/null +++ b/tests/phpunit/tests/functions/wpUniquePrefixedId.php @@ -0,0 +1,196 @@ +assertNotSame( $id1, $id2, 'The IDs are not unique.' ); + $this->assertSame( $expected, array( $id1, $id2 ), 'The IDs did not match the expected values.' ); + } + + /** + * Data provider. + * + * @return array[] + */ + public function data_should_create_unique_prefixed_ids() { + return array( + 'prefix as empty string' => array( + 'prefix' => '', + 'expected' => array( '1', '2' ), + ), + 'prefix as (string) "0"' => array( + 'prefix' => '0', + 'expected' => array( '01', '02' ), + ), + 'prefix as string' => array( + 'prefix' => 'test', + 'expected' => array( 'test1', 'test2' ), + ), + 'prefix as string with spaces' => array( + 'prefix' => ' ', + 'expected' => array( ' 1', ' 2' ), + ), + 'prefix as (string) "1"' => array( + 'prefix' => '1', + 'expected' => array( '11', '12' ), + ), + 'prefix as a (string) "."' => array( + 'prefix' => '.', + 'expected' => array( '.1', '.2' ), + ), + 'prefix as a block name' => array( + 'prefix' => 'core/list-item', + 'expected' => array( 'core/list-item1', 'core/list-item2' ), + ), + ); + } + + /** + * @ticket 59681 + * + * @dataProvider data_should_raise_notice_and_use_empty_string_prefix_when_nonstring_given + * + * @runInSeparateProcess + * @preserveGlobalState disabled + * + * @param mixed $non_string_prefix Non-string prefix. + * @param int $number_of_ids_to_generate Number of IDs to generate. + * As the prefix will default to an empty string, changing the number of IDs generated within each dataset further tests ID uniqueness. + * @param string $expected_message Expected notice message. + * @param array $expected_ids Expected unique IDs. + */ + public function test_should_raise_notice_and_use_empty_string_prefix_when_nonstring_given( $non_string_prefix, $number_of_ids_to_generate, $expected_message, $expected_ids ) { + $this->expectNotice(); + $this->expectNoticeMessage( $expected_message ); + + $ids = array(); + for ( $i = 0; $i < $number_of_ids_to_generate; $i++ ) { + $ids[] = wp_unique_prefixed_id( $non_string_prefix ); + } + + $this->assertSameSets( $ids, array_unique( $ids ), 'IDs are not unique.' ); + $this->assertSameSets( $expected_ids, $ids, 'The IDs did not match the expected values.' ); + } + + /** + * Data provider. + * + * @return array[] + */ + public function data_should_raise_notice_and_use_empty_string_prefix_when_nonstring_given() { + $message = 'wp_unique_prefixed_id(): The prefix must be a string. "%s" data type given.'; + return array( + 'prefix as null' => array( + 'non_string_prefix' => null, + 'number_of_ids_to_generate' => 2, + 'expected_message' => sprintf( $message, 'NULL' ), + 'expected_ids' => array( '1', '2' ), + ), + 'prefix as (int) 0' => array( + 'non_string_prefix' => 0, + 'number_of_ids_to_generate' => 3, + 'expected_message' => sprintf( $message, 'integer' ), + 'expected_ids' => array( '1', '2', '3' ), + ), + 'prefix as (int) 1' => array( + 'non_string_prefix' => 1, + 'number_of_ids_to_generate' => 4, + 'expected_data_type' => sprintf( $message, 'integer' ), + 'expected_ids' => array( '1', '2', '3', '4' ), + ), + 'prefix as (bool) false' => array( + 'non_string_prefix' => false, + 'number_of_ids_to_generate' => 5, + 'expected_data_type' => sprintf( $message, 'boolean' ), + 'expected_ids' => array( '1', '2', '3', '4', '5' ), + ), + 'prefix as (double) 98.7' => array( + 'non_string_prefix' => 98.7, + 'number_of_ids_to_generate' => 6, + 'expected_data_type' => sprintf( $message, 'double' ), + 'expected_ids' => array( '1', '2', '3', '4', '5', '6' ), + ), + ); + } + + /** + * Prefixes that are or will become the same should generate unique IDs. + * + * This test is added to avoid future regressions if the function's prefix data type check is + * modified to type juggle or check for scalar data types. + * + * @ticket 59681 + * + * @dataProvider data_same_prefixes_should_generate_unique_ids + * + * @runInSeparateProcess + * @preserveGlobalState disabled + * + * @param array $prefixes The prefixes to check. + * @param array $expected The expected unique IDs. + */ + public function test_same_prefixes_should_generate_unique_ids( array $prefixes, array $expected ) { + // Suppress E_USER_NOTICE, which will be raised when a prefix is non-string. + $original_error_reporting = error_reporting(); + error_reporting( $original_error_reporting & ~E_USER_NOTICE ); + + $ids = array(); + foreach ( $prefixes as $prefix ) { + $ids[] = wp_unique_prefixed_id( $prefix ); + } + + // Reset error reporting. + error_reporting( $original_error_reporting ); + + $this->assertSameSets( $ids, array_unique( $ids ), 'IDs are not unique.' ); + $this->assertSameSets( $expected, $ids, 'The IDs did not match the expected values.' ); + } + + /** + * Data provider. + * + * @return array[] + */ + public function data_same_prefixes_should_generate_unique_ids() { + return array( + 'prefixes = empty string' => array( + 'prefixes' => array( null, true, '' ), + 'expected' => array( '1', '2', '3' ), + ), + 'prefixes = 0' => array( + 'prefixes' => array( '0', 0, 0.0, false ), + 'expected' => array( '01', '1', '2', '3' ), + ), + 'prefixes = 1' => array( + 'prefixes' => array( '1', 1, 1.0, true ), + 'expected' => array( '11', '1', '2', '3' ), + ), + ); + } +}