From 92667cfa59e8d0514e73fea510bed895cad5522b Mon Sep 17 00:00:00 2001 From: Dennis Snell Date: Wed, 29 Jul 2026 12:16:47 -0500 Subject: [PATCH 1/5] HTML API: Respect enqueued updates in `get_attribute_names_with_prefix()`. Trac ticket: Core-64567. Previously, `get_attribute_names_with_prefix()` was overlooking enqueued attribute and class name updates which occurred before `get_updated_html()` had been called. This resulted in reporting stale data which might overlook attributes which were added, and might report attributes which were removed. In this patch, the method now examines the enqueued updates to determine if any of them introduce or remove attributes. It also examines enqueued class updates to ensure that if the `class` attribute would be added or removed because of them that it will also be properly reported. Co-Authored-By: Anup Kankale Co-Authored-By: Igor Rozum Co-Authored-By: Jeffrey Carandang Co-Authored-By: Jerome B. Co-Authored-By: Khokan Sardar Co-Authored-By: Luis Herranz Co-Authored-By: Mariusz Szatkowski Co-Authored-By: SACHINRAJ CP --- .../html-api/class-wp-html-tag-processor.php | 36 +++- .../tests/html-api/wpHtmlTagProcessor.php | 174 ++++++++++++++++++ 2 files changed, 208 insertions(+), 2 deletions(-) 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..60bc2c5917752 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 @@ -2968,13 +2968,45 @@ public function get_attribute_names_with_prefix( $prefix ): ?array { $comparable = strtolower( $prefix ); + /* + * For the `class` attribute, ensure that enqueued class changes from + * `add_class` and `remove_class` are flushed into attribute updates. + */ + $has_class = isset( $this->attributes['class'] ); + if ( '' === $comparable || str_starts_with( 'class', $comparable ) ) { + foreach ( $this->classname_updates as $class_name => $update ) { + if ( + ( $has_class && self::REMOVE_CLASS === $update ) || + ( ! $has_class && self::ADD_CLASS === $update ) + ) { + $this->class_name_updates_to_attributes_updates(); + break; + } + } + } + + $additions = array(); + $removals = array(); + foreach ( $this->lexical_updates as $update_name => $update ) { + if ( is_int( $update_name ) || 'modifiable text' === $update_name ) { + continue; + } + + if ( '' === $update->text ) { + $removals[ $update_name ] = true; + } elseif ( ! isset( $this->attributes[ $update_name ] ) && str_starts_with( $update_name, $comparable ) ) { + $additions[] = $update_name; + } + } + $matches = array(); foreach ( array_keys( $this->attributes ) as $attr_name ) { - if ( str_starts_with( $attr_name, $comparable ) ) { + if ( str_starts_with( $attr_name, $comparable ) && ! isset( $removals[ $attr_name ] ) ) { $matches[] = $attr_name; } } - return $matches; + + return empty( $additions ) ? $matches : array_merge( $additions, $matches ); } /** diff --git a/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php b/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php index 84d90a84190fc..60c43f36f5bdf 100644 --- a/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php +++ b/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php @@ -464,6 +464,180 @@ public function test_get_attribute_names_with_prefix_returns_attribute_added_by_ ); } + /** + * Ensures that a new attribute added via set_attribute() is reported by + * get_attribute_names_with_prefix() immediately after being added. + * + * @ticket 64567 + * + * @covers WP_HTML_Tag_Processor::get_attribute_names_with_prefix + */ + public function test_get_attribute_names_with_prefix_immediately_reflects_new_attributes() { + $processor = new WP_HTML_Tag_Processor( '
' ); + $processor->next_tag(); + + $this->assertSame( + array( 'existing' ), + $processor->get_attribute_names_with_prefix( '' ), + 'Failed to report existing attribute names: check test setup.' + ); + + $processor->set_attribute( 'new', true ); + + $this->assertSame( + array( 'new', 'existing' ), + $processor->get_attribute_names_with_prefix( '' ), + 'Failed to report newly-added attribute.' + ); + + $this->assertSame( + array( 'existing' ), + $processor->get_attribute_names_with_prefix( 'exist' ), + 'Should have only reported existing attribute matching given prefix.' + ); + } + + /** + * Ensures that changes enqueued for modifiable text do not get reported as added attributes. + * + * This is a fairly-specific test against an internal implementation detail, but is worth + * adding to catch potential regressions since modifiable text updates share a namespace with + * attribute updates. + * + * @ticket 64567 + * + * @covers WP_HTML_Tag_Processor::get_attribute_names_with_prefix + */ + public function test_get_attribute_names_with_prefix_ignores_immediately_added_modifiable_text() { + $processor = new WP_HTML_Tag_Processor( '' ); + $processor->next_tag(); + + $this->assertSame( + array( 'existing' ), + $processor->get_attribute_names_with_prefix( '' ), + 'Failed to report existing attribute names: check test setup.' + ); + + $processor->set_modifiable_text( 'content!' ); + + $this->assertSame( + array( 'existing' ), + $processor->get_attribute_names_with_prefix( '' ), + 'Failed to report that the attributes were unchanged.' + ); + } + + /** + * Ensures that an attribute removed via remove_attribute() is no longer reported + * by get_attribute_names_with_prefix() immediately after being removed. + * + * @ticket 64567 + * + * @covers WP_HTML_Tag_Processor::get_attribute_names_with_prefix + */ + public function test_get_attribute_names_with_prefix_immediately_reflects_removed_attributes() { + $processor = new WP_HTML_Tag_Processor( '
' ); + $processor->next_tag(); + + $this->assertSame( + array( 'existing', 'data-removed' ), + $processor->get_attribute_names_with_prefix( '' ), + 'Failed to report all existing attributes: check test setup.' + ); + + $processor->remove_attribute( 'data-removed' ); + + $this->assertSame( + array(), + $processor->get_attribute_names_with_prefix( 'data-' ), + 'Expected no custom data attributes after removing the only one.' + ); + + $this->assertSame( + array( 'existing' ), + $processor->get_attribute_names_with_prefix( '' ), + 'Expected to report only the attribute which wasn’t removed.' + ); + } + + /** + * Ensures that when the `class` attribute is newly added via add_class(), that + * it’s reported by get_attribute_names_with_prefix() immediately after being added. + * + * @ticket 64567 + * + * @covers WP_HTML_Tag_Processor::get_attribute_names_with_prefix + */ + public function test_get_attribute_names_with_prefix_immediately_reflects_class_after_adding_classes() { + $processor = new WP_HTML_Tag_Processor( '
' ); + $processor->next_tag(); + + $this->assertSame( + array( 'existing' ), + $processor->get_attribute_names_with_prefix( '' ), + 'Expected to only report the existing attribute: check test setup.' + ); + + $processor->add_class( 'added' ); + + $this->assertSame( + array( 'class' ), + $processor->get_attribute_names_with_prefix( 'class' ), + 'Failed to report the newly-added `class` attribute.' + ); + } + + /** + * Ensures get_attribute_names_with_prefix() agrees with get_attribute() + * after pending updates, returning each name once with no stale entries. + * + * @ticket 64567 + * + * @covers WP_HTML_Tag_Processor::get_attribute_names_with_prefix + */ + public function test_get_attribute_names_with_prefix_immediately_agrees_with_get_attribute_after_updates() { + $processor = new WP_HTML_Tag_Processor( '
Test
' ); + $processor->next_tag(); + + $this->assertSame( + array( 'data-keep', 'data-drop' ), + $processor->get_attribute_names_with_prefix( '' ), + 'Failed to find expected existing attributes: check test setup.' + ); + + $this->assertEquals( + '1', + $processor->get_attribute( 'data-keep' ), + 'Failed to find expected existing `data-keep` attribute value: check test setup.' + ); + + $this->assertEquals( + '2', + $processor->get_attribute( 'data-drop' ), + 'Failed to find expected existing `data-drop` attribute value: check test setup.' + ); + + $processor->set_attribute( 'data-keep', 'updated' ); + $processor->set_attribute( 'data-add', 'new' ); + $processor->remove_attribute( 'data-drop' ); + + $names = $processor->get_attribute_names_with_prefix( 'data-' ); + sort( $names ); + + $this->assertSame( + array( 'data-add', 'data-keep' ), + $names, + 'Failed to report the expected attribute names after removing and adding attributes.' + ); + + foreach ( $names as $name ) { + $this->assertNotNull( + $processor->get_attribute( $name ), + "get_attribute_names_with_prefix() reported '{$name}' but get_attribute() did not agree." + ); + } + } + /** * @ticket 56299 * From a15f45c0d30bb3f546a22e839e636718a78f26dc Mon Sep 17 00:00:00 2001 From: Dennis Snell Date: Thu, 30 Jul 2026 12:40:14 -0500 Subject: [PATCH 2/5] Remove unnecessary sort --- tests/phpunit/tests/html-api/wpHtmlTagProcessor.php | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php b/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php index 60c43f36f5bdf..9d4f82e38ee29 100644 --- a/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php +++ b/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php @@ -622,7 +622,6 @@ public function test_get_attribute_names_with_prefix_immediately_agrees_with_get $processor->remove_attribute( 'data-drop' ); $names = $processor->get_attribute_names_with_prefix( 'data-' ); - sort( $names ); $this->assertSame( array( 'data-add', 'data-keep' ), From 8dd7b5dc033ed9e81e3eb5562e3f48387fd5930b Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 30 Jul 2026 12:43:22 -0500 Subject: [PATCH 3/5] Add static typing and remove context of array key name from foreach --- src/wp-includes/html-api/class-wp-html-tag-processor.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 60bc2c5917752..10dd229c5145a 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 @@ -752,7 +752,7 @@ class WP_HTML_Tag_Processor { * ); * * @since 6.2.0 - * @var bool[] + * @var array */ private $classname_updates = array(); @@ -2974,7 +2974,7 @@ public function get_attribute_names_with_prefix( $prefix ): ?array { */ $has_class = isset( $this->attributes['class'] ); if ( '' === $comparable || str_starts_with( 'class', $comparable ) ) { - foreach ( $this->classname_updates as $class_name => $update ) { + foreach ( $this->classname_updates as $update ) { if ( ( $has_class && self::REMOVE_CLASS === $update ) || ( ! $has_class && self::ADD_CLASS === $update ) From 3ffe24ff1b0057851e66c7650a74d9a27e202648 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Fri, 31 Jul 2026 10:49:13 -0500 Subject: [PATCH 4/5] Review updates --- src/wp-includes/html-api/class-wp-html-tag-processor.php | 2 +- tests/phpunit/tests/html-api/wpHtmlTagProcessor.php | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) 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 10dd229c5145a..3bc22c88eeda4 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 @@ -809,7 +809,7 @@ class WP_HTML_Tag_Processor { * ); * * @since 6.2.0 - * @var WP_HTML_Text_Replacement[] + * @var array */ protected $lexical_updates = array(); diff --git a/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php b/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php index 9d4f82e38ee29..189b6d77bb0ae 100644 --- a/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php +++ b/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php @@ -605,13 +605,13 @@ public function test_get_attribute_names_with_prefix_immediately_agrees_with_get 'Failed to find expected existing attributes: check test setup.' ); - $this->assertEquals( + $this->assertSame( '1', $processor->get_attribute( 'data-keep' ), 'Failed to find expected existing `data-keep` attribute value: check test setup.' ); - $this->assertEquals( + $this->assertSame( '2', $processor->get_attribute( 'data-drop' ), 'Failed to find expected existing `data-drop` attribute value: check test setup.' From c93d64961d243b03300c0fb188b27d4ddd580a00 Mon Sep 17 00:00:00 2001 From: Dennis Snell Date: Fri, 31 Jul 2026 14:50:29 -0500 Subject: [PATCH 5/5] Verify removal of `class` attribute after removing all classes. --- .../tests/html-api/wpHtmlTagProcessor.php | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php b/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php index 189b6d77bb0ae..66e01dbdbed3e 100644 --- a/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php +++ b/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php @@ -587,6 +587,41 @@ public function test_get_attribute_names_with_prefix_immediately_reflects_class_ ); } + /** + * Ensures that when the `class` attribute is emptied via remove_class(), that + * it’s reported by get_attribute_names_with_prefix() immediately after being added. + * + * @ticket 64567 + * + * @covers WP_HTML_Tag_Processor::get_attribute_names_with_prefix + */ + public function test_get_attribute_names_with_prefix_immediately_reflects_class_after_removing_all_classes() { + $processor = new WP_HTML_Tag_Processor( '
' ); + $processor->next_tag(); + + $this->assertSame( + array( 'class', 'existing' ), + $processor->get_attribute_names_with_prefix( '' ), + 'Expected to find proper existing attributes: check test setup.' + ); + + $processor->remove_class( 'red' ); + + $this->assertSame( + array( 'class', 'existing' ), + $processor->get_attribute_names_with_prefix( '' ), + 'Should have the same attributes after removing one of two classes.' + ); + + $processor->remove_class( 'green' ); + + $this->assertSame( + array( 'existing' ), + $processor->get_attribute_names_with_prefix( '' ), + 'Should have removed the `class` attribute after removing all class names.' + ); + } + /** * Ensures get_attribute_names_with_prefix() agrees with get_attribute() * after pending updates, returning each name once with no stale entries.