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..07ae17da061a5 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 @@ -4753,11 +4753,26 @@ public function remove_attribute( $name ): bool { // 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, - '' - ); + /* + * Each span may adjust cursor and bookmark positions only once, so + * removal supersedes an update already enqueued for the exact span. + */ + $has_update = false; + foreach ( $this->lexical_updates as $update ) { + if ( $attribute_token->start === $update->start && $attribute_token->length === $update->length ) { + $update->text = ''; + $has_update = true; + break; + } + } + + if ( ! $has_update ) { + $this->lexical_updates[] = new WP_HTML_Text_Replacement( + $attribute_token->start, + $attribute_token->length, + '' + ); + } } return true; diff --git a/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php b/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php index 84d90a84190fc..a628c34ea0e74 100644 --- a/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php +++ b/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php @@ -1354,6 +1354,93 @@ public function test_remove_attribute_with_duplicated_attributes_removes_all_of_ $this->assertNull( $processor->get_attribute( $attribute_to_remove ), 'Failed to remove all copies of duplicated attributes when getting updated HTML.' ); } + /** + * @covers WP_HTML_Tag_Processor::remove_attribute + * @covers WP_HTML_Tag_Processor::seek + * + * @dataProvider data_remove_attribute_with_duplicated_attributes_is_idempotent + * + * @param string $html HTML containing duplicated attributes and a later PATH tag. + * @param string $tag_name Name of the tag containing the duplicated attributes. + * @param string|null $expected_html Expected HTML after one removal, or null to use the actual result. + */ + public function test_remove_attribute_with_duplicated_attributes_is_idempotent( $html, $tag_name, $expected_html ) { + $single_removal = new WP_HTML_Tag_Processor( $html ); + $this->assertTrue( $single_removal->next_tag( $tag_name ), 'Failed to find the tag containing duplicated attributes.' ); + $this->assertTrue( $single_removal->remove_attribute( 'a' ), 'Failed to remove the attribute.' ); + $single_removal_html = $single_removal->get_updated_html(); + if ( null !== $expected_html ) { + $this->assertSame( $expected_html, $single_removal_html, 'Removing duplicated attributes once produced unexpected HTML.' ); + } + + $processor = new WP_HTML_Tag_Processor( $html ); + $this->assertTrue( $processor->next_tag( $tag_name ), 'Failed to find the tag containing duplicated attributes.' ); + + $this->assertTrue( $processor->remove_attribute( 'a' ), 'Failed to remove the attribute.' ); + $this->assertTrue( $processor->remove_attribute( 'a' ), 'Failed to remove the attribute again.' ); + + $this->assertTrue( $processor->next_tag( 'path' ), 'Failed to find the PATH tag.' ); + $this->assertTrue( $processor->set_bookmark( 'path' ), 'Failed to set a bookmark on the PATH tag.' ); + + $this->assertSame( $single_removal_html, $processor->get_updated_html(), 'Removing duplicated attributes twice should have the same effect as removing them once.' ); + $this->assertTrue( $processor->seek( 'path' ), 'Failed to seek to the bookmark after removing duplicated attributes twice.' ); + $this->assertSame( 'PATH', $processor->get_tag(), 'The bookmark moved away from the bookmarked tag.' ); + $this->assertSame( 'x', $processor->get_attribute( 'id' ), 'The bookmark moved away from the bookmarked tag attributes.' ); + } + + /** + * Data provider. + * + * @return array[] + */ + public static function data_remove_attribute_with_duplicated_attributes_is_idempotent() { + return array( + 'Duplicated attributes' => array( '