Skip to content

HTML API: Respect enqueued updates in get_attribute_names_with_prefix(). - #12757

Open
dmsnell wants to merge 3 commits into
WordPress:trunkfrom
dmsnell:html-api/respect-enqueued-attributes-when-iterating-attribute-names
Open

HTML API: Respect enqueued updates in get_attribute_names_with_prefix().#12757
dmsnell wants to merge 3 commits into
WordPress:trunkfrom
dmsnell:html-api/respect-enqueued-attributes-when-iterating-attribute-names

Conversation

@dmsnell

@dmsnell dmsnell commented Jul 29, 2026

Copy link
Copy Markdown
Member

Trac ticket: Core-64567.
Replaces #10828, #12484, #12619.

Previously, get_attribute_names_with_prefix() was overlooking enqueued attribute and class name updates which occurred before get_updated_html() had been called. This resulted in reporting stale data which might overlook attributes which were added, and might report attributes which were removed.

In this patch, the method now examines the enqueued updates to determine if any of them introduce or remove attributes. It also examines enqueued class updates to ensure that if the class attribute would be added or removed because of them that it will also be properly reported.

Copilot AI review requested due to automatic review settings July 29, 2026 17:31
@github-actions

github-actions Bot commented Jul 29, 2026

Copy link
Copy Markdown

The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs. You can also manually ask me to refresh this list by adding the props-bot label.

Core Committers: Use this line as a base for the props when committing in SVN:

Props dmsnell, westonruter, motylanogha.

To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook.

@github-actions

Copy link
Copy Markdown

Test using WordPress Playground

The changes in this pull request can previewed and tested using a WordPress Playground instance.

WordPress Playground is an experimental project that creates a full WordPress instance entirely within the browser.

Some things to be aware of

  • All changes will be lost when closing a tab with a Playground instance.
  • All changes will be lost when refreshing the page.
  • A fresh instance is created each time the link below is clicked.
  • Every time this pull request is updated, a new ZIP file containing all changes is created. If changes are not reflected in the Playground instance,
    it's possible that the most recent build failed, or has not completed. Check the list of workflow runs to be sure.

For more details about these limitations and more, check out the Limitations page in the WordPress Playground documentation.

Test this pull request with WordPress Playground.

@wppoland

Copy link
Copy Markdown

Verified this branch against every case raised on #64567, including the ones from the earlier PRs. All seven pass, where trunk passes only one:

check trunk this PR
set_attribute() reflected without flush FAIL PASS
remove_attribute() excluded without flush FAIL PASS
add_class() reflected without flush FAIL PASS
agreement with get_attribute() after mixed updates FAIL PASS
'modifiable text' key not leaked as an attribute name PASS PASS
serialize_token() omits removed attribute FAIL PASS
serialize_token() emits added attribute FAIL PASS

Method: a standalone harness that loads the HTML API files directly against a checkout, so no test DB is involved. It reproduces trunk's failures exactly as reported in comment:19, then passes 7/7 here.

Two things worth noting.

The 'modifiable text' guard matters and you have it. That case (comment:12, found by @Anupkankale) is the one thing PR #10828 still gets wrong: set_modifiable_text() stores a string key in lexical_updates, so an is_int()-only skip leaks it as a fake attribute name. Your || 'modifiable text' === $update_name handles it.

The serialize_token() regression test isn't in any PR, including this one. WP_HTML_Processor::serialize_token() iterates get_attribute_names_with_prefix( '' ) but reads values via get_attribute(), so before this fix remove_attribute( 'onclick' ) followed by serialize_token() re-emitted the attribute as a boolean: <div onclick class="x">. @luisherranz reported it in comment:14 while prototyping an HTML Sanitizer, and it's the case that makes this a filtering-safety issue rather than a reporting inconsistency. Your fix resolves it indirectly, but nothing pins it, so a future refactor of the name list could silently reintroduce it on the WP_HTML_Processor path.

