From cc9b730f8b1e2ec07de464e72b81837211d607d6 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Tue, 14 Jul 2026 12:37:50 +0200 Subject: [PATCH 01/17] HTML API: Add failing tests for attribute removal creating self-closing flags. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removing an attribute that is preceded by a solidus ("/") can leave the solidus directly before the tag-closing ">", where it becomes a self-closing flag and changes the meaning of the surrounding HTML: ok → remove "attr" → ok In foreign content the G element no longer contains the following text. See #65372. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BZSpa317PhG11sX3YUDcGU --- .../tests/html-api/wpHtmlProcessor.php | 37 ++++++++++++ .../tests/html-api/wpHtmlTagProcessor.php | 56 +++++++++++++++++++ 2 files changed, 93 insertions(+) diff --git a/tests/phpunit/tests/html-api/wpHtmlProcessor.php b/tests/phpunit/tests/html-api/wpHtmlProcessor.php index f3b051ca3639e..ea50aa3cfba0c 100644 --- a/tests/phpunit/tests/html-api/wpHtmlProcessor.php +++ b/tests/phpunit/tests/html-api/wpHtmlProcessor.php @@ -608,6 +608,43 @@ public function test_unquoted_slash_attribute_does_not_self_close_foreign_conten ); } + /** + * Ensures that removing an attribute does not change the structure of the document. + * + * A solidus before an attribute name is ignored when parsing, but if removing + * the attribute left the solidus directly before the tag-closing ">", the tag + * would gain a self-closing flag. In foreign content this changes whether the + * element contains the content that follows it. + * + * @ticket 65372 + * + * @covers WP_HTML_Tag_Processor::remove_attribute + */ + public function test_remove_attribute_does_not_self_close_foreign_content(): void { + $processor = WP_HTML_Processor::create_fragment( 'ok' ); + + $this->assertTrue( $processor->next_tag( 'G' ), 'Failed to find the G tag: check test setup.' ); + $this->assertFalse( $processor->has_self_closing_flag(), 'The G tag must not have a self-closing flag: check test setup.' ); + $this->assertTrue( $processor->remove_attribute( 'attr' ), 'Failed to remove the attribute.' ); + + $updated_html = $processor->get_updated_html(); + $this->assertSame( 'ok', $updated_html, 'Failed to remove the attribute with its preceding solidus.' ); + + $processor = WP_HTML_Processor::create_fragment( $updated_html ); + $this->assertTrue( $processor->next_tag( 'G' ), 'Failed to find the G tag in the updated HTML.' ); + $this->assertFalse( + $processor->has_self_closing_flag(), + 'Failed to prevent the G tag from becoming self-closing when removing the attribute.' + ); + + $this->assertTrue( $processor->next_token(), 'Failed to find text following the G tag in the updated HTML.' ); + $this->assertSame( + array( 'HTML', 'BODY', 'SVG', 'G', '#text' ), + $processor->get_breadcrumbs(), + 'Failed to keep text following the G tag inside the G element.' + ); + } + /** * Ensures that expects_closer works for void-like elements in foreign content. * diff --git a/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php b/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php index 84d90a84190fc..eafc15334d993 100644 --- a/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php +++ b/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php @@ -1332,6 +1332,62 @@ public function test_remove_attribute_with_an_existing_attribute_name_removes_it ); } + /** + * Ensures that removing an attribute does not change how the rest of the tag is parsed. + * + * Solidus characters ("/") may appear before attribute names, where they + * act like attribute separators and are not part of any syntax token. If + * removing an attribute leaves a solidus directly before the tag-closing + * ">", the tag would gain a self-closing flag, changing the meaning of + * the surrounding HTML. + * + * @ticket 65372 + * + * @covers WP_HTML_Tag_Processor::remove_attribute + * + * @dataProvider data_remove_attribute_preserves_self_closing_flag + * + * @param string $html HTML containing an "attr" attribute to remove. + * @param string $expected_html Expected HTML after removing the attribute. + */ + public function test_remove_attribute_preserves_self_closing_flag( $html, $expected_html ) { + $processor = new WP_HTML_Tag_Processor( $html ); + $this->assertTrue( $processor->next_tag(), 'Failed to find the tag: check test setup.' ); + $had_self_closing_flag = $processor->has_self_closing_flag(); + + $this->assertTrue( $processor->remove_attribute( 'attr' ), 'Failed to remove the attribute.' ); + + $updated_html = $processor->get_updated_html(); + $this->assertSame( $expected_html, $updated_html, 'Attribute removal produced unexpected HTML.' ); + + $processor = new WP_HTML_Tag_Processor( $updated_html ); + $this->assertTrue( $processor->next_tag(), 'Failed to find the tag in the updated HTML.' ); + $this->assertSame( + $had_self_closing_flag, + $processor->has_self_closing_flag(), + 'Attribute removal changed the self-closing flag of the tag.' + ); + } + + /** + * Data provider. + * + * @return array[] + */ + public static function data_remove_attribute_preserves_self_closing_flag() { + return array( + 'Solidus before the attribute' => array( 'ok', 'ok' ), + 'Multiple solidi before the attribute' => array( 'ok', 'ok' ), + 'Solidus after the tag name' => array( 'ok', 'ok' ), + 'Solidus before attribute with value' => array( 'ok', 'ok' ), + 'Solidus after another attribute' => array( 'ok', 'ok' ), + 'Solidus before duplicate attribute' => array( 'ok', 'ok' ), + 'Solidus separated from the attribute' => array( 'ok', 'ok' ), + 'Self-closing tag' => array( 'ok', 'ok' ), + 'Self-closing tag after solidus' => array( 'ok', 'ok' ), + ); + } + /** * @ticket 58119 * From 4c90591f1e013c208e08739a5bb2c1992e030136 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Tue, 14 Jul 2026 12:39:47 +0200 Subject: [PATCH 02/17] HTML API: Remove preceding solidus separators when removing attributes. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Solidus characters ("/") may precede an attribute name, where they act like attribute separators and are not part of any syntax token. Removing an attribute without its preceding solidus characters could leave a solidus directly before the tag-closing ">", where it becomes a self-closing flag and changes the meaning of the surrounding HTML: ok → remove "attr" → ok Extend attribute removal spans backward over any solidus characters directly preceding the attribute so the separators are removed with the attribute: ok → remove "attr" → ok See #65372. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BZSpa317PhG11sX3YUDcGU --- .../html-api/class-wp-html-tag-processor.php | 49 +++++++++++++++++-- 1 file changed, 45 insertions(+), 4 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..bc0f7f514cd6b 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 @@ -4745,17 +4745,25 @@ public function remove_attribute( $name ): bool { * * Result:
*/ - $this->lexical_updates[ $name ] = new WP_HTML_Text_Replacement( + $removal_span = $this->get_attribute_removal_span( $this->attributes[ $name ]->start, - $this->attributes[ $name ]->length, + $this->attributes[ $name ]->length + ); + $this->lexical_updates[ $name ] = new WP_HTML_Text_Replacement( + $removal_span->start, + $removal_span->length, '' ); // 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( + $removal_span = $this->get_attribute_removal_span( $attribute_token->start, - $attribute_token->length, + $attribute_token->length + ); + $this->lexical_updates[] = new WP_HTML_Text_Replacement( + $removal_span->start, + $removal_span->length, '' ); } @@ -4763,6 +4771,39 @@ public function remove_attribute( $name ): bool { return true; } + /** + * Returns the full span of the document to remove for an attribute. + * + * Solidus characters ("/") may precede an attribute name, where they act + * like attribute separators and are not part of any syntax token. Such + * solidus characters must be removed with the attribute; otherwise a + * remaining solidus directly before the tag-closing ">" would become a + * self-closing flag and change the meaning of the surrounding HTML. + * + * Example: + * + * inside g + * + * Removing only `attr` would produce `inside g`, where + * the self-closing G element no longer contains the text that follows. + * + * @since 7.1.0 + * + * @param int $start Byte offset into the document where the attribute starts. + * @param int $length Byte length of the attribute. + * @return WP_HTML_Span Span of the document to remove for the attribute. + */ + private function get_attribute_removal_span( int $start, int $length ): WP_HTML_Span { + $tag_name_ends_at = $this->tag_name_starts_at + $this->tag_name_length; + + while ( $start > $tag_name_ends_at && '/' === $this->html[ $start - 1 ] ) { + --$start; + ++$length; + } + + return new WP_HTML_Span( $start, $length ); + } + /** * Adds a new class name to the currently matched tag. * From b133d0254af03c70edbeedd99f3cfe410aca4fa5 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Tue, 14 Jul 2026 12:40:36 +0200 Subject: [PATCH 03/17] HTML API: Add failing tests for attribute updates sharing a document offset. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a removed attribute is separated from the tag name by only solidus characters, its removal span starts at the end of the tag name, the same document offset where new attributes are inserted. The lexical update application loop assumes update spans never overlap and moves its cursor backward in this case, un-doing the removal: ok → set_attribute( 'id', 'test' ) + remove_attribute( 'attr' ) → ok See #65372. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BZSpa317PhG11sX3YUDcGU --- .../tests/html-api/wpHtmlTagProcessor.php | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php b/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php index eafc15334d993..93fc5e93d88f8 100644 --- a/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php +++ b/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php @@ -1388,6 +1388,51 @@ public static function data_remove_attribute_preserves_self_closing_flag() { ); } + /** + * Ensures that removing an attribute with its preceding solidus does not + * conflict with new attributes inserted at the end of the tag name. + * + * When a removed attribute is separated from the tag name by only solidus + * characters, the removed span starts at the end of the tag name, the same + * place where new attributes are inserted. + * + * @ticket 65372 + * + * @covers WP_HTML_Tag_Processor::remove_attribute + * @covers WP_HTML_Tag_Processor::set_attribute + * @covers WP_HTML_Tag_Processor::add_class + * + * @dataProvider data_set_attribute_or_add_class_while_removing_solidus_separated_attribute + * + * @param string $method Method used to add an attribute: "set_attribute" or "add_class". + * @param string $expected_html Expected HTML after the updates are applied. + */ + public function test_can_set_attribute_while_removing_solidus_separated_attribute( $method, $expected_html ) { + $processor = new WP_HTML_Tag_Processor( 'ok' ); + $this->assertTrue( $processor->next_tag(), 'Failed to find the tag: check test setup.' ); + + if ( 'set_attribute' === $method ) { + $processor->set_attribute( 'id', 'test' ); + } else { + $processor->add_class( 'test' ); + } + $this->assertTrue( $processor->remove_attribute( 'attr' ), 'Failed to remove the attribute.' ); + + $this->assertSame( $expected_html, $processor->get_updated_html(), 'Applying the updates produced unexpected HTML.' ); + } + + /** + * Data provider. + * + * @return array[] + */ + public static function data_set_attribute_or_add_class_while_removing_solidus_separated_attribute() { + return array( + 'set_attribute' => array( 'set_attribute', 'ok' ), + 'add_class' => array( 'add_class', 'ok' ), + ); + } + /** * @ticket 58119 * From 4b77c870790f13d8031444764294da053772d1a3 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Tue, 14 Jul 2026 12:42:18 +0200 Subject: [PATCH 04/17] HTML API: Keep the copied-bytes cursor monotonic when applying updates. The lexical update application loop assumed that update spans never share a document offset. Attribute removal spans extended over their preceding solidus separators may start at the end of the tag name, where new attributes are inserted. When both updates were enqueued, the cursor moved backward after the zero-length insertion applied, copying the removed attribute back into the document. Skip copying when an update starts at or before the copied-bytes cursor and never move the cursor backward. Updates which previously moved the cursor backward produced corrupted output, so this changes no valid behavior. See #65372. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BZSpa317PhG11sX3YUDcGU --- .../html-api/class-wp-html-tag-processor.php | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 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 bc0f7f514cd6b..2a2b45216fc9e 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 @@ -2629,9 +2629,19 @@ private function apply_attributes_updates( int $shift_this_point ): int { $accumulated_shift_for_given_point += $shift; } - $output_buffer .= substr( $this->html, $bytes_already_copied, $diff->start - $bytes_already_copied ); + /* + * Updates may share a document offset, e.g. a new attribute inserted + * at the end of the tag name and a removed attribute whose span starts + * there because it was separated from the tag name by only solidus + * characters. The copied-bytes cursor must never move backward, or + * already-replaced spans of the document would be copied again. + */ + if ( $diff->start > $bytes_already_copied ) { + $output_buffer .= substr( $this->html, $bytes_already_copied, $diff->start - $bytes_already_copied ); + } + $output_buffer .= $diff->text; - $bytes_already_copied = $diff->start + $diff->length; + $bytes_already_copied = max( $bytes_already_copied, $diff->start + $diff->length ); } $this->html = $output_buffer . substr( $this->html, $bytes_already_copied ); From a12950fa2a514d1355a7ebc2dfc90a94ff89269b Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Tue, 14 Jul 2026 14:36:41 +0200 Subject: [PATCH 05/17] HTML API: Add failing tests for repeated attribute removal corrupting bookmarks. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removing an attribute also enqueues removals for its duplicates under numeric keys. Repeating the removal enqueues the duplicate removals again. Bookmark positions and the internal cursor are shifted by every enqueued update when updates are applied, so the repeated updates shift them more than the document actually changed:
ok → remove "a" twice, flush, seek to PATH → lands on the "ok" text, get_tag() is null The updated HTML appears correct; only positional accounting breaks. This also affects trunk, where the repeated updates additionally corrupt the updated HTML itself. See #65372. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BZSpa317PhG11sX3YUDcGU --- .../tests/html-api/wpHtmlTagProcessor.php | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php b/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php index 93fc5e93d88f8..877d0cc484b03 100644 --- a/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php +++ b/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php @@ -1433,6 +1433,53 @@ public static function data_set_attribute_or_add_class_while_removing_solidus_se ); } + /** + * Ensures that repeating an attribute removal does not corrupt bookmarks. + * + * Removing an attribute also enqueues removals for its duplicates. + * Repeating the removal must not enqueue those removals again: every + * enqueued update shifts bookmark positions and the internal cursor + * when updates are applied, so repeated updates for the same span + * would shift them more than the document actually changed. + * + * @ticket 65372 + * + * @covers WP_HTML_Tag_Processor::remove_attribute + * @covers WP_HTML_Tag_Processor::seek + * + * @dataProvider data_repeated_attribute_removal_preserves_bookmarks + * + * @param string $html HTML containing duplicate "a" attributes and a PATH tag. + * @param string $expected_html Expected HTML after removing the attribute. + */ + public function test_repeated_attribute_removal_preserves_bookmarks( $html, $expected_html ) { + $processor = new WP_HTML_Tag_Processor( $html ); + $this->assertTrue( $processor->next_tag(), 'Failed to find the first tag: check test setup.' ); + $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: check test setup.' ); + $this->assertTrue( $processor->set_bookmark( 'path' ), 'Failed to set a bookmark on the PATH tag.' ); + + $this->assertSame( $expected_html, $processor->get_updated_html(), 'Removing the attribute twice produced unexpected HTML.' ); + + $this->assertTrue( $processor->seek( 'path' ), 'Failed to seek to the bookmark.' ); + $this->assertSame( 'PATH', $processor->get_tag(), 'Seeking to the bookmark landed on the wrong location in the document.' ); + $this->assertSame( 'x', $processor->get_attribute( 'id' ), 'Failed to find the attribute of the tag at the bookmark.' ); + } + + /** + * Data provider. + * + * @return array[] + */ + public static function data_repeated_attribute_removal_preserves_bookmarks() { + return array( + 'Duplicate attributes' => array( '
ok', '
ok' ), + 'Duplicate attribute after a solidus' => array( 'ok', 'ok' ), + ); + } + /** * @ticket 58119 * From e9f9b697a4517ae245594c33c5f0812f6d6cbf18 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Tue, 14 Jul 2026 14:38:01 +0200 Subject: [PATCH 06/17] HTML API: Only enqueue duplicate attribute removals once. Removing an attribute also enqueues removals for its duplicates under numeric keys, so repeated removals of the same attribute enqueued the duplicate removals again. Bookmark positions and the internal cursor are shifted by every enqueued update when updates are applied; the repeated updates shifted them more than the document actually changed, corrupting bookmarks and seek() destinations. Skip enqueuing the duplicate removals when they are already enqueued. Duplicate removals are enqueued as a batch: if the first one is present, all of them are. See #65372. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BZSpa317PhG11sX3YUDcGU --- .../html-api/class-wp-html-tag-processor.php | 39 ++++++++++++++----- 1 file changed, 30 insertions(+), 9 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 2a2b45216fc9e..71613650e5948 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 @@ -4766,16 +4766,37 @@ public function remove_attribute( $name ): bool { ); // Removes any duplicated attributes if they were also present. - foreach ( $this->duplicate_attributes[ $name ] ?? array() as $attribute_token ) { - $removal_span = $this->get_attribute_removal_span( - $attribute_token->start, - $attribute_token->length - ); - $this->lexical_updates[] = new WP_HTML_Text_Replacement( - $removal_span->start, - $removal_span->length, - '' + $duplicate_attributes = $this->duplicate_attributes[ $name ] ?? array(); + if ( count( $duplicate_attributes ) > 0 ) { + /* + * Duplicate removals are enqueued as a batch: if the first one is + * already enqueued, all of them are. Repeated removals of the same + * attribute must not enqueue the batch again. Bookmark positions + * and the internal cursor are shifted by every enqueued update + * when updates are applied, so repeated updates for the same span + * would shift them more than the document actually changed. + */ + $first_removal_span = $this->get_attribute_removal_span( + $duplicate_attributes[0]->start, + $duplicate_attributes[0]->length ); + foreach ( $this->lexical_updates as $update ) { + if ( $first_removal_span->start === $update->start && $first_removal_span->length === $update->length ) { + return true; + } + } + + foreach ( $duplicate_attributes as $attribute_token ) { + $removal_span = $this->get_attribute_removal_span( + $attribute_token->start, + $attribute_token->length + ); + $this->lexical_updates[] = new WP_HTML_Text_Replacement( + $removal_span->start, + $removal_span->length, + '' + ); + } } return true; From dc35c143f2a776bd6e78c56d8838f86b0255ae9d Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Tue, 14 Jul 2026 14:57:06 +0200 Subject: [PATCH 07/17] HTML API: Add failing test for suppressed duplicate attribute removals. The guard against repeatedly enqueuing duplicate attribute removals matches enqueued updates by their span alone. An update enqueued over the same span with different replacement text is not a removal of that span, but it suppresses the duplicate removal batch, leaving later duplicates in the document. See #65372. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BZSpa317PhG11sX3YUDcGU --- .../tests/html-api/wpHtmlTagProcessor.php | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php b/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php index 877d0cc484b03..b5917d4c8fbc7 100644 --- a/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php +++ b/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php @@ -1480,6 +1480,38 @@ public static function data_repeated_attribute_removal_preserves_bookmarks() { ); } + /** + * Ensures that removing an attribute removes all of its duplicates even + * when another lexical update targets a duplicate's span. + * + * Duplicate removals are enqueued as a batch, detected by an already- + * enqueued removal of the first duplicate's span. An enqueued update + * over the same span with different replacement text is not a removal + * of that span and must not suppress the duplicate removals. + * + * @ticket 65372 + * + * @covers WP_HTML_Tag_Processor::remove_attribute + */ + public function test_remove_attribute_removes_duplicates_when_another_update_targets_a_duplicate_span() { + $processor = new class('ok') extends WP_HTML_Tag_Processor { + public function enqueue_replacement( int $start, int $length, string $text ): void { + $this->lexical_updates[] = new WP_HTML_Text_Replacement( $start, $length, $text ); + } + }; + + $this->assertTrue( $processor->next_tag(), 'Failed to find the tag: check test setup.' ); + + // Enqueue an update over the span of the first duplicate "a", at offset 5. + $processor->enqueue_replacement( 5, 1, 'b' ); + + $this->assertTrue( $processor->remove_attribute( 'a' ), 'Failed to remove the attribute.' ); + + $processor = new WP_HTML_Tag_Processor( $processor->get_updated_html() ); + $this->assertTrue( $processor->next_tag(), 'Failed to find the tag in the updated HTML.' ); + $this->assertNull( $processor->get_attribute( 'a' ), 'Failed to remove all duplicates of the attribute.' ); + } + /** * @ticket 58119 * From 34139ff5b204915bf90c2a0c11c2558720ec74d6 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Tue, 14 Jul 2026 14:57:41 +0200 Subject: [PATCH 08/17] HTML API: Only match removals when detecting enqueued duplicate removals. The guard against repeatedly enqueuing duplicate attribute removals matched enqueued updates by their span alone, so an update enqueued over the same span with different replacement text suppressed the duplicate removal batch, leaving later duplicates in the document. Require empty replacement text: only a removal of the first duplicate's span indicates that the batch is already enqueued. See #65372. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BZSpa317PhG11sX3YUDcGU --- .../html-api/class-wp-html-tag-processor.php | 22 +++++++++++++------ 1 file changed, 15 insertions(+), 7 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 71613650e5948..1e75854ddcdaf 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 @@ -4769,19 +4769,27 @@ public function remove_attribute( $name ): bool { $duplicate_attributes = $this->duplicate_attributes[ $name ] ?? array(); if ( count( $duplicate_attributes ) > 0 ) { /* - * Duplicate removals are enqueued as a batch: if the first one is - * already enqueued, all of them are. Repeated removals of the same - * attribute must not enqueue the batch again. Bookmark positions - * and the internal cursor are shifted by every enqueued update - * when updates are applied, so repeated updates for the same span - * would shift them more than the document actually changed. + * Duplicate removals are enqueued as a batch: if a removal of the + * first duplicate's span is already enqueued, all of them are. + * Repeated removals of the same attribute must not enqueue the + * batch again. Bookmark positions and the internal cursor are + * shifted by every enqueued update when updates are applied, so + * repeated updates for the same span would shift them more than + * the document actually changed. + * + * Only an update with empty replacement text is a removal; other + * updates over the same span must not suppress the batch. */ $first_removal_span = $this->get_attribute_removal_span( $duplicate_attributes[0]->start, $duplicate_attributes[0]->length ); foreach ( $this->lexical_updates as $update ) { - if ( $first_removal_span->start === $update->start && $first_removal_span->length === $update->length ) { + if ( + '' === $update->text && + $first_removal_span->start === $update->start && + $first_removal_span->length === $update->length + ) { return true; } } From 23e9a7803a9ab998d08638ae178f074904a7b3f6 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Tue, 14 Jul 2026 14:59:44 +0200 Subject: [PATCH 09/17] HTML API: Extend attribute removal test coverage. Cover both operation orders when an attribute is added while a solidus-separated attribute is removed, and verify bookmark positions after the shared-offset updates are applied. Verify that a genuine self-closing flag in foreign content survives attribute removal along with the resulting document structure. See #65372. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BZSpa317PhG11sX3YUDcGU --- .../tests/html-api/wpHtmlProcessor.php | 34 +++++++++++++++++++ .../tests/html-api/wpHtmlTagProcessor.php | 32 +++++++++++++---- 2 files changed, 60 insertions(+), 6 deletions(-) diff --git a/tests/phpunit/tests/html-api/wpHtmlProcessor.php b/tests/phpunit/tests/html-api/wpHtmlProcessor.php index ea50aa3cfba0c..fd6f4ba0050ea 100644 --- a/tests/phpunit/tests/html-api/wpHtmlProcessor.php +++ b/tests/phpunit/tests/html-api/wpHtmlProcessor.php @@ -645,6 +645,40 @@ public function test_remove_attribute_does_not_self_close_foreign_content(): voi ); } + /** + * Ensures that removing an attribute preserves a genuine self-closing flag + * and the resulting document structure in foreign content. + * + * @ticket 65372 + * + * @covers WP_HTML_Tag_Processor::remove_attribute + */ + public function test_remove_attribute_preserves_self_closing_foreign_content(): void { + $processor = WP_HTML_Processor::create_fragment( 'ok' ); + + $this->assertTrue( $processor->next_tag( 'MI' ), 'Failed to find the MI tag: check test setup.' ); + $this->assertTrue( $processor->has_self_closing_flag(), 'The MI tag must have a self-closing flag: check test setup.' ); + $this->assertFalse( $processor->expects_closer(), 'The self-closing MI tag must not expect a closer: check test setup.' ); + $this->assertTrue( $processor->remove_attribute( 'attr' ), 'Failed to remove the attribute.' ); + + $updated_html = $processor->get_updated_html(); + $this->assertSame( 'ok', $updated_html, 'Removing the attribute produced unexpected HTML.' ); + + $processor = WP_HTML_Processor::create_fragment( $updated_html ); + $this->assertTrue( $processor->next_tag( 'MI' ), 'Failed to find the MI tag in the updated HTML.' ); + $this->assertTrue( + $processor->has_self_closing_flag(), + 'Failed to preserve the self-closing flag of the MI tag when removing the attribute.' + ); + + $this->assertTrue( $processor->next_token(), 'Failed to find text following the MI tag in the updated HTML.' ); + $this->assertSame( + array( 'HTML', 'BODY', 'MATH', '#text' ), + $processor->get_breadcrumbs(), + 'Failed to keep text following the self-closing MI tag outside the MI element.' + ); + } + /** * Ensures that expects_closer works for void-like elements in foreign content. * diff --git a/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php b/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php index b5917d4c8fbc7..82daa08e1bdda 100644 --- a/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php +++ b/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php @@ -1394,31 +1394,49 @@ public static function data_remove_attribute_preserves_self_closing_flag() { * * When a removed attribute is separated from the tag name by only solidus * characters, the removed span starts at the end of the tag name, the same - * place where new attributes are inserted. + * place where new attributes are inserted. The updates must apply + * correctly in either order of operations, and bookmark positions must + * reflect the applied updates. * * @ticket 65372 * * @covers WP_HTML_Tag_Processor::remove_attribute * @covers WP_HTML_Tag_Processor::set_attribute * @covers WP_HTML_Tag_Processor::add_class + * @covers WP_HTML_Tag_Processor::seek * * @dataProvider data_set_attribute_or_add_class_while_removing_solidus_separated_attribute * * @param string $method Method used to add an attribute: "set_attribute" or "add_class". + * @param string $order Order of operations: "add first" or "remove first". * @param string $expected_html Expected HTML after the updates are applied. */ - public function test_can_set_attribute_while_removing_solidus_separated_attribute( $method, $expected_html ) { - $processor = new WP_HTML_Tag_Processor( 'ok' ); + public function test_can_set_attribute_while_removing_solidus_separated_attribute( $method, $order, $expected_html ) { + $processor = new WP_HTML_Tag_Processor( 'ok' ); $this->assertTrue( $processor->next_tag(), 'Failed to find the tag: check test setup.' ); + if ( 'remove first' === $order ) { + $this->assertTrue( $processor->remove_attribute( 'attr' ), 'Failed to remove the attribute.' ); + } + if ( 'set_attribute' === $method ) { $processor->set_attribute( 'id', 'test' ); } else { $processor->add_class( 'test' ); } - $this->assertTrue( $processor->remove_attribute( 'attr' ), 'Failed to remove the attribute.' ); + + if ( 'add first' === $order ) { + $this->assertTrue( $processor->remove_attribute( 'attr' ), 'Failed to remove the attribute.' ); + } + + $this->assertTrue( $processor->next_tag( 'path' ), 'Failed to find the PATH tag: check test setup.' ); + $this->assertTrue( $processor->set_bookmark( 'path' ), 'Failed to set a bookmark on the PATH tag.' ); $this->assertSame( $expected_html, $processor->get_updated_html(), 'Applying the updates produced unexpected HTML.' ); + + $this->assertTrue( $processor->seek( 'path' ), 'Failed to seek to the bookmark.' ); + $this->assertSame( 'PATH', $processor->get_tag(), 'Seeking to the bookmark landed on the wrong location in the document.' ); + $this->assertSame( 'x', $processor->get_attribute( 'id' ), 'Failed to find the attribute of the tag at the bookmark.' ); } /** @@ -1428,8 +1446,10 @@ public function test_can_set_attribute_while_removing_solidus_separated_attribut */ public static function data_set_attribute_or_add_class_while_removing_solidus_separated_attribute() { return array( - 'set_attribute' => array( 'set_attribute', 'ok' ), - 'add_class' => array( 'add_class', 'ok' ), + 'set_attribute, then remove' => array( 'set_attribute', 'add first', 'ok' ), + 'remove, then set_attribute' => array( 'set_attribute', 'remove first', 'ok' ), + 'add_class, then remove' => array( 'add_class', 'add first', 'ok' ), + 'remove, then add_class' => array( 'add_class', 'remove first', 'ok' ), ); } From 8585f155b14b41ee3b379d2cabbfd5a8c1c13364 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Tue, 14 Jul 2026 14:59:44 +0200 Subject: [PATCH 10/17] HTML API: Document lexical update span invariants. State the invariants the update application loop relies on: updates with positive length must never overlap, only zero-length insertions may share an offset with another update's span, and same-offset ordering places insertions after a removal of the span they share. See #65372. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BZSpa317PhG11sX3YUDcGU --- src/wp-includes/html-api/class-wp-html-tag-processor.php | 7 +++++++ 1 file changed, 7 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 1e75854ddcdaf..9ec679d599d96 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 @@ -2635,6 +2635,13 @@ private function apply_attributes_updates( int $shift_this_point ): int { * there because it was separated from the tag name by only solidus * characters. The copied-bytes cursor must never move backward, or * already-replaced spans of the document would be copied again. + * + * Only zero-length insertions may share their offset with another + * update's span; the sort places them after a removal of that span, + * so the inserted text lands where the removed span was. Updates + * with positive length must never overlap each other: the cursor + * and bookmark position accounting below assume that every update + * replaces a distinct span of the document. */ if ( $diff->start > $bytes_already_copied ) { $output_buffer .= substr( $this->html, $bytes_already_copied, $diff->start - $bytes_already_copied ); From b13dbfaac71adc3247c152b67fb7f908f6f0a1c9 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Tue, 14 Jul 2026 15:07:33 +0200 Subject: [PATCH 11/17] HTML API: Expand failing test for updates targeting duplicate attribute spans. Detecting an already-enqueued batch of duplicate attribute removals by the first duplicate's span alone fails in two ways when another lexical update targets that span: - An enqueued removal of only the first duplicate's span suppresses the batch, leaving later duplicates in the document. - Any other update over the span coexists with the batch, so two updates replace the same span and bookmark positions shift more than the document actually changed. Exercise both cases on the same processor with exact expected HTML and a bookmark past the edits. See #65372. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BZSpa317PhG11sX3YUDcGU --- .../tests/html-api/wpHtmlTagProcessor.php | 44 ++++++++++++++----- 1 file changed, 34 insertions(+), 10 deletions(-) diff --git a/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php b/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php index 82daa08e1bdda..a659a0012252b 100644 --- a/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php +++ b/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php @@ -1504,17 +1504,24 @@ public static function data_repeated_attribute_removal_preserves_bookmarks() { * Ensures that removing an attribute removes all of its duplicates even * when another lexical update targets a duplicate's span. * - * Duplicate removals are enqueued as a batch, detected by an already- - * enqueued removal of the first duplicate's span. An enqueued update - * over the same span with different replacement text is not a removal - * of that span and must not suppress the duplicate removals. + * Every duplicate's removal must be enqueued exactly once. Bookmark + * positions and the internal cursor are shifted by every enqueued update + * when updates are applied: a missing removal leaves a duplicate in the + * document, and an update over an already-updated span shifts positions + * more than the document actually changed. An update enqueued over a + * duplicate's span by other means is superseded by the removal. * * @ticket 65372 * * @covers WP_HTML_Tag_Processor::remove_attribute + * @covers WP_HTML_Tag_Processor::seek + * + * @dataProvider data_updates_targeting_duplicate_attribute_spans + * + * @param string $enqueued_text Replacement text enqueued over the first duplicate's span. */ - public function test_remove_attribute_removes_duplicates_when_another_update_targets_a_duplicate_span() { - $processor = new class('ok') extends WP_HTML_Tag_Processor { + public function test_remove_attribute_removes_duplicates_when_another_update_targets_a_duplicate_span( $enqueued_text ) { + $processor = new class('ok') extends WP_HTML_Tag_Processor { public function enqueue_replacement( int $start, int $length, string $text ): void { $this->lexical_updates[] = new WP_HTML_Text_Replacement( $start, $length, $text ); } @@ -1523,13 +1530,30 @@ public function enqueue_replacement( int $start, int $length, string $text ): vo $this->assertTrue( $processor->next_tag(), 'Failed to find the tag: check test setup.' ); // Enqueue an update over the span of the first duplicate "a", at offset 5. - $processor->enqueue_replacement( 5, 1, 'b' ); + $processor->enqueue_replacement( 5, 1, $enqueued_text ); $this->assertTrue( $processor->remove_attribute( 'a' ), 'Failed to remove the attribute.' ); - $processor = new WP_HTML_Tag_Processor( $processor->get_updated_html() ); - $this->assertTrue( $processor->next_tag(), 'Failed to find the tag in the updated HTML.' ); - $this->assertNull( $processor->get_attribute( 'a' ), 'Failed to remove all duplicates of the attribute.' ); + $this->assertTrue( $processor->next_tag( 'path' ), 'Failed to find the PATH tag: check test setup.' ); + $this->assertTrue( $processor->set_bookmark( 'path' ), 'Failed to set a bookmark on the PATH tag.' ); + + $this->assertSame( 'ok', $processor->get_updated_html(), 'Removing the attribute produced unexpected HTML.' ); + + $this->assertTrue( $processor->seek( 'path' ), 'Failed to seek to the bookmark.' ); + $this->assertSame( 'PATH', $processor->get_tag(), 'Seeking to the bookmark landed on the wrong location in the document.' ); + $this->assertSame( 'x', $processor->get_attribute( 'id' ), 'Failed to find the attribute of the tag at the bookmark.' ); + } + + /** + * Data provider. + * + * @return array[] + */ + public static function data_updates_targeting_duplicate_attribute_spans() { + return array( + 'A removal of the duplicate' => array( '' ), + 'A replacement of the duplicate' => array( 'b' ), + ); } /** From 8141c7a16ca542b099d99c4bb24a9a676f0d463f Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Tue, 14 Jul 2026 15:35:03 +0200 Subject: [PATCH 12/17] HTML API: Enqueue duplicate attribute removals individually. Detecting an already-enqueued batch of duplicate attribute removals by the first duplicate's span alone fails when another lexical update targets that span: an enqueued removal of only the first duplicate's span suppressed the whole batch, and any other update over a duplicate's span coexisted with the batch, replacing the same span twice and shifting bookmark positions more than the document changed. Verify each duplicate's span individually and enqueue only missing removals. An update already enqueued over a duplicate's span is superseded by the removal, so no two updates replace overlapping spans of the document. See #65372. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BZSpa317PhG11sX3YUDcGU --- .../html-api/class-wp-html-tag-processor.php | 47 +++++++++---------- 1 file changed, 21 insertions(+), 26 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 9ec679d599d96..a8b72c5db60e8 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 @@ -4773,39 +4773,34 @@ public function remove_attribute( $name ): bool { ); // Removes any duplicated attributes if they were also present. - $duplicate_attributes = $this->duplicate_attributes[ $name ] ?? array(); - if ( count( $duplicate_attributes ) > 0 ) { + foreach ( $this->duplicate_attributes[ $name ] ?? array() as $attribute_token ) { + $removal_span = $this->get_attribute_removal_span( + $attribute_token->start, + $attribute_token->length + ); + /* - * Duplicate removals are enqueued as a batch: if a removal of the - * first duplicate's span is already enqueued, all of them are. - * Repeated removals of the same attribute must not enqueue the - * batch again. Bookmark positions and the internal cursor are - * shifted by every enqueued update when updates are applied, so - * repeated updates for the same span would shift them more than - * the document actually changed. + * Enqueue a removal for each duplicate whose span is not already + * updated. Bookmark positions and the internal cursor are shifted + * by every enqueued update when updates are applied, so repeated + * removals of the same attribute must not enqueue removals again. * - * Only an update with empty replacement text is a removal; other - * updates over the same span must not suppress the batch. + * An update already enqueued over the same span with other + * replacement text is superseded: removing the attribute removes + * the entire span. This also ensures that no two updates replace + * overlapping spans of the document, which the position + * accounting in the update application relies on. */ - $first_removal_span = $this->get_attribute_removal_span( - $duplicate_attributes[0]->start, - $duplicate_attributes[0]->length - ); + $is_missing = true; foreach ( $this->lexical_updates as $update ) { - if ( - '' === $update->text && - $first_removal_span->start === $update->start && - $first_removal_span->length === $update->length - ) { - return true; + if ( $removal_span->start === $update->start && $removal_span->length === $update->length ) { + $update->text = ''; + $is_missing = false; + break; } } - foreach ( $duplicate_attributes as $attribute_token ) { - $removal_span = $this->get_attribute_removal_span( - $attribute_token->start, - $attribute_token->length - ); + if ( $is_missing ) { $this->lexical_updates[] = new WP_HTML_Text_Replacement( $removal_span->start, $removal_span->length, From feb910fc7720fca9e8079e4170ec78843bfbf90b Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Tue, 14 Jul 2026 15:35:28 +0200 Subject: [PATCH 13/17] HTML API: Document non-overlap obligation for lexical update producers. State on the protected lexical updates property that code creating updates must ensure no two updates replace overlapping spans of the document, since cursor and bookmark position accounting assumes every update replaces a distinct span. See #65372. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BZSpa317PhG11sX3YUDcGU --- src/wp-includes/html-api/class-wp-html-tag-processor.php | 6 ++++++ 1 file changed, 6 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 a8b72c5db60e8..ff970f027ab49 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 @@ -791,6 +791,12 @@ class WP_HTML_Tag_Processor { * boundaries, however, so these should never be exposed outside of this * class or any classes which intentionally expand its functionality. * + * Any code which creates these lexical updates must also ensure that no + * two updates replace overlapping spans of the document: the cursor and + * bookmark position accounting when applying the updates assumes that + * every update replaces a distinct span. Only zero-length insertions may + * share their offset with another update's span. + * * These are enqueued while editing the document instead of being immediately * applied to avoid processing overhead, string allocations, and string * copies when applying many updates to a single document. From 17c5726348ba70889566fc46637d32161927cf24 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Tue, 14 Jul 2026 15:43:37 +0200 Subject: [PATCH 14/17] HTML API: Add failing tests for updates within extended removal spans. Attribute removal spans extend over preceding solidus characters, so an update enqueued over the attribute's original token span intersects the removal span without matching it exactly. The exact-span supersede misses it and enqueues a removal replacing an overlapping span: - A replacement within the span survives in the output. - A removal within the span double-counts the removed bytes, so bookmark positions shift more than the document changed and seek() lands off the bookmarked tag. See #65372. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BZSpa317PhG11sX3YUDcGU --- .../tests/html-api/wpHtmlTagProcessor.php | 43 +++++++++++-------- 1 file changed, 25 insertions(+), 18 deletions(-) diff --git a/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php b/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php index a659a0012252b..d09658c5e1cea 100644 --- a/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php +++ b/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php @@ -1501,27 +1501,32 @@ public static function data_repeated_attribute_removal_preserves_bookmarks() { } /** - * Ensures that removing an attribute removes all of its duplicates even - * when another lexical update targets a duplicate's span. + * Ensures that removing an attribute removes the entire spans of the + * attribute and its duplicates even when other lexical updates target + * spans within them. * - * Every duplicate's removal must be enqueued exactly once. Bookmark - * positions and the internal cursor are shifted by every enqueued update - * when updates are applied: a missing removal leaves a duplicate in the - * document, and an update over an already-updated span shifts positions - * more than the document actually changed. An update enqueued over a - * duplicate's span by other means is superseded by the removal. + * Exactly one update must replace each removed span. Bookmark positions + * and the internal cursor are shifted by every enqueued update when + * updates are applied: a missing removal leaves a duplicate in the + * document, and updates over intersecting spans shift positions more + * than the document actually changed. An update enqueued within a + * removed span by other means is superseded by the removal. * * @ticket 65372 * * @covers WP_HTML_Tag_Processor::remove_attribute * @covers WP_HTML_Tag_Processor::seek * - * @dataProvider data_updates_targeting_duplicate_attribute_spans + * @dataProvider data_updates_targeting_removed_attribute_spans * - * @param string $enqueued_text Replacement text enqueued over the first duplicate's span. + * @param string $html HTML containing "a" attributes and a PATH tag. + * @param int $update_start Byte offset of the enqueued update. + * @param int $update_length Byte length of the enqueued update. + * @param string $update_text Replacement text of the enqueued update. + * @param string $expected_html Expected HTML after removing the attribute. */ - public function test_remove_attribute_removes_duplicates_when_another_update_targets_a_duplicate_span( $enqueued_text ) { - $processor = new class('ok') extends WP_HTML_Tag_Processor { + public function test_remove_attribute_removes_attribute_spans_when_other_updates_target_them( $html, $update_start, $update_length, $update_text, $expected_html ) { + $processor = new class($html) extends WP_HTML_Tag_Processor { public function enqueue_replacement( int $start, int $length, string $text ): void { $this->lexical_updates[] = new WP_HTML_Text_Replacement( $start, $length, $text ); } @@ -1529,15 +1534,14 @@ public function enqueue_replacement( int $start, int $length, string $text ): vo $this->assertTrue( $processor->next_tag(), 'Failed to find the tag: check test setup.' ); - // Enqueue an update over the span of the first duplicate "a", at offset 5. - $processor->enqueue_replacement( 5, 1, $enqueued_text ); + $processor->enqueue_replacement( $update_start, $update_length, $update_text ); $this->assertTrue( $processor->remove_attribute( 'a' ), 'Failed to remove the attribute.' ); $this->assertTrue( $processor->next_tag( 'path' ), 'Failed to find the PATH tag: check test setup.' ); $this->assertTrue( $processor->set_bookmark( 'path' ), 'Failed to set a bookmark on the PATH tag.' ); - $this->assertSame( 'ok', $processor->get_updated_html(), 'Removing the attribute produced unexpected HTML.' ); + $this->assertSame( $expected_html, $processor->get_updated_html(), 'Removing the attribute produced unexpected HTML.' ); $this->assertTrue( $processor->seek( 'path' ), 'Failed to seek to the bookmark.' ); $this->assertSame( 'PATH', $processor->get_tag(), 'Seeking to the bookmark landed on the wrong location in the document.' ); @@ -1549,10 +1553,13 @@ public function enqueue_replacement( int $start, int $length, string $text ): vo * * @return array[] */ - public static function data_updates_targeting_duplicate_attribute_spans() { + public static function data_updates_targeting_removed_attribute_spans() { return array( - 'A removal of the duplicate' => array( '' ), - 'A replacement of the duplicate' => array( 'b' ), + 'Removal of a duplicate, exact span' => array( 'ok', 5, 1, '', 'ok' ), + 'Replacement of a duplicate, exact span' => array( 'ok', 5, 1, 'b', 'ok' ), + 'Removal within extended duplicate span' => array( 'ok', 6, 1, '', 'ok' ), + 'Replacement within extended duplicate span' => array( 'ok', 6, 1, 'b', 'ok' ), + 'Replacement within extended attribute span' => array( 'ok', 3, 1, 'b', 'ok' ), ); } From 4a30f313b180c7117bd410fbbb3e02679cbbd324 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Tue, 14 Jul 2026 15:45:18 +0200 Subject: [PATCH 15/17] HTML API: Supersede updates within removed attribute spans. Attribute removal spans extend over preceding solidus characters, so detecting an already-enqueued removal by exact span match misses updates enqueued over spans within the removal span, such as the attribute's original token span. The removal was then enqueued alongside them, and two updates replaced overlapping spans of the document: replaced text could survive in the output, and bookmark positions shifted more than the document changed. Remove every enqueued update replacing a span which intersects the removal span before enqueuing the removal: removal replaces the entire span, and exactly one update accounts for the removed bytes. This applies to the attribute's own span and to its duplicates' spans, and it also keeps repeated removals of the same attribute idempotent. See #65372. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BZSpa317PhG11sX3YUDcGU --- .../html-api/class-wp-html-tag-processor.php | 66 +++++++++++-------- 1 file changed, 37 insertions(+), 29 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 ff970f027ab49..cd2c7c12f051c 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 @@ -4768,10 +4768,11 @@ public function remove_attribute( $name ): bool { * * Result:
*/ - $removal_span = $this->get_attribute_removal_span( + $removal_span = $this->get_attribute_removal_span( $this->attributes[ $name ]->start, $this->attributes[ $name ]->length ); + $this->supersede_updates_in_removal_span( $removal_span ); $this->lexical_updates[ $name ] = new WP_HTML_Text_Replacement( $removal_span->start, $removal_span->length, @@ -4784,38 +4785,45 @@ public function remove_attribute( $name ): bool { $attribute_token->start, $attribute_token->length ); + $this->supersede_updates_in_removal_span( $removal_span ); + $this->lexical_updates[] = new WP_HTML_Text_Replacement( + $removal_span->start, + $removal_span->length, + '' + ); + } - /* - * Enqueue a removal for each duplicate whose span is not already - * updated. Bookmark positions and the internal cursor are shifted - * by every enqueued update when updates are applied, so repeated - * removals of the same attribute must not enqueue removals again. - * - * An update already enqueued over the same span with other - * replacement text is superseded: removing the attribute removes - * the entire span. This also ensures that no two updates replace - * overlapping spans of the document, which the position - * accounting in the update application relies on. - */ - $is_missing = true; - foreach ( $this->lexical_updates as $update ) { - if ( $removal_span->start === $update->start && $removal_span->length === $update->length ) { - $update->text = ''; - $is_missing = false; - break; - } - } + return true; + } - if ( $is_missing ) { - $this->lexical_updates[] = new WP_HTML_Text_Replacement( - $removal_span->start, - $removal_span->length, - '' - ); + /** + * Removes enqueued lexical updates replacing spans within a removed span. + * + * Removing a span of the document supersedes updates already enqueued + * over intersecting spans: removal replaces the entire span, and no two + * updates may replace overlapping spans of the document, since bookmark + * and cursor position accounting assumes that every update replaces a + * distinct span. This also ensures that each removal is enqueued exactly + * once, no matter how many times an attribute is removed. + * + * Zero-length insertions replace no spans of the document and are + * unaffected; they may share their offset with a removed span. + * + * @since 7.1.0 + * + * @param WP_HTML_Span $removal_span Span of the document being removed. + */ + private function supersede_updates_in_removal_span( WP_HTML_Span $removal_span ): void { + $removal_span_end = $removal_span->start + $removal_span->length; + foreach ( $this->lexical_updates as $key => $update ) { + if ( + $update->length > 0 && + $update->start < $removal_span_end && + $removal_span->start < $update->start + $update->length + ) { + unset( $this->lexical_updates[ $key ] ); } } - - return true; } /** From 9ca5adef8a09d6af6d4b00d19572e4a623caa299 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Tue, 14 Jul 2026 17:56:48 +0200 Subject: [PATCH 16/17] HTML API: Reconcile all removed attribute spans in a single pass. Superseding enqueued updates separately for the attribute's span and for each duplicate's span scanned the update queue once per span while each removal grew the queue, making removal of a duplicated attribute quadratic in the number of duplicates: removing an attribute duplicated 10,000 times in a 20 KB tag took hundreds of times longer than parsing the document. Collect the removal spans first and supersede intersecting updates in one sweep over the queue. Attribute spans appear in document order and do not overlap, so each enqueued update is checked against the sorted spans with a single binary search: an update can only intersect the last removal span starting before the update's end. Removing an attribute duplicated 20,000 times now costs less than parsing the document (2.7 s before, 2.4 ms after). See #65372. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BZSpa317PhG11sX3YUDcGU --- .../html-api/class-wp-html-tag-processor.php | 73 +++++++++++++------ 1 file changed, 51 insertions(+), 22 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 cd2c7c12f051c..4c109d34e747b 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 @@ -4768,27 +4768,32 @@ public function remove_attribute( $name ): bool { * * Result:
*/ - $removal_span = $this->get_attribute_removal_span( + $removal_spans = array(); + $removal_spans[] = $this->get_attribute_removal_span( $this->attributes[ $name ]->start, $this->attributes[ $name ]->length ); - $this->supersede_updates_in_removal_span( $removal_span ); + foreach ( $this->duplicate_attributes[ $name ] ?? array() as $attribute_token ) { + $removal_spans[] = $this->get_attribute_removal_span( + $attribute_token->start, + $attribute_token->length + ); + } + + $this->supersede_updates_in_removal_spans( $removal_spans ); + $this->lexical_updates[ $name ] = new WP_HTML_Text_Replacement( - $removal_span->start, - $removal_span->length, + $removal_spans[0]->start, + $removal_spans[0]->length, '' ); // Removes any duplicated attributes if they were also present. - foreach ( $this->duplicate_attributes[ $name ] ?? array() as $attribute_token ) { - $removal_span = $this->get_attribute_removal_span( - $attribute_token->start, - $attribute_token->length - ); - $this->supersede_updates_in_removal_span( $removal_span ); + $removal_span_count = count( $removal_spans ); + for ( $i = 1; $i < $removal_span_count; $i++ ) { $this->lexical_updates[] = new WP_HTML_Text_Replacement( - $removal_span->start, - $removal_span->length, + $removal_spans[ $i ]->start, + $removal_spans[ $i ]->length, '' ); } @@ -4797,9 +4802,9 @@ public function remove_attribute( $name ): bool { } /** - * Removes enqueued lexical updates replacing spans within a removed span. + * Removes enqueued lexical updates replacing spans within removed spans. * - * Removing a span of the document supersedes updates already enqueued + * Removing spans of the document supersedes updates already enqueued * over intersecting spans: removal replaces the entire span, and no two * updates may replace overlapping spans of the document, since bookmark * and cursor position accounting assumes that every update replaces a @@ -4809,18 +4814,42 @@ public function remove_attribute( $name ): bool { * Zero-length insertions replace no spans of the document and are * unaffected; they may share their offset with a removed span. * + * The given spans must be sorted by starting position and must not + * overlap each other, as is the case for attribute spans, which appear + * in document order. Each enqueued update is then checked against the + * spans with a single binary search: an update can only intersect the + * last span starting before the update's end. + * * @since 7.1.0 * - * @param WP_HTML_Span $removal_span Span of the document being removed. + * @param WP_HTML_Span[] $removal_spans Non-overlapping spans of the document being + * removed, sorted by starting position. */ - private function supersede_updates_in_removal_span( WP_HTML_Span $removal_span ): void { - $removal_span_end = $removal_span->start + $removal_span->length; + private function supersede_updates_in_removal_spans( array $removal_spans ): void { + $span_count = count( $removal_spans ); + foreach ( $this->lexical_updates as $key => $update ) { - if ( - $update->length > 0 && - $update->start < $removal_span_end && - $removal_span->start < $update->start + $update->length - ) { + if ( 0 === $update->length ) { + continue; + } + + $update_end = $update->start + $update->length; + + // Find the last removal span starting before the end of the update. + $candidate = null; + $low = 0; + $high = $span_count - 1; + while ( $low <= $high ) { + $probe = intdiv( $low + $high, 2 ); + if ( $removal_spans[ $probe ]->start < $update_end ) { + $candidate = $removal_spans[ $probe ]; + $low = $probe + 1; + } else { + $high = $probe - 1; + } + } + + if ( null !== $candidate && $update->start < $candidate->start + $candidate->length ) { unset( $this->lexical_updates[ $key ] ); } } From 16e3a6b8583dfd508b0ee3361018f43d29bb2115 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Tue, 14 Jul 2026 19:45:26 +0200 Subject: [PATCH 17/17] lints --- tests/phpunit/tests/html-api/wpHtmlTagProcessor.php | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php b/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php index d09658c5e1cea..2731f6fe384d8 100644 --- a/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php +++ b/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php @@ -1555,11 +1555,11 @@ public function enqueue_replacement( int $start, int $length, string $text ): vo */ public static function data_updates_targeting_removed_attribute_spans() { return array( - 'Removal of a duplicate, exact span' => array( 'ok', 5, 1, '', 'ok' ), - 'Replacement of a duplicate, exact span' => array( 'ok', 5, 1, 'b', 'ok' ), - 'Removal within extended duplicate span' => array( 'ok', 6, 1, '', 'ok' ), - 'Replacement within extended duplicate span' => array( 'ok', 6, 1, 'b', 'ok' ), - 'Replacement within extended attribute span' => array( 'ok', 3, 1, 'b', 'ok' ), + 'Removal of a duplicate, exact span' => array( 'ok', 5, 1, '', 'ok' ), + 'Replacement of a duplicate, exact span' => array( 'ok', 5, 1, 'b', 'ok' ), + 'Removal within extended duplicate span' => array( 'ok', 6, 1, '', 'ok' ), + 'Replacement within extended duplicate span' => array( 'ok', 6, 1, 'b', 'ok' ), + 'Replacement within extended attribute span' => array( 'ok', 3, 1, 'b', 'ok' ), ); }