From 4126235079c46cb0471a38615a1ba6e2b6a7e829 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Mon, 19 Oct 2026 16:29:36 +0400 Subject: [PATCH 1/2] HTML API: Respect enqueued `class` updates when listing attribute names. `get_attribute_names_with_prefix()` flushes enqueued `add_class()` and `remove_class()` calls only when they could add or remove the `class` attribute. It decided whether the tag has a `class` attribute from the input document alone and ignored an enqueued `set_attribute()` or `remove_attribute()` for `class`, so two kinds of sequence gave the wrong answer: - The input has a `class` attribute, `remove_attribute( 'class' )` or `set_attribute( 'class', false )` enqueues its removal, and `add_class()` follows. The method did not flush, found the removal, and omitted `class`, while `get_updated_html()` produces a `class` attribute. - The input has no `class` attribute, `set_attribute( 'class', ... )` enqueues one, and `remove_class()` removes every class in it. The method did not flush, found the addition, and reported `class`, while `get_updated_html()` produces no `class` attribute. The method now uses the enqueued `class` attribute update, when one exists, to decide whether the attribute exists. Every sequence of up to three calls to `add_class()`, `remove_class()`, `remove_attribute( 'class' )` and `set_attribute( 'class', ... )` was compared, on tags with no, an empty, a boolean, a single and a duplicated `class` attribute, against a new Tag Processor's reading of `get_updated_html()`. Before this change 254 of 5,550 sequences disagreed; after it, none do. `get_attribute( 'class' )` agreed in every sequence before and after. Follow-up to [62960]. Props jonsurrell. See #65967. --- .../html-api/class-wp-html-tag-processor.php | 5 +- .../tests/html-api/wpHtmlTagProcessor.php | 83 +++++++++++++++++++ 2 files changed, 87 insertions(+), 1 deletion(-) 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 68c227f221066..3a0bb2d4f4a62 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 @@ -2970,8 +2970,11 @@ public function get_attribute_names_with_prefix( $prefix ): ?array { /* * For the `class` attribute, ensure that enqueued class changes from * `add_class` and `remove_class` are flushed into attribute updates. + * An enqueued `class` attribute update takes precedence over the + * input document in determining whether the attribute exists. */ - $has_class = isset( $this->attributes['class'] ); + $enqueued_class = $this->get_enqueued_attribute_value( 'class' ); + $has_class = false === $enqueued_class ? isset( $this->attributes['class'] ) : null !== $enqueued_class; if ( '' === $comparable || str_starts_with( 'class', $comparable ) ) { foreach ( $this->classname_updates as $update ) { if ( diff --git a/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php b/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php index 1bfaa01bf0453..6fbdf91a36bf1 100644 --- a/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php +++ b/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php @@ -672,6 +672,89 @@ public function test_get_attribute_names_with_prefix_immediately_agrees_with_get } } + /** + * Ensures that get_attribute_names_with_prefix() reflects class name updates + * which follow an enqueued update to the `class` attribute. + * + * @ticket 65967 + * + * @covers WP_HTML_Tag_Processor::get_attribute_names_with_prefix + * + * @dataProvider data_class_name_updates_after_class_attribute_updates + * + * @param string $html HTML containing a DIV tag. + * @param array[] $updates Method name and arguments of each update to the DIV tag, in order. + * @param string[] $expected_names Attribute names on the DIV tag after the updates. + */ + public function test_get_attribute_names_with_prefix_reflects_class_name_updates_after_class_attribute_updates( string $html, array $updates, array $expected_names ): void { + $processor = new WP_HTML_Tag_Processor( $html ); + $processor->next_tag(); + + foreach ( $updates as $update ) { + $processor->{$update[0]}( ...array_slice( $update, 1 ) ); + } + + $this->assertSame( + $expected_names, + $processor->get_attribute_names_with_prefix( '' ), + 'Failed to report the attribute names resulting from the enqueued updates.' + ); + + $updated = new WP_HTML_Tag_Processor( $processor->get_updated_html() ); + $updated->next_tag(); + + $this->assertSame( + $expected_names, + $updated->get_attribute_names_with_prefix( '' ), + 'Found different attribute names in the updated HTML: check test setup.' + ); + } + + /** + * Data provider. + * + * @return array>, 2: list}> + */ + public static function data_class_name_updates_after_class_attribute_updates(): array { + return array( + 'Class added after removing class attribute' => array( + '
', + array( array( 'remove_attribute', 'class' ), array( 'add_class', 'b' ) ), + array( 'class' ), + ), + 'Class added after setting class attribute false' => array( + '
', + array( array( 'set_attribute', 'class', false ), array( 'add_class', 'b' ) ), + array( 'class' ), + ), + 'Class added after removing empty class attribute' => array( + '
', + array( array( 'remove_attribute', 'class' ), array( 'add_class', 'b' ) ), + array( 'class' ), + ), + 'Class added after removing boolean class attribute' => array( + '
', + array( array( 'remove_attribute', 'class' ), array( 'add_class', 'b' ) ), + array( 'class' ), + ), + 'Class added after removing duplicated class attribute' => array( + '
', + array( array( 'remove_attribute', 'class' ), array( 'add_class', 'b' ) ), + array( 'class' ), + ), + 'Class added after removing class attribute, with other attributes' => array( + '
', + array( array( 'remove_attribute', 'class' ), array( 'add_class', 'b' ) ), + array( 'id', 'class', 'title' ), + ), + 'Only class removed after adding class attribute' => array( + '
', + array( array( 'set_attribute', 'class', 'a' ), array( 'remove_class', 'a' ) ), + array(), + ), + ); + } + /** * @ticket 56299 * From 6f4492643339d4c8326dc4ecbd80a32979b1b318 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Tue, 20 Oct 2026 13:36:33 +0400 Subject: [PATCH 2/2] HTML API: Show the calls and updated HTML in the class name update tests. Each data provider row now makes its calls in a closure, in place of a list of method names and arguments, and states the HTML that `get_updated_html()` produces. The test asserts that HTML directly, in place of comparing the attribute names against a new Tag Processor's reading of the output, so each row shows that only the reported attribute names were wrong. The output was already correct before the fix. The duplicated `class` attribute row adds `c` in place of `b`, so its expected HTML, `
`, shows that the duplicate `class="b"` was removed. See #65967. --- .../tests/html-api/wpHtmlTagProcessor.php | 63 +++++++++++++------ 1 file changed, 43 insertions(+), 20 deletions(-) diff --git a/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php b/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php index 6fbdf91a36bf1..a206ce1465e53 100644 --- a/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php +++ b/tests/phpunit/tests/html-api/wpHtmlTagProcessor.php @@ -683,16 +683,14 @@ public function test_get_attribute_names_with_prefix_immediately_agrees_with_get * @dataProvider data_class_name_updates_after_class_attribute_updates * * @param string $html HTML containing a DIV tag. - * @param array[] $updates Method name and arguments of each update to the DIV tag, in order. + * @param Closure $update Enqueues updates to the DIV tag. * @param string[] $expected_names Attribute names on the DIV tag after the updates. + * @param string $expected_html HTML after the updates. */ - public function test_get_attribute_names_with_prefix_reflects_class_name_updates_after_class_attribute_updates( string $html, array $updates, array $expected_names ): void { + public function test_get_attribute_names_with_prefix_reflects_class_name_updates_after_class_attribute_updates( string $html, Closure $update, array $expected_names, string $expected_html ): void { $processor = new WP_HTML_Tag_Processor( $html ); $processor->next_tag(); - - foreach ( $updates as $update ) { - $processor->{$update[0]}( ...array_slice( $update, 1 ) ); - } + $update( $processor ); $this->assertSame( $expected_names, @@ -700,57 +698,82 @@ public function test_get_attribute_names_with_prefix_reflects_class_name_updates 'Failed to report the attribute names resulting from the enqueued updates.' ); - $updated = new WP_HTML_Tag_Processor( $processor->get_updated_html() ); - $updated->next_tag(); - $this->assertSame( - $expected_names, - $updated->get_attribute_names_with_prefix( '' ), - 'Found different attribute names in the updated HTML: check test setup.' + $expected_html, + $processor->get_updated_html(), + 'Failed to apply the enqueued updates.' ); } /** * Data provider. * - * @return array>, 2: list}> + * @return array, 3: string}> */ public static function data_class_name_updates_after_class_attribute_updates(): array { return array( 'Class added after removing class attribute' => array( '
', - array( array( 'remove_attribute', 'class' ), array( 'add_class', 'b' ) ), + static function ( WP_HTML_Tag_Processor $processor ): void { + $processor->remove_attribute( 'class' ); + $processor->add_class( 'b' ); + }, array( 'class' ), + '
', ), 'Class added after setting class attribute false' => array( '
', - array( array( 'set_attribute', 'class', false ), array( 'add_class', 'b' ) ), + static function ( WP_HTML_Tag_Processor $processor ): void { + $processor->set_attribute( 'class', false ); + $processor->add_class( 'b' ); + }, array( 'class' ), + '
', ), 'Class added after removing empty class attribute' => array( '
', - array( array( 'remove_attribute', 'class' ), array( 'add_class', 'b' ) ), + static function ( WP_HTML_Tag_Processor $processor ): void { + $processor->remove_attribute( 'class' ); + $processor->add_class( 'b' ); + }, array( 'class' ), + '
', ), 'Class added after removing boolean class attribute' => array( '
', - array( array( 'remove_attribute', 'class' ), array( 'add_class', 'b' ) ), + static function ( WP_HTML_Tag_Processor $processor ): void { + $processor->remove_attribute( 'class' ); + $processor->add_class( 'b' ); + }, array( 'class' ), + '
', ), 'Class added after removing duplicated class attribute' => array( '
', - array( array( 'remove_attribute', 'class' ), array( 'add_class', 'b' ) ), + static function ( WP_HTML_Tag_Processor $processor ): void { + $processor->remove_attribute( 'class' ); + $processor->add_class( 'c' ); + }, array( 'class' ), + '
', ), 'Class added after removing class attribute, with other attributes' => array( '
', - array( array( 'remove_attribute', 'class' ), array( 'add_class', 'b' ) ), + static function ( WP_HTML_Tag_Processor $processor ): void { + $processor->remove_attribute( 'class' ); + $processor->add_class( 'b' ); + }, array( 'id', 'class', 'title' ), + '
', ), 'Only class removed after adding class attribute' => array( '
', - array( array( 'set_attribute', 'class', 'a' ), array( 'remove_class', 'a' ) ), + static function ( WP_HTML_Tag_Processor $processor ): void { + $processor->set_attribute( 'class', 'a' ); + $processor->remove_class( 'a' ); + }, array(), + '
', ), ); }