[php-src] master: Merge branch 'PHP-8.5'

Ilia Alshanetsky <[email protected]>
Newsgroups gmane.comp.php.cvs.general
Message-ID <[email protected]>
Author: Ilia Alshanetsky (iliaal)
Date: 2026-08-30T11:59:03-04:00

Commit: https://github.com/php/php-src/commit/0db31e369ce454cdbb9db746200a17e9d4cdf0f7
Raw diff: https://github.com/php/php-src/commit/0db31e369ce454cdbb9db746200a17e9d4cdf0f7.diff

Merge branch 'PHP-8.5'

* PHP-8.5:
  dom: invalidate node list caches on class attribute mutations

Changed paths:
  A  ext/dom/tests/modern/common/getElementsByClassName_cache_invalidation.phpt
  M  NEWS
  M  ext/dom/element.c
  M  ext/dom/token_list.c


Diff:

diff --git a/NEWS b/NEWS
index f79aeae74292..2dd3d1275b5a 100644
--- a/NEWS
+++ b/NEWS
@@ -6,6 +6,10 @@ PHP                                                                        NEWS
   . Fixed bug GH-23242 (PHP development server does not support Expect
     100-continue flow control). (Sjoerd Langkemper)
 
+- DOM:
+  . Fixed stale getElementsByClassName() and other node list caches after
+    className/classList writes and attribute removals. (Ilia Alshanetsky)
+
 - Intl:
   . Fixed a memory leak when dumping IntlCalendar instances. (Ilia Alshanetsky)
   . Fixed Collator::sortWithSortKeys() allocating fixed 2MiB buffers
diff --git a/ext/dom/element.c b/ext/dom/element.c
index 354466623ca4..5fcffaf42055 100644
--- a/ext/dom/element.c
+++ b/ext/dom/element.c
@@ -154,6 +154,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));
 }
 
@@ -542,7 +543,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);
@@ -597,6 +598,7 @@ static bool dom_remove_attribute(xmlNodePtr thisp, xmlNodePtr attrp)
 			return false;
 		default: ZEND_UNREACHABLE();
 	}
+	php_libxml_invalidate_node_list_cache(document);
 	return true;
 }
 
@@ -622,7 +624,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)
@@ -640,7 +642,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 */
@@ -798,6 +800,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);
@@ -1198,6 +1201,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;
 			}
@@ -1212,6 +1216,7 @@ PHP_METHOD(DOMElement, removeAttributeNS)
 		} else {
 			xmlUnlinkNode((xmlNodePtr) attrp);
 		}
+		php_libxml_invalidate_node_list_cache(intern->document);
 	}
 }
 /* }}} end dom_element_remove_attribute_ns */
@@ -1947,7 +1952,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 000000000000..4efdad1b59b4
--- /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 0e7797616554..30f308a14d67 100644
--- a/ext/dom/token_list.c
+++ b/ext/dom/token_list.c
@@ -182,6 +182,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) {
@@ -430,6 +431,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;
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.