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..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 @@ -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,43 @@ public function remove_attribute( $name ): bool { * * Result:
*/ + $attribute_token = $this->attributes[ $name ]; + $replacement = '/' === $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 = '/' === $this->html[ $attribute_token->start - 1 ] ? ' ' : ''; + + $this->lexical_updates[] = new WP_HTML_Text_Replacement( + $attribute_token->start, + $attribute_token->length, + $replacement + ); + } } return true; 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( + '', + 'G', + false, + true, + array( 'HTML', 'BODY', 'SVG', 'G', '#text' ), + ), + 'MathML element with self-closing flag' => array( + '', + '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..7edfb75ed21b9 100644 --- a/tests/phpunit/tests/html-api/wpHtmlTagProcessor-bookmark.php +++ b/tests/phpunit/tests/html-api/wpHtmlTagProcessor-bookmark.php @@ -181,6 +181,31 @@ 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_duplicate_attribute_removal_does_not_break_following_bookmark() { + $processor = new WP_HTML_Tag_Processor( '