Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 36 additions & 4 deletions src/wp-includes/html-api/class-wp-html-tag-processor.php
Original file line number Diff line number Diff line change
Expand Up @@ -752,7 +752,7 @@ class WP_HTML_Tag_Processor {
* );
*
* @since 6.2.0
* @var bool[]
* @var array<non-empty-string, self::ADD_CLASS|self::REMOVE_CLASS>
*/
private $classname_updates = array();

Expand Down Expand Up @@ -809,7 +809,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();

Expand Down Expand Up @@ -2968,13 +2968,45 @@ public function get_attribute_names_with_prefix( $prefix ): ?array {

$comparable = strtolower( $prefix );

/*
* 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 ) ) {
foreach ( $this->classname_updates as $update ) {
if (
( $has_class && self::REMOVE_CLASS === $update ) ||
( ! $has_class && self::ADD_CLASS === $update )
) {
$this->class_name_updates_to_attributes_updates();
break;
}
}
}

$additions = array();
$removals = array();
foreach ( $this->lexical_updates as $update_name => $update ) {
if ( is_int( $update_name ) || 'modifiable text' === $update_name ) {
Comment thread
westonruter marked this conversation as resolved.
continue;
}

if ( '' === $update->text ) {
$removals[ $update_name ] = true;
} elseif ( ! isset( $this->attributes[ $update_name ] ) && str_starts_with( $update_name, $comparable ) ) {
$additions[] = $update_name;
}
}

$matches = array();
foreach ( array_keys( $this->attributes ) as $attr_name ) {
if ( str_starts_with( $attr_name, $comparable ) ) {
if ( str_starts_with( $attr_name, $comparable ) && ! isset( $removals[ $attr_name ] ) ) {
$matches[] = $attr_name;
}
}
return $matches;

return empty( $additions ) ? $matches : array_merge( $additions, $matches );
}

/**
Expand Down
173 changes: 173 additions & 0 deletions tests/phpunit/tests/html-api/wpHtmlTagProcessor.php
Original file line number Diff line number Diff line change
Expand Up @@ -464,6 +464,179 @@ public function test_get_attribute_names_with_prefix_returns_attribute_added_by_
);
}

/**
* Ensures that a new attribute added via set_attribute() is reported by
* get_attribute_names_with_prefix() immediately after being added.
*
* @ticket 64567
*
* @covers WP_HTML_Tag_Processor::get_attribute_names_with_prefix
*/
public function test_get_attribute_names_with_prefix_immediately_reflects_new_attributes() {
$processor = new WP_HTML_Tag_Processor( '<div existing>' );
$processor->next_tag();

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

$processor->set_attribute( 'new', true );

$this->assertSame(
array( 'new', 'existing' ),
$processor->get_attribute_names_with_prefix( '' ),
'Failed to report newly-added attribute.'
);

$this->assertSame(
array( 'existing' ),
$processor->get_attribute_names_with_prefix( 'exist' ),
'Should have only reported existing attribute matching given prefix.'
);
}

/**
* Ensures that changes enqueued for modifiable text do not get reported as added attributes.
*
* This is a fairly-specific test against an internal implementation detail, but is worth
* adding to catch potential regressions since modifiable text updates share a namespace with
* attribute updates.
*
* @ticket 64567
*
* @covers WP_HTML_Tag_Processor::get_attribute_names_with_prefix
*/
public function test_get_attribute_names_with_prefix_ignores_immediately_added_modifiable_text() {
$processor = new WP_HTML_Tag_Processor( '<title existing></title>' );
$processor->next_tag();

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

$processor->set_modifiable_text( 'content!' );

$this->assertSame(
array( 'existing' ),
$processor->get_attribute_names_with_prefix( '' ),
'Failed to report that the attributes were unchanged.'
);
}

/**
* Ensures that an attribute removed via remove_attribute() is no longer reported
* by get_attribute_names_with_prefix() immediately after being removed.
*
* @ticket 64567
*
* @covers WP_HTML_Tag_Processor::get_attribute_names_with_prefix
*/
public function test_get_attribute_names_with_prefix_immediately_reflects_removed_attributes() {
$processor = new WP_HTML_Tag_Processor( '<div existing data-removed>' );
$processor->next_tag();

$this->assertSame(
array( 'existing', 'data-removed' ),
$processor->get_attribute_names_with_prefix( '' ),
'Failed to report all existing attributes: check test setup.'
);

$processor->remove_attribute( 'data-removed' );

$this->assertSame(
array(),
$processor->get_attribute_names_with_prefix( 'data-' ),
'Expected no custom data attributes after removing the only one.'
);

$this->assertSame(
array( 'existing' ),
$processor->get_attribute_names_with_prefix( '' ),
'Expected to report only the attribute which wasn’t removed.'
);
}

/**
* Ensures that when the `class` attribute is newly added via add_class(), that
* it’s reported by get_attribute_names_with_prefix() immediately after being added.
*
* @ticket 64567
*
* @covers WP_HTML_Tag_Processor::get_attribute_names_with_prefix
*/
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 on lines +563 to +588

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.

Would it make sense to add a test case for the class removal as well?


/**
* 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_immediately_agrees_with_get_attribute_after_updates() {
$processor = new WP_HTML_Tag_Processor( '<div data-keep="1" data-drop="2">Test</div>' );
$processor->next_tag();

$this->assertSame(
array( 'data-keep', 'data-drop' ),
$processor->get_attribute_names_with_prefix( '' ),
'Failed to find expected existing attributes: check test setup.'
);

$this->assertSame(
'1',
$processor->get_attribute( 'data-keep' ),
'Failed to find expected existing `data-keep` attribute value: check test setup.'
);

$this->assertSame(
'2',
$processor->get_attribute( 'data-drop' ),
'Failed to find expected existing `data-drop` attribute value: check test setup.'
);

$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-' );
Comment thread
westonruter marked this conversation as resolved.

$this->assertSame(
array( 'data-add', 'data-keep' ),
$names,
'Failed to report the expected attribute names after removing and adding attributes.'
);

foreach ( $names as $name ) {
$this->assertNotNull(
$processor->get_attribute( $name ),
"get_attribute_names_with_prefix() reported '{$name}' but get_attribute() did not agree."
);
}
}

/**
* @ticket 56299
*
Expand Down
Loading