It belongs in tests/phpunit/tests/html-api/wpHtmlProcessor-serialize.php rather than the tag processor file, since serialize_token() is a WP_HTML_Processor method. Fails on trunk, passes here:

	/**
	 * Ensures that serialize_token() reflects enqueued attribute updates instead
	 * of emitting removed attributes as value-less attributes or omitting added ones.
	 *
	 * serialize_token() iterates the names from get_attribute_names_with_prefix( '' )
	 * but reads each value through get_attribute(). Before #64567 the name list did not
	 * reflect enqueued updates while the values did, so a removed attribute survived in
	 * the output as a boolean attribute and an added attribute was dropped entirely.
	 *
	 * @ticket 64567
	 *
	 * @covers WP_HTML_Processor::serialize_token
	 */
	public function test_serialize_token_reflects_enqueued_attribute_updates() {
		$processor = WP_HTML_Processor::create_fragment( '<div onclick="alert(1)" class="x">Text</div>' );
		$processor->next_tag();
		$processor->remove_attribute( 'onclick' );

		$this->assertSame(
			'<div class="x">',
			$processor->serialize_token(),
			'An attribute enqueued for removal was serialized as a value-less attribute.'
		);

		$processor = WP_HTML_Processor::create_fragment( '<div class="x">Text</div>' );
		$processor->next_tag();
		$processor->set_attribute( 'id', 'new' );

		$this->assertStringContainsString(
			'id="new"',
			$processor->serialize_token(),
			'An attribute enqueued via set_attribute() was not serialized.'
		);
	}

Take it straight into this branch if it's useful — no attribution needed. Also attached to the ticket as 64567-serialize-tests.diff and posted in comment:15.

To be precise about what I checked: the seven behaviours above were verified through the standalone harness, not by running the PHPUnit suite, so the test above is verified as behaviour rather than as a green CI run.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR updates WP_HTML_Tag_Processor::get_attribute_names_with_prefix() so it reflects pending (enqueued) attribute/class mutations—without requiring a prior get_updated_html()—to avoid returning stale attribute name lists.

Changes:

  • Incorporates enqueued attribute add/remove updates into get_attribute_names_with_prefix().
  • Flushes pending class-name updates so class attribute presence is reflected in name lookups.
  • Adds PHPUnit coverage for immediate (pre-flush) visibility of attribute/class changes and for ignoring modifiable-text updates.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 5 comments.

File Description
src/wp-includes/html-api/class-wp-html-tag-processor.php Updates prefix-based attribute-name lookup to consider enqueued lexical updates (including class-derived updates).
tests/phpunit/tests/html-api/wpHtmlTagProcessor.php Adds tests covering immediate reflection of enqueued attribute removals/additions, class updates, and ignoring modifiable-text updates.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/wp-includes/html-api/class-wp-html-tag-processor.php
Comment thread tests/phpunit/tests/html-api/wpHtmlTagProcessor.php
Comment thread tests/phpunit/tests/html-api/wpHtmlTagProcessor.php Outdated
Comment thread tests/phpunit/tests/html-api/wpHtmlTagProcessor.php Outdated
Comment thread src/wp-includes/html-api/class-wp-html-tag-processor.php
Copilot AI review requested due to automatic review settings July 29, 2026 18:40

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (3)

