From 3a2e0362c591b069a5e265c4b5bb97d25d6b3169 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Tue, 14 Jul 2026 09:17:21 +0200 Subject: [PATCH 01/11] HTML API: Test attribute removal preserves tag semantics --- tests/phpunit/tests/html-api/wpHtmlTagProcessor.php | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php b/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php index 84d90a84190fc..f193f1e1bf7ef 100644 --- a/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php +++ b/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php @@ -1332,6 +1332,17 @@ public function test_remove_attribute_with_an_existing_attribute_name_removes_it ); } + /** + * @covers WP_HTML_Tag_Processor::remove_attribute + */ + public function test_remove_attribute_does_not_create_a_self_closing_flag() { + $processor = new WP_HTML_Tag_Processor( 'ok' ); + $processor->next_tag( 'g' ); + $processor->remove_attribute( 'attr' ); + + $this->assertSame( 'ok', $processor->get_updated_html() ); + } + /** * @ticket 58119 * From 251d187b3f32ea2751ab54a4f59e59c83b5275f7 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Tue, 14 Jul 2026 09:22:52 +0200 Subject: [PATCH 02/11] HTML API: Cover slash-adjacent duplicate removals --- .../tests/html-api/wpHtmlTagProcessor.php | 31 ++++++++++++++++--- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php b/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php index f193f1e1bf7ef..e80aba1320dc3 100644 --- a/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php +++ b/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php @@ -1334,13 +1334,36 @@ public function test_remove_attribute_with_an_existing_attribute_name_removes_it /** * @covers WP_HTML_Tag_Processor::remove_attribute + * + * @dataProvider data_remove_attribute_does_not_create_a_self_closing_flag + * + * @param string $html HTML containing the attribute to remove. + * @param int $removal_count Number of times to remove the attribute. + * @param string $expected Expected HTML after removing the attribute. */ - public function test_remove_attribute_does_not_create_a_self_closing_flag() { - $processor = new WP_HTML_Tag_Processor( 'ok' ); + public function test_remove_attribute_does_not_create_a_self_closing_flag( $html, $removal_count, $expected ) { + $processor = new WP_HTML_Tag_Processor( $html ); $processor->next_tag( 'g' ); - $processor->remove_attribute( 'attr' ); - $this->assertSame( 'ok', $processor->get_updated_html() ); + for ( $i = 0; $i < $removal_count; $i++ ) { + $processor->remove_attribute( 'attr' ); + $this->assertNull( $processor->get_attribute( 'attr' ) ); + } + + $this->assertSame( $expected, $processor->get_updated_html() ); + } + + /** + * Data provider. + * + * @return array[] + */ + public static function data_remove_attribute_does_not_create_a_self_closing_flag() { + return array( + 'Attribute preceded by a slash' => array( 'ok', 1, 'ok' ), + 'Duplicate attribute preceded by a slash' => array( 'ok', 1, 'ok' ), + 'Duplicate attribute removed twice' => array( 'ok', 2, 'ok' ), + ); } /** From 2a0308b0b16ab0ed182072d3bc6226a0d93ec30c Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Tue, 14 Jul 2026 09:24:11 +0200 Subject: [PATCH 03/11] HTML API: Preserve tag semantics when removing attributes --- .../html-api/class-wp-html-tag-processor.php | 55 +++++++++++++++---- 1 file changed, 44 insertions(+), 11 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..86f6305484e20 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 @@ -2798,8 +2798,11 @@ private function get_enqueued_attribute_value( string $comparable_name ) { $enqueued_text = $this->lexical_updates[ $comparable_name ]->text; - // Removed attributes erase the entire span. - if ( '' === $enqueued_text ) { + /* + * Removed attributes erase the entire span, unless a space is needed + * to prevent a preceding slash from becoming a self-closing flag. + */ + if ( '' === $enqueued_text || ' ' === $enqueued_text ) { return null; } @@ -4745,19 +4748,49 @@ public function remove_attribute( $name ): bool { * * Result:
*/ + $attribute_token = $this->attributes[ $name ]; + $replacement = ( + $attribute_token->start > 0 && + '/' === $this->html[ $attribute_token->start - 1 ] + ) ? ' ' : ''; + $this->lexical_updates[ $name ] = new WP_HTML_Text_Replacement( - $this->attributes[ $name ]->start, - $this->attributes[ $name ]->length, - '' + $attribute_token->start, + $attribute_token->length, + $replacement ); + $duplicate_attributes = $this->duplicate_attributes[ $name ] ?? array(); + $duplicates_already_removed = false; + + /* + * Duplicate removals are always enqueued as a batch. If the first is + * already present, all are present, and none should be enqueued again. + */ + if ( count( $duplicate_attributes ) > 0 ) { + $first_duplicate = $duplicate_attributes[0]; + foreach ( $this->lexical_updates as $update ) { + if ( $first_duplicate->start === $update->start && $first_duplicate->length === $update->length ) { + $duplicates_already_removed = true; + break; + } + } + } + // Removes any duplicated attributes if they were also present. - foreach ( $this->duplicate_attributes[ $name ] ?? array() as $attribute_token ) { - $this->lexical_updates[] = new WP_HTML_Text_Replacement( - $attribute_token->start, - $attribute_token->length, - '' - ); + if ( ! $duplicates_already_removed ) { + foreach ( $duplicate_attributes as $attribute_token ) { + $replacement = ( + $attribute_token->start > 0 && + '/' === $this->html[ $attribute_token->start - 1 ] + ) ? ' ' : ''; + + $this->lexical_updates[] = new WP_HTML_Text_Replacement( + $attribute_token->start, + $attribute_token->length, + $replacement + ); + } } return true; From e1d2d71bd599312db9ca3a692e185c03015346ff Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Tue, 14 Jul 2026 13:05:09 +0200 Subject: [PATCH 04/11] HTML API: Strengthen attribute removal semantics tests --- .../tests/html-api/wpHtmlProcessor.php | 54 +++++++++++++++++++ .../html-api/wpHtmlTagProcessor-bookmark.php | 24 +++++++++ .../tests/html-api/wpHtmlTagProcessor.php | 23 ++++---- 3 files changed, 92 insertions(+), 9 deletions(-) diff --git a/tests/phpunit/tests/html-api/wpHtmlProcessor.php b/tests/phpunit/tests/html-api/wpHtmlProcessor.php index f3b051ca3639e..17401f4514dfa 100644 --- a/tests/phpunit/tests/html-api/wpHtmlProcessor.php +++ b/tests/phpunit/tests/html-api/wpHtmlProcessor.php @@ -608,6 +608,60 @@ public function test_unquoted_slash_attribute_does_not_self_close_foreign_conten ); } + /** + * Ensures that removing attributes preserves foreign-content tree semantics. + * + * @ticket 65372 + * + * @covers ::remove_attribute + * + * @dataProvider data_remove_attribute_preserves_foreign_content_tree + * + * @param string $html HTML containing the attribute to remove. + * @param string $tag_name Foreign element tag name to modify. + * @param bool $expected_has_self_closing_flag Expected self-closing flag state. + * @param bool $expected_expects_closer Expected closer expectation. + * @param array $expected_text_breadcrumbs Expected breadcrumbs for the following text. + */ + public function test_remove_attribute_preserves_foreign_content_tree( $html, $tag_name, $expected_has_self_closing_flag, $expected_expects_closer, $expected_text_breadcrumbs ) { + $processor = WP_HTML_Processor::create_fragment( $html ); + $this->assertTrue( $processor->next_tag( $tag_name ), "Failed to find the {$tag_name} tag: check test setup." ); + $this->assertTrue( $processor->remove_attribute( 'attr' ), 'Could not remove the target attribute.' ); + + $processor = WP_HTML_Processor::create_fragment( $processor->get_updated_html() ); + $this->assertTrue( $processor->next_tag( $tag_name ), "Failed to find the updated {$tag_name} tag." ); + $this->assertNull( $processor->get_attribute( 'attr' ), 'The updated tag retained the removed attribute.' ); + $this->assertSame( $expected_has_self_closing_flag, $processor->has_self_closing_flag(), 'Removing the attribute changed the self-closing flag state.' ); + $this->assertSame( $expected_expects_closer, $processor->expects_closer(), 'Removing the attribute changed the closer expectation.' ); + + $this->assertTrue( $processor->next_token(), 'Failed to find text following the updated tag.' ); + $this->assertSame( $expected_text_breadcrumbs, $processor->get_breadcrumbs(), 'Removing the attribute changed the text node ancestry.' ); + } + + /** + * Data provider. + * + * @return array[] + */ + public static function data_remove_attribute_preserves_foreign_content_tree() { + return array( + 'SVG element without self-closing flag' => array( + 'inside', + 'G', + false, + true, + array( 'HTML', 'BODY', 'SVG', 'G', '#text' ), + ), + 'MathML element with self-closing flag' => array( + 'outside', + 'MI', + true, + false, + array( 'HTML', 'BODY', 'MATH', '#text' ), + ), + ); + } + /** * Ensures that expects_closer works for void-like elements in foreign content. * diff --git a/tests/phpunit/tests/html-api/wpHtmlTagProcessor-bookmark.php b/tests/phpunit/tests/html-api/wpHtmlTagProcessor-bookmark.php index beb4cc60f6d95..ccb4cddb38994 100644 --- a/tests/phpunit/tests/html-api/wpHtmlTagProcessor-bookmark.php +++ b/tests/phpunit/tests/html-api/wpHtmlTagProcessor-bookmark.php @@ -181,6 +181,30 @@ public function test_removing_long_attributes_doesnt_break_seek() { ); } + /** + * @ticket 65372 + * + * @covers WP_HTML_Tag_Processor::remove_attribute + * @covers WP_HTML_Tag_Processor::seek + * @covers WP_HTML_Tag_Processor::set_bookmark + */ + public function test_repeated_slash_adjacent_duplicate_removal_does_not_break_following_bookmark() { + $processor = new WP_HTML_Tag_Processor( '' ); + $this->assertTrue( $processor->next_tag( 'g' ), 'Could not find the G tag: check test setup.' ); + $this->assertTrue( $processor->set_bookmark( 'g' ), 'Could not bookmark the G tag.' ); + $this->assertTrue( $processor->next_tag( 'path' ), 'Could not find the PATH tag: check test setup.' ); + $this->assertTrue( $processor->set_bookmark( 'path' ), 'Could not bookmark the PATH tag.' ); + + $this->assertTrue( $processor->seek( 'g' ), 'Could not seek back to the G tag.' ); + $processor->remove_attribute( 'attr' ); + $processor->remove_attribute( 'attr' ); + $processor->get_updated_html(); + + $this->assertTrue( $processor->seek( 'path' ), 'Could not seek to the PATH tag after removing the attributes.' ); + $this->assertSame( 'PATH', $processor->get_tag(), 'The PATH bookmark no longer points to its tag.' ); + $this->assertSame( 'x', $processor->get_attribute( 'id' ), 'The PATH bookmark points to the wrong tag.' ); + } + /** * @ticket 56299 * diff --git a/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php b/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php index e80aba1320dc3..d43761e04a3dd 100644 --- a/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php +++ b/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php @@ -1333,17 +1333,21 @@ public function test_remove_attribute_with_an_existing_attribute_name_removes_it } /** + * @ticket 65372 + * * @covers WP_HTML_Tag_Processor::remove_attribute * - * @dataProvider data_remove_attribute_does_not_create_a_self_closing_flag + * @dataProvider data_remove_attribute_preserves_self_closing_flag_state * - * @param string $html HTML containing the attribute to remove. - * @param int $removal_count Number of times to remove the attribute. - * @param string $expected Expected HTML after removing the attribute. + * @param string $html HTML containing the attribute to remove. + * @param int $removal_count Number of times to remove the attribute. + * @param string $expected Expected HTML after removing the attribute. + * @param bool $expected_has_self_closing_flag Expected self-closing flag state. */ - public function test_remove_attribute_does_not_create_a_self_closing_flag( $html, $removal_count, $expected ) { + public function test_remove_attribute_preserves_self_closing_flag_state( $html, $removal_count, $expected, $expected_has_self_closing_flag ) { $processor = new WP_HTML_Tag_Processor( $html ); $processor->next_tag( 'g' ); + $this->assertSame( $expected_has_self_closing_flag, $processor->has_self_closing_flag(), 'Test setup has the wrong self-closing flag state.' ); for ( $i = 0; $i < $removal_count; $i++ ) { $processor->remove_attribute( 'attr' ); @@ -1351,6 +1355,7 @@ public function test_remove_attribute_does_not_create_a_self_closing_flag( $html } $this->assertSame( $expected, $processor->get_updated_html() ); + $this->assertSame( $expected_has_self_closing_flag, $processor->has_self_closing_flag(), 'Removing the attribute changed the self-closing flag state.' ); } /** @@ -1358,11 +1363,11 @@ public function test_remove_attribute_does_not_create_a_self_closing_flag( $html * * @return array[] */ - public static function data_remove_attribute_does_not_create_a_self_closing_flag() { + public static function data_remove_attribute_preserves_self_closing_flag_state() { return array( - 'Attribute preceded by a slash' => array( 'ok', 1, 'ok' ), - 'Duplicate attribute preceded by a slash' => array( 'ok', 1, 'ok' ), - 'Duplicate attribute removed twice' => array( 'ok', 2, 'ok' ), + 'Attribute preceded by a slash' => array( 'ok', 1, 'ok', false ), + 'Attribute before self-closing flag' => array( 'ok', 1, 'ok', true ), + 'Duplicate attribute removed twice' => array( 'ok', 2, 'ok', false ), ); } From 15ef73ec2785c02ebe45111036b356f62450583f Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Tue, 14 Jul 2026 13:05:13 +0200 Subject: [PATCH 05/11] HTML API: Remove redundant attribute offset checks --- .../html-api/class-wp-html-tag-processor.php | 10 ++-------- 1 file changed, 2 insertions(+), 8 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 86f6305484e20..53af5191ab811 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 @@ -4749,10 +4749,7 @@ public function remove_attribute( $name ): bool { * Result:
*/ $attribute_token = $this->attributes[ $name ]; - $replacement = ( - $attribute_token->start > 0 && - '/' === $this->html[ $attribute_token->start - 1 ] - ) ? ' ' : ''; + $replacement = '/' === $this->html[ $attribute_token->start - 1 ] ? ' ' : ''; $this->lexical_updates[ $name ] = new WP_HTML_Text_Replacement( $attribute_token->start, @@ -4780,10 +4777,7 @@ public function remove_attribute( $name ): bool { // Removes any duplicated attributes if they were also present. if ( ! $duplicates_already_removed ) { foreach ( $duplicate_attributes as $attribute_token ) { - $replacement = ( - $attribute_token->start > 0 && - '/' === $this->html[ $attribute_token->start - 1 ] - ) ? ' ' : ''; + $replacement = '/' === $this->html[ $attribute_token->start - 1 ] ? ' ' : ''; $this->lexical_updates[] = new WP_HTML_Text_Replacement( $attribute_token->start, From d986b3c337e2d25af6da3a970d74b45a648123e1 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Tue, 14 Jul 2026 14:50:04 +0200 Subject: [PATCH 06/11] HTML API: Test duplicate removal bookmark accounting --- .../html-api/wpHtmlTagProcessor-bookmark.php | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/tests/phpunit/tests/html-api/wpHtmlTagProcessor-bookmark.php b/tests/phpunit/tests/html-api/wpHtmlTagProcessor-bookmark.php index ccb4cddb38994..7edfb75ed21b9 100644 --- a/tests/phpunit/tests/html-api/wpHtmlTagProcessor-bookmark.php +++ b/tests/phpunit/tests/html-api/wpHtmlTagProcessor-bookmark.php @@ -188,16 +188,17 @@ public function test_removing_long_attributes_doesnt_break_seek() { * @covers WP_HTML_Tag_Processor::seek * @covers WP_HTML_Tag_Processor::set_bookmark */ - public function test_repeated_slash_adjacent_duplicate_removal_does_not_break_following_bookmark() { - $processor = new WP_HTML_Tag_Processor( '' ); - $this->assertTrue( $processor->next_tag( 'g' ), 'Could not find the G tag: check test setup.' ); - $this->assertTrue( $processor->set_bookmark( 'g' ), 'Could not bookmark the G tag.' ); + public function test_repeated_duplicate_attribute_removal_does_not_break_following_bookmark() { + $processor = new WP_HTML_Tag_Processor( '
' ); + $this->assertTrue( $processor->next_tag( 'div' ), 'Could not find the DIV tag: check test setup.' ); + $this->assertTrue( $processor->set_bookmark( 'div' ), 'Could not bookmark the DIV tag.' ); $this->assertTrue( $processor->next_tag( 'path' ), 'Could not find the PATH tag: check test setup.' ); $this->assertTrue( $processor->set_bookmark( 'path' ), 'Could not bookmark the PATH tag.' ); - $this->assertTrue( $processor->seek( 'g' ), 'Could not seek back to the G tag.' ); - $processor->remove_attribute( 'attr' ); - $processor->remove_attribute( 'attr' ); + $this->assertTrue( $processor->seek( 'div' ), 'Could not seek back to the DIV tag.' ); + $this->assertTrue( $processor->remove_attribute( 'a' ), 'Could not remove the duplicated attribute.' ); + $this->assertTrue( $processor->remove_attribute( 'a' ), 'Could not remove the duplicated attribute again.' ); + $this->assertNull( $processor->get_attribute( 'a' ), 'The duplicated attribute was not removed.' ); $processor->get_updated_html(); $this->assertTrue( $processor->seek( 'path' ), 'Could not seek to the PATH tag after removing the attributes.' ); From a2629bf83baf3cd7373dc3e7b20b26c9c2f7dc14 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Mon, 19 Oct 2026 16:27:40 +0400 Subject: [PATCH 07/11] HTML API: Report slash-preceded attribute removals in get_attribute_names_with_prefix(). When a slash precedes a removed attribute, `remove_attribute()` enqueues a single space instead of an empty string so the slash does not become a self-closing flag. `get_enqueued_attribute_value()` already treats that space as a removal, but `get_attribute_names_with_prefix()` only recognized the empty string, so it kept listing the removed attribute until the updates were applied. See #65967. --- .../html-api/class-wp-html-tag-processor.php | 3 +- .../tests/html-api/wpHtmlTagProcessor.php | 32 +++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) 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 97e412387f9f2..0751f8ab380c4 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 @@ -2994,7 +2994,8 @@ public function get_attribute_names_with_prefix( $prefix ): ?array { continue; } - if ( '' === $update->text ) { + // A removal replaces the attribute with nothing, or with a space when a slash precedes it. + if ( '' === $update->text || ' ' === $update->text ) { $removals[ $update_name ] = true; } elseif ( ! isset( $this->attributes[ $update_name ] ) && str_starts_with( $update_name, $comparable ) ) { $additions[] = $update_name; diff --git a/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php b/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php index 0dea5ec008023..08b04226cc7d8 100644 --- a/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php +++ b/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php @@ -560,6 +560,38 @@ public function test_get_attribute_names_with_prefix_immediately_reflects_remove ); } + /** + * Ensures that an attribute removed via remove_attribute() is no longer reported + * by get_attribute_names_with_prefix() when a slash precedes the attribute. + * + * @ticket 65967 + * + * @covers WP_HTML_Tag_Processor::get_attribute_names_with_prefix + */ + public function test_get_attribute_names_with_prefix_immediately_reflects_removed_attribute_after_slash() { + $processor = new WP_HTML_Tag_Processor( '' ); + $processor->next_tag(); + + $this->assertSame( + array( 'data-x' ), + $processor->get_attribute_names_with_prefix( 'data-' ), + 'Failed to report the existing attribute: check test setup.' + ); + + $processor->remove_attribute( 'data-x' ); + + $this->assertNull( + $processor->get_attribute( 'data-x' ), + 'Expected get_attribute() to report the removed attribute as missing.' + ); + + $this->assertSame( + array(), + $processor->get_attribute_names_with_prefix( 'data-' ), + 'Expected no custom data attributes after removing the only one.' + ); + } + /** * 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. From 68783ba2c7e27a6965c9312e99b527d9dc311b25 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Mon, 19 Oct 2026 16:28:56 +0400 Subject: [PATCH 08/11] HTML API: Test that removing an attribute preserves the tag's meaning. Adds tests that remove one attribute from tags where slashes separate attributes, follow the tag name, end an unquoted value, or form the self-closing flag, and where another attribute directly follows a quoted value. Each test re-parses the updated HTML and asserts that the tag name, the self-closing flag, the remaining attributes, and the following text are unchanged, so a stray or missing separator fails the test even when the expected string is wrong. Ported from #13208, with the expected strings updated to the single space this change writes in place of a slash-preceded attribute. See #65967. --- .../tests/html-api/wpHtmlTagProcessor.php | 101 ++++++++++++++++++ 1 file changed, 101 insertions(+) diff --git a/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php b/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php index 08b04226cc7d8..8062314a99c68 100644 --- a/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php +++ b/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php @@ -1611,6 +1611,107 @@ public static function data_remove_attribute_preserves_self_closing_flag_state() ); } + /** + * Ensures that removing an attribute does not change anything else about the tag. + * + * Inside a tag, `/` is ignored unless immediately followed by `>`, where it's the + * self-closing flag; otherwise it merely separates attributes. Removing only the + * attribute's own span can leave a stray `/` which changes the meaning of the tag, + * e.g. `` becoming ``. Conversely, removing too much can merge the + * tag name or a neighboring attribute into whatever follows, e.g. `` + * becoming ``. + * + * @ticket 65967 + * + * @covers WP_HTML_Tag_Processor::remove_attribute + * + * @dataProvider data_remove_attribute_preserves_tag_semantics + * + * @param string $html HTML containing a G tag whose attribute will be removed. + * @param string $attribute_to_remove Name of the attribute to remove. + * @param string $expected Expected HTML after removing the attribute. + */ + public function test_remove_attribute_preserves_tag_semantics( string $html, string $attribute_to_remove, string $expected ): void { + $processor = new WP_HTML_Tag_Processor( $html ); + $processor->next_tag( 'G' ); + $had_self_closing_flag = $processor->has_self_closing_flag(); + $expected_attributes = array(); + foreach ( $processor->get_attribute_names_with_prefix( '' ) as $name ) { + if ( strtolower( $attribute_to_remove ) !== $name ) { + $expected_attributes[ $name ] = $processor->get_attribute( $name ); + } + } + + $this->assertTrue( $processor->remove_attribute( $attribute_to_remove ), 'Failed to remove the attribute.' ); + $updated_html = $processor->get_updated_html(); + $this->assertSame( $expected, $updated_html, 'Updated HTML did not match the expected output.' ); + + $this->assert_svg_g_tag_semantics( $updated_html, $had_self_closing_flag, $expected_attributes ); + } + + /** + * Data provider. + * + * @return array + */ + public static function data_remove_attribute_preserves_tag_semantics(): array { + return array( + 'Slash before attribute' => array( 'ok', 'attr', 'ok' ), + 'Slash before attribute, no whitespace' => array( 'ok', 'attr', 'ok' ), + 'Multiple slashes before attribute' => array( 'ok', 'attr', 'ok' ), + 'Slash separating attributes, remove last' => array( 'ok', 'b', 'ok' ), + 'Slash separating attributes, remove first' => array( 'ok', 'a', 'ok' ), + 'Slash after quoted value, remove last' => array( 'ok', 'b', 'ok' ), + 'Slash before duplicated attributes' => array( 'ok', 'a', 'ok' ), + 'Slash and whitespace before attribute' => array( 'ok', 'attr', 'ok' ), + 'Slash inside unquoted value' => array( 'ok', 'attr', 'ok' ), + 'Slash ending unquoted value' => array( 'ok', 'attr', 'ok' ), + 'Slash ending unquoted value, remove other' => array( 'ok', 'b', 'ok' ), + 'Self-closing flag is preserved' => array( 'ok', 'attr', 'ok' ), + 'Self-closing flag after quoted value' => array( 'ok', 'attr', 'ok' ), + 'Self-closing flag, slash before attribute' => array( 'ok', 'attr', 'ok' ), + 'Attribute directly after quoted value' => array( 'ok', 'a', 'ok' ), + 'Attribute directly after quoted value, middle' => array( 'ok', 'a', 'ok' ), + 'Equals directly after quoted value' => array( 'ok', 'a', 'ok' ), + 'Quote directly after quoted value' => array( 'ok', 'a', 'ok' ), + 'Slash directly after quoted value' => array( 'ok', 'a', 'ok' ), + 'Whitespace after quoted value' => array( 'ok', 'a', 'ok' ), + ); + } + + /** + * Asserts that the tag following SVG in the given HTML is a G tag with the + * expected self-closing flag and attributes, followed by the text "ok". + * + * @param string $html HTML of the form `ok`. + * @param bool $expected_has_self_closing_flag Expected self-closing flag state. + * @param array $expected_attributes Expected attribute values by name. + */ + private function assert_svg_g_tag_semantics( string $html, bool $expected_has_self_closing_flag, array $expected_attributes ): void { + $processor = new WP_HTML_Tag_Processor( $html ); + $processor->next_tag(); + $processor->next_tag(); + $this->assertSame( 'G', $processor->get_tag(), 'The update changed the tag name.' ); + $this->assertSame( + $expected_has_self_closing_flag, + $processor->has_self_closing_flag(), + $expected_has_self_closing_flag + ? 'The update dropped the self-closing flag.' + : 'The update introduced a self-closing flag.' + ); + + $actual_attributes = array(); + foreach ( $processor->get_attribute_names_with_prefix( '' ) as $name ) { + $actual_attributes[ $name ] = $processor->get_attribute( $name ); + } + ksort( $expected_attributes ); + ksort( $actual_attributes ); + $this->assertSame( $expected_attributes, $actual_attributes, 'The update changed the other attributes.' ); + + $processor->next_token(); + $this->assertSame( 'ok', $processor->get_modifiable_text(), 'The update changed the content following the tag.' ); + } + /** * @ticket 58119 * From ce72073d65133e7d4064a19c3edf9f70ba5d2554 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Mon, 19 Oct 2026 16:29:59 +0400 Subject: [PATCH 09/11] HTML API: Test adding and removing attributes at the end of the tag name. `set_attribute()` and `add_class()` insert new attributes directly after the tag name. When only a slash separates the tag name from an attribute that is then removed, the insertion and the removal are a single byte apart. These tests apply both in either order, with a self-closing flag, with another attribute following, and with two removals of different attributes, then re-parse the updated HTML and assert the tag name, self-closing flag, attributes, and following text. Ported from WordPress/wordpress-develop#13208, with the expected strings updated to this change's output and a re-parse check added. See #65967. --- .../tests/html-api/wpHtmlTagProcessor.php | 94 ++++++++++++++++++- 1 file changed, 93 insertions(+), 1 deletion(-) diff --git a/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php b/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php index 8062314a99c68..fbd2d209dbe41 100644 --- a/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php +++ b/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php @@ -1679,6 +1679,98 @@ public static function data_remove_attribute_preserves_tag_semantics(): array { ); } + /** + * Ensures that attributes added and removed on the same tag don't interfere + * when only `/` separates the removed attribute from the tag name. + * + * New attributes are inserted directly after the tag name, so in `` + * the insertion and the removal are separated only by the `/`. + * + * @ticket 65967 + * + * @covers WP_HTML_Tag_Processor::remove_attribute + * @covers WP_HTML_Tag_Processor::set_attribute + * + * @dataProvider data_set_and_remove_attribute_at_tag_name_end + * + * @param string $html HTML containing a G tag. + * @param list $ops Operations to apply, each a method name followed by its arguments. + * @param string $expected Expected HTML after applying the operations. + * @param array $expected_attributes Expected attributes on the updated G tag. + */ + public function test_set_and_remove_attribute_at_tag_name_end( string $html, array $ops, string $expected, array $expected_attributes ): void { + $processor = new WP_HTML_Tag_Processor( $html ); + $processor->next_tag( 'G' ); + $had_self_closing_flag = $processor->has_self_closing_flag(); + foreach ( $ops as $op ) { + $method = array_shift( $op ); + $processor->$method( ...$op ); + } + + $updated_html = $processor->get_updated_html(); + $this->assertSame( $expected, $updated_html, 'Updated HTML did not match the expected output.' ); + + $this->assert_svg_g_tag_semantics( $updated_html, $had_self_closing_flag, $expected_attributes ); + } + + /** + * Data provider. + * + * @return array, string, array}> + */ + public static function data_set_and_remove_attribute_at_tag_name_end(): array { + return array( + 'Set then remove' => array( + 'ok', + array( array( 'set_attribute', 'b', 'x' ), array( 'remove_attribute', 'a' ) ), + 'ok', + array( 'b' => 'x' ), + ), + 'Remove then set' => array( + 'ok', + array( array( 'remove_attribute', 'a' ), array( 'set_attribute', 'b', 'x' ) ), + 'ok', + array( 'b' => 'x' ), + ), + 'Set two then remove' => array( + 'ok', + array( array( 'set_attribute', 'c', 'y' ), array( 'set_attribute', 'b', 'x' ), array( 'remove_attribute', 'a' ) ), + 'ok', + array( + 'b' => 'x', + 'c' => 'y', + ), + ), + 'Add class then remove' => array( + 'ok', + array( array( 'add_class', 'c' ), array( 'remove_attribute', 'a' ) ), + 'ok', + array( 'class' => 'c' ), + ), + 'Set then remove, self-closing' => array( + 'ok', + array( array( 'set_attribute', 'b', 'x' ), array( 'remove_attribute', 'a' ) ), + 'ok', + array( 'b' => 'x' ), + ), + 'Set then remove, attribute follows' => array( + 'ok', + array( array( 'set_attribute', 'b', 'x' ), array( 'remove_attribute', 'a' ) ), + 'ok', + array( + 'b' => 'x', + 'c' => true, + ), + ), + 'Set, remove both' => array( + 'ok', + array( array( 'set_attribute', 'b', 'x' ), array( 'remove_attribute', 'a' ), array( 'remove_attribute', 'c' ) ), + 'ok', + array( 'b' => 'x' ), + ), + ); + } + /** * Asserts that the tag following SVG in the given HTML is a G tag with the * expected self-closing flag and attributes, followed by the text "ok". @@ -1706,7 +1798,7 @@ private function assert_svg_g_tag_semantics( string $html, bool $expected_has_se } ksort( $expected_attributes ); ksort( $actual_attributes ); - $this->assertSame( $expected_attributes, $actual_attributes, 'The update changed the other attributes.' ); + $this->assertSame( $expected_attributes, $actual_attributes, 'The updated tag has the wrong attributes.' ); $processor->next_token(); $this->assertSame( 'ok', $processor->get_modifiable_text(), 'The update changed the content following the tag.' ); From 0d402ce989fe15c075de7eb1553b81eff4bcf519 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Mon, 19 Oct 2026 16:30:36 +0400 Subject: [PATCH 10/11] HTML API: Explain why remove_attribute() replaces a slash-preceded attribute with a space. Documents that a `/` directly before an attribute can only be a separator, why leaving it before `>` would add a self-closing flag, and which readers treat the single-space replacement as a removal. The explanation of why the `/` is always a separator comes from WordPress/wordpress-develop#13208. See #65967. --- .../html-api/class-wp-html-tag-processor.php | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) 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 0751f8ab380c4..3e9aa8640c77e 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 @@ -4802,6 +4802,24 @@ public function remove_attribute( $name ): bool { * replacement: `` * * Result:
+ * + * Inside a tag, `/` is ignored unless `>` immediately follows it, which + * makes it the self-closing flag. A `/` directly before an attribute is + * always one of these ignored separators: tag names and attribute names + * end at `/`, and when a `/` appears in an unquoted value, the characters + * after it belong to the value, not to a new attribute. Removing the + * attribute could leave that `/` directly before the `>`, so a space + * replaces the attribute instead. + * + * Example – remove the attribute attr from : + * + * ^--^ + * replacement: ` ` + * + * Result: + * + * get_enqueued_attribute_value() and get_attribute_names_with_prefix() + * read a replacement of a single space as a removal. */ $attribute_token = $this->attributes[ $name ]; $replacement = '/' === $this->html[ $attribute_token->start - 1 ] ? ' ' : ''; From e6b834db4ec50c6fddc9907bbbe5498e52fc6ec7 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Mon, 19 Oct 2026 16:31:02 +0400 Subject: [PATCH 11/11] HTML API: Annotate the attribute removal tests with the 7.2 ticket. The tests added with this change named #65372, the closed 7.1 ticket. The change belongs to #65967. See #65967. --- tests/phpunit/tests/html-api/wpHtmlProcessor.php | 2 +- tests/phpunit/tests/html-api/wpHtmlTagProcessor-bookmark.php | 2 +- tests/phpunit/tests/html-api/wpHtmlTagProcessor.php | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/phpunit/tests/html-api/wpHtmlProcessor.php b/tests/phpunit/tests/html-api/wpHtmlProcessor.php index 63a2d2782faf5..60ea50485027f 100644 --- a/tests/phpunit/tests/html-api/wpHtmlProcessor.php +++ b/tests/phpunit/tests/html-api/wpHtmlProcessor.php @@ -611,7 +611,7 @@ public function test_unquoted_slash_attribute_does_not_self_close_foreign_conten /** * Ensures that removing attributes preserves foreign-content tree semantics. * - * @ticket 65372 + * @ticket 65967 * * @covers ::remove_attribute * diff --git a/tests/phpunit/tests/html-api/wpHtmlTagProcessor-bookmark.php b/tests/phpunit/tests/html-api/wpHtmlTagProcessor-bookmark.php index 7edfb75ed21b9..625dfdead79dc 100644 --- a/tests/phpunit/tests/html-api/wpHtmlTagProcessor-bookmark.php +++ b/tests/phpunit/tests/html-api/wpHtmlTagProcessor-bookmark.php @@ -182,7 +182,7 @@ public function test_removing_long_attributes_doesnt_break_seek() { } /** - * @ticket 65372 + * @ticket 65967 * * @covers WP_HTML_Tag_Processor::remove_attribute * @covers WP_HTML_Tag_Processor::seek diff --git a/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php b/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php index fbd2d209dbe41..21631b251fafc 100644 --- a/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php +++ b/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php @@ -1573,7 +1573,7 @@ public function test_remove_attribute_with_an_existing_attribute_name_removes_it } /** - * @ticket 65372 + * @ticket 65967 * * @covers WP_HTML_Tag_Processor::remove_attribute *