Opened 5 months ago
Last modified 41 hours ago
#64567 new defect (bug)
HTML API: get_attribute_names_with_prefix() should agree with enqueued attribute updates.
| Reported by: | dmsnell | Owned by: | |
|---|---|---|---|
| Priority: | normal | Milestone: | 7.1 |
| Component: | HTML API | Version: | 6.2 |
| Severity: | minor | Keywords: | good-first-bug has-patch |
| Cc: | Focuses: |
Description
The get_attribute_names_with_prefix() method only looks at the parsed attributes for a tag, meaning that it will return stale data once new attributes have been set or existing ones have been removed. It should not need to wait for ->get_updated_html() to report the proper values, and should always agree with ->get_attribute()
<?php $p = new WP_HTML_Tag_Processor( '<div>' ); $p->next_tag(); $p->set_attribute( 'id', 'test' ); var_dump( $p->get_attribute_names_with_prefix( '' ) ); // array(0) {} $p->get_updated_html(); var_dump( $p->get_attribute_names_with_prefix( '' ) ); // array(1) { [0] => string(2) "id" }
Attachments (4)
Change History (19)
This ticket was mentioned in PR #10828 on WordPress/wordpress-develop by @whaze.
5 months ago
#2
- Keywords has-patch added
#3
@
5 months ago
Testing:
<?php $p = new WP_HTML_Tag_Processor( '<div>' ); $p->next_tag(); $p->set_attribute( 'id', 'test' ); // Now correctly returns ['id'] var_dump( $p->get_attribute_names_with_prefix( '' ) );
gclapps0612-cmd commented on PR #10828:
5 months ago
#4
Rest
On Fri, Jan 30, 2026, 03:22 Jerome B. *@*.*> wrote:
Fixed: get_attribute_names_with_prefix() now agrees with enqueued
attribute updates
The method now returns real-time attribute names that reflect
set_attribute() and remove_attribute() calls without needing to call
get_updated_html() first.
Trac ticket: https://core.trac.wordpress.org/ticket/64567
You can view, comment on, or merge this pull request online at:
Commit Summary
- 6ee4b36 <https://github.com/WordPress/wordpress-develop/pull/10828/commits/6ee4b368a55001bec4515f73a785564419a79e47> Fixed: get_attribute_names_with_prefix() now agrees with enqueued attribute updates
File Changes
(1 file <https://github.com/WordPress/wordpress-develop/pull/10828/files>)
- *M* src/wp-includes/html-api/class-wp-html-tag-processor.php <https://github.com/WordPress/wordpress-develop/pull/10828/files#diff-0c2d7bb49853cc0faafd5412a25ee90e8cbb4f9293e7c3bb308494aba7d08272> (35)
Patch Links:
- https://github.com/WordPress/wordpress-develop/pull/10828.patch
- https://github.com/WordPress/wordpress-develop/pull/10828.diff
—
Reply to this email directly, view it on GitHub
<https://github.com/WordPress/wordpress-develop/pull/10828>, or
unsubscribe
<https://github.com/notifications/unsubscribe-auth/BXZWJXFX6GBMB55QFJVVC6L4JMIFJAVCNFSM6AAAAACTNCJCJWVHI2DSMVQWIX3LMV43ASLTON2WKOZTHA3TINJZGA2DMOI>
.
You are receiving this because you are subscribed to this thread.Message
ID: *@*.*>
This ticket was mentioned in Slack in #core by peiraisotta. View the logs.
5 months ago
#6
@
5 months ago
Tested using this PR: https://github.com/WordPress/wordpress-develop/pull/10828
add_filter( 'render_block_core/quote', 'test_attribute_names_with_prefix', 10, 2 );
function test_attribute_names_with_prefix( string $block_content, array $block ): string {
$p = new WP_HTML_Tag_Processor( '<div>' );
$p->next_tag();
$p->set_attribute( 'id', 'test' );
echo '<pre>';
print_r( $p->get_attribute_names_with_prefix( '' ) );
echo '</pre>';
$processor = new WP_HTML_Tag_Processor( $block_content );
$processor->next_tag();
$processor->set_attribute( 'id', 'test' );
echo '<pre>';
print_r( $processor->get_attribute_names_with_prefix( '' ) );
echo '</pre>';
die();
return $processor->get_updated_html();
}
The code above returns the correct attributes here:
Array
(
[0] => id
)
Array
(
[0] => class
[1] => id
)
#7
@
5 months ago
Thanks everyone for your help. We seem to have a growing amount of uncoordinated activity, with two proposed patches and one manual test.
What would be great is if we combined our efforts and added new unit tests alongside the existing Tag Processor tests for get_attribute_names_with_prefix(). If we have those, then we won’t need to perform manual testing — we’ll only need to review the test code itself.
Getting the test cases in, including but potentially not limited to adding an attribute, removing an attribute, and changing an attribute, with or without existing duplicates, might reveal some differences between the different proposals.
This ticket was mentioned in Slack in #core by audrasjb. View the logs.
4 months ago
#9
@
4 months ago
- Milestone 7.0 → 7.1
As per today's 7.0 pre-RC1 bug scrub:
We have two patches, but they need to be merged into one, and @dmsnell dmsnell proposed some additional tests. We're good to punt it to 7.1.
@motylanogha commented on PR #10828:
3 weeks ago
#10
Following the 7.0 pre-RC1 scrub note ("two patches… need to be merged into one, and @dmsnell proposed some additional tests"), here are unit tests for the path that actually exercises this ticket — querying attribute names without first flushing updates through get_updated_html(). The existing test_get_attribute_names_with_prefix_returns_attribute_added_by_set_attribute() calls get_updated_html() first, which masks the bug.
These pass on this branch and fail on trunk (proving they catch the reported behavior):
/** * Ensures that an attribute added via set_attribute() is reported by * get_attribute_names_with_prefix() without first flushing the updates * through get_updated_html(). * * @ticket 64567 * * @covers WP_HTML_Tag_Processor::get_attribute_names_with_prefix */ public function test_get_attribute_names_with_prefix_reflects_set_attribute_without_flushing() { $processor = new WP_HTML_Tag_Processor( '<div>Test</div>' ); $processor->next_tag(); $processor->set_attribute( 'id', 'test' ); $this->assertSame( array( 'id' ), $processor->get_attribute_names_with_prefix( '' ), 'Did not report an attribute enqueued via set_attribute() before the updates were flushed.' ); } /** * Ensures that an attribute removed via remove_attribute() is no longer * reported by get_attribute_names_with_prefix() without first flushing the * updates through get_updated_html(). * * @ticket 64567 * * @covers WP_HTML_Tag_Processor::get_attribute_names_with_prefix */ public function test_get_attribute_names_with_prefix_reflects_removed_attribute_without_flushing() { $processor = new WP_HTML_Tag_Processor( '<div id="main" data-foo="bar">Test</div>' ); $processor->next_tag(); $processor->remove_attribute( 'data-foo' ); $this->assertSame( array(), $processor->get_attribute_names_with_prefix( 'data-' ), 'Reported an attribute that had been enqueued for removal before the updates were flushed.' ); $this->assertSame( array( 'id' ), $processor->get_attribute_names_with_prefix( '' ), 'Did not reflect a removed attribute in the full attribute name list before flushing.' ); } /** * Ensures that a class enqueued via add_class() surfaces the `class` * attribute in get_attribute_names_with_prefix() without first flushing the * updates through get_updated_html(). * * @ticket 64567 * * @covers WP_HTML_Tag_Processor::get_attribute_names_with_prefix */ public function test_get_attribute_names_with_prefix_reflects_added_class_without_flushing() { $processor = new WP_HTML_Tag_Processor( '<div>Test</div>' ); $processor->next_tag(); $processor->add_class( 'highlight' ); $this->assertSame( array( 'class' ), $processor->get_attribute_names_with_prefix( 'class' ), 'Did not report the class attribute enqueued via add_class() before the updates were flushed.' ); } /** * Ensures get_attribute_names_with_prefix() agrees with get_attribute() * after pending updates, returning each name once with no stale entries. * * @ticket 64567 * * @covers WP_HTML_Tag_Processor::get_attribute_names_with_prefix */ public function test_get_attribute_names_with_prefix_agrees_with_get_attribute_after_updates() { $processor = new WP_HTML_Tag_Processor( '<div data-keep="1" data-drop="2">Test</div>' ); $processor->next_tag(); $processor->set_attribute( 'data-keep', 'updated' ); $processor->set_attribute( 'data-add', 'new' ); $processor->remove_attribute( 'data-drop' ); $names = $processor->get_attribute_names_with_prefix( 'data-' ); sort( $names ); $this->assertSame( array( 'data-add', 'data-keep' ), $names, 'Enqueued additions, updates, and removals were not reflected accurately, or a name was duplicated.' ); foreach ( $names as $name ) { $this->assertNotNull( $processor->get_attribute( $name ), "get_attribute_names_with_prefix() reported \"{$name}\" but get_attribute() did not agree." ); } }
They go in tests/phpunit/tests/html-api/wpHtmlTagProcessor.php. Happy to push as a commit if that's easier.
@
3 weeks ago
Unit tests for get_attribute_names_with_prefix() (no-flush path). Pass against PR #10828, fail on trunk.
#11
@
3 weeks ago
Added unit tests for the no-flush path of WP_HTML_Tag_Processor::get_attribute_names_with_prefix() — querying attribute names after set_attribute(), remove_attribute(), and add_class() without first calling get_updated_html() (the existing test masks the issue by flushing first). Four tests covering set/remove/add-class and agreement with get_attribute(). They pass against the patch in PR #10828 and fail on trunk. Patch attached (64567-tests.diff); same tests posted on the PR.
#12
@
4 days ago
Thanks for tackling this, @whaze — the approach of merging the enqueued lexical_updates on top of the parsed attributes is exactly right, and it lines this method up with how get_attribute() already behaves.
One edge case I hit while testing: the second loop skips integer keys with is_int( $name ), but lexical_updates can also hold the string key 'modifiable text' (enqueued by set_modifiable_text()). Since that key is a string, it isn't skipped, so for any matching prefix it gets returned as if it were an attribute name.
Repro:
$processor = new WP_HTML_Tag_Processor( '<script id="x">old</script>' );
$processor->next_tag();
$processor->set_modifiable_text( 'new content' );
$processor->get_attribute_names_with_prefix( );
actual: array( 'id', 'modifiable text' )
expected: array( 'i
A one-line guard in the skip condition handles it:
foreach ( $this->lexical_updates as $name => $update ) {
Skip positional updates and the modifiable-text update; neither is an attribute.
if ( is_int( $name ) 'modifiable text' === $name ) { continue; }
...
}
If it helps, I've written unit tests for the no-flush path — set / remov/ change attribute, a a cross-check that thereported names agree with get_attribute(), plus a regression test for the 'modifiable text' case above. They fail on trunk and pass with your fix (the modifiable-text odded). Happy to pushthem to this branch or open a PR against it — whichever you'd prefer.
<details>
<summary>5 unit tests for the no-flush path (click to expand)</summary>
/
- @ticket 64567
- @covers WP_HTML_Tag_Processor::get_attribute_names_with_prefix */
public function test_get_attribute_names_with_prefix_reflects_set_attribute_without_flushing() {
$processor = new WP_HTML_Tag_Processor( '<div data-foo="bar">Test</d);
$processor->next_tag();
$processor->set_attribute( 'data-test-id', '14' );
$this->assertSame(
array( 'data-foo', 'data-test-id' ),
$processor->get_attribute_names_with_prefix( 'data-' ),
'Enqueued attribute added via set_attribute() was not reflecflushing updates.'
);
}
/
- @ticket 64567
- @covers WP_HTML_Tag_Processor::get_attribute_names_with_prefix */
public function test_get_attribute_names_with_prefix_reflects_remove_attribute_without_flushing() {
$processor = new WP_HTML_Tag_Processor( '<div data-foo="bar" data-keep="1">Test</div>' );
$processor->next_tag();
$processor->remove_attribute( 'data-foo' );
$this->assertSame(
array( 'data-keep' ),
$processor->get_attribute_names_with_prefix( 'data-' ),
'Enqueued attribute removal via remove_attribute() was not rbefore flushing updates.'
);
}
/
- @ticket 64567
- @covers WP_HTML_Tag_Processor::get_attribute_names_with_prefix */
public function test_get_attribute_names_with_prefix_reflects_added_class_without_flushing() {
$processor = new WP_HTML_Tag_Processor( '<div data-foo="bar">Test</d);
$processor->next_tag();
$processor->add_class( 'highlight' );
$this->assertSame(
array( 'class' ),
$processor->get_attribute_names_with_prefix( 'class' ),
'Enqueued class attribute created via add_class() was not re flushing updates.'
);
}
/
- @ticket 64567
- @covers WP_HTML_Tag_Processor::get_attribute_names_with_prefix */
public function test_get_attribute_names_with_prefix_agrees_with_get_attribute_after_updates() {
$processor = new WP_HTML_Tag_Processor( '<div data-a="1" data-b="2" data-c="3">Test</div>' );
$processor->next_tag();
$processor->set_attribute( 'data-b', 'updated' ); Change an existattribute.
$processor->remove_attribute( 'data-c' ); Remove an existattribute.
$processor->set_attribute( 'data-d', 'new' ); Add a new attri
$names = $processor->get_attribute_names_with_prefix( 'data-' );
sort( $names );
$this->assertSame(
array( 'data-a', 'data-b', 'data-d' ),
$names,
'Reported attribute names did not reflect the enqueued changflushing updates.'
);
foreach ( $names as $name ) {
$this->assertNotNull(
$processor->get_attribute( $name ),
"get_attribute_names_with_prefix() reported '{$name} reports it as absent."
);
}
$this->assertNull(
$processor->get_attribute( 'data-c' ),
'Removed attribute data-c should be null via get_attribute()
);
$this->assertNotContains(
'data-c',
$names,
'Removed attribute data-c should not appear in get_attribute_names_with_prefix().'
);
}
/
- @ticket 64567
- @covers WP_HTML_Tag_Processor::get_attribute_names_with_prefix */
public function test_get_attribute_names_with_prefix_ignores_modifiable_text_update() {
$processor = new WP_HTML_Tag_Processor( '<script id="x">old</script>
$processor->next_tag();
$processor->set_modifiable_text( 'new content' );
$this->assertSame(
array( 'id' ),
$processor->get_attribute_names_with_prefix( ),
"The internal 'modifiable text' update key leaked as an attr
);
}
</details>
#13
@
4 days ago
Good catch, @anupkankale — confirmed. The modifiable text entry lives in the same lexical_updates array as attribute changes but under a string key, so the is_int() skip in PR #10828 lets it leak. I reproduced it against the PR:
$processor = new WP_HTML_Tag_Processor( '<script id="x">old</script>' ); $processor->next_tag(); $processor->set_modifiable_text( 'new content' ); $processor->get_attribute_names_with_prefix( '' ); // PR #10828: array( 'id', 'modifiable text' ) <-- leak // expected: array( 'id' )
Your one-line guard is the right fix and matches the existing style:
foreach ( $this->lexical_updates as $name => $update ) { // Skip positional updates and the modifiable-text update; neither is an attribute. if ( is_int( $name ) || 'modifiable text' === $name ) { continue; } // ... }
Rather than open a competing test patch, I've folded a regression test for exactly this case into the existing no-flush test set from comment:10 / comment:11, so there's a single consolidated diff to review (refreshed 64567-tests.diff). It now covers set / remove / change attribute, add_class, agreement with get_attribute(), and the modifiable text leak.
Verified matrix:
- trunk: the four no-flush tests fail (the reported bug), the modifiable-text test passes (trunk never merges updates).
- PR #10828 as-is: the four pass, the modifiable-text test fails (reproduces the leak).
- PR #10828 + the guard above: all five pass.
@whaze — would you like to add the guard line to the PR, or should I push it along with the test onto the branch?
@
4 days ago
Refreshed: adds a 5th test covering the 'modifiable text' leak (comment:12). No-flush path for get_attribute_names_with_prefix() — set / remove / change attribute, add_class, agreement with get_attribute(), and modifiable-text regression. Pass against PR #10828 + the one-line guard; fail on trunk.
#14
@
42 hours ago
While prototyping an HTML Sanitizer on top of the HTML API (draft PR, Trac ticket), Fable ran into a downstream symptom of this ticket in WP_HTML_Processor::serialize_token() that's worth folding into the fix and its tests.
serialize_token() iterates get_attribute_names_with_prefix( '' ) (the stale list this ticket describes) but reads each value through get_attribute(), which does reflect enqueued updates. The two methods disagreeing produces mangled serialization exactly when the name set changes. All four cases on trunk:
<?php $p = WP_HTML_Processor::create_fragment( '<div onclick="alert(1)" class="x">Text</div>' ); $p->next_tag(); $p->remove_attribute( 'onclick' ); echo $p->serialize_token(); // Actual: <div onclick class="x"> <-- removal degrades to a value-less attribute // Expected: <div class="x"> $p = WP_HTML_Processor::create_fragment( '<div class="x">Text</div>' ); $p->next_tag(); $p->set_attribute( 'id', 'new' ); echo $p->serialize_token(); // Actual: <div class="x"> <-- addition is silently dropped // Expected: <div class="x" id="new"> // Changing an existing value composes correctly, because the *name* survives: // set_attribute( 'class', 'changed' ) => <div class="changed"> OK // add_class( 'extra' ) => <div class="x extra"> OK
The removal case is the concerning one: remove_attribute( 'onclick' ) followed by serialize_token() emits <div onclick class="x">. The removed name survives via the stale list, get_attribute() returns null for it, and the emitter treats non-string values as boolean attributes. Filtering-then-serializing is exactly the composition that content filters built on the HTML API will use, so this arguably raises the severity: the API can re-emit an attribute a caller explicitly removed.
The good news is that PR #10828 plus the 'modifiable text' guard from comment:12 should resolve both broken cases with no changes to serialize_token() itself: once the name list agrees with the enqueued updates, removals are omitted and additions are picked up (their values already come from get_attribute()).
Since serialize_token() is where the disagreement becomes visible in output, a regression test on that path alongside the no-flush tests in 64567-tests.2.diff would pin the behavior (this one fails on trunk):
<?php /** * Ensures that serialize_token() reflects enqueued attribute updates instead * of emitting removed attributes as value-less attributes or omitting added ones. * * @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.' ); }
#15
@
41 hours ago
Confirmed on trunk — reproduced all four cases from comment:14 via a standalone harness. serialize_token() iterates get_attribute_names_with_prefix( '' ) but reads each value through get_attribute(), and the two disagree exactly as described:
remove_attribute( 'onclick' ) => <div onclick class="x"> (removed attr re-emitted as boolean) set_attribute( 'id', 'new' ) => <div class="x"> (addition dropped) set_attribute( 'class', 'changed') => <div class="changed"> OK add_class( 'extra' ) => <div class="x extra"> OK
And PR #10828 resolves both broken cases with no change to serialize_token() itself — once the name list agrees with the enqueued updates, the removal is omitted and the addition is picked up:
remove_attribute( 'onclick' ) => <div class="x"> set_attribute( 'id', 'new' ) => <div class="x" id="new">
The proposed regression test fails on trunk and passes with PR #10828, so I've folded it in. I placed it in wpHtmlProcessor-serialize.php (Tests_HtmlApi_WpHtmlProcessor_Serialize) rather than the tag-processor file where the no-flush tests live, since serialize_token() is a WP_HTML_Processor method:
-
tests/phpunit/tests/html-api/wpHtmlProcessor-serialize.php
a b class Tests_HtmlApi_WpHtmlProcessor_Serialize extends WP_UnitTestCase { 389 389 ), 390 390 ); 391 391 } 392 393 /** 394 * Ensures that serialize_token() reflects enqueued attribute updates instead 395 * of emitting removed attributes as value-less attributes or omitting added ones. 396 * 397 * @ticket 64567 398 * 399 * @covers WP_HTML_Processor::serialize_token 400 */ 401 public function test_serialize_token_reflects_enqueued_attribute_updates() { 402 $processor = WP_HTML_Processor::create_fragment( '<div onclick="alert(1)" class="x">Text</div>' ); 403 $processor->next_tag(); 404 $processor->remove_attribute( 'onclick' ); 405 406 $this->assertSame( 407 '<div class="x">', 408 $processor->serialize_token(), 409 'An attribute enqueued for removal was serialized as a value-less attribute.' 410 ); 411 412 $processor = WP_HTML_Processor::create_fragment( '<div class="x">Text</div>' ); 413 $processor->next_tag(); 414 $processor->set_attribute( 'id', 'new' ); 415 416 $this->assertStringContainsString( 417 'id="new"', 418 $processor->serialize_token(), 419 'An attribute enqueued via set_attribute() was not serialized.' 420 ); 421 } 392 422 }
One note: the 'modifiable text' leak from comment:12 doesn't surface through these particular cases — a <div> doesn't enqueue a modifiable-text update, so this test passes with or without the comment:12 guard. It pins the serialize_token() behavior, but the guard from comment:12/13 still needs to land in PR #10828 separately; it isn't covered here.
![(please configure the [header_logo] section in trac.ini)](/chrome/site/your_project_logo.png)
Fixed: get_attribute_names_with_prefix() now agrees with enqueued attribute updates
The method now returns real-time attribute names that reflect set_attribute() and remove_attribute() calls without needing to call get_updated_html() first.
Trac ticket: https://core.trac.wordpress.org/ticket/64567
---