src/wp-includes/html-api/class-wp-html-tag-processor.php:2976

  • The new logic explicitly handles add_class()/remove_class() affecting whether the class attribute exists, but the added tests only cover the “class attribute added” case. Please add a test where remove_class() removes the last remaining class and verify get_attribute_names_with_prefix( '' ) (and/or 'cla') no longer reports class before get_updated_html() is called.
		/*
		 * For the `class` attribute, ensure that enqueued class changes from
		 * `add_class` and `remove_class` are flushed into attribute updates.
		 */
		$has_class = isset( $this->attributes['class'] );
		if ( '' === $comparable || str_starts_with( 'class', $comparable ) ) {

my.phpunit.xml:8

  • my.phpunit.xml appears to be a developer-specific PHPUnit configuration (custom bootstrap, limited suites). Keeping this in the PR can confuse contributors and tooling; core typically uses phpunit.xml.dist and expects local overrides to remain untracked.
<?xml version="1.0" encoding="utf-8" ?>
<phpunit
	xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
	xsi:noNamespaceSchemaLocation="http://schema.phpunit.de/9.2/phpunit.xsd"
	bootstrap="my.bootstrap.php"
	testdox="true"
	colors="true"
>

my.bootstrap.php:12

  • my.bootstrap.php looks like a local test bootstrap and is not wired into the project’s standard PHPUnit runner. Committing this file (including the commented #require_once) is likely accidental and adds maintenance surface without being used by CI.
// Core stuff.
#require_once __DIR__ . '/wp-test-config.php';
require_once __DIR__ . '/src/wp-load.php';
wp_cache_init();
require_wp_db();

require_once __DIR__ . '/tests/phpunit/includes/phpunit-adapter-testcase.php';
require_once __DIR__ . '/tests/phpunit/includes/abstract-testcase.php';
require_once __DIR__ . '/tests/phpunit/includes/testcase.php';
require_once __DIR__ . '/tests/phpunit/includes/functions.php';

Copilot AI review requested due to automatic review settings July 29, 2026 18:55
@dmsnell
dmsnell force-pushed the html-api/respect-enqueued-attributes-when-iterating-attribute-names branch from 55203cc to 5d1151c Compare July 29, 2026 18:55

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (1)

src/wp-includes/html-api/class-wp-html-tag-processor.php:2986

  • $has_class is derived only from the originally-parsed attributes ($this->attributes['class']) and ignores any already-enqueued lexical update for class (e.g. remove_attribute( 'class' )). In that situation, a subsequent add_class() should re-introduce class, but this guard won’t flush class name updates and the method will incorrectly treat class as removed (because $removals['class'] is set). Consider deriving $has_class from get_enqueued_attribute_value( 'class' ) first so pending removals/additions are respected when deciding whether class updates could add/remove the attribute.
		$has_class = isset( $this->attributes['class'] );
		if ( '' === $comparable || str_starts_with( 'class', $comparable ) ) {
			foreach ( $this->classname_updates as $class_name => $update ) {
				if (
					( $has_class && self::REMOVE_CLASS === $update ) ||

…x()`.

Trac ticket: Core-64567.

Previously, `get_attribute_names_with_prefix()` was overlooking enqueued
attribute and class name updates which occurred before
`get_updated_html()` had been called. This resulted in reporting stale
data which might overlook attributes which were added, and might report
attributes which were removed.

In this patch, the method now examines the enqueued updates to determine
if any of them introduce or remove attributes. It also examines enqueued
class updates to ensure that if the `class` attribute would be added or
removed because of them that it will also be properly reported.

Co-Authored-By: Anup Kankale <[email protected]>
Co-Authored-By: Igor Rozum <[email protected]>
Co-Authored-By: Jeffrey Carandang <[email protected]>
Co-Authored-By: Jerome B. <[email protected]>
Co-Authored-By: Khokan Sardar <[email protected]>
Co-Authored-By: Luis Herranz <[email protected]>
Co-Authored-By: Mariusz Szatkowski <[email protected]>
Co-Authored-By: SACHINRAJ CP <[email protected]>
Copilot AI review requested due to automatic review settings July 29, 2026 20:21
@dmsnell
dmsnell force-pushed the html-api/respect-enqueued-attributes-when-iterating-attribute-names branch from 5d1151c to 92667cf Compare July 29, 2026 20:21
@dmsnell

dmsnell commented Jul 29, 2026

Copy link
Copy Markdown
Member Author

Rebased and squashed for merge prep.

@dmsnell

dmsnell commented Jul 29, 2026

Copy link
Copy Markdown
Member Author

thanks @wppoland

Take it straight into this branch if it's useful — no attribution needed.

I’m going to leave that out for now and let it be part of improving the serialization tests. it’s valuable, but in part because it’s the beta part of the release I want to focus on the core fix, which is get_attribute_names_with_prefix()

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (2)

src/wp-includes/html-api/class-wp-html-tag-processor.php:2986

  • The conditional flush of classname_updates is based only on whether class exists in the parsed attributes and whether updates are ADD/REMOVE. This misses cases where the enqueued class state changes the attribute’s existence, e.g. remove_attribute( 'class' ) followed by add_class( 'x' ) (should re-add class), or set_attribute( 'class', 'a' ) followed by remove_class( 'a' ) (should remove class). In these cases, get_attribute_names_with_prefix() can incorrectly omit or include class because class_name_updates_to_attributes_updates() is never called.
		$has_class = isset( $this->attributes['class'] );
		if ( '' === $comparable || str_starts_with( 'class', $comparable ) ) {
			foreach ( $this->classname_updates as $class_name => $update ) {
				if (
					( $has_class && self::REMOVE_CLASS === $update ) ||
					( ! $has_class && self::ADD_CLASS === $update )
				) {
					$this->class_name_updates_to_attributes_updates();
					break;
				}
			}
		}

tests/phpunit/tests/html-api/wpHtmlTagProcessor.php:588

  • The added tests cover attribute additions/removals and a basic add_class() case, but they don’t exercise edge cases where class existence flips due to mixing set_attribute()/remove_attribute( 'class' ) with add_class()/remove_class(). These combinations are where get_attribute_names_with_prefix() is most likely to disagree with get_attribute() and would help prevent regressions.
	public function test_get_attribute_names_with_prefix_immediately_reflects_class_after_adding_classes() {
		$processor = new WP_HTML_Tag_Processor( '<div existing>' );
		$processor->next_tag();

		$this->assertSame(
			array( 'existing' ),
			$processor->get_attribute_names_with_prefix( '' ),
			'Expected to only report the existing attribute: check test setup.'
		);

		$processor->add_class( 'added' );

		$this->assertSame(
			array( 'class' ),
			$processor->get_attribute_names_with_prefix( 'class' ),
			'Failed to report the newly-added `class` attribute.'
		);
	}

Comment thread tests/phpunit/tests/html-api/wpHtmlTagProcessor.php
*/
$has_class = isset( $this->attributes['class'] );
if ( '' === $comparable || str_starts_with( 'class', $comparable ) ) {
foreach ( $this->classname_updates as $class_name => $update ) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: the $class_name variable is not used.

Suggested change
foreach ( $this->classname_updates as $class_name => $update ) {
foreach ( $this->classname_updates as $update ) {

@westonruter westonruter Jul 30, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Aside: It would be nice if WP_HTML_Tag_Processor::$classname_updates were typed as array<non-empty-string, self::ADD_CLASS|self::REMOVE_CLASS>.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did not realize this would iterate the values of the associative array, which makes me want to leave the unused var in place as it seems so prone to misunderstanding.

can add the type annotations, though this is currently a bug-fix and not an enhancement. is that fine for a beta merge?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, docs changes are free to do any time. And I see static analysis issues as being defects which should likewise be done at any time (similar to unit tests).

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

static analysis issues

this is more of an issue with the static analyzer than an issue the static analyzer uncovered though, right? the code is right and there are no static defects? I just want to make sure I understand properly.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

changes made in 8dd7b5d

Copilot AI review requested due to automatic review settings July 30, 2026 17:40

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (2)

tests/phpunit/tests/html-api/wpHtmlTagProcessor.php:590

  • There’s no regression test covering cases where add_class() / remove_class() must override an enqueued class attribute removal/addition (e.g. remove_attribute( 'class' ) followed by add_class()). This scenario currently breaks get_attribute_names_with_prefix() because it may skip flushing class updates, so adding a focused test here would prevent future regressions.
	/**
	 * Ensures get_attribute_names_with_prefix() agrees with get_attribute()
	 * after pending updates, returning each name once with no stale entries.
	 *
	 * @ticket 64567

src/wp-includes/html-api/class-wp-html-tag-processor.php:2986

  • The conditional flush of $this->classname_updates is based only on whether the parsed tag currently has a class attribute (isset( $this->attributes['class'] )). This misses cases where the enqueued state of class differs from the parsed state (e.g. <div class="a">, then remove_attribute( 'class' ), then add_class( 'b' ); or set_attribute( 'class', 'a' ) then remove_class( 'a' )). In these cases, get_attribute() will call class_name_updates_to_attributes_updates() and correctly reflect the final presence/absence of class, but get_attribute_names_with_prefix() may skip flushing and return stale results (e.g. omit class even though it will be reintroduced, or report class even though it will be removed).
		$has_class = isset( $this->attributes['class'] );
		if ( '' === $comparable || str_starts_with( 'class', $comparable ) ) {
			foreach ( $this->classname_updates as $update ) {
				if (
					( $has_class && self::REMOVE_CLASS === $update ) ||

Copilot AI review requested due to automatic review settings July 30, 2026 17:46

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (1)

src/wp-includes/html-api/class-wp-html-tag-processor.php:757

  • The docblock claims $classname_updates is keyed by non-empty-string, but add_class() / remove_class() don’t validate $class_name and can enqueue an empty-string key. This makes the type annotation inaccurate for static analysis and documentation.
	 * @since 6.2.0
	 * @var array<non-empty-string, self::ADD_CLASS|self::REMOVE_CLASS>
	 */
	private $classname_updates = array();

'Failed to find expected existing attributes: check test setup.'
);

$this->assertEquals(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Per https://core.trac.wordpress.org/ticket/38266

Suggested change
$this->assertEquals(
$this->assertSame(

'Failed to find expected existing `data-keep` attribute value: check test setup.'
);

$this->assertEquals(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
$this->assertEquals(
$this->assertSame(

$additions = array();
$removals = array();
foreach ( $this->lexical_updates as $update_name => $update ) {
if ( is_int( $update_name ) || 'modifiable text' === $update_name ) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This line was somewhat surprising to me, specifically is_int( $update_name ), given that when looking at $this->lexical_updates it is typed as WP_HTML_Text_Replacement[]. The examples don't really show non-int keys either. Seems like this would be an opportunity to flesh out the docs for what non-int keys means, and do:

--- a/src/wp-includes/html-api/class-wp-html-tag-processor.php
+++ b/src/wp-includes/html-api/class-wp-html-tag-processor.php
@@ -810,7 +810,7 @@ class WP_HTML_Tag_Processor {
 	 *     );
 	 *
 	 * @since 6.2.0
-	 * @var WP_HTML_Text_Replacement[]
+	 * @var array<int|string, WP_HTML_Text_Replacement>
 	 */
 	protected $lexical_updates = array();

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants