Commit fbca6cb2219 for php.net
commit fbca6cb2219353420490aa746151d0660a4037bc
Author: Ilia Alshanetsky <ilia@ilia.ws>
Date: Sat Aug 29 07:58:59 2026 -0400
dom: invalidate node list caches on class attribute mutations
Reflected attribute writes such as className and id, classList
mutations, and removeAttribute()/removeAttributeNS()/removeAttributeNode()
modified attributes without bumping the document cache tag, so live
HTMLCollection caches like getElementsByClassName() kept serving stale
lengths and items. Invalidate the node list caches at every one of these
mutation points. Sibling audit: Attr:: writes, setAttribute(),
setAttributeNode() and setAttributeNS() already invalidate;
php_dom_ns_compat_mark_attribute() only mirrors namespace declarations
during reconciliation and is not user-visible.
Closes GH-23501
diff --git a/NEWS b/NEWS
index ccb18766e2a..693ff78fafd 100644
--- a/NEWS
+++ b/NEWS
@@ -9,6 +9,8 @@ PHP NEWS
middle generator delegates again). (Lazizbek Ergashev)
- DOM:
+ . Fixed stale getElementsByClassName() and other node list caches after
+ className/classList writes and attribute removals. (Ilia Alshanetsky)
. Fixed a use-after-free when cloning a DOMNameSpaceNode after
DOMDocument::xinclude(). (iliaal)
. Fixed a crash in DOMXPath when a php:function callback receives a nodeset
diff --git a/ext/dom/element.c b/ext/dom/element.c
index 2320216f824..8dd7f375605 100644
--- a/ext/dom/element.c
+++ b/ext/dom/element.c
@@ -156,6 +156,7 @@ static xmlAttrPtr dom_element_reflected_attribute_write(dom_object *obj, zval *n
/* Typed property, so it is a string already */
ZEND_ASSERT(Z_TYPE_P(newval) == IS_STRING);
+ php_libxml_invalidate_node_list_cache(obj->document);
return xmlSetNsProp(nodep, NULL, (const xmlChar *) name, (const xmlChar *) Z_STRVAL_P(newval));
}
@@ -544,7 +545,7 @@ static void dom_deep_ns_redef(xmlNodePtr node, xmlNsPtr ns_to_redefine)
efree(worklist);
}
-static bool dom_remove_attribute(xmlNodePtr thisp, xmlNodePtr attrp)
+static bool dom_remove_attribute(xmlNodePtr thisp, xmlNodePtr attrp, php_libxml_ref_obj *document)
{
ZEND_ASSERT(thisp != NULL);
ZEND_ASSERT(attrp != NULL);
@@ -599,6 +600,7 @@ static bool dom_remove_attribute(xmlNodePtr thisp, xmlNodePtr attrp)
return false;
EMPTY_SWITCH_DEFAULT_CASE();
}
+ php_libxml_invalidate_node_list_cache(document);
return true;
}
@@ -624,7 +626,7 @@ PHP_METHOD(DOMElement, removeAttribute)
RETURN_FALSE;
}
- RETURN_BOOL(dom_remove_attribute(nodep, attrp));
+ RETURN_BOOL(dom_remove_attribute(nodep, attrp, intern->document));
}
PHP_METHOD(Dom_Element, removeAttribute)
@@ -642,7 +644,7 @@ PHP_METHOD(Dom_Element, removeAttribute)
attrp = dom_get_attribute_or_nsdecl(intern, nodep, BAD_CAST name, name_len);
if (attrp != NULL) {
- dom_remove_attribute(nodep, attrp);
+ dom_remove_attribute(nodep, attrp, intern->document);
}
}
/* }}} end dom_element_remove_attribute */
@@ -800,6 +802,7 @@ static void dom_element_remove_attribute_node(INTERNAL_FUNCTION_PARAMETERS, zend
RETURN_FALSE;
}
+ php_libxml_invalidate_node_list_cache(intern->document);
xmlUnlinkNode((xmlNodePtr) attrp);
DOM_RET_OBJ((xmlNodePtr) attrp, intern);
@@ -1200,6 +1203,7 @@ PHP_METHOD(DOMElement, removeAttributeNS)
if (nsptr != NULL) {
if (xmlStrEqual(BAD_CAST uri, nsptr->href)) {
dom_eliminate_ns(nodep, nsptr);
+ php_libxml_invalidate_node_list_cache(intern->document);
} else {
return;
}
@@ -1214,6 +1218,7 @@ PHP_METHOD(DOMElement, removeAttributeNS)
} else {
xmlUnlinkNode((xmlNodePtr) attrp);
}
+ php_libxml_invalidate_node_list_cache(intern->document);
}
}
/* }}} end dom_element_remove_attribute_ns */
@@ -1922,7 +1927,7 @@ PHP_METHOD(DOMElement, toggleAttribute)
/* Step 5 */
if (force_is_null || !force) {
- retval = !dom_remove_attribute(thisp, attribute);
+ retval = !dom_remove_attribute(thisp, attribute, intern->document);
goto out;
}
diff --git a/ext/dom/tests/modern/common/getElementsByClassName_cache_invalidation.phpt b/ext/dom/tests/modern/common/getElementsByClassName_cache_invalidation.phpt
new file mode 100644
index 00000000000..4efdad1b59b
--- /dev/null
+++ b/ext/dom/tests/modern/common/getElementsByClassName_cache_invalidation.phpt
@@ -0,0 +1,44 @@
+--TEST--
+getElementsByClassName() cache must be invalidated by class attribute mutations
+--EXTENSIONS--
+dom
+--FILE--
+<?php
+function mk($body) {
+ return Dom\HTMLDocument::createFromString("<!DOCTYPE html><html><body>$body</body></html>");
+}
+
+$checks = [
+ 'className' => function ($doc, $span) { $span->className = 'zzz'; },
+ 'classList-remove' => function ($doc, $span) { $span->classList->remove('foo'); },
+ 'classList-value' => function ($doc, $span) { $span->classList->value = 'zzz'; },
+ 'setAttribute' => function ($doc, $span) { $span->setAttribute('class', 'zzz'); },
+ 'removeAttribute' => function ($doc, $span) { $span->removeAttribute('class'); },
+ 'removeAttributeNode' => function ($doc, $span) { $span->removeAttributeNode($span->attributes['class']); },
+];
+foreach ($checks as $label => $fn) {
+ $doc = mk('<span class="foo"></span>');
+ $coll = $doc->getElementsByClassName('foo');
+ if ($coll->length !== 1) {
+ echo "$label: unexpected initial length\n";
+ continue;
+ }
+ $fn($doc, $doc->querySelector('span'));
+ echo "$label: ", $coll->length === 0 ? "OK" : "STALE {$coll->length}", "\n";
+}
+
+$doc = mk('<span></span>');
+$coll = $doc->getElementsByClassName('foo');
+var_dump($coll->length);
+$doc->querySelector('span')->className = 'foo';
+echo $coll->length === 1 ? "growth OK" : "growth STALE", "\n";
+?>
+--EXPECT--
+className: OK
+classList-remove: OK
+classList-value: OK
+setAttribute: OK
+removeAttribute: OK
+removeAttributeNode: OK
+int(0)
+growth OK
diff --git a/ext/dom/token_list.c b/ext/dom/token_list.c
index 524ff699f41..34e2aa6b3ca 100644
--- a/ext/dom/token_list.c
+++ b/ext/dom/token_list.c
@@ -184,6 +184,7 @@ static void dom_token_list_update(dom_token_list_object *intern)
HashTable *token_set = TOKEN_LIST_GET_SET(intern);
php_libxml_invalidate_cache_tag(&intern->cache_tag);
+ php_libxml_invalidate_node_list_cache(intern->dom.document);
/* 1. If the associated element does not have an associated attribute and token set is empty, then return. */
if (attr == NULL && zend_hash_num_elements(token_set) == 0) {
@@ -432,6 +433,7 @@ zend_result dom_token_list_value_write(dom_object *obj, zval *newval)
zend_value_error("Value must not contain any null bytes");
return FAILURE;
}
+ php_libxml_invalidate_node_list_cache(intern->dom.document);
xmlSetNsProp(dom_token_list_get_element(intern), NULL, BAD_CAST "class", BAD_CAST Z_STRVAL_P(newval));
/* Note: we don't update the set here, the set is always lazily updated for performance reasons. */
return SUCCESS;