From 3a2e0362c591b069a5e265c4b5bb97d25d6b3169 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Tue, 14 Jul 2026 09:17:21 +0200 Subject: [PATCH 1/6] 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 2/6] 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 3/6] 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 4/6] 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 5/6] 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 6/6] 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.' );