diff --git a/src/wp-includes/html-api/class-wp-html-tag-processor.php b/src/wp-includes/html-api/class-wp-html-tag-processor.php index ace3e14bea565..28955faf420f5 100644 --- a/src/wp-includes/html-api/class-wp-html-tag-processor.php +++ b/src/wp-includes/html-api/class-wp-html-tag-processor.php @@ -2966,17 +2966,46 @@ public function get_attribute_names_with_prefix( $prefix ): ?array { return null; } - $comparable = strtolower( $prefix ); + $this->class_name_updates_to_attributes_updates(); + + $comparable = strtolower( $prefix ); + $candidate_names = array_merge( $this->newly_added_attribute_names(), array_keys( $this->attributes ) ); $matches = array(); - foreach ( array_keys( $this->attributes ) as $attr_name ) { - if ( str_starts_with( $attr_name, $comparable ) ) { + foreach ( $candidate_names as $attr_name ) { + if ( + str_starts_with( $attr_name, $comparable ) && + null !== $this->get_enqueued_attribute_value( $attr_name ) + ) { $matches[] = $attr_name; } } return $matches; } + /** + * Returns the comparable names of attributes enqueued by `set_attribute()` + * that were not present in the original input document. + * + * @since 7.1.0 + * + * @return string[] Comparable names of newly-added attributes. + */ + private function newly_added_attribute_names(): array { + $new_updates = array(); + foreach ( $this->lexical_updates as $name => $update ) { + // The `modifiable text` key is reserved for enqueued text content updates, not an attribute name. + if ( is_string( $name ) && 'modifiable text' !== $name && ! isset( $this->attributes[ $name ] ) ) { + $new_updates[ $name ] = $update; + } + } + + // Matches the order these attributes would appear in after `get_updated_html()`. + uasort( $new_updates, array( self::class, 'sort_start_ascending' ) ); + + return array_keys( $new_updates ); + } + /** * Returns the namespace of the matched token. * diff --git a/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php b/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php index 84d90a84190fc..a76ba0db85ac6 100644 --- a/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php +++ b/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php @@ -464,6 +464,114 @@ public function test_get_attribute_names_with_prefix_returns_attribute_added_by_ ); } + /** + * @ticket 64567 + * + * @covers WP_HTML_Tag_Processor::get_attribute_names_with_prefix + */ + public function test_get_attribute_names_with_prefix_finds_attribute_added_by_set_attribute_before_get_updated_html() { + $processor = new WP_HTML_Tag_Processor( '
Test
' ); + $processor->next_tag(); + $processor->set_attribute( 'data-test-id', '14' ); + + $this->assertSame( + array( 'data-test-id', 'data-foo' ), + $processor->get_attribute_names_with_prefix( 'data-' ), + "Accessing attribute names doesn't find attribute added via set_attribute before get_updated_html() is called" + ); + } + + /** + * @ticket 64567 + * + * @covers WP_HTML_Tag_Processor::get_attribute_names_with_prefix + */ + public function test_get_attribute_names_with_prefix_finds_attribute_set_on_tag_with_no_original_attributes() { + $processor = new WP_HTML_Tag_Processor( '
' ); + $processor->next_tag(); + $processor->set_attribute( 'id', 'test' ); + + $this->assertSame( + array( 'id' ), + $processor->get_attribute_names_with_prefix( '' ), + "Accessing attribute names doesn't find an attribute added via set_attribute() on a tag with no original attributes, before get_updated_html() is called" + ); + } + + /** + * @ticket 64567 + * + * @covers WP_HTML_Tag_Processor::get_attribute_names_with_prefix + */ + public function test_get_attribute_names_with_prefix_excludes_attribute_removed_by_remove_attribute() { + $processor = new WP_HTML_Tag_Processor( '
Test
' ); + $processor->next_tag(); + $processor->remove_attribute( 'data-foo' ); + + $this->assertSame( + array( 'data-baz' ), + $processor->get_attribute_names_with_prefix( 'data-' ), + "Accessing attribute names shouldn't find an attribute removed via remove_attribute(), before get_updated_html() is called" + ); + } + + /** + * @ticket 64567 + * + * @covers WP_HTML_Tag_Processor::get_attribute_names_with_prefix + */ + public function test_get_attribute_names_with_prefix_finds_class_attribute_created_by_add_class() { + $processor = new WP_HTML_Tag_Processor( '
Test
' ); + $processor->next_tag(); + $processor->add_class( 'main' ); + + $this->assertSame( + array( 'class' ), + $processor->get_attribute_names_with_prefix( 'c' ), + "Accessing attribute names doesn't find the class attribute created by add_class(), before get_updated_html() is called" + ); + } + + /** + * @ticket 64567 + * + * @covers WP_HTML_Tag_Processor::get_attribute_names_with_prefix + */ + public function test_get_attribute_names_with_prefix_excludes_class_attribute_emptied_by_remove_class() { + $processor = new WP_HTML_Tag_Processor( '
Test
' ); + $processor->next_tag(); + $processor->remove_class( 'main' ); + + $this->assertSame( + array(), + $processor->get_attribute_names_with_prefix( 'c' ), + "Accessing attribute names shouldn't find the class attribute after remove_class() empties it, before get_updated_html() is called" + ); + } + + /** + * @ticket 64567 + * + * @covers WP_HTML_Tag_Processor::get_attribute_names_with_prefix + */ + public function test_get_attribute_names_with_prefix_orders_newly_added_attributes_to_match_get_updated_html() { + $processor = new WP_HTML_Tag_Processor( '
' ); + $processor->next_tag(); + $processor->set_attribute( 'zebra', '1' ); + $processor->set_attribute( 'apple', '2' ); + + $this->assertSame( + array( 'apple', 'zebra' ), + $processor->get_attribute_names_with_prefix( '' ), + 'Newly-added attribute names should be ordered as they will appear in the serialized output, not in call order' + ); + $this->assertSame( + '
', + $processor->get_updated_html(), + 'Sanity check: serialized attribute order should match the order asserted above' + ); + } + /** * @ticket 56299 *