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 545af0650386b..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
@@ -2796,8 +2796,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;
}
@@ -2991,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;
@@ -4798,20 +4802,62 @@ 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 ] ? ' ' : '';
+
$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 58e7ef18cba41..60ea50485027f 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 65967
+ *
+ * @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..625dfdead79dc 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 65967
+ *
+ * @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( '' );
+ $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( '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.' );
+ $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 1bfaa01bf0453..21631b251fafc 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.
@@ -1540,6 +1572,238 @@ public function test_remove_attribute_with_an_existing_attribute_name_removes_it
);
}
+ /**
+ * @ticket 65967
+ *
+ * @covers WP_HTML_Tag_Processor::remove_attribute
+ *
+ * @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 bool $expected_has_self_closing_flag Expected self-closing flag state.
+ */
+ 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' );
+ $this->assertNull( $processor->get_attribute( 'attr' ) );
+ }
+
+ $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.' );
+ }
+
+ /**
+ * Data provider.
+ *
+ * @return array[]
+ */
+ public static function data_remove_attribute_preserves_self_closing_flag_state() {
+ return array(
+ 'Attribute preceded by a slash' => array( '