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( '
' );
+ $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
*