From 5b290e0e71bb10990dd6fe7773f80aeb1f21bf4e Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Mon, 19 Oct 2026 16:29:36 +0400 Subject: [PATCH] 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 545af0650386b..e62a9131eb4ab 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 *