Skip to content

HTML API: Prevent remove_attribute() from introducing a self-closing flag - #13208

Draft
sirreal wants to merge 5 commits into
WordPress:trunkfrom
sirreal:fix/html-api-remove-attribute-self-closing-flag
Draft

HTML API: Prevent remove_attribute() from introducing a self-closing flag#13208
sirreal wants to merge 5 commits into
WordPress:trunkfrom
sirreal:fix/html-api-remove-attribute-self-closing-flag

Conversation

@sirreal

@sirreal sirreal commented Aug 20, 2026

Copy link
Copy Markdown
Member

Problem

Inside a tag, a / is ignored unless it's immediately followed by >, in which case it's the self-closing flag. When an attribute is directly preceded by a /, removing just the attribute's own span can leave that / adjacent to the >, introducing a self-closing flag that wasn't in the input:

$p = new WP_HTML_Tag_Processor( '<svg><g /attr>ok' );
$p->next_tag( 'G' );
$p->remove_attribute( 'attr' );
echo $p->get_updated_html();
// Before: <svg><g />ok   ← `g` is now self-closing; "ok" is no longer its child
// After:  <svg><g >ok

Other affected shapes: <g/attr><g/>, <g a/b> (remove b) → <g a/>, <g a="x"/b> (remove b) → <g a="x"/>, and duplicated attributes like <g /a /a><g / />.

Fix

Attribute removals now also consume any / characters immediately preceding the attribute. This is safe because a / can only appear directly before an attribute as a separator: tag names and attribute names terminate at /, and inside an unquoted value / is part of the value rather than preceding an attribute.

Two consequences of consuming the slashes are handled:

  • Separator preservation. Only a quoted value can be directly followed by another attribute, e.g. <g/a="x"b>. Removing /a="x" outright would merge the tag name with the following attribute (<gb>); <g c/a="x"b> would become <g cb>. When the consumed slashes were the only separator and an attribute immediately follows, a single space takes their place: <g b>, <g c b>.
  • Update ordering. When the removed attribute directly follows the tag name (<g/a>), the removal now starts at the same offset where set_attribute() inserts new attributes. Lexical updates were ordered by start then by text, which placed the removal (empty text) before the insertion and corrupted the output (<g> b="x"/a>). Zero-length insertions now sort before consuming replacements at the same offset.

The same logic applies when removing duplicated attributes.

Whitespace between a / and the attribute is intentionally left alone (<g / attr><g / >), since / > is not a self-closing flag.

Testing

  • test_remove_attribute_preserves_tag_semantics: data provider of slash/quote/equals shapes; asserts exact output, then re-parses and checks the tag name, self-closing flag, remaining attribute set, and following text are unchanged.
  • test_set_and_remove_attribute_at_tag_name_end: combinations of set_attribute()/add_class() and remove_attribute() where the removal reaches the tag-name end.

Beyond the unit tests, a combinatorial fuzzer over malformed tag shapes (separators ' ', /, //, / , ''…; attribute shapes a, a=b, a="b", a=/, a=b/, =, =a, a ="b"…; closers >, />, / >, //>…; up to three attributes; duplicates) applied remove_attribute, set_attribute, add_class, remove_class and combinations, then re-parsed the output and diffed the token stream against the expected change. Before this PR: ~83k of 1.6M checks diverged. After: 0 of 2.3M, excluding one unrelated pre-existing class noted below.

Out of scope

The fuzzer also surfaced a pre-existing set_attribute( $name, true ) issue: a bare boolean name emitted directly before whitespace followed by = merges with it. E.g. <x => + set_attribute( 'b', true )<x b =>, which parses as b="" and drops the = attribute. That's independent of removal and not addressed here.

Trac ticket:

None.


🤖 Generated with Claude Code

…g flag.

A `/` inside a tag is only a self-closing flag when immediately followed
by `>`. When an attribute is directly preceded by a `/`, e.g. `<g /attr>`,
removing the attribute leaves `<g />`, introducing a self-closing flag that
wasn't in the input and changing the tag's semantics.

These tests currently fail and document the expected behavior.
…flag.

Inside a tag, a `/` is ignored unless it's immediately followed by `>`,
in which case it's the self-closing flag. When an attribute is directly
preceded by a `/`, e.g. `<g /attr>`, removing just the attribute's own
span left `<g />`, introducing a self-closing flag that wasn't in the
input and changing the semantics of the tag.

Removals now also consume any `/` characters immediately preceding the
attribute. This is safe because a `/` can only appear before an attribute
as a separator: tag names and attribute names end at `/`, and inside an
unquoted value it's part of the value rather than preceding an attribute.

The same logic applies when removing duplicated attributes.
@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.

…ntax.

Broaden the self-closing flag test to assert that the tag name, the
remaining attributes, and the following content are all unchanged, and
add cases where removing an attribute along with the `/` preceding it
would otherwise merge the tag name or a neighboring attribute with what
follows, e.g. `<g/a="x"b>` becoming `<gb>`.

Also add a test for combining set_attribute() and remove_attribute() on
the same tag when the removal extends back to the end of the tag name,
which is where new attributes are inserted.

These tests currently fail.
…n attribute.

Consuming the `/` characters before a removed attribute introduced two
problems of its own:

1. Only a quoted attribute value can be directly followed by another
   attribute, e.g. `<g/a="x"b>`. Removing `/a="x"` outright merged the
   tag name with the following attribute: `<gb>`. Likewise `<g c/a="x"b>`
   became `<g cb>`. When the consumed slashes were the only separator
   and an attribute follows, a single space now takes their place.

2. When the removed attribute directly follows the tag name, e.g.
   `<g/a>`, the removal now starts at the same offset where new
   attributes are inserted by set_attribute(). Lexical updates were
   sorted by start and then by text, which ordered the removal (empty
   text) before the insertion; applying the removal first advanced the
   copy cursor past the insertion point and produced corrupted output
   such as `<g> b="x"/a>`. Zero-length insertions now sort before
   consuming replacements at the same offset.
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.

1 participant